1use 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
17pub 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#[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#[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#[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#[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#[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#[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#[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#[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 #[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 #[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 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#[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
261pub 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
305pub 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
310pub 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
316pub 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#[derive(Debug, Clone)]
329pub struct RunRoot {
330 issue_root: IssueRoot,
331 run_id: String,
332}
333
334impl RunRoot {
335 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 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}