use std::fmt::Write as _;
use std::path::{Path, PathBuf};
use argonauts_core::events::RunEventKind;
use argonauts_core::events::plan::PlanEvent;
use argonauts_core::names::{DraftId, ModelRef, PlanLabel, SessionKey};
use argonauts_core::steps::plan::{LabeledPlan, PlanDraft};
use argonauts_core::{create_private_dir_all, sha256_hex, write_private_file};
use crate::engine::host::Host;
use crate::error::{EngineError, EngineResult};
use crate::state::ids::IdKind;
use crate::state::plan::SealedDraft;
pub fn seal(
host: &Host,
label: PlanLabel,
worker: &ModelRef,
round: u8,
draft: &PlanDraft,
session: Option<&SessionKey>,
) -> EngineResult<DraftId> {
let id = DraftId::numbered(host.allocate(IdKind::Draft)?);
let dir = host.repo().run_dir.join("drafts");
create_private_dir_all(&dir)?;
let text = render(draft);
let path = dir.join(format!("{id}.md"));
write_private_file(&path, text.as_bytes())?;
write_private_file(&structured_path(&path), &serde_json::to_vec_pretty(draft)?)?;
host.record(
None,
session,
RunEventKind::Plan(PlanEvent::Sealed {
draft: id.clone(),
label,
worker: worker.clone(),
round,
summary: draft.summary.clone(),
path,
sha256: sha256_hex(text.as_bytes()),
}),
)?;
Ok(id)
}
fn structured_path(text: &Path) -> PathBuf {
text.with_extension("json")
}
pub fn read_text(sealed: &SealedDraft) -> EngineResult<String> {
let text = std::fs::read_to_string(&sealed.path)?;
if sha256_hex(text.as_bytes()) != sealed.sha256 {
return Err(EngineError::Refused(format!(
"draft {} changed after it was sealed",
sealed.draft
)));
}
Ok(text)
}
pub fn load(sealed: &SealedDraft) -> EngineResult<PlanDraft> {
let bytes = std::fs::read(structured_path(&sealed.path))?;
Ok(serde_json::from_slice(&bytes)?)
}
pub fn labeled(drafts: &[&SealedDraft]) -> EngineResult<Vec<LabeledPlan>> {
drafts
.iter()
.map(|sealed| {
Ok(LabeledPlan {
label: sealed.label,
text: read_text(sealed)?,
})
})
.collect()
}
pub fn render(draft: &PlanDraft) -> String {
let mut text = String::new();
let _ = writeln!(text, "## Summary\n\n{}\n", draft.summary.trim());
let _ = writeln!(text, "## Approach\n\n{}\n", draft.approach.trim());
if !draft.pieces.is_empty() {
text.push_str("## Pieces\n\n");
for piece in &draft.pieces {
let _ = writeln!(text, "### {}\n\n{}\n", piece.name, piece.description.trim());
list_line(&mut text, "Paths", &piece.paths);
list_line(&mut text, "Interfaces", &piece.interfaces);
list_line(&mut text, "Depends on", &piece.depends_on);
text.push('\n');
}
}
if !draft.choices.is_empty() {
text.push_str("## Choices\n\n");
for choice in &draft.choices {
let _ = writeln!(
text,
"- {}: {}. {}",
choice.topic,
choice.choice,
choice.rationale.trim()
);
}
text.push('\n');
}
bullets(&mut text, "Risks", &draft.risks);
let _ = writeln!(text, "## Test strategy\n\n{}\n", draft.test_strategy.trim());
bullets(&mut text, "New dependencies", &draft.new_dependencies);
text.truncate(text.trim_end().len());
text.push('\n');
text
}
fn list_line(text: &mut String, name: &str, items: &[String]) {
if !items.is_empty() {
let _ = writeln!(text, "- {name}: {}", items.join(", "));
}
}
fn bullets(text: &mut String, heading: &str, items: &[String]) {
if items.is_empty() {
return;
}
let _ = writeln!(text, "## {heading}\n");
for item in items {
let _ = writeln!(text, "- {}", item.trim());
}
text.push('\n');
}
#[cfg(test)]
pub(crate) mod tests {
use argonauts_core::steps::plan::{PlanChoice, PlanPiece};
use super::*;
pub(crate) fn draft(summary: &str) -> PlanDraft {
PlanDraft {
summary: summary.to_owned(),
approach: "Add a middleware after auth.".to_owned(),
pieces: vec![PlanPiece {
name: "limiter".to_owned(),
description: "The token bucket.".to_owned(),
paths: vec!["src/limit/".to_owned()],
interfaces: vec!["Limiter::check".to_owned()],
depends_on: Vec::new(),
}],
choices: vec![PlanChoice {
topic: "counters".to_owned(),
choice: "Redis".to_owned(),
rationale: "Survives restarts.".to_owned(),
}],
risks: vec!["Redis down means no limits.".to_owned()],
test_strategy: "Unit tests for the bucket.".to_owned(),
new_dependencies: Vec::new(),
}
}
#[test]
fn render_lists_every_part_in_order() {
let text = render(&draft("Rate limit the API."));
let order = [
"## Summary",
"Rate limit the API.",
"## Approach",
"## Pieces",
"### limiter",
"- Paths: src/limit/",
"- Interfaces: Limiter::check",
"## Choices",
"- counters: Redis. Survives restarts.",
"## Risks",
"## Test strategy",
];
let mut from = 0;
for part in order {
let at = text[from..]
.find(part)
.unwrap_or_else(|| panic!("{part}\n{text}"));
from += at + part.len();
}
assert!(!text.contains("New dependencies"));
assert!(text.ends_with("bucket.\n"));
}
#[test]
fn a_changed_sealed_file_is_refused() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("dr-001.md");
std::fs::write(&path, "the plan\n").unwrap();
let sealed = SealedDraft {
draft: DraftId::numbered(1),
label: 1,
worker: "codex".parse().unwrap(),
round: 1,
summary: String::new(),
path: path.clone(),
sha256: sha256_hex(b"the plan\n"),
session: None,
};
assert_eq!(read_text(&sealed).unwrap(), "the plan\n");
std::fs::write(&path, "the plan, edited\n").unwrap();
assert!(matches!(read_text(&sealed), Err(EngineError::Refused(_))));
}
}