Skip to main content

plan_issue/tracking/
run_state.rs

1//! Typed local run-state for `plan-issue tracking`.
2//!
3//! Schema identifier: `plan-issue.execution-run.v1`. File layout under the
4//! existing state-dir contract is documented in
5//! `docs/source/plan-issue-redesign/plan-tracking-issue-run-state-controller-v1.md`.
6
7use std::collections::BTreeMap;
8use std::fs;
9use std::io;
10use std::path::{Path, PathBuf};
11
12use serde::{Deserialize, Serialize};
13use serde_json::Value;
14
15use crate::runtime_layout::{self, IssueRoot};
16
17/// Stable schema identifier embedded in every `run-state.json` document.
18pub const RUN_STATE_SCHEMA: &str = "plan-issue.execution-run.v1";
19
20const RUNS_DIR: &str = "runs";
21const RUN_STATE_FILE: &str = "run-state.json";
22const EVENTS_FILE: &str = "events.jsonl";
23const INPUTS_DIR: &str = "inputs";
24const RENDERED_DIR: &str = "rendered";
25const ARTIFACTS_DIR: &str = "artifacts";
26
27/// High-level execution phase. Maps onto FSM states but stays human-friendly.
28#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
29#[serde(rename_all = "snake_case")]
30pub enum RunPhase {
31    Initial,
32    Implementing,
33    Validating,
34    Reviewing,
35    Blocked,
36    ReadyForClose,
37    Closed,
38}
39
40impl RunPhase {
41    pub fn as_str(self) -> &'static str {
42        match self {
43            Self::Initial => "initial",
44            Self::Implementing => "implementing",
45            Self::Validating => "validating",
46            Self::Reviewing => "reviewing",
47            Self::Blocked => "blocked",
48            Self::ReadyForClose => "ready_for_close",
49            Self::Closed => "closed",
50        }
51    }
52}
53
54/// Selected scope (sprint, task, title) recorded in the run.
55#[derive(Debug, Clone, Default, Serialize, Deserialize)]
56pub struct SelectedScope {
57    #[serde(skip_serializing_if = "Option::is_none")]
58    pub sprint: Option<i32>,
59    #[serde(skip_serializing_if = "Option::is_none")]
60    pub task: Option<String>,
61    #[serde(skip_serializing_if = "Option::is_none")]
62    pub title: Option<String>,
63}
64
65/// Linked PR reference captured in the run state.
66#[derive(Debug, Clone, Serialize, Deserialize)]
67pub struct LinkedPr {
68    #[serde(rename = "ref")]
69    pub r#ref: String,
70    #[serde(default, skip_serializing_if = "Option::is_none")]
71    pub url: Option<String>,
72    #[serde(default, skip_serializing_if = "Option::is_none")]
73    pub status: Option<String>,
74}
75
76/// Compact validation summary captured in run state.
77#[derive(Debug, Clone, Serialize, Deserialize)]
78pub struct ValidationSummary {
79    pub overall: String,
80    #[serde(default, skip_serializing_if = "Vec::is_empty")]
81    pub commands: Vec<ValidationCommandRow>,
82    #[serde(default, skip_serializing_if = "Option::is_none")]
83    pub waiver: Option<String>,
84    #[serde(default, skip_serializing_if = "Option::is_none")]
85    pub evidence_path: Option<PathBuf>,
86}
87
88#[derive(Debug, Clone, Serialize, Deserialize)]
89pub struct ValidationCommandRow {
90    pub command: String,
91    pub status: String,
92    #[serde(default, skip_serializing_if = "Option::is_none")]
93    pub evidence: Option<String>,
94}
95
96/// Compact review summary captured in run state.
97#[derive(Debug, Clone, Serialize, Deserialize)]
98pub struct ReviewSummary {
99    pub decision: String,
100    #[serde(default, skip_serializing_if = "Vec::is_empty")]
101    pub lenses: Vec<String>,
102    #[serde(default, skip_serializing_if = "Vec::is_empty")]
103    pub findings: Vec<ReviewFindingSummary>,
104    #[serde(default, skip_serializing_if = "Vec::is_empty")]
105    pub findings_disposition: Vec<String>,
106    #[serde(default, skip_serializing_if = "Option::is_none")]
107    pub evidence: Option<String>,
108}
109
110#[derive(Debug, Clone, Serialize, Deserialize)]
111pub struct ReviewFindingSummary {
112    pub id: String,
113    pub severity: String,
114    pub disposition: String,
115    pub summary: String,
116}
117
118/// Captured reconciliation snapshot from a prior FSM evaluation.
119#[derive(Debug, Clone, Default, Serialize, Deserialize)]
120pub struct LastReconciled {
121    #[serde(default, skip_serializing_if = "Option::is_none")]
122    pub at: Option<String>,
123    #[serde(default, skip_serializing_if = "Option::is_none")]
124    pub fsm_state: Option<String>,
125    #[serde(default, skip_serializing_if = "Option::is_none")]
126    pub dashboard_status: Option<String>,
127    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
128    pub latest_comments: BTreeMap<String, String>,
129}
130
131/// Pending transition the controller will perform next.
132#[derive(Debug, Clone, Serialize, Deserialize)]
133pub struct PendingTransition {
134    pub kind: String,
135    #[serde(default, skip_serializing_if = "Option::is_none")]
136    pub reason: Option<String>,
137}
138
139/// Typed run state document persisted as `run-state.json`.
140#[derive(Debug, Clone, Serialize, Deserialize)]
141pub struct ExecutionRun {
142    pub schema: String,
143    pub run_id: String,
144    pub repo: String,
145    pub issue: u64,
146    pub profile: String,
147    pub phase: RunPhase,
148    pub created_at: String,
149    pub updated_at: String,
150    #[serde(default, skip_serializing_if = "Option::is_none")]
151    pub bundle: Option<PathBuf>,
152    #[serde(default, skip_serializing_if = "Option::is_none")]
153    pub execution_state_file: Option<PathBuf>,
154    #[serde(default, skip_serializing_if = "Option::is_none")]
155    pub selected_scope: Option<SelectedScope>,
156    #[serde(default, skip_serializing_if = "Option::is_none")]
157    pub branch: Option<String>,
158    #[serde(default, skip_serializing_if = "Option::is_none")]
159    pub worktree: Option<PathBuf>,
160    #[serde(default, skip_serializing_if = "Option::is_none")]
161    pub pr: Option<LinkedPr>,
162    /// Every linked PR observed across the run, accumulated in first-seen
163    /// order (dedup by ref). `pr` stays the current / most-recent lane PR;
164    /// `linked_prs` lets a dispatch dashboard name *every* lane PR instead of
165    /// only the latest. `#[serde(default)]` keeps older run states readable.
166    #[serde(default, skip_serializing_if = "Vec::is_empty")]
167    pub linked_prs: Vec<LinkedPr>,
168    #[serde(default, skip_serializing_if = "Option::is_none")]
169    pub last_reconciled: Option<LastReconciled>,
170    #[serde(default, skip_serializing_if = "Option::is_none")]
171    pub pending_transition: Option<PendingTransition>,
172    #[serde(default, skip_serializing_if = "Option::is_none")]
173    pub validation: Option<ValidationSummary>,
174    #[serde(default, skip_serializing_if = "Option::is_none")]
175    pub review: Option<ReviewSummary>,
176    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
177    pub artifacts: BTreeMap<String, String>,
178    #[serde(default, skip_serializing_if = "Vec::is_empty")]
179    pub notes: Vec<String>,
180    /// Free-form extra fields kept around so future schema additions do not
181    /// silently drop on read/write round-trip.
182    #[serde(default, skip_serializing_if = "Option::is_none")]
183    pub extra: Option<Value>,
184}
185
186impl ExecutionRun {
187    pub fn new(
188        run_id: impl Into<String>,
189        repo: impl Into<String>,
190        issue: u64,
191        profile: impl Into<String>,
192        phase: RunPhase,
193        at: impl Into<String>,
194    ) -> Self {
195        let at = at.into();
196        Self {
197            schema: RUN_STATE_SCHEMA.to_string(),
198            run_id: run_id.into(),
199            repo: repo.into(),
200            issue,
201            profile: profile.into(),
202            phase,
203            created_at: at.clone(),
204            updated_at: at,
205            bundle: None,
206            execution_state_file: None,
207            selected_scope: None,
208            branch: None,
209            worktree: None,
210            pr: None,
211            linked_prs: Vec::new(),
212            last_reconciled: None,
213            pending_transition: None,
214            validation: None,
215            review: None,
216            artifacts: BTreeMap::new(),
217            notes: Vec::new(),
218            extra: None,
219        }
220    }
221
222    /// Record the current linked PR and accumulate it into `linked_prs`
223    /// (dedup by ref, first-seen order). `pr` tracks the most-recent lane PR
224    /// for backward compatibility; `linked_prs` retains every lane PR so the
225    /// dispatch dashboard can name them all, not just the latest.
226    pub fn set_linked_pr(&mut self, pr: LinkedPr) {
227        if !self
228            .linked_prs
229            .iter()
230            .any(|existing| existing.r#ref == pr.r#ref)
231        {
232            self.linked_prs.push(pr.clone());
233        }
234        self.pr = Some(pr);
235    }
236}
237
238/// Validation errors emitted by [`parse_run_state`].
239#[derive(Debug, Clone, PartialEq, Eq)]
240pub enum RunStateError {
241    SchemaMismatch { actual: String },
242    MissingField(&'static str),
243    Malformed(String),
244}
245
246impl std::fmt::Display for RunStateError {
247    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
248        match self {
249            Self::SchemaMismatch { actual } => write!(
250                f,
251                "run-state.json schema mismatch: expected {RUN_STATE_SCHEMA}, got {actual}"
252            ),
253            Self::MissingField(name) => write!(f, "run-state.json missing required field `{name}`"),
254            Self::Malformed(msg) => write!(f, "run-state.json malformed: {msg}"),
255        }
256    }
257}
258
259impl std::error::Error for RunStateError {}
260
261/// Parse a `run-state.json` body and validate the schema id and required
262/// fields.
263pub fn parse_run_state(raw: &str) -> Result<ExecutionRun, RunStateError> {
264    let value: Value = serde_json::from_str(raw)
265        .map_err(|err| RunStateError::Malformed(format!("failed to parse JSON: {err}")))?;
266    let schema = value
267        .get("schema")
268        .and_then(Value::as_str)
269        .ok_or(RunStateError::MissingField("schema"))?;
270    if schema != RUN_STATE_SCHEMA {
271        return Err(RunStateError::SchemaMismatch {
272            actual: schema.to_string(),
273        });
274    }
275    for required in [
276        "run_id",
277        "repo",
278        "issue",
279        "profile",
280        "phase",
281        "created_at",
282        "updated_at",
283    ] {
284        if value.get(required).is_none() {
285            return Err(RunStateError::MissingField(name_for(required)));
286        }
287    }
288    serde_json::from_value::<ExecutionRun>(value)
289        .map_err(|err| RunStateError::Malformed(err.to_string()))
290}
291
292fn name_for(field: &str) -> &'static str {
293    match field {
294        "run_id" => "run_id",
295        "repo" => "repo",
296        "issue" => "issue",
297        "profile" => "profile",
298        "phase" => "phase",
299        "created_at" => "created_at",
300        "updated_at" => "updated_at",
301        _ => "unknown",
302    }
303}
304
305/// Serialize an [`ExecutionRun`] to canonical JSON.
306pub fn render_run_state(run: &ExecutionRun) -> Result<String, RunStateError> {
307    serde_json::to_string_pretty(run).map_err(|err| RunStateError::Malformed(err.to_string()))
308}
309
310/// Read `run-state.json` from disk.
311pub fn read_run_state(path: &Path) -> io::Result<ExecutionRun> {
312    let raw = fs::read_to_string(path)?;
313    parse_run_state(&raw).map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err))
314}
315
316/// Write `run-state.json` to disk. Creates parent directories as needed.
317pub fn write_run_state(path: &Path, run: &ExecutionRun) -> io::Result<()> {
318    if let Some(parent) = path.parent() {
319        runtime_layout::ensure_dir(parent)?;
320    }
321    let rendered =
322        render_run_state(run).map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err))?;
323    fs::write(path, rendered)
324}
325
326/// Issue-scoped run root rooted under the existing
327/// [`crate::runtime_layout::IssueRoot`] contract.
328#[derive(Debug, Clone)]
329pub struct RunRoot {
330    issue_root: IssueRoot,
331    run_id: String,
332}
333
334impl RunRoot {
335    /// Build the run root under `<issue-root>/runs/<run-id>/`.
336    pub fn new(
337        repo_slug: &str,
338        issue_number: u64,
339        run_id: impl Into<String>,
340    ) -> Result<Self, runtime_layout::RuntimeLayoutError> {
341        let issue_root = IssueRoot::new(repo_slug, issue_number)?;
342        Ok(Self {
343            issue_root,
344            run_id: run_id.into(),
345        })
346    }
347
348    pub fn issue_root(&self) -> &IssueRoot {
349        &self.issue_root
350    }
351
352    pub fn run_id(&self) -> &str {
353        &self.run_id
354    }
355
356    pub fn root(&self) -> PathBuf {
357        self.issue_root.root().join(RUNS_DIR).join(&self.run_id)
358    }
359
360    pub fn run_state_path(&self) -> PathBuf {
361        self.root().join(RUN_STATE_FILE)
362    }
363
364    pub fn events_path(&self) -> PathBuf {
365        self.root().join(EVENTS_FILE)
366    }
367
368    pub fn inputs_dir(&self) -> PathBuf {
369        self.root().join(INPUTS_DIR)
370    }
371
372    pub fn rendered_dir(&self) -> PathBuf {
373        self.root().join(RENDERED_DIR)
374    }
375
376    pub fn artifacts_dir(&self) -> PathBuf {
377        self.root().join(ARTIFACTS_DIR)
378    }
379
380    /// Ensure the full directory tree exists.
381    pub fn ensure_layout(&self) -> io::Result<()> {
382        runtime_layout::ensure_dir(&self.root())?;
383        runtime_layout::ensure_dir(&self.inputs_dir())?;
384        runtime_layout::ensure_dir(&self.rendered_dir())?;
385        runtime_layout::ensure_dir(&self.artifacts_dir())?;
386        Ok(())
387    }
388}
389
390#[cfg(test)]
391mod tests {
392    use super::*;
393    use serde_json::json;
394
395    #[test]
396    fn tracking_run_state_round_trips_required_and_recommended_fields() {
397        let mut run = ExecutionRun::new(
398            "20260526-150405-issue-123",
399            "owner/repo",
400            123,
401            "tracking",
402            RunPhase::Implementing,
403            "2026-05-26T15:04:05Z",
404        );
405        run.bundle = Some(PathBuf::from("docs/plans/example"));
406        run.execution_state_file = Some(PathBuf::from(
407            "docs/plans/example/example-execution-state.md",
408        ));
409        run.selected_scope = Some(SelectedScope {
410            sprint: Some(1),
411            task: Some("1.2".to_string()),
412            title: Some("visible lint".to_string()),
413        });
414        run.branch = Some("feat/x".to_string());
415        run.pr = Some(LinkedPr {
416            r#ref: "owner/repo#456".to_string(),
417            url: Some("https://example.com/pr/456".to_string()),
418            status: Some("open".to_string()),
419        });
420        run.validation = Some(ValidationSummary {
421            overall: "pass".to_string(),
422            commands: vec![ValidationCommandRow {
423                command: "cargo test".to_string(),
424                status: "pass".to_string(),
425                evidence: Some("log.txt".to_string()),
426            }],
427            waiver: None,
428            evidence_path: None,
429        });
430        let rendered = render_run_state(&run).expect("render");
431        let parsed = parse_run_state(&rendered).expect("parse");
432        assert_eq!(parsed.run_id, run.run_id);
433        assert_eq!(parsed.repo, run.repo);
434        assert_eq!(parsed.issue, run.issue);
435        assert_eq!(parsed.phase, run.phase);
436        assert_eq!(
437            parsed.selected_scope.as_ref().and_then(|s| s.task.clone()),
438            Some("1.2".to_string())
439        );
440        assert_eq!(
441            parsed.validation.as_ref().map(|v| v.overall.clone()),
442            Some("pass".to_string())
443        );
444    }
445
446    #[test]
447    fn tracking_run_state_rejects_wrong_schema() {
448        let raw = json!({
449            "schema": "wrong.schema.v1",
450            "run_id": "x",
451            "repo": "o/r",
452            "issue": 1,
453            "profile": "tracking",
454            "phase": "initial",
455            "created_at": "x",
456            "updated_at": "x"
457        })
458        .to_string();
459        match parse_run_state(&raw) {
460            Err(RunStateError::SchemaMismatch { actual }) => assert_eq!(actual, "wrong.schema.v1"),
461            other => panic!("expected SchemaMismatch, got {other:?}"),
462        }
463    }
464
465    #[test]
466    fn tracking_run_state_rejects_missing_required_field() {
467        let raw = json!({
468            "schema": RUN_STATE_SCHEMA,
469            "run_id": "x",
470            "issue": 1,
471            "profile": "tracking",
472            "phase": "initial",
473            "created_at": "x",
474            "updated_at": "x"
475        })
476        .to_string();
477        match parse_run_state(&raw) {
478            Err(RunStateError::MissingField("repo")) => {}
479            other => panic!("expected MissingField(repo), got {other:?}"),
480        }
481    }
482
483    #[test]
484    fn tracking_run_state_runroot_layout_under_state_dir() {
485        let runroot = RunRoot::new("owner__repo", 123, "20260526-run-1").expect("runroot");
486        let expected_root_suffix = "owner__repo/issue-123/runs/20260526-run-1";
487        assert!(
488            runroot
489                .root()
490                .to_string_lossy()
491                .ends_with(expected_root_suffix),
492            "runroot suffix drift: {}",
493            runroot.root().display()
494        );
495        assert!(
496            runroot
497                .run_state_path()
498                .to_string_lossy()
499                .ends_with("runs/20260526-run-1/run-state.json")
500        );
501        assert!(
502            runroot
503                .events_path()
504                .to_string_lossy()
505                .ends_with("runs/20260526-run-1/events.jsonl")
506        );
507    }
508}