use rusqlite::Connection;
use serde_json::Value;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::Instant;
use tempfile::TempDir;
fn run_decapod(dir: &Path, args: &[&str], envs: &[(&str, &str)]) -> std::process::Output {
let mut cmd = Command::new(env!("CARGO_BIN_EXE_decapod"));
cmd.current_dir(dir).args(args);
for (k, v) in envs {
cmd.env(k, v);
}
cmd.output().expect("run decapod")
}
fn setup_repo() -> (TempDir, PathBuf, String) {
let tmp = TempDir::new().expect("tmpdir");
let dir = tmp.path().to_path_buf();
let init = Command::new("git")
.current_dir(&dir)
.args(["init", "-b", "master"])
.output()
.expect("git init");
assert!(init.status.success(), "git init failed");
let out = run_decapod(&dir, &["init", "--force"], &[]);
assert!(
out.status.success(),
"decapod init failed: {}",
String::from_utf8_lossy(&out.stderr)
);
let acquire = run_decapod(
&dir,
&["session", "acquire"],
&[
("DECAPOD_AGENT_ID", "unknown"),
("DECAPOD_VALIDATE_SKIP_GIT_GATES", "1"),
],
);
assert!(
acquire.status.success(),
"session acquire failed: {}",
String::from_utf8_lossy(&acquire.stderr)
);
let acquire_stdout = String::from_utf8_lossy(&acquire.stdout);
let password = acquire_stdout
.lines()
.find_map(|line| {
line.strip_prefix("Password: ")
.map(|s| s.trim().to_string())
})
.expect("session password in output");
let todo_list = run_decapod(
&dir,
&["todo", "list"],
&[
("DECAPOD_AGENT_ID", "unknown"),
("DECAPOD_SESSION_PASSWORD", &password),
("DECAPOD_VALIDATE_SKIP_GIT_GATES", "1"),
],
);
assert!(
todo_list.status.success(),
"todo list failed: {}",
String::from_utf8_lossy(&todo_list.stderr)
);
(tmp, dir, password)
}
#[test]
fn validate_terminates_with_typed_error_under_db_contention() {
let (_tmp, dir, password) = setup_repo();
let db_path = dir.join(".decapod").join("data").join("todo.db");
assert!(db_path.exists(), "todo db should exist before lock test");
let conn = Connection::open(&db_path).expect("open todo db");
conn.execute_batch("BEGIN EXCLUSIVE;")
.expect("acquire exclusive lock");
let start = Instant::now();
let validate = run_decapod(
&dir,
&["validate"],
&[
("DECAPOD_AGENT_ID", "unknown"),
("DECAPOD_SESSION_PASSWORD", &password),
("DECAPOD_VALIDATE_SKIP_GIT_GATES", "1"),
("DECAPOD_VALIDATE_TIMEOUT_SECS", "2"),
],
);
let elapsed = start.elapsed();
assert!(
!validate.status.success(),
"validate should fail under forced lock contention"
);
let stderr = String::from_utf8_lossy(&validate.stderr);
assert!(
stderr.contains("VALIDATE_TIMEOUT_OR_LOCK"),
"validate stderr should contain typed bounded-time failure marker; got: {stderr}"
);
assert!(
elapsed.as_secs() < 10,
"validate must terminate quickly under contention; elapsed={elapsed:?}"
);
conn.execute_batch("ROLLBACK;").expect("release lock");
}
#[test]
fn validate_terminates_with_typed_error_under_immediate_lock_contention() {
let (_tmp, dir, password) = setup_repo();
let db_path = dir.join(".decapod").join("data").join("todo.db");
assert!(db_path.exists(), "todo db should exist before lock test");
let conn = Connection::open(&db_path).expect("open todo db");
conn.execute_batch("BEGIN IMMEDIATE;")
.expect("acquire immediate lock");
let start = Instant::now();
let validate = run_decapod(
&dir,
&["validate"],
&[
("DECAPOD_AGENT_ID", "unknown"),
("DECAPOD_SESSION_PASSWORD", &password),
("DECAPOD_VALIDATE_SKIP_GIT_GATES", "1"),
("DECAPOD_VALIDATE_TIMEOUT_SECS", "2"),
],
);
let elapsed = start.elapsed();
assert!(
!validate.status.success(),
"validate should fail under immediate lock contention"
);
let stderr = String::from_utf8_lossy(&validate.stderr);
assert!(
stderr.contains("VALIDATE_TIMEOUT_OR_LOCK"),
"validate stderr should contain typed bounded-time failure marker; got: {stderr}"
);
assert!(
elapsed.as_secs() < 10,
"validate must terminate quickly under contention; elapsed={elapsed:?}"
);
conn.execute_batch("ROLLBACK;").expect("release lock");
}
#[test]
fn validate_timeout_does_not_strand_db_for_followup_commands() {
let (_tmp, dir, password) = setup_repo();
let db_path = dir.join(".decapod").join("data").join("todo.db");
assert!(db_path.exists(), "todo db should exist before lock test");
let conn = Connection::open(&db_path).expect("open todo db");
conn.execute_batch("BEGIN EXCLUSIVE;")
.expect("acquire exclusive lock");
let validate = run_decapod(
&dir,
&["validate"],
&[
("DECAPOD_AGENT_ID", "unknown"),
("DECAPOD_SESSION_PASSWORD", &password),
("DECAPOD_VALIDATE_SKIP_GIT_GATES", "1"),
("DECAPOD_VALIDATE_TIMEOUT_SECONDS", "2"),
],
);
assert!(
!validate.status.success(),
"validate should fail under forced lock contention"
);
let stderr = String::from_utf8_lossy(&validate.stderr);
assert!(
stderr.contains("VALIDATE_TIMEOUT_OR_LOCK"),
"validate stderr should contain typed bounded-time failure marker; got: {stderr}"
);
conn.execute_batch("ROLLBACK;").expect("release lock");
let followup = run_decapod(
&dir,
&["todo", "list"],
&[
("DECAPOD_AGENT_ID", "unknown"),
("DECAPOD_SESSION_PASSWORD", &password),
("DECAPOD_VALIDATE_SKIP_GIT_GATES", "1"),
],
);
assert!(
followup.status.success(),
"follow-up command should succeed after lock release; stderr:\n{}",
String::from_utf8_lossy(&followup.stderr)
);
}
#[test]
fn validate_json_reports_self_heal_and_structured_summary() {
let (_tmp, dir, password) = setup_repo();
let validate = run_decapod(
&dir,
&["validate", "--format", "json"],
&[
("DECAPOD_CONTAINER", "1"),
("DECAPOD_AGENT_ID", "unknown"),
("DECAPOD_SESSION_PASSWORD", &password),
("DECAPOD_VALIDATE_SKIP_GIT_GATES", "1"),
],
);
assert!(
validate.status.success(),
"validate --format json should succeed in a container-marked workspace; stderr:\n{}",
String::from_utf8_lossy(&validate.stderr)
);
let payload: Value =
serde_json::from_slice(&validate.stdout).expect("validate json payload should parse");
assert_eq!(payload["status"], "ok");
assert_eq!(payload["report"]["status"], "ok");
assert!(payload["report"]["fail_count"].as_u64().unwrap_or(1) == 0);
assert!(payload["report"]["gate_timings"].is_array());
assert!(payload["self_heal"].is_array());
assert!(
!payload["self_heal"]
.as_array()
.unwrap()
.iter()
.any(|action| action["action"] == "heal_container_runtime_override"),
"validate should not write container-runtime override markers automatically"
);
}
#[test]
fn validate_clears_stale_container_override_when_runtime_is_available() {
let (_tmp, dir, password) = setup_repo();
let override_path = dir.join(".decapod").join("OVERRIDE.md");
fs::write(
&override_path,
concat!(
"### plugins/CONTAINER\n",
"## Runtime Guard Override (auto-generated)\n",
"DECAPOD_CONTAINER_RUNTIME_DISABLED=true\n",
"reason: stale test marker\n",
"remediation: remove when runtime is healthy\n",
"warning: disabling isolated containers increases risk of concurrent agents stepping on each other.\n",
),
)
.expect("write override");
let fake_bin = dir.join("fake-bin");
fs::create_dir_all(&fake_bin).expect("mkdir fake-bin");
let fake_docker = fake_bin.join("docker");
fs::write(
&fake_docker,
"#!/bin/sh\nif [ \"$1\" = \"info\" ]; then exit 0; fi\nexit 0\n",
)
.expect("write fake docker");
let chmod = Command::new("chmod")
.args(["+x", fake_docker.to_str().expect("fake docker path")])
.status()
.expect("chmod fake docker");
assert!(chmod.success(), "chmod should succeed");
let path = std::env::var("PATH").unwrap_or_default();
let runtime_path = format!("{}:{}", fake_bin.display(), path);
let validate = run_decapod(
&dir,
&["validate", "--format", "json"],
&[
("DECAPOD_AGENT_ID", "unknown"),
("DECAPOD_SESSION_PASSWORD", &password),
("DECAPOD_VALIDATE_SKIP_GIT_GATES", "1"),
("PATH", &runtime_path),
],
);
assert!(
validate.status.success(),
"validate should clear stale runtime override; stderr:\n{}",
String::from_utf8_lossy(&validate.stderr)
);
let payload: Value =
serde_json::from_slice(&validate.stdout).expect("validate json payload should parse");
let heals = payload["self_heal"].as_array().expect("self_heal array");
assert!(
heals.iter().any(|action| {
action["action"] == "heal_container_runtime_override" && action["outcome"] == "cleared"
}),
"expected stale container override to be cleared; payload: {payload}"
);
let override_content = fs::read_to_string(&override_path).expect("read override");
assert!(
!override_content.contains("DECAPOD_CONTAINER_RUNTIME_DISABLED=true"),
"container disable marker should be removed"
);
}
#[test]
fn validate_parallel_contention_emits_typed_reasoned_diagnostics() {
let (_tmp, dir, password) = setup_repo();
let db_path = dir.join(".decapod").join("data").join("todo.db");
assert!(db_path.exists(), "todo db should exist before lock test");
let conn = Connection::open(&db_path).expect("open todo db");
conn.execute_batch("BEGIN EXCLUSIVE;")
.expect("acquire exclusive lock");
let mut outputs = Vec::new();
for _ in 0..4 {
outputs.push(run_decapod(
&dir,
&["validate"],
&[
("DECAPOD_AGENT_ID", "unknown"),
("DECAPOD_SESSION_PASSWORD", &password),
("DECAPOD_VALIDATE_SKIP_GIT_GATES", "1"),
("DECAPOD_VALIDATE_TIMEOUT_SECS", "2"),
("DECAPOD_DIAGNOSTICS", "1"),
],
));
}
conn.execute_batch("ROLLBACK;").expect("release lock");
for output in &outputs {
assert!(
!output.status.success(),
"validate should fail under forced lock contention"
);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("VALIDATE_TIMEOUT_OR_LOCK"),
"expected typed failure marker; got: {stderr}"
);
assert!(
stderr.contains(".decapod/generated/artifacts/diagnostics/validate/"),
"expected diagnostics artifact path in stderr; got: {stderr}"
);
}
let diagnostics_dir = dir
.join(".decapod")
.join("generated")
.join("artifacts")
.join("diagnostics")
.join("validate");
assert!(
diagnostics_dir.exists(),
"diagnostics directory should be created under .decapod/generated/artifacts/diagnostics/validate"
);
let mut diagnostic_count = 0usize;
for entry in fs::read_dir(&diagnostics_dir).expect("read diagnostics dir") {
let entry = entry.expect("diagnostics dir entry");
let path = entry.path();
if path.extension().and_then(|s| s.to_str()) != Some("json") {
continue;
}
diagnostic_count += 1;
let raw = fs::read_to_string(&path).expect("read diagnostics artifact");
let payload: Value = serde_json::from_str(&raw).expect("parse diagnostics artifact");
assert_eq!(payload["kind"], "validate_diagnostic");
assert_eq!(payload["op"], "validate");
assert_eq!(payload["reason_code"], "timeout_acquiring_lock");
assert!(payload["elapsed_ms"].as_u64().is_some());
assert!(payload["timeout_secs"].as_u64().unwrap_or(0) > 0);
assert!(payload["artifact_hash"].as_str().unwrap_or("").len() >= 64);
assert!(
!raw.contains(&dir.to_string_lossy().to_string()),
"diagnostics should not leak absolute worktree path"
);
assert!(
!raw.to_ascii_lowercase().contains("hostname"),
"diagnostics should not leak hostname"
);
}
assert!(
diagnostic_count >= 4,
"expected at least one diagnostics artifact per failed validate run"
);
}
#[test]
fn validate_diagnostics_disabled_does_not_write_artifacts() {
let (_tmp, dir, password) = setup_repo();
let db_path = dir.join(".decapod").join("data").join("todo.db");
assert!(db_path.exists(), "todo db should exist before lock test");
let conn = Connection::open(&db_path).expect("open todo db");
conn.execute_batch("BEGIN EXCLUSIVE;")
.expect("acquire exclusive lock");
let output = run_decapod(
&dir,
&["validate"],
&[
("DECAPOD_AGENT_ID", "unknown"),
("DECAPOD_SESSION_PASSWORD", &password),
("DECAPOD_VALIDATE_SKIP_GIT_GATES", "1"),
("DECAPOD_VALIDATE_TIMEOUT_SECS", "2"),
],
);
conn.execute_batch("ROLLBACK;").expect("release lock");
assert!(
!output.status.success(),
"validate should fail under forced lock contention"
);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("VALIDATE_TIMEOUT_OR_LOCK"),
"expected typed failure marker; got: {stderr}"
);
assert!(
!stderr.contains("Diagnostics:"),
"diagnostics path should not be emitted when DECAPOD_DIAGNOSTICS is disabled"
);
let diagnostics_validate_dir = dir
.join(".decapod")
.join("generated")
.join("artifacts")
.join("diagnostics")
.join("validate");
let has_diagnostics_files = diagnostics_validate_dir.exists()
&& fs::read_dir(&diagnostics_validate_dir)
.expect("read diagnostics dir")
.any(|entry| {
entry
.ok()
.and_then(|e| {
e.path()
.extension()
.and_then(|ext| ext.to_str())
.map(|ext| ext == "json")
})
.unwrap_or(false)
});
assert!(
!has_diagnostics_files,
"diagnostics artifacts must not be written unless explicitly enabled"
);
}
#[test]
fn validate_diagnostics_payload_is_sanitized() {
let (_tmp, dir, password) = setup_repo();
let db_path = dir.join(".decapod").join("data").join("todo.db");
assert!(db_path.exists(), "todo db should exist before lock test");
let conn = Connection::open(&db_path).expect("open todo db");
conn.execute_batch("BEGIN EXCLUSIVE;")
.expect("acquire exclusive lock");
let output = run_decapod(
&dir,
&["validate"],
&[
("DECAPOD_AGENT_ID", "unknown"),
("DECAPOD_SESSION_PASSWORD", &password),
("DECAPOD_VALIDATE_SKIP_GIT_GATES", "1"),
("DECAPOD_VALIDATE_TIMEOUT_SECS", "2"),
("DECAPOD_DIAGNOSTICS", "1"),
],
);
conn.execute_batch("ROLLBACK;").expect("release lock");
assert!(
!output.status.success(),
"validate should fail under forced lock contention"
);
let diagnostics_dir = dir
.join(".decapod")
.join("generated")
.join("artifacts")
.join("diagnostics")
.join("validate");
let artifact_path = fs::read_dir(&diagnostics_dir)
.expect("read diagnostics dir")
.find_map(|entry| {
let path = entry.ok()?.path();
(path.extension().and_then(|s| s.to_str()) == Some("json")).then_some(path)
})
.expect("at least one diagnostics artifact");
let raw = fs::read_to_string(&artifact_path).expect("read diagnostics artifact");
let payload: Value = serde_json::from_str(&raw).expect("parse diagnostics artifact");
assert_eq!(payload["kind"], "validate_diagnostic");
assert_eq!(payload["op"], "validate");
assert_eq!(payload["reason_code"], "timeout_acquiring_lock");
let object = payload.as_object().expect("diagnostics payload object");
let forbidden_keys = [
"hostname", "username", "env", "cwd", "path", "pid", "command",
];
for key in forbidden_keys {
assert!(
!object.contains_key(key),
"diagnostics payload must not contain forbidden key '{key}'"
);
}
let forbidden_patterns = [
"/home/",
"C:/",
"USER=",
"HOSTNAME=",
"PATH=",
"DECAPOD_SESSION_PASSWORD",
];
for pat in forbidden_patterns {
assert!(
!raw.contains(pat),
"diagnostics payload must not contain forbidden pattern '{pat}'"
);
}
let run_id = payload["run_id"].as_str().expect("run_id string");
assert_eq!(run_id.len(), 32, "run_id must be 128-bit hex");
assert!(
run_id.chars().all(|c| c.is_ascii_hexdigit()),
"run_id must be lower/upper hex only"
);
assert!(
!run_id.contains('-'),
"run_id must be non-ULID/non-hyphenated to avoid inferential timestamp encoding"
);
}
#[test]
fn validate_smoke_runtime_is_bounded_without_contention() {
let (_tmp, dir, password) = setup_repo();
let mut durations_ms = Vec::new();
for _ in 0..3 {
let start = Instant::now();
let output = run_decapod(
&dir,
&["validate"],
&[
("DECAPOD_AGENT_ID", "unknown"),
("DECAPOD_SESSION_PASSWORD", &password),
("DECAPOD_VALIDATE_SKIP_GIT_GATES", "1"),
("DECAPOD_VALIDATE_TIMEOUT_SECS", "10"),
],
);
let elapsed = start.elapsed().as_millis() as u64;
durations_ms.push(elapsed);
assert!(
output.status.success(),
"validate should pass without forced contention; stderr:\n{}",
String::from_utf8_lossy(&output.stderr)
);
assert!(
elapsed < 10_000,
"validate runtime exceeded bounded smoke threshold: {elapsed}ms"
);
}
let total: u64 = durations_ms.iter().sum();
assert!(
total < 20_000,
"three sequential validates should stay within bounded aggregate runtime; got {total}ms"
);
}