1use serde::{Deserialize, Serialize};
8use std::path::{Path, PathBuf};
9
10#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
12pub struct CronInstructions {
13 pub project: String,
15 pub phase: u32,
17 pub status: String,
19 pub retry_after: String,
21 pub resume: ResumeCommand,
23 pub hermes_cron: HermesCronJob,
25}
26
27#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
29pub struct ResumeCommand {
30 pub command: String,
32 pub args: Vec<String>,
34}
35
36#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
38pub struct HermesCronJob {
39 pub schedule: String,
41 pub name: String,
43 pub command: String,
45 pub once: bool,
47}
48
49#[derive(Debug, thiserror::Error)]
51pub enum ShipError {
52 #[error("ship I/O failed: {0}")]
54 Io(#[from] std::io::Error),
55 #[error("ship JSON failed: {0}")]
57 Json(#[from] serde_json::Error),
58 #[error("no last-ship record found — nothing to confirm or reject")]
60 Missing,
61}
62
63pub fn cron_instructions_path(project_root: &Path, phase: u32) -> PathBuf {
67 project_root
68 .join(".devflow")
69 .join(format!("cron-instructions-{phase:02}.json"))
70}
71
72pub(crate) fn legacy_cron_instructions_path(project_root: &Path) -> PathBuf {
75 project_root.join(".devflow").join("cron-instructions.json")
76}
77
78pub fn write_cron_instructions(
80 project_root: &Path,
81 instructions: &CronInstructions,
82) -> Result<(), ShipError> {
83 let path = cron_instructions_path(project_root, instructions.phase);
84 if let Some(parent) = path.parent() {
85 std::fs::create_dir_all(parent)?;
86 }
87 std::fs::write(&path, serde_json::to_string_pretty(instructions)?)?;
88 Ok(())
89}
90
91pub fn load_cron_instructions(
94 project_root: &Path,
95 phase: u32,
96) -> Result<CronInstructions, ShipError> {
97 let path = cron_instructions_path(project_root, phase);
98 if path.exists() {
99 return Ok(serde_json::from_str(&std::fs::read_to_string(&path)?)?);
100 }
101 let legacy = legacy_cron_instructions_path(project_root);
102 if legacy.exists() {
103 let instructions: CronInstructions =
104 serde_json::from_str(&std::fs::read_to_string(&legacy)?)?;
105 if instructions.phase == phase {
106 return Ok(instructions);
107 }
108 }
109 Err(ShipError::Missing)
110}
111
112pub fn list_cron_instructions(project_root: &Path) -> Vec<CronInstructions> {
115 let mut found = Vec::new();
116 if let Ok(entries) = std::fs::read_dir(project_root.join(".devflow")) {
117 for entry in entries.flatten() {
118 let name = entry.file_name();
119 let Some(name) = name.to_str() else { continue };
120 if !name.starts_with("cron-instructions") || !name.ends_with(".json") {
121 continue;
122 }
123 if let Ok(contents) = std::fs::read_to_string(entry.path())
124 && let Ok(instructions) = serde_json::from_str::<CronInstructions>(&contents)
125 {
126 found.push(instructions);
127 }
128 }
129 }
130 found.sort_by_key(|i| i.phase);
131 found.dedup_by_key(|i| i.phase);
132 found
133}
134
135pub fn delete_cron_instructions(project_root: &Path, phase: u32) -> Result<(), ShipError> {
138 let path = cron_instructions_path(project_root, phase);
139 if path.exists() {
140 std::fs::remove_file(path)?;
141 }
142 let legacy = legacy_cron_instructions_path(project_root);
143 if legacy.exists()
144 && let Ok(contents) = std::fs::read_to_string(&legacy)
145 && serde_json::from_str::<CronInstructions>(&contents)
146 .map(|i| i.phase == phase)
147 .unwrap_or(true)
148 {
149 std::fs::remove_file(&legacy)?;
150 }
151 Ok(())
152}
153
154pub fn build_cron_instructions(
156 project_root: &Path,
157 phase: u32,
158 retry_after: &str,
159 next_agents: &str,
160) -> CronInstructions {
161 let project = project_root.display().to_string();
162 let args = vec![
163 "sequentagent".to_string(),
164 "--phase".to_string(),
165 phase.to_string(),
166 "--agents".to_string(),
167 next_agents.to_string(),
168 ];
169 CronInstructions {
170 project: project.clone(),
171 phase,
172 status: "rate_limited".to_string(),
173 retry_after: retry_after.to_string(),
174 resume: ResumeCommand {
175 command: "devflow".to_string(),
176 args,
177 },
178 hermes_cron: HermesCronJob {
179 schedule: cron_schedule_from_retry_after(retry_after).unwrap_or_default(),
180 name: format!("devflow-phase-{phase:02}-resume"),
181 command: format!(
182 "cd {} && devflow sequentagent --phase {phase} --agents {next_agents}",
183 shell_quote(&project)
184 ),
185 once: true,
186 },
187 }
188}
189
190pub fn build_single_agent_cron_instructions(
197 project_root: &Path,
198 phase: u32,
199 retry_after: &str,
200) -> CronInstructions {
201 let project = project_root.display().to_string();
202 let args = vec![
203 "resume".to_string(),
204 "--phase".to_string(),
205 phase.to_string(),
206 ];
207 CronInstructions {
208 project: project.clone(),
209 phase,
210 status: "rate_limited".to_string(),
211 retry_after: retry_after.to_string(),
212 resume: ResumeCommand {
213 command: "devflow".to_string(),
214 args,
215 },
216 hermes_cron: HermesCronJob {
217 schedule: cron_schedule_from_retry_after(retry_after).unwrap_or_default(),
218 name: format!("devflow-phase-{phase:02}-resume"),
219 command: format!(
220 "cd {} && devflow resume --phase {phase}",
221 shell_quote(&project)
222 ),
223 once: true,
224 },
225 }
226}
227
228pub fn cron_schedule_from_retry_after(retry_after: &str) -> Option<String> {
231 parse_retry_timestamp(retry_after).map(|ts| ts.round_up_minute().to_cron())
233}
234
235#[derive(Debug, Clone, Copy, PartialEq, Eq)]
236struct RetryTimestamp {
237 year: i32,
238 month: u32,
239 day: u32,
240 hour: u32,
241 minute: u32,
242 second: u32,
243}
244
245impl RetryTimestamp {
246 fn round_up_minute(self) -> Self {
247 if self.second == 0 {
248 return self;
249 }
250 Self::from_epoch_minutes(self.to_epoch_minutes() + 1)
251 }
252
253 fn to_cron(self) -> String {
254 format!(
255 "{} {} {} {} *",
256 self.minute, self.hour, self.day, self.month
257 )
258 }
259
260 fn to_epoch_minutes(self) -> i64 {
261 let days = days_from_civil(self.year, self.month, self.day);
262 days * 24 * 60 + i64::from(self.hour) * 60 + i64::from(self.minute)
263 }
264
265 fn from_epoch_minutes(minutes: i64) -> Self {
266 let days = minutes.div_euclid(24 * 60);
267 let minute_of_day = minutes.rem_euclid(24 * 60);
268 let (year, month, day) = civil_from_days(days);
269 Self {
270 year,
271 month,
272 day,
273 hour: (minute_of_day / 60) as u32,
274 minute: (minute_of_day % 60) as u32,
275 second: 0,
276 }
277 }
278}
279
280fn parse_retry_timestamp(input: &str) -> Option<RetryTimestamp> {
281 parse_unix_seconds(input).or_else(|| parse_rfc3339ish(input))
282}
283
284fn parse_unix_seconds(input: &str) -> Option<RetryTimestamp> {
285 let seconds = input.trim().parse::<i64>().ok()?;
286 let minutes = seconds.div_euclid(60) + i64::from(seconds.rem_euclid(60) > 0);
287 Some(RetryTimestamp::from_epoch_minutes(minutes))
288}
289
290fn parse_rfc3339ish(input: &str) -> Option<RetryTimestamp> {
291 let input = input.trim();
292 let split_at = input.find('T').or_else(|| input.find(' '))?;
293 let (date, rest) = input.split_at(split_at);
294 let time = rest.get(1..)?;
295 let mut date_parts = date.split('-');
296 let year = date_parts.next()?.parse::<i32>().ok()?;
297 let month = date_parts.next()?.parse::<u32>().ok()?;
298 let day = date_parts.next()?.parse::<u32>().ok()?;
299 if date_parts.next().is_some() {
300 return None;
301 }
302
303 let (time, offset_minutes) = split_time_and_offset(time);
304 let mut time_parts = time.split(':');
305 let hour = time_parts.next()?.parse::<u32>().ok()?;
306 let minute = time_parts.next()?.parse::<u32>().ok()?;
307 let second = time_parts
308 .next()
309 .map(|s| s.split('.').next().unwrap_or_default().parse::<u32>().ok())
310 .unwrap_or(Some(0))?;
311 if month == 0 || month > 12 || day == 0 || day > 31 || hour > 23 || minute > 59 || second > 60 {
312 return None;
313 }
314
315 let ts = RetryTimestamp {
316 year,
317 month,
318 day,
319 hour,
320 minute,
321 second,
322 };
323 let utc_minutes = ts.to_epoch_minutes() - i64::from(offset_minutes);
324 let mut normalized = RetryTimestamp::from_epoch_minutes(utc_minutes);
325 normalized.second = second;
332 Some(normalized)
333}
334
335fn split_time_and_offset(time: &str) -> (&str, i32) {
336 let trimmed = time.trim_end_matches('Z');
337 if trimmed.len() > 6 {
338 if let Some(idx) = trimmed.rfind('+') {
339 return (
340 &trimmed[..idx],
341 parse_offset_minutes(&trimmed[idx..]).unwrap_or(0),
342 );
343 }
344 if let Some(idx) = trimmed.rfind('-')
345 && idx > 0
346 {
347 return (
348 &trimmed[..idx],
349 parse_offset_minutes(&trimmed[idx..]).unwrap_or(0),
350 );
351 }
352 }
353 (trimmed, 0)
354}
355
356fn parse_offset_minutes(offset: &str) -> Option<i32> {
357 const MAX_OFFSET_HOURS: i32 = 23;
366 const MAX_OFFSET_MINUTES: i32 = 59;
367 let sign = if offset.starts_with('-') { -1 } else { 1 };
368 let rest = offset.get(1..)?;
369 let (hours_part, minutes_part) = match rest.split_once(':') {
370 Some((hours, minutes)) => (hours, minutes),
371 None => match rest.len() {
372 2 => (rest, "0"), 4 => (&rest[..2], &rest[2..]), _ => return None,
375 },
376 };
377 let hours = hours_part.parse::<i32>().ok()?;
378 let minutes = minutes_part.parse::<i32>().ok()?;
379 if !(0..=MAX_OFFSET_HOURS).contains(&hours) || !(0..=MAX_OFFSET_MINUTES).contains(&minutes) {
380 return None;
381 }
382 Some(sign * (hours * 60 + minutes))
383}
384
385fn days_from_civil(year: i32, month: u32, day: u32) -> i64 {
386 let year = year - i32::from(month <= 2);
387 let era = i64::from(year).div_euclid(400);
388 let yoe = i64::from(year) - era * 400;
389 let month = i64::from(month);
390 let doy = (153 * (month + if month > 2 { -3 } else { 9 }) + 2) / 5 + i64::from(day) - 1;
391 let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
392 era * 146_097 + doe - 719_468
393}
394
395fn civil_from_days(days: i64) -> (i32, u32, u32) {
396 let z = days + 719_468;
397 let era = z.div_euclid(146_097);
398 let doe = z - era * 146_097;
399 let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096).div_euclid(365);
400 let year = yoe + era * 400;
401 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
402 let mp = (5 * doy + 2).div_euclid(153);
403 let day = doy - (153 * mp + 2).div_euclid(5) + 1;
404 let month = mp + if mp < 10 { 3 } else { -9 };
405 let year = year + i64::from(month <= 2);
406 (year as i32, month as u32, day as u32)
407}
408
409fn shell_quote(value: &str) -> String {
410 if value.chars().all(|c| {
417 c.is_ascii_alphanumeric()
418 || matches!(c, '/' | '.' | '_' | '-' | '~' | ':' | '@' | '+' | '=' | '%')
419 }) {
420 value.to_string()
421 } else {
422 format!("'{}'", value.replace('\'', "'\\''"))
423 }
424}
425
426pub fn prepend_changelog(existing: &str, version: &str, date: &str) -> String {
429 const HEADER: &str = "# Changelog\n\n\
430 All notable changes to this project are documented here.\n";
431 let entry = format!("## {version} — {date}\n\n- Released phase via DevFlow.\n");
432
433 if existing.trim().is_empty() {
434 return format!("{HEADER}\n{entry}");
435 }
436 if let Some(idx) = existing.find("\n\n") {
439 let (head, tail) = existing.split_at(idx + 2);
440 format!("{head}{entry}\n{tail}")
441 } else {
442 format!("{entry}\n{existing}")
443 }
444}
445
446#[cfg(test)]
447mod tests {
448 use super::*;
449
450 #[test]
451 fn cron_instructions_save_load_round_trips() {
452 let dir = tempfile::tempdir().unwrap();
453 let record = build_cron_instructions(dir.path(), 7, "2026-06-18T15:45:30Z", "codex,claude");
454
455 write_cron_instructions(dir.path(), &record).unwrap();
456
457 assert_eq!(load_cron_instructions(dir.path(), 7).unwrap(), record);
458 }
459
460 #[test]
461 fn delete_cron_instructions_is_idempotent() {
462 let dir = tempfile::tempdir().unwrap();
463 let record = build_cron_instructions(dir.path(), 7, "2026-06-18T15:45:30Z", "codex,claude");
464 write_cron_instructions(dir.path(), &record).unwrap();
465
466 delete_cron_instructions(dir.path(), 7).unwrap();
467 assert!(!cron_instructions_path(dir.path(), 7).exists());
468 delete_cron_instructions(dir.path(), 7).unwrap();
469 }
470
471 #[test]
474 fn cron_instructions_are_per_phase() {
475 let dir = tempfile::tempdir().unwrap();
476 let a = build_cron_instructions(dir.path(), 7, "2026-06-18T15:45:30Z", "claude,codex");
477 let b = build_cron_instructions(dir.path(), 8, "2026-06-18T16:45:30Z", "codex,claude");
478 write_cron_instructions(dir.path(), &a).unwrap();
479 write_cron_instructions(dir.path(), &b).unwrap();
480
481 assert_eq!(load_cron_instructions(dir.path(), 7).unwrap(), a);
482 assert_eq!(load_cron_instructions(dir.path(), 8).unwrap(), b);
483 let listed = list_cron_instructions(dir.path());
484 assert_eq!(listed.iter().map(|i| i.phase).collect::<Vec<_>>(), [7, 8]);
485
486 delete_cron_instructions(dir.path(), 7).unwrap();
487 assert!(load_cron_instructions(dir.path(), 7).is_err());
488 assert_eq!(load_cron_instructions(dir.path(), 8).unwrap(), b);
489 }
490
491 #[test]
494 fn legacy_cron_instructions_are_read_and_deleted() {
495 let dir = tempfile::tempdir().unwrap();
496 let record = build_cron_instructions(dir.path(), 5, "2026-06-18T15:45:30Z", "claude,codex");
497 let legacy = legacy_cron_instructions_path(dir.path());
498 std::fs::create_dir_all(legacy.parent().unwrap()).unwrap();
499 std::fs::write(&legacy, serde_json::to_string_pretty(&record).unwrap()).unwrap();
500
501 assert_eq!(load_cron_instructions(dir.path(), 5).unwrap(), record);
502 assert!(load_cron_instructions(dir.path(), 6).is_err());
503 assert_eq!(list_cron_instructions(dir.path()).len(), 1);
504
505 delete_cron_instructions(dir.path(), 5).unwrap();
506 assert!(!legacy.exists());
507 }
508
509 #[test]
510 fn cron_schedule_rounds_up_to_nearest_minute() {
511 assert_eq!(
512 cron_schedule_from_retry_after("2026-06-18T15:45:30Z"),
513 Some("46 15 18 6 *".to_string())
514 );
515 assert_eq!(
516 cron_schedule_from_retry_after("2026-06-18T15:45:00Z"),
517 Some("45 15 18 6 *".to_string())
518 );
519 }
520
521 #[test]
522 fn cron_schedule_normalizes_negative_offset() {
523 assert_eq!(
525 cron_schedule_from_retry_after("2026-06-18T15:45:30-05:00"),
526 Some("46 20 18 6 *".to_string())
527 );
528 assert_eq!(
530 cron_schedule_from_retry_after("2026-06-18T15:45:00-05:30"),
531 Some("15 21 18 6 *".to_string())
532 );
533 }
534
535 #[test]
542 fn cron_schedule_parses_all_iso8601_offset_forms() {
543 assert_eq!(
545 cron_schedule_from_retry_after("2026-06-18T15:45:30+0530"),
546 cron_schedule_from_retry_after("2026-06-18T15:45:30+05:30"),
547 );
548 assert_eq!(
550 cron_schedule_from_retry_after("2026-06-18T15:45:30-05"),
551 Some("46 20 18 6 *".to_string())
552 );
553 }
554
555 #[test]
556 fn parse_offset_minutes_bounds_and_forms() {
557 assert_eq!(parse_offset_minutes("+05:30"), Some(330));
558 assert_eq!(parse_offset_minutes("+0530"), Some(330));
559 assert_eq!(parse_offset_minutes("-0530"), Some(-330));
560 assert_eq!(parse_offset_minutes("+05"), Some(300));
561 assert_eq!(parse_offset_minutes("-05"), Some(-300));
562 assert_eq!(parse_offset_minutes("+24"), None);
564 assert_eq!(parse_offset_minutes("+05:60"), None);
565 assert_eq!(parse_offset_minutes("+5"), None);
566 assert_eq!(parse_offset_minutes("+530"), None);
567 assert_eq!(parse_offset_minutes("+abcd"), None);
568 }
569
570 #[test]
571 fn cron_schedule_formats_unix_seconds() {
572 assert_eq!(
573 cron_schedule_from_retry_after("1766678401"),
574 Some("1 16 25 12 *".to_string())
575 );
576 }
577
578 #[test]
579 fn shell_quote_leaves_common_safe_chars_unquoted() {
580 assert_eq!(
581 shell_quote("user@host:1.2.3+build"),
582 "user@host:1.2.3+build"
583 );
584 assert_eq!(shell_quote("~/proj/build=1_2%3"), "~/proj/build=1_2%3");
585 }
586
587 #[test]
588 fn shell_quote_quotes_unsafe_input() {
589 assert_eq!(shell_quote("a b"), "'a b'");
590 assert_eq!(shell_quote("it's"), "'it'\\''s'");
591 }
592
593 #[test]
594 fn cron_instructions_include_resume_command() {
595 let dir = tempfile::tempdir().unwrap();
596 let record = build_cron_instructions(dir.path(), 7, "2026-06-18T15:45:30Z", "codex,claude");
597
598 assert_eq!(record.resume.command, "devflow");
599 assert_eq!(
600 record.resume.args,
601 ["sequentagent", "--phase", "7", "--agents", "codex,claude"]
602 );
603 assert!(
604 record
605 .hermes_cron
606 .command
607 .contains("devflow sequentagent --phase 7 --agents codex,claude")
608 );
609 assert!(record.hermes_cron.once);
610 }
611
612 #[test]
617 fn single_agent_cron_instructions_resume_command_is_devflow_resume() {
618 let dir = tempfile::tempdir().unwrap();
619 let record = build_single_agent_cron_instructions(dir.path(), 9, "2026-06-18T15:45:30Z");
620
621 assert_eq!(record.resume.command, "devflow");
622 assert_eq!(record.resume.args, ["resume", "--phase", "9"]);
623 assert!(
624 record
625 .hermes_cron
626 .command
627 .contains("devflow resume --phase 9")
628 );
629 assert!(!record.hermes_cron.command.contains("sequentagent"));
630 assert!(!record.hermes_cron.command.contains(" start"));
631 assert!(record.hermes_cron.once);
632 }
633
634 #[test]
635 fn cron_instructions_reject_unparseable_retry_time() {
636 let dir = tempfile::tempdir().unwrap();
637 let record = build_cron_instructions(dir.path(), 7, "unknown", "codex,claude");
638
639 assert_ne!(record.hermes_cron.schedule, "* * * * *");
640 assert!(record.hermes_cron.schedule.is_empty());
641 }
642
643 #[test]
644 fn prepend_changelog_creates_header_when_empty() {
645 let out = prepend_changelog("", "0.5.2", "2026-06-18");
646 assert!(out.starts_with("# Changelog"));
647 assert!(out.contains("## 0.5.2 — 2026-06-18"));
648 }
649
650 #[test]
651 fn prepend_changelog_inserts_after_header() {
652 let existing = "# Changelog\n\n## 0.5.1 — 2026-06-17\n\n- old\n";
653 let out = prepend_changelog(existing, "0.5.2", "2026-06-18");
654 let new_idx = out.find("0.5.2").unwrap();
655 let old_idx = out.find("0.5.1").unwrap();
656 assert!(new_idx < old_idx, "new entry should come before old");
657 assert!(out.starts_with("# Changelog"));
658 }
659}