1use serde::{Deserialize, Serialize};
28use std::path::PathBuf;
29
30pub const SCHEMA: &str = "harness.goal.v1";
32
33#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
35#[serde(rename_all = "snake_case")]
36pub enum PhaseStatus {
37 Pending,
38 Running,
39 Done,
40 Failed,
44}
45
46#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
48pub struct Phase {
49 pub title: String,
50 #[serde(default = "pending")]
51 pub status: PhaseStatus,
52 #[serde(default)]
54 pub note: String,
55}
56
57fn pending() -> PhaseStatus {
58 PhaseStatus::Pending
59}
60
61impl Phase {
62 pub fn new(title: impl Into<String>) -> Self {
63 Self {
64 title: title.into(),
65 status: PhaseStatus::Pending,
66 note: String::new(),
67 }
68 }
69}
70
71#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
73pub struct Goal {
74 pub schema: String,
75 pub id: String,
77 pub objective: String,
81 #[serde(default)]
83 pub context: Vec<PathBuf>,
84 #[serde(default)]
87 pub posture: String,
88 #[serde(default)]
91 pub invariants: Vec<String>,
92 #[serde(default)]
93 pub phases: Vec<Phase>,
94 #[serde(default)]
98 pub verify: String,
99 #[serde(default)]
100 pub created_ms: i64,
101 #[serde(default)]
102 pub updated_ms: i64,
103}
104
105impl Goal {
106 pub fn new(id: impl Into<String>, objective: impl Into<String>, now_ms: i64) -> Self {
107 Self {
108 schema: SCHEMA.into(),
109 id: id.into(),
110 objective: objective.into(),
111 context: Vec::new(),
112 posture: String::new(),
113 invariants: Vec::new(),
114 phases: Vec::new(),
115 verify: String::new(),
116 created_ms: now_ms,
117 updated_ms: now_ms,
118 }
119 }
120
121 pub fn with_context<I, P>(mut self, paths: I) -> Self
122 where
123 I: IntoIterator<Item = P>,
124 P: Into<PathBuf>,
125 {
126 self.context = paths.into_iter().map(Into::into).collect();
127 self
128 }
129
130 pub fn with_posture(mut self, p: impl Into<String>) -> Self {
131 self.posture = p.into();
132 self
133 }
134
135 pub fn with_invariants<I, S>(mut self, items: I) -> Self
136 where
137 I: IntoIterator<Item = S>,
138 S: Into<String>,
139 {
140 self.invariants = items.into_iter().map(Into::into).collect();
141 self
142 }
143
144 pub fn with_phases<I, S>(mut self, titles: I) -> Self
145 where
146 I: IntoIterator<Item = S>,
147 S: Into<String>,
148 {
149 self.phases = titles.into_iter().map(|t| Phase::new(t)).collect();
150 self
151 }
152
153 pub fn with_verify(mut self, v: impl Into<String>) -> Self {
154 self.verify = v.into();
155 self
156 }
157
158 pub fn current(&self) -> Option<(usize, &Phase)> {
164 self.phases
165 .iter()
166 .enumerate()
167 .find(|(_, p)| p.status == PhaseStatus::Running)
168 .or_else(|| {
169 self.phases
170 .iter()
171 .enumerate()
172 .find(|(_, p)| matches!(p.status, PhaseStatus::Pending | PhaseStatus::Failed))
173 })
174 }
175
176 pub fn complete(&self) -> bool {
179 !self.phases.is_empty() && self.phases.iter().all(|p| p.status == PhaseStatus::Done)
180 }
181
182 pub fn start_current(&mut self, now_ms: i64) -> Option<usize> {
184 let i = self.current()?.0;
185 self.phases[i].status = PhaseStatus::Running;
186 self.updated_ms = now_ms;
187 Some(i)
188 }
189
190 pub fn finish(&mut self, i: usize, note: impl Into<String>, now_ms: i64) {
191 if let Some(p) = self.phases.get_mut(i) {
192 p.status = PhaseStatus::Done;
193 p.note = note.into();
194 self.updated_ms = now_ms;
195 }
196 }
197
198 pub fn fail(&mut self, i: usize, why: impl Into<String>, now_ms: i64) {
199 if let Some(p) = self.phases.get_mut(i) {
200 p.status = PhaseStatus::Failed;
201 p.note = why.into();
202 self.updated_ms = now_ms;
203 }
204 }
205
206 pub fn brief(&self) -> String {
212 let mut s = format!("# Objective\n{}\n", self.objective.trim());
213
214 if !self.posture.is_empty() {
215 s.push_str(&format!("\n# How to work\n{}\n", self.posture.trim()));
216 }
217 if !self.context.is_empty() {
218 s.push_str("\n# Read first\n");
219 for p in &self.context {
220 s.push_str(&format!("- {}\n", p.display()));
221 }
222 }
223 if !self.invariants.is_empty() {
224 s.push_str("\n# Do not change\n");
225 for i in &self.invariants {
226 s.push_str(&format!("- {i}\n"));
227 }
228 }
229 if !self.phases.is_empty() {
230 let done = self
231 .phases
232 .iter()
233 .filter(|p| p.status == PhaseStatus::Done)
234 .count();
235 s.push_str(&format!(
236 "\n# Phase {} of {}\n",
237 done + 1,
238 self.phases.len()
239 ));
240 match self.current() {
241 Some((_, p)) => {
242 s.push_str(&format!("{}\n", p.title));
243 if p.status == PhaseStatus::Failed && !p.note.is_empty() {
246 s.push_str(&format!(
247 "\nThis phase was attempted and did not hold: {}\n\
248 Address that before anything else.\n",
249 p.note
250 ));
251 }
252 }
253 None => s.push_str("All phases are done.\n"),
254 }
255 }
256 if !self.verify.is_empty() {
257 s.push_str(&format!("\n# Done when\n{}\n", self.verify.trim()));
258 }
259 s
260 }
261}
262
263pub struct GoalStore {
269 dir: PathBuf,
270}
271
272impl GoalStore {
273 pub fn open(dir: impl Into<PathBuf>) -> std::io::Result<Self> {
274 let dir = dir.into();
275 std::fs::create_dir_all(&dir)?;
276 Ok(Self { dir })
277 }
278
279 fn path(&self, id: &str) -> PathBuf {
280 let safe: String = id
284 .chars()
285 .map(|c| {
286 if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
287 c
288 } else {
289 '_'
290 }
291 })
292 .collect();
293 self.dir.join(format!("{safe}.json"))
294 }
295
296 pub fn save(&self, g: &Goal) -> std::io::Result<()> {
297 let p = self.path(&g.id);
300 let tmp = p.with_extension("json.tmp");
301 std::fs::write(&tmp, serde_json::to_string_pretty(g).unwrap_or_default())?;
302 std::fs::rename(&tmp, &p)
303 }
304
305 pub fn load(&self, id: &str) -> std::io::Result<Goal> {
306 let s = std::fs::read_to_string(self.path(id))?;
307 serde_json::from_str(&s).map_err(std::io::Error::other)
308 }
309
310 pub fn unfinished(&self) -> Vec<Goal> {
313 let mut out: Vec<Goal> = std::fs::read_dir(&self.dir)
314 .into_iter()
315 .flatten()
316 .flatten()
317 .filter(|e| e.path().extension().is_some_and(|x| x == "json"))
318 .filter_map(|e| std::fs::read_to_string(e.path()).ok())
319 .filter_map(|s| serde_json::from_str::<Goal>(&s).ok())
320 .filter(|g| !g.complete())
321 .collect();
322 out.sort_by_key(|g| g.created_ms);
323 out
324 }
325}
326
327#[cfg(test)]
328mod tests {
329 use super::*;
330
331 fn goal() -> Goal {
332 Goal::new("g1", "Port the deploy from Netlify to Azure", 1000)
333 .with_context(["docs/deploy.md", "infra/"])
334 .with_posture("Cautious. Prefer reversible steps.")
335 .with_invariants(["the public API shape", "the database schema"])
336 .with_phases(["stand up the Azure app", "move the DNS", "retire Netlify"])
337 .with_verify("the five gold queries return the same answers as production")
338 }
339
340 fn store() -> (GoalStore, PathBuf) {
341 let d = std::env::temp_dir().join(format!(
342 "harness-goal-{}-{:?}",
343 std::process::id(),
344 std::thread::current().id()
345 ));
346 let _ = std::fs::remove_dir_all(&d);
347 (GoalStore::open(&d).unwrap(), d)
348 }
349
350 #[test]
351 fn a_goal_survives_the_process_that_started_it() {
352 let (s, d) = store();
354 let mut g = goal();
355 let i = g.start_current(1001).unwrap();
356 g.finish(i, "app service created", 1002);
357 s.save(&g).unwrap();
358
359 let back = s.load("g1").unwrap();
360 assert_eq!(back, g);
361 assert_eq!(back.current().unwrap().1.title, "move the DNS");
362 let _ = std::fs::remove_dir_all(&d);
363 }
364
365 #[test]
366 fn a_failed_phase_is_retried_not_skipped() {
367 let mut g = goal();
368 let i = g.start_current(1).unwrap();
369 g.fail(i, "the app service quota was exhausted", 2);
370 let (j, p) = g.current().unwrap();
371 assert_eq!(j, i, "resume must land back on the phase that failed");
372 assert_eq!(p.status, PhaseStatus::Failed);
373 let b = g.brief();
375 assert!(b.contains("did not hold"), "{b}");
376 assert!(b.contains("quota was exhausted"), "{b}");
377 }
378
379 #[test]
380 fn the_brief_restates_the_objective_at_every_phase() {
381 let mut g = goal();
383 for k in 0..2 {
384 let i = g.start_current(k).unwrap();
385 g.finish(i, "ok", k);
386 }
387 let b = g.brief();
388 assert!(b.contains("Port the deploy from Netlify to Azure"));
389 assert!(b.contains("Phase 3 of 3"));
390 assert!(b.contains("retire Netlify"));
391 assert!(b.contains("the database schema"), "invariants must carry");
392 }
393
394 #[test]
395 fn context_is_listed_as_paths_not_pasted_in() {
396 let g = goal();
397 let b = g.brief();
398 assert!(b.contains("docs/deploy.md"));
399 assert!(b.len() < 800, "the brief grew into a prompt:\n{b}");
402 }
403
404 #[test]
405 fn a_goal_with_no_phases_is_never_complete() {
406 let g = Goal::new("empty", "do the thing", 0);
408 assert!(!g.complete());
409 assert!(g.current().is_none());
410 }
411
412 #[test]
413 fn completion_requires_every_phase() {
414 let mut g = goal();
415 while let Some(i) = g.start_current(9) {
416 assert!(!g.complete());
417 g.finish(i, "ok", 9);
418 }
419 assert!(g.complete());
420 assert!(g.current().is_none());
421 }
422
423 #[test]
424 fn unfinished_lists_only_what_is_still_owed() {
425 let (s, d) = store();
426 let mut a = Goal::new("a", "first", 10).with_phases(["one"]);
427 let b = Goal::new("b", "second", 20).with_phases(["one"]);
428 let i = a.start_current(11).unwrap();
429 a.finish(i, "done", 12);
430 s.save(&a).unwrap();
431 s.save(&b).unwrap();
432
433 let left = s.unfinished();
434 assert_eq!(left.len(), 1);
435 assert_eq!(left[0].id, "b");
436 let _ = std::fs::remove_dir_all(&d);
437 }
438
439 #[test]
440 fn a_crafted_id_cannot_escape_the_store_directory() {
441 let (s, d) = store();
442 let g = Goal::new("../../etc/passwd", "nope", 0);
443 s.save(&g).unwrap();
444 assert!(!std::path::Path::new("/etc/passwd.json").exists());
445 let files: Vec<_> = std::fs::read_dir(&d).unwrap().flatten().collect();
446 assert_eq!(files.len(), 1);
447 assert!(!files[0].file_name().to_string_lossy().contains(".."));
448 let _ = std::fs::remove_dir_all(&d);
449 }
450
451 #[test]
452 fn an_older_goal_file_without_the_newer_fields_still_loads() {
453 let json = r#"{"schema":"harness.goal.v1","id":"old","objective":"ship"}"#;
455 let g: Goal = serde_json::from_str(json).unwrap();
456 assert_eq!(g.objective, "ship");
457 assert!(g.phases.is_empty() && g.invariants.is_empty());
458 }
459}