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
72fn 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 cron_schedule_from_retry_after(retry_after: &str) -> Option<String> {
193 parse_retry_timestamp(retry_after).map(|ts| ts.round_up_minute().to_cron())
195}
196
197#[derive(Debug, Clone, Copy, PartialEq, Eq)]
198struct RetryTimestamp {
199 year: i32,
200 month: u32,
201 day: u32,
202 hour: u32,
203 minute: u32,
204 second: u32,
205}
206
207impl RetryTimestamp {
208 fn round_up_minute(self) -> Self {
209 if self.second == 0 {
210 return self;
211 }
212 Self::from_epoch_minutes(self.to_epoch_minutes() + 1)
213 }
214
215 fn to_cron(self) -> String {
216 format!(
217 "{} {} {} {} *",
218 self.minute, self.hour, self.day, self.month
219 )
220 }
221
222 fn to_epoch_minutes(self) -> i64 {
223 let days = days_from_civil(self.year, self.month, self.day);
224 days * 24 * 60 + i64::from(self.hour) * 60 + i64::from(self.minute)
225 }
226
227 fn from_epoch_minutes(minutes: i64) -> Self {
228 let days = minutes.div_euclid(24 * 60);
229 let minute_of_day = minutes.rem_euclid(24 * 60);
230 let (year, month, day) = civil_from_days(days);
231 Self {
232 year,
233 month,
234 day,
235 hour: (minute_of_day / 60) as u32,
236 minute: (minute_of_day % 60) as u32,
237 second: 0,
238 }
239 }
240}
241
242fn parse_retry_timestamp(input: &str) -> Option<RetryTimestamp> {
243 parse_unix_seconds(input).or_else(|| parse_rfc3339ish(input))
244}
245
246fn parse_unix_seconds(input: &str) -> Option<RetryTimestamp> {
247 let seconds = input.trim().parse::<i64>().ok()?;
248 let minutes = seconds.div_euclid(60) + i64::from(seconds.rem_euclid(60) > 0);
249 Some(RetryTimestamp::from_epoch_minutes(minutes))
250}
251
252fn parse_rfc3339ish(input: &str) -> Option<RetryTimestamp> {
253 let input = input.trim();
254 let split_at = input.find('T').or_else(|| input.find(' '))?;
255 let (date, rest) = input.split_at(split_at);
256 let time = rest.get(1..)?;
257 let mut date_parts = date.split('-');
258 let year = date_parts.next()?.parse::<i32>().ok()?;
259 let month = date_parts.next()?.parse::<u32>().ok()?;
260 let day = date_parts.next()?.parse::<u32>().ok()?;
261 if date_parts.next().is_some() {
262 return None;
263 }
264
265 let (time, offset_minutes) = split_time_and_offset(time);
266 let mut time_parts = time.split(':');
267 let hour = time_parts.next()?.parse::<u32>().ok()?;
268 let minute = time_parts.next()?.parse::<u32>().ok()?;
269 let second = time_parts
270 .next()
271 .map(|s| s.split('.').next().unwrap_or_default().parse::<u32>().ok())
272 .unwrap_or(Some(0))?;
273 if month == 0 || month > 12 || day == 0 || day > 31 || hour > 23 || minute > 59 || second > 60 {
274 return None;
275 }
276
277 let ts = RetryTimestamp {
278 year,
279 month,
280 day,
281 hour,
282 minute,
283 second,
284 };
285 let utc_minutes = ts.to_epoch_minutes() - i64::from(offset_minutes);
286 let mut normalized = RetryTimestamp::from_epoch_minutes(utc_minutes);
287 normalized.second = second;
294 Some(normalized)
295}
296
297fn split_time_and_offset(time: &str) -> (&str, i32) {
298 let trimmed = time.trim_end_matches('Z');
299 if trimmed.len() > 6 {
300 if let Some(idx) = trimmed.rfind('+') {
301 return (
302 &trimmed[..idx],
303 parse_offset_minutes(&trimmed[idx..]).unwrap_or(0),
304 );
305 }
306 if let Some(idx) = trimmed.rfind('-')
307 && idx > 0
308 {
309 return (
310 &trimmed[..idx],
311 parse_offset_minutes(&trimmed[idx..]).unwrap_or(0),
312 );
313 }
314 }
315 (trimmed, 0)
316}
317
318fn parse_offset_minutes(offset: &str) -> Option<i32> {
319 const MAX_OFFSET_HOURS: i32 = 23;
328 const MAX_OFFSET_MINUTES: i32 = 59;
329 let sign = if offset.starts_with('-') { -1 } else { 1 };
330 let rest = offset.get(1..)?;
331 let (hours_part, minutes_part) = match rest.split_once(':') {
332 Some((hours, minutes)) => (hours, minutes),
333 None => match rest.len() {
334 2 => (rest, "0"), 4 => (&rest[..2], &rest[2..]), _ => return None,
337 },
338 };
339 let hours = hours_part.parse::<i32>().ok()?;
340 let minutes = minutes_part.parse::<i32>().ok()?;
341 if !(0..=MAX_OFFSET_HOURS).contains(&hours) || !(0..=MAX_OFFSET_MINUTES).contains(&minutes) {
342 return None;
343 }
344 Some(sign * (hours * 60 + minutes))
345}
346
347fn days_from_civil(year: i32, month: u32, day: u32) -> i64 {
348 let year = year - i32::from(month <= 2);
349 let era = i64::from(year).div_euclid(400);
350 let yoe = i64::from(year) - era * 400;
351 let month = i64::from(month);
352 let doy = (153 * (month + if month > 2 { -3 } else { 9 }) + 2) / 5 + i64::from(day) - 1;
353 let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
354 era * 146_097 + doe - 719_468
355}
356
357fn civil_from_days(days: i64) -> (i32, u32, u32) {
358 let z = days + 719_468;
359 let era = z.div_euclid(146_097);
360 let doe = z - era * 146_097;
361 let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096).div_euclid(365);
362 let year = yoe + era * 400;
363 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
364 let mp = (5 * doy + 2).div_euclid(153);
365 let day = doy - (153 * mp + 2).div_euclid(5) + 1;
366 let month = mp + if mp < 10 { 3 } else { -9 };
367 let year = year + i64::from(month <= 2);
368 (year as i32, month as u32, day as u32)
369}
370
371fn shell_quote(value: &str) -> String {
372 if value.chars().all(|c| {
379 c.is_ascii_alphanumeric()
380 || matches!(c, '/' | '.' | '_' | '-' | '~' | ':' | '@' | '+' | '=' | '%')
381 }) {
382 value.to_string()
383 } else {
384 format!("'{}'", value.replace('\'', "'\\''"))
385 }
386}
387
388pub fn prepend_changelog(existing: &str, version: &str, date: &str) -> String {
391 const HEADER: &str = "# Changelog\n\n\
392 All notable changes to this project are documented here.\n";
393 let entry = format!("## {version} — {date}\n\n- Released phase via DevFlow.\n");
394
395 if existing.trim().is_empty() {
396 return format!("{HEADER}\n{entry}");
397 }
398 if let Some(idx) = existing.find("\n\n") {
401 let (head, tail) = existing.split_at(idx + 2);
402 format!("{head}{entry}\n{tail}")
403 } else {
404 format!("{entry}\n{existing}")
405 }
406}
407
408#[cfg(test)]
409mod tests {
410 use super::*;
411
412 #[test]
413 fn cron_instructions_save_load_round_trips() {
414 let dir = tempfile::tempdir().unwrap();
415 let record = build_cron_instructions(dir.path(), 7, "2026-06-18T15:45:30Z", "codex,claude");
416
417 write_cron_instructions(dir.path(), &record).unwrap();
418
419 assert_eq!(load_cron_instructions(dir.path(), 7).unwrap(), record);
420 }
421
422 #[test]
423 fn delete_cron_instructions_is_idempotent() {
424 let dir = tempfile::tempdir().unwrap();
425 let record = build_cron_instructions(dir.path(), 7, "2026-06-18T15:45:30Z", "codex,claude");
426 write_cron_instructions(dir.path(), &record).unwrap();
427
428 delete_cron_instructions(dir.path(), 7).unwrap();
429 assert!(!cron_instructions_path(dir.path(), 7).exists());
430 delete_cron_instructions(dir.path(), 7).unwrap();
431 }
432
433 #[test]
436 fn cron_instructions_are_per_phase() {
437 let dir = tempfile::tempdir().unwrap();
438 let a = build_cron_instructions(dir.path(), 7, "2026-06-18T15:45:30Z", "claude,codex");
439 let b = build_cron_instructions(dir.path(), 8, "2026-06-18T16:45:30Z", "codex,claude");
440 write_cron_instructions(dir.path(), &a).unwrap();
441 write_cron_instructions(dir.path(), &b).unwrap();
442
443 assert_eq!(load_cron_instructions(dir.path(), 7).unwrap(), a);
444 assert_eq!(load_cron_instructions(dir.path(), 8).unwrap(), b);
445 let listed = list_cron_instructions(dir.path());
446 assert_eq!(listed.iter().map(|i| i.phase).collect::<Vec<_>>(), [7, 8]);
447
448 delete_cron_instructions(dir.path(), 7).unwrap();
449 assert!(load_cron_instructions(dir.path(), 7).is_err());
450 assert_eq!(load_cron_instructions(dir.path(), 8).unwrap(), b);
451 }
452
453 #[test]
456 fn legacy_cron_instructions_are_read_and_deleted() {
457 let dir = tempfile::tempdir().unwrap();
458 let record = build_cron_instructions(dir.path(), 5, "2026-06-18T15:45:30Z", "claude,codex");
459 let legacy = legacy_cron_instructions_path(dir.path());
460 std::fs::create_dir_all(legacy.parent().unwrap()).unwrap();
461 std::fs::write(&legacy, serde_json::to_string_pretty(&record).unwrap()).unwrap();
462
463 assert_eq!(load_cron_instructions(dir.path(), 5).unwrap(), record);
464 assert!(load_cron_instructions(dir.path(), 6).is_err());
465 assert_eq!(list_cron_instructions(dir.path()).len(), 1);
466
467 delete_cron_instructions(dir.path(), 5).unwrap();
468 assert!(!legacy.exists());
469 }
470
471 #[test]
472 fn cron_schedule_rounds_up_to_nearest_minute() {
473 assert_eq!(
474 cron_schedule_from_retry_after("2026-06-18T15:45:30Z"),
475 Some("46 15 18 6 *".to_string())
476 );
477 assert_eq!(
478 cron_schedule_from_retry_after("2026-06-18T15:45:00Z"),
479 Some("45 15 18 6 *".to_string())
480 );
481 }
482
483 #[test]
484 fn cron_schedule_normalizes_negative_offset() {
485 assert_eq!(
487 cron_schedule_from_retry_after("2026-06-18T15:45:30-05:00"),
488 Some("46 20 18 6 *".to_string())
489 );
490 assert_eq!(
492 cron_schedule_from_retry_after("2026-06-18T15:45:00-05:30"),
493 Some("15 21 18 6 *".to_string())
494 );
495 }
496
497 #[test]
504 fn cron_schedule_parses_all_iso8601_offset_forms() {
505 assert_eq!(
507 cron_schedule_from_retry_after("2026-06-18T15:45:30+0530"),
508 cron_schedule_from_retry_after("2026-06-18T15:45:30+05:30"),
509 );
510 assert_eq!(
512 cron_schedule_from_retry_after("2026-06-18T15:45:30-05"),
513 Some("46 20 18 6 *".to_string())
514 );
515 }
516
517 #[test]
518 fn parse_offset_minutes_bounds_and_forms() {
519 assert_eq!(parse_offset_minutes("+05:30"), Some(330));
520 assert_eq!(parse_offset_minutes("+0530"), Some(330));
521 assert_eq!(parse_offset_minutes("-0530"), Some(-330));
522 assert_eq!(parse_offset_minutes("+05"), Some(300));
523 assert_eq!(parse_offset_minutes("-05"), Some(-300));
524 assert_eq!(parse_offset_minutes("+24"), None);
526 assert_eq!(parse_offset_minutes("+05:60"), None);
527 assert_eq!(parse_offset_minutes("+5"), None);
528 assert_eq!(parse_offset_minutes("+530"), None);
529 assert_eq!(parse_offset_minutes("+abcd"), None);
530 }
531
532 #[test]
533 fn cron_schedule_formats_unix_seconds() {
534 assert_eq!(
535 cron_schedule_from_retry_after("1766678401"),
536 Some("1 16 25 12 *".to_string())
537 );
538 }
539
540 #[test]
541 fn shell_quote_leaves_common_safe_chars_unquoted() {
542 assert_eq!(
543 shell_quote("user@host:1.2.3+build"),
544 "user@host:1.2.3+build"
545 );
546 assert_eq!(shell_quote("~/proj/build=1_2%3"), "~/proj/build=1_2%3");
547 }
548
549 #[test]
550 fn shell_quote_quotes_unsafe_input() {
551 assert_eq!(shell_quote("a b"), "'a b'");
552 assert_eq!(shell_quote("it's"), "'it'\\''s'");
553 }
554
555 #[test]
556 fn cron_instructions_include_resume_command() {
557 let dir = tempfile::tempdir().unwrap();
558 let record = build_cron_instructions(dir.path(), 7, "2026-06-18T15:45:30Z", "codex,claude");
559
560 assert_eq!(record.resume.command, "devflow");
561 assert_eq!(
562 record.resume.args,
563 ["sequentagent", "--phase", "7", "--agents", "codex,claude"]
564 );
565 assert!(
566 record
567 .hermes_cron
568 .command
569 .contains("devflow sequentagent --phase 7 --agents codex,claude")
570 );
571 assert!(record.hermes_cron.once);
572 }
573
574 #[test]
575 fn cron_instructions_reject_unparseable_retry_time() {
576 let dir = tempfile::tempdir().unwrap();
577 let record = build_cron_instructions(dir.path(), 7, "unknown", "codex,claude");
578
579 assert_ne!(record.hermes_cron.schedule, "* * * * *");
580 assert!(record.hermes_cron.schedule.is_empty());
581 }
582
583 #[test]
584 fn prepend_changelog_creates_header_when_empty() {
585 let out = prepend_changelog("", "0.5.2", "2026-06-18");
586 assert!(out.starts_with("# Changelog"));
587 assert!(out.contains("## 0.5.2 — 2026-06-18"));
588 }
589
590 #[test]
591 fn prepend_changelog_inserts_after_header() {
592 let existing = "# Changelog\n\n## 0.5.1 — 2026-06-17\n\n- old\n";
593 let out = prepend_changelog(existing, "0.5.2", "2026-06-18");
594 let new_idx = out.find("0.5.2").unwrap();
595 let old_idx = out.find("0.5.1").unwrap();
596 assert!(new_idx < old_idx, "new entry should come before old");
597 assert!(out.starts_with("# Changelog"));
598 }
599}