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