//! Task-card parser — Slice 5. //! //! Watches `run_events` payloads for the INT-XX marker protocol //! (see skills/foundation/int-xx-marker-protocol.md) and materializes //! `mission_tasks` rows with typed state so the mission canvas Tasks //! tab can render a status timeline. //! //! Recognized markers (case-sensitive, on their own line): //! TASK: INT-NN — → status='created' //! WORK: INT-NN → status='working' //! HANDOFF: INT-NN → status='validating' //! TEST_PASS: INT-NN → status='validating' (unchanged if already validating+) //! TEST_FAIL: INT-NN — <reason> → status='failed' //! REVIEW_APPROVE: INT-NN → status='validating' //! REVIEW_BLOCK: INT-NN — <reason> → status='failed' //! COMPLETED: INT-NN → status='complete' //! //! Uses UPSERT keyed on (phase_id, external_id) so the same INT //! progressing through states updates a single row; state transitions //! are monotonic where reasonable (once complete, only failed can //! demote — but the loop-once semantics of INT items mean this rarely //! matters in practice). use serde_json::Value; use sqlx::PgPool; use sqlx::Row; use uuid::Uuid; use cm_db::repo::missions::UpsertTask; /// A parsed marker from a single run-event payload. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Marker { pub int_id: String, // "INT-05" pub kind: MarkerKind, pub title: Option<String>, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum MarkerKind { Task, PlanComplete, Work, Handoff, TestPass, TestFail, ReviewApprove, ReviewBlock, Completed, } impl MarkerKind { /// The status this marker implies. Preferences monotonic forward /// motion — the UPSERT layer may still overwrite prior states. pub fn status(&self) -> &'static str { match self { // The planner finished specifying; no work has started, so the item // is in the same state a fresh TASK leaves it in. A distinct status // would need a column value the UI does not render, and inventing // one to look complete is how a status stops meaning anything. MarkerKind::Task | MarkerKind::PlanComplete => "created", MarkerKind::Work => "working", MarkerKind::Handoff | MarkerKind::TestPass | MarkerKind::ReviewApprove => "validating", MarkerKind::TestFail | MarkerKind::ReviewBlock => "failed", MarkerKind::Completed => "complete", } } } /// Line-scanner over the raw text of a run's output. Cheap enough to /// re-run per event; the UPSERT layer collapses duplicates. pub fn parse(text: &str) -> Vec<Marker> { let mut out = Vec::new(); for line in text.lines() { let trimmed = line.trim(); if let Some(m) = parse_line(trimmed) { out.push(m); } } out } /// `INT-` followed by at least one digit and nothing else. fn is_int_id(id: &str) -> bool { match id.strip_prefix("INT-") { Some(rest) => !rest.is_empty() && rest.chars().all(|c| c.is_ascii_digit()), None => false, } } fn parse_line(line: &str) -> Option<Marker> { // Match `<KIND>: INT-NN` (rest optional). Strict on the colon and // the INT- prefix — anything laxer starts matching prose. let (kind_str, rest) = line.split_once(':')?; let kind = match kind_str.trim() { "TASK" => MarkerKind::Task, // Documented in `skills/foundation/int-xx-marker-protocol.md` since the // skill was written, and never implemented here. Agents that followed // the skill exactly emitted it and were silently ignored — observed on // a live mission, found by the Skill-Use measurement. Implemented // rather than removed from the skill: the planner needs a way to say // it is done specifying, and agents already emit this one. "PLAN_COMPLETE" => MarkerKind::PlanComplete, "WORK" => MarkerKind::Work, "HANDOFF" => MarkerKind::Handoff, "TEST_PASS" => MarkerKind::TestPass, "TEST_FAIL" => MarkerKind::TestFail, "REVIEW_APPROVE" => MarkerKind::ReviewApprove, "REVIEW_BLOCK" => MarkerKind::ReviewBlock, "COMPLETED" => MarkerKind::Completed, _ => return None, }; let rest = rest.trim(); let (id_tok, tail) = match rest.split_once(char::is_whitespace) { Some((a, b)) => (a, Some(b.trim())), None => (rest, None), }; let int_id = id_tok.trim_end_matches(&[',', ';', '.'][..]).to_string(); // Strictly `INT-<digits>`. `starts_with("INT-")` alone accepted range forms // like `INT-01..02`, which parse into an id matching no real item — so a // task card appeared for something that did not exist while the two items // it was meant to cover stayed open. Observed live. Rejecting is right: // the marker is ignored, which is visible, instead of creating a plausible // row, which is not. if !is_int_id(&int_id) { return None; } // Title: after the id + any of ` — / – / - ` separators let title = tail.and_then(|t| { let t = t.trim_start_matches(['—', '–', '-', ':'].as_slice()).trim(); if t.is_empty() { None } else { Some(t.to_string()) } }); Some(Marker { int_id, kind, title, }) } /// Scan every event of a topology_run that has a mission_id set; /// parse markers out of the event payload's `text` / `output` fields; /// UPSERT each into `mission_tasks`. /// /// Idempotent: re-running against the same run's events collapses /// into the current-truth state (last marker per INT wins per pass). pub async fn apply_for_run(pool: &PgPool, run_id: Uuid) -> Result<usize, String> { // Load the run + its mission binding. Bail cheaply if no mission. let run = sqlx::query( "SELECT mission_id, mission_phase_id FROM topology_runs WHERE id = $1", ) .bind(run_id) .fetch_optional(pool) .await .map_err(|e| format!("load run: {e}"))?; let Some(run) = run else { return Ok(0); }; let mission_id: Option<Uuid> = run.try_get("mission_id").ok().flatten(); let phase_id: Option<Uuid> = run.try_get("mission_phase_id").ok().flatten(); let (Some(mission_id), Some(phase_id)) = (mission_id, phase_id) else { return Ok(0); }; // Read event payloads in order. let rows = sqlx::query( "SELECT payload FROM run_events WHERE run_id = $1 ORDER BY seq ASC", ) .bind(run_id) .fetch_all(pool) .await .map_err(|e| format!("load events: {e}"))?; let mut all_markers: Vec<Marker> = Vec::new(); for r in rows { let payload: Value = r.get("payload"); for text in extract_text_fields(&payload) { all_markers.extend(parse(&text)); } } let mut applied = 0usize; for m in &all_markers { let title = m.title.clone().unwrap_or_else(|| m.int_id.clone()); cm_db::repo::missions::upsert_task( pool, UpsertTask { mission_id, phase_id, external_id: &m.int_id, title: &title, assigned_agent_id: None, status: m.kind.status(), run_id: Some(run_id), }, ) .await .map_err(|e| format!("upsert_task {}: {e}", m.int_id))?; applied += 1; } Ok(applied) } /// Walk a payload's JSON tree and yield every string field named /// `text`, `output`, `content`, or `message`. Handles the shapes we /// see across ZeroClaw events (chunk / tool_result / done / final). fn extract_text_fields(v: &Value) -> Vec<String> { fn walk(v: &Value, out: &mut Vec<String>) { match v { Value::Object(map) => { for (k, child) in map { if matches!(k.as_str(), "text" | "output" | "content" | "message") { if let Value::String(s) = child { out.push(s.clone()); } } walk(child, out); } } Value::Array(items) => { for item in items { walk(item, out); } } _ => {} } } let mut out = Vec::new(); walk(v, &mut out); out } #[cfg(test)] mod tests { use super::*; #[test] fn parses_completed_marker() { let out = parse("COMPLETED: INT-05"); assert_eq!(out.len(), 1); assert_eq!(out[0].int_id, "INT-05"); assert_eq!(out[0].kind, MarkerKind::Completed); } #[test] fn parses_task_with_title() { let out = parse("TASK: INT-12 — wire the loader"); assert_eq!(out[0].kind, MarkerKind::Task); assert_eq!(out[0].title.as_deref(), Some("wire the loader")); } #[test] fn parses_test_fail_with_reason() { let out = parse("TEST_FAIL: INT-03 — flake in async_setup"); assert_eq!(out[0].kind, MarkerKind::TestFail); assert_eq!(out[0].title.as_deref(), Some("flake in async_setup")); } #[test] fn ignores_marker_inside_prose() { // Not on its own line + not starting with the exact kind: let out = parse("we should not TASK: INT-05 like this"); assert!(out.is_empty()); } #[test] fn scans_multiline_batch() { let src = "TASK: INT-01 — foo\nWORK: INT-01\nother text\nCOMPLETED: INT-01\n"; let out = parse(src); assert_eq!(out.len(), 3); assert_eq!(out[2].kind, MarkerKind::Completed); } }