Skip to main content

plan_issue/
dispatch_record.rs

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