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 crate::workflow::ensure_devflow_dir(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_single_agent_cron_instructions(
160 project_root: &Path,
161 phase: u32,
162 retry_after: &str,
163) -> CronInstructions {
164 let project = project_root.display().to_string();
165 let args = vec![
166 "resume".to_string(),
167 "--phase".to_string(),
168 phase.to_string(),
169 ];
170 CronInstructions {
171 project: project.clone(),
172 phase,
173 status: "rate_limited".to_string(),
174 retry_after: retry_after.to_string(),
175 resume: ResumeCommand {
176 command: "devflow".to_string(),
177 args,
178 },
179 hermes_cron: HermesCronJob {
180 schedule: cron_schedule_from_retry_after(retry_after).unwrap_or_default(),
181 name: format!("devflow-phase-{phase:02}-resume"),
182 command: format!(
183 "cd {} && devflow resume --phase {phase}",
184 shell_quote(&project)
185 ),
186 once: true,
187 },
188 }
189}
190
191pub fn cron_schedule_from_retry_after(retry_after: &str) -> Option<String> {
194 parse_retry_timestamp(retry_after).map(|ts| ts.round_up_minute().to_cron())
196}
197
198#[derive(Debug, Clone, Copy, PartialEq, Eq)]
199struct RetryTimestamp {
200 year: i32,
201 month: u32,
202 day: u32,
203 hour: u32,
204 minute: u32,
205 second: u32,
206}
207
208impl RetryTimestamp {
209 fn round_up_minute(self) -> Self {
210 if self.second == 0 {
211 return self;
212 }
213 Self::from_epoch_minutes(self.to_epoch_minutes() + 1)
214 }
215
216 fn to_cron(self) -> String {
217 format!(
218 "{} {} {} {} *",
219 self.minute, self.hour, self.day, self.month
220 )
221 }
222
223 fn to_epoch_minutes(self) -> i64 {
224 let days = days_from_civil(self.year, self.month, self.day);
225 days * 24 * 60 + i64::from(self.hour) * 60 + i64::from(self.minute)
226 }
227
228 fn from_epoch_minutes(minutes: i64) -> Self {
229 let days = minutes.div_euclid(24 * 60);
230 let minute_of_day = minutes.rem_euclid(24 * 60);
231 let (year, month, day) = civil_from_days(days);
232 Self {
233 year,
234 month,
235 day,
236 hour: (minute_of_day / 60) as u32,
237 minute: (minute_of_day % 60) as u32,
238 second: 0,
239 }
240 }
241}
242
243fn parse_retry_timestamp(input: &str) -> Option<RetryTimestamp> {
244 parse_unix_seconds(input).or_else(|| parse_rfc3339ish(input))
245}
246
247fn parse_unix_seconds(input: &str) -> Option<RetryTimestamp> {
248 let seconds = input.trim().parse::<i64>().ok()?;
249 let minutes = seconds.div_euclid(60) + i64::from(seconds.rem_euclid(60) > 0);
250 Some(RetryTimestamp::from_epoch_minutes(minutes))
251}
252
253fn parse_rfc3339ish(input: &str) -> Option<RetryTimestamp> {
254 let input = input.trim();
255 let split_at = input.find('T').or_else(|| input.find(' '))?;
256 let (date, rest) = input.split_at(split_at);
257 let time = rest.get(1..)?;
258 let mut date_parts = date.split('-');
259 let year = date_parts.next()?.parse::<i32>().ok()?;
260 let month = date_parts.next()?.parse::<u32>().ok()?;
261 let day = date_parts.next()?.parse::<u32>().ok()?;
262 if date_parts.next().is_some() {
263 return None;
264 }
265
266 let (time, offset_minutes) = split_time_and_offset(time);
267 let mut time_parts = time.split(':');
268 let hour = time_parts.next()?.parse::<u32>().ok()?;
269 let minute = time_parts.next()?.parse::<u32>().ok()?;
270 let second = time_parts
271 .next()
272 .map(|s| s.split('.').next().unwrap_or_default().parse::<u32>().ok())
273 .unwrap_or(Some(0))?;
274 if month == 0 || month > 12 || day == 0 || day > 31 || hour > 23 || minute > 59 || second > 60 {
275 return None;
276 }
277
278 let ts = RetryTimestamp {
279 year,
280 month,
281 day,
282 hour,
283 minute,
284 second,
285 };
286 let utc_minutes = ts.to_epoch_minutes() - i64::from(offset_minutes);
287 let mut normalized = RetryTimestamp::from_epoch_minutes(utc_minutes);
288 normalized.second = second;
295 Some(normalized)
296}
297
298fn split_time_and_offset(time: &str) -> (&str, i32) {
299 let trimmed = time.trim_end_matches('Z');
300 if trimmed.len() > 6 {
301 if let Some(idx) = trimmed.rfind('+') {
302 return (
303 &trimmed[..idx],
304 parse_offset_minutes(&trimmed[idx..]).unwrap_or(0),
305 );
306 }
307 if let Some(idx) = trimmed.rfind('-')
308 && idx > 0
309 {
310 return (
311 &trimmed[..idx],
312 parse_offset_minutes(&trimmed[idx..]).unwrap_or(0),
313 );
314 }
315 }
316 (trimmed, 0)
317}
318
319fn parse_offset_minutes(offset: &str) -> Option<i32> {
320 const MAX_OFFSET_HOURS: i32 = 23;
329 const MAX_OFFSET_MINUTES: i32 = 59;
330 let sign = if offset.starts_with('-') { -1 } else { 1 };
331 let rest = offset.get(1..)?;
332 let (hours_part, minutes_part) = match rest.split_once(':') {
333 Some((hours, minutes)) => (hours, minutes),
334 None => match rest.len() {
335 2 => (rest, "0"), 4 => (&rest[..2], &rest[2..]), _ => return None,
338 },
339 };
340 let hours = hours_part.parse::<i32>().ok()?;
341 let minutes = minutes_part.parse::<i32>().ok()?;
342 if !(0..=MAX_OFFSET_HOURS).contains(&hours) || !(0..=MAX_OFFSET_MINUTES).contains(&minutes) {
343 return None;
344 }
345 Some(sign * (hours * 60 + minutes))
346}
347
348fn days_from_civil(year: i32, month: u32, day: u32) -> i64 {
349 let year = year - i32::from(month <= 2);
350 let era = i64::from(year).div_euclid(400);
351 let yoe = i64::from(year) - era * 400;
352 let month = i64::from(month);
353 let doy = (153 * (month + if month > 2 { -3 } else { 9 }) + 2) / 5 + i64::from(day) - 1;
354 let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
355 era * 146_097 + doe - 719_468
356}
357
358fn civil_from_days(days: i64) -> (i32, u32, u32) {
359 let z = days + 719_468;
360 let era = z.div_euclid(146_097);
361 let doe = z - era * 146_097;
362 let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096).div_euclid(365);
363 let year = yoe + era * 400;
364 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
365 let mp = (5 * doy + 2).div_euclid(153);
366 let day = doy - (153 * mp + 2).div_euclid(5) + 1;
367 let month = mp + if mp < 10 { 3 } else { -9 };
368 let year = year + i64::from(month <= 2);
369 (year as i32, month as u32, day as u32)
370}
371
372fn shell_quote(value: &str) -> String {
373 if value.chars().all(|c| {
380 c.is_ascii_alphanumeric()
381 || matches!(c, '/' | '.' | '_' | '-' | '~' | ':' | '@' | '+' | '=' | '%')
382 }) {
383 value.to_string()
384 } else {
385 format!("'{}'", value.replace('\'', "'\\''"))
386 }
387}
388
389pub fn prepend_changelog(existing: &str, version: &str, date: &str, body: &str) -> String {
399 const HEADER: &str = "# Changelog\n\n\
400 All notable changes to this project are documented here.\n";
401 const FALLBACK: &str = "- No changes recorded since the previous release.";
402 let trimmed_body = body.trim_end_matches('\n');
403 let body_content = if trimmed_body.trim().is_empty() {
404 FALLBACK
405 } else {
406 trimmed_body
407 };
408 let entry = format!("## {version} — {date}\n\n{body_content}\n");
409
410 if existing.trim().is_empty() {
411 return format!("{HEADER}\n{entry}");
412 }
413 if let Some(idx) = existing.find("\n\n") {
416 let (head, tail) = existing.split_at(idx + 2);
417 format!("{head}{entry}\n{tail}")
418 } else {
419 format!("{entry}\n{existing}")
420 }
421}
422
423#[cfg(test)]
424mod tests {
425 use super::*;
426
427 #[test]
428 fn cron_instructions_save_load_round_trips() {
429 let dir = tempfile::tempdir().unwrap();
430 let record = build_single_agent_cron_instructions(dir.path(), 7, "2026-06-18T15:45:30Z");
431
432 write_cron_instructions(dir.path(), &record).unwrap();
433
434 assert_eq!(load_cron_instructions(dir.path(), 7).unwrap(), record);
435 }
436
437 #[test]
438 fn delete_cron_instructions_is_idempotent() {
439 let dir = tempfile::tempdir().unwrap();
440 let record = build_single_agent_cron_instructions(dir.path(), 7, "2026-06-18T15:45:30Z");
441 write_cron_instructions(dir.path(), &record).unwrap();
442
443 delete_cron_instructions(dir.path(), 7).unwrap();
444 assert!(!cron_instructions_path(dir.path(), 7).exists());
445 delete_cron_instructions(dir.path(), 7).unwrap();
446 }
447
448 #[test]
451 fn cron_instructions_are_per_phase() {
452 let dir = tempfile::tempdir().unwrap();
453 let a = build_single_agent_cron_instructions(dir.path(), 7, "2026-06-18T15:45:30Z");
454 let b = build_single_agent_cron_instructions(dir.path(), 8, "2026-06-18T16:45:30Z");
455 write_cron_instructions(dir.path(), &a).unwrap();
456 write_cron_instructions(dir.path(), &b).unwrap();
457
458 assert_eq!(load_cron_instructions(dir.path(), 7).unwrap(), a);
459 assert_eq!(load_cron_instructions(dir.path(), 8).unwrap(), b);
460 let listed = list_cron_instructions(dir.path());
461 assert_eq!(listed.iter().map(|i| i.phase).collect::<Vec<_>>(), [7, 8]);
462
463 delete_cron_instructions(dir.path(), 7).unwrap();
464 assert!(load_cron_instructions(dir.path(), 7).is_err());
465 assert_eq!(load_cron_instructions(dir.path(), 8).unwrap(), b);
466 }
467
468 #[test]
471 fn legacy_cron_instructions_are_read_and_deleted() {
472 let dir = tempfile::tempdir().unwrap();
473 let record = build_single_agent_cron_instructions(dir.path(), 5, "2026-06-18T15:45:30Z");
474 let legacy = legacy_cron_instructions_path(dir.path());
475 std::fs::create_dir_all(legacy.parent().unwrap()).unwrap();
476 std::fs::write(&legacy, serde_json::to_string_pretty(&record).unwrap()).unwrap();
477
478 assert_eq!(load_cron_instructions(dir.path(), 5).unwrap(), record);
479 assert!(load_cron_instructions(dir.path(), 6).is_err());
480 assert_eq!(list_cron_instructions(dir.path()).len(), 1);
481
482 delete_cron_instructions(dir.path(), 5).unwrap();
483 assert!(!legacy.exists());
484 }
485
486 #[test]
487 fn cron_schedule_rounds_up_to_nearest_minute() {
488 assert_eq!(
489 cron_schedule_from_retry_after("2026-06-18T15:45:30Z"),
490 Some("46 15 18 6 *".to_string())
491 );
492 assert_eq!(
493 cron_schedule_from_retry_after("2026-06-18T15:45:00Z"),
494 Some("45 15 18 6 *".to_string())
495 );
496 }
497
498 #[test]
499 fn cron_schedule_normalizes_negative_offset() {
500 assert_eq!(
502 cron_schedule_from_retry_after("2026-06-18T15:45:30-05:00"),
503 Some("46 20 18 6 *".to_string())
504 );
505 assert_eq!(
507 cron_schedule_from_retry_after("2026-06-18T15:45:00-05:30"),
508 Some("15 21 18 6 *".to_string())
509 );
510 }
511
512 #[test]
519 fn cron_schedule_parses_all_iso8601_offset_forms() {
520 assert_eq!(
522 cron_schedule_from_retry_after("2026-06-18T15:45:30+0530"),
523 cron_schedule_from_retry_after("2026-06-18T15:45:30+05:30"),
524 );
525 assert_eq!(
527 cron_schedule_from_retry_after("2026-06-18T15:45:30-05"),
528 Some("46 20 18 6 *".to_string())
529 );
530 }
531
532 #[test]
533 fn parse_offset_minutes_bounds_and_forms() {
534 assert_eq!(parse_offset_minutes("+05:30"), Some(330));
535 assert_eq!(parse_offset_minutes("+0530"), Some(330));
536 assert_eq!(parse_offset_minutes("-0530"), Some(-330));
537 assert_eq!(parse_offset_minutes("+05"), Some(300));
538 assert_eq!(parse_offset_minutes("-05"), Some(-300));
539 assert_eq!(parse_offset_minutes("+24"), None);
541 assert_eq!(parse_offset_minutes("+05:60"), None);
542 assert_eq!(parse_offset_minutes("+5"), None);
543 assert_eq!(parse_offset_minutes("+530"), None);
544 assert_eq!(parse_offset_minutes("+abcd"), None);
545 }
546
547 #[test]
548 fn cron_schedule_formats_unix_seconds() {
549 assert_eq!(
550 cron_schedule_from_retry_after("1766678401"),
551 Some("1 16 25 12 *".to_string())
552 );
553 }
554
555 #[test]
556 fn shell_quote_leaves_common_safe_chars_unquoted() {
557 assert_eq!(
558 shell_quote("user@host:1.2.3+build"),
559 "user@host:1.2.3+build"
560 );
561 assert_eq!(shell_quote("~/proj/build=1_2%3"), "~/proj/build=1_2%3");
562 }
563
564 #[test]
565 fn shell_quote_quotes_unsafe_input() {
566 assert_eq!(shell_quote("a b"), "'a b'");
567 assert_eq!(shell_quote("it's"), "'it'\\''s'");
568 }
569
570 #[test]
575 fn single_agent_cron_instructions_resume_command_is_devflow_resume() {
576 let dir = tempfile::tempdir().unwrap();
577 let record = build_single_agent_cron_instructions(dir.path(), 9, "2026-06-18T15:45:30Z");
578
579 assert_eq!(record.resume.command, "devflow");
580 assert_eq!(record.resume.args, ["resume", "--phase", "9"]);
581 assert!(
582 record
583 .hermes_cron
584 .command
585 .contains("devflow resume --phase 9")
586 );
587 assert!(!record.hermes_cron.command.contains("sequentagent"));
588 assert!(!record.hermes_cron.command.contains(" start"));
589 assert!(record.hermes_cron.once);
590 }
591
592 #[test]
593 fn cron_instructions_reject_unparseable_retry_time() {
594 let dir = tempfile::tempdir().unwrap();
595 let record = build_single_agent_cron_instructions(dir.path(), 7, "unknown");
596
597 assert_ne!(record.hermes_cron.schedule, "* * * * *");
598 assert!(record.hermes_cron.schedule.is_empty());
599 }
600
601 #[test]
602 fn prepend_changelog_creates_header_when_empty() {
603 let out = prepend_changelog("", "0.5.2", "2026-06-18", "- some change\n");
604 assert!(out.starts_with("# Changelog"));
605 assert!(out.contains("## 0.5.2 — 2026-06-18"));
606 assert!(out.contains("- some change"));
607 }
608
609 #[test]
610 fn prepend_changelog_inserts_after_header() {
611 let existing = "# Changelog\n\n## 0.5.1 — 2026-06-17\n\n- old\n";
612 let out = prepend_changelog(existing, "0.5.2", "2026-06-18", "- new change\n");
613 let new_idx = out.find("0.5.2").unwrap();
614 let old_idx = out.find("0.5.1").unwrap();
615 assert!(new_idx < old_idx, "new entry should come before old");
616 assert!(out.starts_with("# Changelog"));
617 assert!(out.contains("- new change"));
618 }
619
620 #[test]
621 fn prepend_changelog_uses_the_generated_body() {
622 let body = "### Added\n\n- add the widget endpoint\n";
623 let out = prepend_changelog("", "1.2.0", "2026-07-29", body);
624 assert!(out.contains("## 1.2.0 — 2026-07-29"));
625 assert!(out.contains("### Added"));
626 assert!(out.contains("- add the widget endpoint"));
627 let fallback = prepend_changelog("", "1.2.1", "2026-07-30", "");
629 assert!(fallback.contains("- No changes recorded since the previous release."));
630 }
631}