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