#![cfg(all(unix, any(feature = "internal-mocks", debug_assertions)))]
mod common;
use serde_json::Value;
use std::io::Write;
use std::process::{Command, Stdio};
fn config(mock_uri: &str, extra_step: &str, finish_after: &str) -> String {
let path = common::unique_path("generation-v2", "yaml");
let yaml = format!(
"config_version: \"2\"\n\
agent:\n name: generations\n\
mcp:\n servers:\n - name: mock\n endpoint: {mock_uri}\n\
store:\n kind: mcp\n mcp:\n server: mock\n\
workflows:\n - name: chain\n steps:\n\
\x20 s: {{kind: once}}\n\
\x20 a: {{kind: noop, depends_on: [s]}}\n\
{extra_step}\
\x20 f: {{kind: finish, depends_on: [{finish_after}], status: completed, output: done}}\n\
lifecycle:\n run_until: idle\n idle_grace: 1s\n\
observability:\n log_level: info\n"
);
std::fs::File::create(&path)
.unwrap()
.write_all(yaml.as_bytes())
.unwrap();
path
}
fn life(cfg: &str, extra_args: &[&str]) -> Vec<Value> {
let out = Command::new(env!("CARGO_BIN_EXE_agentd"))
.args(["--config", cfg])
.args(extra_args)
.stdin(Stdio::null())
.output()
.expect("run agentd");
let stderr = String::from_utf8_lossy(&out.stderr).into_owned();
assert_eq!(
out.status.code(),
Some(0),
"life {extra_args:?} should exit 0; stderr:\n{stderr}"
);
stderr
.lines()
.filter_map(|l| serde_json::from_str::<Value>(l).ok())
.collect()
}
fn event<'a>(lines: &'a [Value], name: &str) -> Option<&'a Value> {
lines.iter().find(|l| l["event"] == name)
}
fn expect<'a>(lines: &'a [Value], name: &str) -> &'a Value {
event(lines, name).unwrap_or_else(|| {
panic!(
"no {name} among: {:?}",
lines.iter().map(|l| &l["event"]).collect::<Vec<_>>()
)
})
}
#[test]
fn fresh_starts_a_new_generation_and_does_not_resume() {
let mock = common::spawn_mock_mcp("mock://noop", false);
let cfg = config(&mock.uri(), "", "a");
assert_eq!(expect(&life(&cfg, &[]), "restore.fresh")["generation"], 1);
let l2 = expect(&life(&cfg, &[]), "restore.done").clone();
assert_eq!(l2["generation"], 2);
assert_eq!(l2["entities"], 1, "an ordinary start resumes prior state");
let l3 = life(&cfg, &["--fresh"]);
assert!(
event(&l3, "restore.done").is_none(),
"--fresh must not resume: {l3:?}"
);
let fresh = expect(&l3, "restore.fresh");
assert_eq!(fresh["generation"], 3);
assert_eq!(fresh["superseded"], 2);
assert!(
fresh["retired"].as_u64().unwrap_or(0) >= 1,
"the previous generation's records are retired, not deleted: {fresh}"
);
let l4 = expect(&life(&cfg, &[]), "restore.done").clone();
assert_eq!(l4["generation"], 4);
assert_eq!(l4["entities"], 1, "retired records stay retired: {l4}");
std::fs::remove_file(&cfg).ok();
}
#[test]
fn an_edited_workflow_reports_config_changed_and_still_resumes() {
let mock = common::spawn_mock_mcp("mock://noop", false);
let cfg = config(&mock.uri(), "", "a");
life(&cfg, &[]); let before = expect(&life(&cfg, &[]), "restore.done").clone();
assert_eq!(before["generation"], 2);
std::fs::remove_file(&cfg).ok();
let edited = config(&mock.uri(), " b: {kind: noop, depends_on: [a]}\n", "b");
let lines = life(&edited, &[]);
let changed = expect(&lines, "store.config_changed");
assert_eq!(
changed["level"], "warn",
"it is the operator's cue: {changed}"
);
assert_eq!(
changed["sections"],
serde_json::json!(["workflows"]),
"only the section that moved is named: {changed}"
);
assert_eq!(
changed["msg"],
"state was written under a different configuration — resuming anyway; --fresh to start a new generation"
);
let after = expect(&lines, "restore.done");
assert_eq!(after["generation"], 3);
assert_eq!(after["entities"], 1, "state is resumed anyway: {after}");
let again = life(&edited, &[]);
assert!(
event(&again, "store.config_changed").is_none(),
"an unchanged configuration must not re-report: {again:?}"
);
assert_eq!(expect(&again, "restore.done")["generation"], 4);
std::fs::remove_file(&edited).ok();
}
#[test]
fn fresh_is_documented_in_help() {
let out = Command::new(env!("CARGO_BIN_EXE_agentd"))
.arg("--help")
.output()
.expect("run agentd --help");
let help = String::from_utf8_lossy(&out.stdout);
assert!(
help.contains("--fresh"),
"--help must list --fresh:\n{help}"
);
assert!(
help.contains("start a NEW generation"),
"--help must say what it does:\n{help}"
);
}