plan_issue/
dispatch_record.rs1use std::path::Path;
12
13use serde::{Deserialize, Serialize};
14
15use nils_common::fs as common_fs;
16
17pub const WORKFLOW_ROLE_IMPLEMENTATION: &str = "implementation";
20
21#[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 pub worktree: String,
40 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: 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
87pub 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 "\"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}