ralph/commands/task/decompose/
write.rs1use super::resolve::resolve_effective_parent_for_write;
17use super::support::{
18 allocate_sequential_ids, annotate_parent, created_node_count, descendant_ids_for_parent,
19 done_queue_ref, ensure_subtree_is_replaceable, insertion_index, materialize_children,
20 materialize_node, request_context,
21};
22use super::types::{
23 DecompositionChildPolicy, DecompositionPreview, DecompositionSource, TaskDecomposeWriteResult,
24};
25use crate::contracts::QueueFile;
26use crate::{config, queue, timeutil};
27use anyhow::{Context, Result, bail};
28
29pub fn write_task_decomposition(
30 resolved: &config::Resolved,
31 preview: &DecompositionPreview,
32 force: bool,
33) -> Result<TaskDecomposeWriteResult> {
34 if !preview.write_blockers.is_empty() {
35 bail!(preview.write_blockers.join("\n"));
36 }
37
38 let _queue_lock = queue::acquire_queue_lock(&resolved.repo_root, "task decompose", force)?;
39 let mut active = queue::load_queue(&resolved.queue_path)?;
40 let done = queue::load_queue_or_default(&resolved.done_path)?;
41 let done_ref = done_queue_ref(&done, &resolved.done_path);
42 let max_depth = resolved.config.queue.max_dependency_depth.unwrap_or(10);
43 validate_queue_set(&active, done_ref, resolved, max_depth)
44 .context("validate queue set before task decompose write")?;
45
46 let effective_parent = resolve_effective_parent_for_write(&active, done_ref, preview)?;
47 let existing_descendant_ids = effective_parent
48 .as_ref()
49 .map(|task| descendant_ids_for_parent(&active, task.id.as_str()))
50 .transpose()?
51 .unwrap_or_default();
52
53 enforce_child_policy(
54 preview,
55 effective_parent.as_ref(),
56 &active,
57 done_ref,
58 &existing_descendant_ids,
59 )?;
60 crate::undo::create_undo_snapshot(resolved, &undo_snapshot_label(preview))?;
61
62 let created_count = created_node_count(preview);
63 if created_count == 0 {
64 bail!("Task decomposition produced no child tasks to write.");
65 }
66
67 let ids = allocate_sequential_ids(
68 &active,
69 done_ref,
70 &resolved.id_prefix,
71 resolved.id_width,
72 max_depth,
73 created_count,
74 )?;
75 let now = timeutil::now_utc_rfc3339()?;
76 let request_context = request_context(preview);
77 let mut next_id_index = 0usize;
78 let mut created_tasks = materialize_created_tasks(
79 preview,
80 effective_parent.as_ref(),
81 &ids,
82 &mut next_id_index,
83 &request_context,
84 &now,
85 )?;
86
87 let root_task_id = match (&preview.source, preview.attach_target.as_ref()) {
88 (DecompositionSource::ExistingTask { .. }, None) => None,
89 _ => created_tasks.first().map(|task| task.id.clone()),
90 };
91 let parent_task_id = effective_parent.as_ref().map(|task| task.id.clone());
92 let created_ids = created_tasks
93 .iter()
94 .map(|task| task.id.clone())
95 .collect::<Vec<_>>();
96 let replaced_ids = if preview.child_policy == DecompositionChildPolicy::Replace {
97 existing_descendant_ids.iter().cloned().collect::<Vec<_>>()
98 } else {
99 Vec::new()
100 };
101
102 let removed_ids = existing_descendant_ids;
103 if !removed_ids.is_empty() && preview.child_policy == DecompositionChildPolicy::Replace {
104 active
105 .tasks
106 .retain(|task| !removed_ids.contains(task.id.as_str()));
107 }
108
109 let insert_at = insertion_index(
110 &active,
111 effective_parent.as_ref(),
112 &removed_ids,
113 preview.child_policy,
114 )?;
115
116 if let Some(parent) = effective_parent {
117 annotate_parent(
118 &mut active,
119 &parent.id,
120 &preview.source,
121 preview.attach_target.as_ref(),
122 &created_tasks,
123 &now,
124 )?;
125 }
126
127 for (offset, task) in created_tasks.drain(..).enumerate() {
128 active.tasks.insert(insert_at + offset, task);
129 }
130
131 validate_queue_set(&active, done_ref, resolved, max_depth)
132 .context("validate queue set after task decompose write")?;
133 queue::save_queue(&resolved.queue_path, &active)?;
134
135 Ok(TaskDecomposeWriteResult {
136 root_task_id,
137 parent_task_id,
138 created_ids,
139 replaced_ids,
140 parent_annotated: preview.attach_target.is_some()
141 || matches!(preview.source, DecompositionSource::ExistingTask { .. }),
142 })
143}
144
145fn validate_queue_set(
146 active: &QueueFile,
147 done_ref: Option<&QueueFile>,
148 resolved: &config::Resolved,
149 max_depth: u8,
150) -> Result<()> {
151 queue::validate_queue_set(
152 active,
153 done_ref,
154 &resolved.id_prefix,
155 resolved.id_width,
156 max_depth,
157 )
158 .map(|_| ())
159}
160
161fn enforce_child_policy(
162 preview: &DecompositionPreview,
163 effective_parent: Option<&crate::contracts::Task>,
164 active: &QueueFile,
165 done_ref: Option<&QueueFile>,
166 existing_descendant_ids: &std::collections::HashSet<String>,
167) -> Result<()> {
168 match preview.child_policy {
169 DecompositionChildPolicy::Fail => {
170 if !existing_descendant_ids.is_empty() {
171 let parent_id = effective_parent
172 .as_ref()
173 .map(|task| task.id.as_str())
174 .unwrap_or("");
175 bail!(
176 "Task {} already has child tasks. Refusing write for `ralph task decompose --child-policy fail`.",
177 parent_id
178 );
179 }
180 }
181 DecompositionChildPolicy::Replace => {
182 if !existing_descendant_ids.is_empty() {
183 ensure_subtree_is_replaceable(active, done_ref, existing_descendant_ids)?;
184 }
185 }
186 DecompositionChildPolicy::Append => {}
187 }
188 Ok(())
189}
190
191fn materialize_created_tasks(
192 preview: &DecompositionPreview,
193 effective_parent: Option<&crate::contracts::Task>,
194 ids: &[String],
195 next_id_index: &mut usize,
196 request_context: &str,
197 now: &str,
198) -> Result<Vec<crate::contracts::Task>> {
199 match (&preview.source, effective_parent) {
200 (DecompositionSource::ExistingTask { .. }, Some(parent))
201 if preview.attach_target.is_none() =>
202 {
203 materialize_children(
204 &preview.plan.root.children,
205 Some(parent.id.as_str()),
206 ids,
207 next_id_index,
208 preview.child_status,
209 request_context,
210 now,
211 )
212 }
213 (_, Some(parent)) => {
214 let root_task = materialize_node(
215 &preview.plan.root,
216 Some(parent.id.as_str()),
217 ids,
218 next_id_index,
219 preview.child_status,
220 request_context,
221 now,
222 )?;
223 let root_id = root_task.id.clone();
224 let mut tasks = vec![root_task];
225 tasks.extend(materialize_children(
226 &preview.plan.root.children,
227 Some(root_id.as_str()),
228 ids,
229 next_id_index,
230 preview.child_status,
231 request_context,
232 now,
233 )?);
234 Ok(tasks)
235 }
236 (_, None) => {
237 let root_task = materialize_node(
238 &preview.plan.root,
239 None,
240 ids,
241 next_id_index,
242 preview.child_status,
243 request_context,
244 now,
245 )?;
246 let root_id = root_task.id.clone();
247 let mut tasks = vec![root_task];
248 tasks.extend(materialize_children(
249 &preview.plan.root.children,
250 Some(root_id.as_str()),
251 ids,
252 next_id_index,
253 preview.child_status,
254 request_context,
255 now,
256 )?);
257 Ok(tasks)
258 }
259 }
260}
261
262fn undo_snapshot_label(preview: &DecompositionPreview) -> String {
263 match (&preview.source, preview.attach_target.as_ref()) {
264 (DecompositionSource::Freeform { request }, None) => {
265 format!("task decompose write for request '{request}'")
266 }
267 (DecompositionSource::Freeform { request }, Some(parent)) => {
268 format!(
269 "task decompose attach request '{}' under {}",
270 request, parent.task.id
271 )
272 }
273 (DecompositionSource::ExistingTask { task }, None) => {
274 format!("task decompose {} into child tasks", task.id)
275 }
276 (DecompositionSource::ExistingTask { task }, Some(parent)) => {
277 format!(
278 "task decompose {} attached under {}",
279 task.id, parent.task.id
280 )
281 }
282 }
283}