Skip to main content

plan_issue/
dispatch_record.rs

1//! Per-task dispatch record JSON.
2//!
3//! Defined in `plan-issue-contract-v2.md` "Canonical Runtime Artifacts (v2)".
4//!
5//! The binary writes the nine required keys at sprint start. Optional adapter
6//! fields (`runtime_name`, `runtime_role`, `runtime_role_fallback_reason`)
7//! are intentionally **absent** — they belong to the active runtime adapter
8//! and are added post-emission by the wrapper / main-agent.
9
10use std::path::Path;
11
12use serde::{Deserialize, Serialize};
13
14use nils_common::fs as common_fs;
15
16/// `workflow_role` value emitted by `start-sprint` for every implementation
17/// task.
18pub const WORKFLOW_ROLE_IMPLEMENTATION: &str = "implementation";
19
20/// Stable, sorted JSON object written to `dispatch-<TASK_ID>.json`.
21///
22/// Field order matches the canonical contract.
23///
24/// **Deprecation note (Task 1.4)**: `worktree` is retained verbatim for
25/// backwards compatibility with existing v1 readers. New consumers should
26/// read `worktree_abs_path` instead — both fields carry the canonical
27/// absolute path under
28/// `<state-dir>/out/plan-issue-delivery/<slug>/issue-<N>/worktrees/`,
29/// but `worktree_abs_path` is the explicit, self-describing name and will
30/// remain stable across future refactors. `worktree` may eventually be
31/// removed in a v3 schema bump.
32#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
33pub struct DispatchRecord {
34    pub task_id: String,
35    pub task_prompt_path: String,
36    pub plan_snapshot_path: String,
37    /// Deprecated alias for `worktree_abs_path`; kept for v1 readers.
38    pub worktree: String,
39    /// Canonical absolute path of the assigned worktree (Task 1.4).
40    pub worktree_abs_path: String,
41    pub branch: String,
42    pub execution_mode: String,
43    pub pr_group: String,
44    pub base_branch: String,
45    pub workflow_role: String,
46}
47
48impl DispatchRecord {
49    #[allow(clippy::too_many_arguments)]
50    pub fn implementation(
51        task_id: impl Into<String>,
52        task_prompt_path: impl Into<String>,
53        plan_snapshot_path: impl Into<String>,
54        worktree_abs_path: impl Into<String>,
55        branch: impl Into<String>,
56        execution_mode: impl Into<String>,
57        pr_group: impl Into<String>,
58        base_branch: impl Into<String>,
59    ) -> Self {
60        let worktree_abs = worktree_abs_path.into();
61        Self {
62            task_id: task_id.into(),
63            task_prompt_path: task_prompt_path.into(),
64            plan_snapshot_path: plan_snapshot_path.into(),
65            // `worktree` was already the assigned absolute path post the
66            // canonical-runtime refactor; keep it identical to
67            // `worktree_abs_path` for v1 reader compatibility.
68            worktree: worktree_abs.clone(),
69            worktree_abs_path: worktree_abs,
70            branch: branch.into(),
71            execution_mode: execution_mode.into(),
72            pr_group: pr_group.into(),
73            base_branch: base_branch.into(),
74            workflow_role: WORKFLOW_ROLE_IMPLEMENTATION.to_string(),
75        }
76    }
77
78    pub fn to_pretty_json(&self) -> String {
79        let mut text = serde_json::to_string_pretty(self)
80            .expect("DispatchRecord serializes via serde_json without panicking");
81        text.push('\n');
82        text
83    }
84}
85
86/// Pretty-print + write a dispatch record to disk (creating parent dirs).
87pub fn write_dispatch_record(
88    path: &Path,
89    record: &DispatchRecord,
90) -> Result<(), common_fs::WriteTextError> {
91    common_fs::write_text(path, &record.to_pretty_json())
92}
93
94#[cfg(test)]
95mod tests {
96    use super::*;
97
98    fn sample() -> DispatchRecord {
99        DispatchRecord::implementation(
100            "S1T1",
101            "/state-dir/out/plan-issue-delivery/owner__repo/issue-7/sprint-1/prompts/S1T1.md",
102            "/state-dir/out/plan-issue-delivery/owner__repo/issue-7/plan/plan.snapshot.md",
103            "/state-dir/out/plan-issue-delivery/owner__repo/issue-7/worktrees/pr-isolated/S1T1",
104            "issue/s1-t1",
105            "pr-isolated",
106            "s1-t1",
107            "plan/issue-7",
108        )
109    }
110
111    #[test]
112    fn test_serializes_required_keys() {
113        let json = sample().to_pretty_json();
114        for key in [
115            "\"task_id\"",
116            "\"task_prompt_path\"",
117            "\"plan_snapshot_path\"",
118            "\"worktree\"",
119            // Task 1.4: explicit absolute path field for orchestrators.
120            "\"worktree_abs_path\"",
121            "\"branch\"",
122            "\"execution_mode\"",
123            "\"pr_group\"",
124            "\"base_branch\"",
125            "\"workflow_role\"",
126        ] {
127            assert!(
128                json.contains(key),
129                "json missing required key {key}: {json}"
130            );
131        }
132        for absent in [
133            "\"runtime_name\"",
134            "\"runtime_role\"",
135            "\"runtime_role_fallback_reason\"",
136            "\"subagent_init_snapshot_path\"",
137        ] {
138            assert!(
139                !json.contains(absent),
140                "json must not include adapter key {absent}: {json}"
141            );
142        }
143    }
144
145    #[test]
146    fn test_worktree_abs_path_mirrors_worktree() {
147        let record = sample();
148        assert_eq!(record.worktree_abs_path, record.worktree);
149        assert!(
150            record.worktree_abs_path.starts_with('/'),
151            "worktree_abs_path must be absolute: {}",
152            record.worktree_abs_path
153        );
154    }
155
156    #[test]
157    fn test_default_workflow_role_is_implementation() {
158        let record = sample();
159        assert_eq!(record.workflow_role, "implementation");
160        let parsed: serde_json::Value = serde_json::from_str(&record.to_pretty_json()).unwrap();
161        assert_eq!(parsed["workflow_role"], "implementation");
162    }
163
164    #[test]
165    fn test_round_trip_equals() {
166        let original = sample();
167        let json = serde_json::to_string(&original).expect("to_string");
168        let restored: DispatchRecord = serde_json::from_str(&json).expect("from_str");
169        assert_eq!(restored, original);
170    }
171
172    #[test]
173    fn write_dispatch_record_creates_parent_dirs() {
174        let tmp = tempfile::TempDir::new().expect("tempdir");
175        let path = tmp.path().join("manifests").join("dispatch-S1T1.json");
176        write_dispatch_record(&path, &sample()).expect("write");
177        let text = std::fs::read_to_string(&path).expect("read");
178        assert!(text.contains("\"task_id\": \"S1T1\""), "{text}");
179        assert!(text.ends_with('\n'), "trailing newline");
180    }
181}