plexus_substrate/activations/orcha/
ticket_compiler.rs1use super::types::{OrchaEdgeDef, OrchaNodeDef, OrchaNodeSpec};
2use std::collections::HashMap;
3
4pub struct CompiledGraph {
7 pub nodes: Vec<OrchaNodeDef>,
8 pub edges: Vec<OrchaEdgeDef>,
9}
10
11pub fn compile_tickets(input: &str) -> Result<CompiledGraph, String> {
56 let sections = parse_sections(input);
57 build_graph(sections)
58}
59
60struct RawSection {
63 id: String,
64 type_tag: String,
65 body_lines: Vec<String>,
66}
67
68fn parse_sections(input: &str) -> Vec<RawSection> {
75 let mut sections: Vec<RawSection> = Vec::new();
76 let mut current: Option<(String, String, Vec<String>)> = None;
77
78 for line in input.lines() {
79 if let Some((id, type_tag)) = try_parse_ticket_heading(line) {
80 if let Some((prev_id, prev_type, lines)) = current.take() {
81 sections.push(RawSection { id: prev_id, type_tag: prev_type, body_lines: lines });
82 }
83 current = Some((id, type_tag, Vec::new()));
84 } else if let Some((_, _, ref mut lines)) = current {
85 lines.push(line.to_string());
86 }
87 }
89 if let Some((id, type_tag, lines)) = current {
90 sections.push(RawSection { id, type_tag, body_lines: lines });
91 }
92 sections
93}
94
95fn try_parse_ticket_heading(line: &str) -> Option<(String, String)> {
102 let rest = line.strip_prefix("# ")?;
104 if rest.starts_with('#') {
105 return None;
106 }
107 let rest = rest.trim();
108
109 let bracket_open = rest.find('[')?;
111 let after_open = &rest[bracket_open + 1..];
112 let bracket_close = after_open.find(']')?;
113 let type_tag = after_open[..bracket_close].trim().to_string();
114 if type_tag.is_empty() {
115 return None;
116 }
117
118 let before_bracket = rest[..bracket_open].trim();
120 let id = before_bracket
121 .split(':')
122 .next()
123 .unwrap_or(before_bracket)
124 .trim()
125 .to_string();
126
127 if id.is_empty() || id.contains(' ') {
128 return None;
129 }
130
131 Some((id, type_tag))
132}
133
134struct ParsedTicket {
137 id: String,
138 type_tag: String,
139 deps: Vec<String>,
140 task: Option<String>,
142 command: Option<String>,
144 validate: Option<String>,
146}
147
148fn build_graph(sections: Vec<RawSection>) -> Result<CompiledGraph, String> {
149 let parsed: Vec<ParsedTicket> = sections
150 .into_iter()
151 .map(parse_section_body)
152 .collect::<Result<_, _>>()?;
153
154 let mut completion_id: HashMap<String, String> = HashMap::new();
158 for t in &parsed {
159 let effective = if t.validate.is_some() {
160 format!("{}-validate", t.id)
161 } else {
162 t.id.clone()
163 };
164 completion_id.insert(t.id.clone(), effective);
165 }
166
167 let mut nodes: Vec<OrchaNodeDef> = Vec::new();
168 let mut edges: Vec<OrchaEdgeDef> = Vec::new();
169
170 for t in &parsed {
171 let spec = match t.type_tag.as_str() {
173 "agent" => {
174 let task = t.task.clone().ok_or_else(|| {
175 format!("Ticket '{}' [agent] has no body text", t.id)
176 })?;
177 OrchaNodeSpec::Task { task, max_retries: None }
178 }
179 "agent/synthesize" => {
180 let task = t.task.clone().ok_or_else(|| {
181 format!("Ticket '{}' [agent/synthesize] has no body text", t.id)
182 })?;
183 OrchaNodeSpec::Synthesize { task, max_retries: None }
184 }
185 "prog" => {
186 let command = t.command.clone().ok_or_else(|| {
187 format!("Ticket '{}' [prog] has no body text", t.id)
188 })?;
189 OrchaNodeSpec::Validate { command, cwd: None, max_retries: None }
190 }
191 "review" => {
192 let prompt = t.task.clone().ok_or_else(|| {
193 format!("Ticket '{}' [review] has no body text", t.id)
194 })?;
195 OrchaNodeSpec::Review { prompt }
196 }
197 "planner" => {
198 let task = t.task.clone().ok_or_else(|| {
199 format!("Ticket '{}' [planner] has no body text", t.id)
200 })?;
201 OrchaNodeSpec::Plan { task }
202 }
203 other => {
204 return Err(format!(
205 "Unknown ticket type [{}] in ticket '{}'",
206 other, t.id
207 ))
208 }
209 };
210 nodes.push(OrchaNodeDef { id: t.id.clone(), spec });
211
212 if let Some(ref cmd) = t.validate {
214 nodes.push(OrchaNodeDef {
215 id: format!("{}-validate", t.id),
216 spec: OrchaNodeSpec::Validate { command: cmd.clone(), cwd: None, max_retries: None },
217 });
218 edges.push(OrchaEdgeDef {
220 from: t.id.clone(),
221 to: format!("{}-validate", t.id),
222 });
223 }
224
225 for dep in &t.deps {
230 let effective_dep = completion_id
231 .get(dep)
232 .cloned()
233 .unwrap_or_else(|| dep.clone());
234 edges.push(OrchaEdgeDef { from: effective_dep, to: t.id.clone() });
235 }
236 }
237
238 Ok(CompiledGraph { nodes, edges })
239}
240
241fn parse_section_body(section: RawSection) -> Result<ParsedTicket, String> {
242 let RawSection { id, type_tag, body_lines } = section;
243
244 let mut deps: Vec<String> = Vec::new();
245 let mut validate: Option<String> = None;
246 let mut prose_lines: Vec<String> = Vec::new();
247
248 for line in &body_lines {
249 let trimmed = line.trim();
250
251 if trimmed.starts_with("<!--") || trimmed.starts_with("//") {
253 continue;
254 }
255
256 if let Some(rest) = trimmed
258 .strip_prefix("blocked_by:")
259 .or_else(|| trimmed.strip_prefix("blocked-by:"))
260 {
261 let list = rest.trim().trim_start_matches('[').trim_end_matches(']');
262 deps = list
263 .split(',')
264 .map(|s| s.trim().to_string())
265 .filter(|s| !s.is_empty())
266 .collect();
267 continue;
268 }
269
270 if let Some(cmd) = trimmed.strip_prefix("validate:") {
272 let cmd = cmd.trim().to_string();
273 if !cmd.is_empty() {
274 validate = Some(cmd);
275 }
276 continue;
277 }
278
279 if trimmed.starts_with("unlocks:") {
281 continue;
282 }
283
284 prose_lines.push(line.to_string());
285 }
286
287 let start = prose_lines.iter().position(|l| !l.trim().is_empty()).unwrap_or(prose_lines.len());
289 let end = prose_lines
290 .iter()
291 .rposition(|l| !l.trim().is_empty())
292 .map(|i| i + 1)
293 .unwrap_or(0);
294 let body = if start < end { prose_lines[start..end].join("\n") } else { String::new() };
295
296 let (task, command) = match type_tag.as_str() {
297 "prog" => (None, if body.is_empty() { None } else { Some(body) }),
298 _ => (if body.is_empty() { None } else { Some(body) }, None),
299 };
300
301 Ok(ParsedTicket { id, type_tag, deps, task, command, validate })
302}
303
304#[cfg(test)]
307mod tests {
308 use super::*;
309
310 #[test]
311 fn test_basic_agent_ticket() {
312 let input = "\
313# T01: Write the parser [agent]
314
315Implement a JSON webhook parser with typed errors.
316";
317 let g = compile_tickets(input).unwrap();
318 assert_eq!(g.nodes.len(), 1);
319 assert_eq!(g.edges.len(), 0);
320 match &g.nodes[0].spec {
321 OrchaNodeSpec::Task { task, .. } => assert!(task.contains("JSON webhook parser")),
322 _ => panic!("wrong spec"),
323 }
324 }
325
326 #[test]
327 fn test_preamble_is_skipped() {
328 let input = "\
329# My Epic Plan
330
331This is an overview document with context and background.
332
333## Architecture
334
335Some architecture notes here.
336
337# T01: First ticket [agent]
338
339Do the thing.
340";
341 let g = compile_tickets(input).unwrap();
342 assert_eq!(g.nodes.len(), 1);
344 assert_eq!(g.nodes[0].id, "T01");
345 }
346
347 #[test]
348 fn test_validate_sibling() {
349 let input = "\
350# T01: Write it [agent]
351
352Implement the feature.
353
354blocked_by: []
355validate: cargo test -- feature_tests
356";
357 let g = compile_tickets(input).unwrap();
358 assert_eq!(g.nodes.len(), 2);
359 assert!(g.nodes.iter().any(|n| n.id == "T01"));
360 assert!(g.nodes.iter().any(|n| n.id == "T01-validate"));
361 assert_eq!(g.edges.len(), 1);
362 assert_eq!(g.edges[0], OrchaEdgeDef { from: "T01".into(), to: "T01-validate".into() });
363 }
364
365 #[test]
366 fn test_dep_rewriting_through_validate() {
367 let input = "\
368# T01: First [agent]
369
370Do the first thing.
371
372validate: cargo test -- t01
373
374# T02: Second [agent]
375
376Do the second thing.
377
378blocked_by: [T01]
379";
380 let g = compile_tickets(input).unwrap();
381 assert_eq!(g.nodes.len(), 3);
383
384 let edge_pairs: Vec<(&str, &str)> =
385 g.edges.iter().map(|e| (e.from.as_str(), e.to.as_str())).collect();
386
387 assert!(edge_pairs.contains(&("T01", "T01-validate")));
389 assert!(edge_pairs.contains(&("T01-validate", "T02")));
391 assert!(!edge_pairs.contains(&("T01", "T02")));
393 }
394
395 #[test]
396 fn test_prog_ticket() {
397 let input = "\
398# validate-build [prog]
399
400blocked_by: [T01]
401cargo build --release 2>&1 | grep -c '^error' | xargs test 0 -eq
402";
403 let g = compile_tickets(input).unwrap();
404 assert_eq!(g.nodes.len(), 1);
405 match &g.nodes[0].spec {
406 OrchaNodeSpec::Validate { command, .. } => {
407 assert!(command.contains("cargo build"));
408 }
409 _ => panic!("wrong spec"),
410 }
411 }
412
413 #[test]
414 fn test_subsections_become_prose() {
415 let input = "\
416# UX-4: Move ir.json [agent]
417
418blocked_by: [UX-2]
419unlocks: [UX-9]
420
421## Problem
422
423The ir.json file is written into the wrong place.
424
425## Acceptance Criteria
426
427- Output dir contains only TypeScript files
428- ir.json lives in cache
429";
430 let g = compile_tickets(input).unwrap();
431 assert_eq!(g.nodes.len(), 1);
432 match &g.nodes[0].spec {
433 OrchaNodeSpec::Task { task, .. } => {
434 assert!(task.contains("## Problem"));
435 assert!(task.contains("## Acceptance Criteria"));
436 assert!(task.contains("ir.json"));
437 assert!(!task.contains("blocked_by"));
439 assert!(!task.contains("unlocks"));
440 }
441 _ => panic!("wrong spec"),
442 }
443 assert_eq!(g.edges.len(), 1);
445 assert_eq!(g.edges[0].from, "UX-2");
446 assert_eq!(g.edges[0].to, "UX-4");
447 }
448
449 #[test]
450 fn test_synthesize_type() {
451 let input = "\
452# T03: Synthesize report [agent/synthesize]
453
454Review all prior work and write a final integration report.
455";
456 let g = compile_tickets(input).unwrap();
457 assert!(matches!(&g.nodes[0].spec, OrchaNodeSpec::Synthesize { .. }));
458 }
459
460 #[test]
461 fn test_multiple_deps() {
462 let input = "\
463# A [agent]
464Task A.
465
466# B [agent]
467Task B.
468
469# C [agent]
470Task C.
471
472blocked_by: [A, B]
473";
474 let g = compile_tickets(input).unwrap();
475 let edge_pairs: Vec<(&str, &str)> =
476 g.edges.iter().map(|e| (e.from.as_str(), e.to.as_str())).collect();
477 assert!(edge_pairs.contains(&("A", "C")));
478 assert!(edge_pairs.contains(&("B", "C")));
479 }
480}
481
482impl PartialEq for OrchaEdgeDef {
483 fn eq(&self, other: &Self) -> bool {
484 self.from == other.from && self.to == other.to
485 }
486}