use anyhow::Result;
use rusqlite::Connection;
use serde_json::json;
use crate::infrastructure::config::Config;
use crate::infrastructure::db;
fn project_head(conn: &Connection, project: &str) -> Option<String> {
let proj = db::get_project(conn, project).ok().flatten()?;
let path = proj.path?;
crate::infrastructure::git::head_commit(std::path::Path::new(&path))
}
pub fn guard_branch_mutation(
conn: &Connection,
id_input: &str,
task: &crate::infrastructure::model::Task,
force: bool,
) -> Result<()> {
if force || id_input.parse::<i64>().is_err() {
return Ok(()); }
let Some(rec) = db::get_task_branch(conn, &task.uuid) else {
return Ok(());
};
let Some(path) = db::get_project(conn, &task.project)
.ok()
.flatten()
.and_then(|p| p.path)
else {
return Ok(());
};
let Some(current) = crate::infrastructure::git::current_branch(std::path::Path::new(&path))
else {
return Ok(());
};
if current != rec.branch {
anyhow::bail!(
"refusing to act on task {} by display id — it is tied to branch '{}' but the \
project is currently on '{}'. Recycled display ids can point at a different task \
after recompaction. Re-run with the stable uuid `{}` (or pass --force).",
task.id.unwrap_or(0),
rec.branch,
current,
&task.uuid.to_string()[..8]
);
}
Ok(())
}
fn kind_arg(kind: Option<&str>) -> &str {
match kind {
Some("acceptance") => db::STEP_KIND_ACCEPTANCE,
_ => db::STEP_KIND_STEP,
}
}
const NEXT_MEMORY_LIMIT: usize = 3;
fn relevant_memories(
conn: &Connection,
task: &crate::infrastructure::model::Task,
) -> Vec<(String, String)> {
db::find_similar_strong_memories(conn, &task.description, &task.tags)
.unwrap_or_default()
.into_iter()
.take(NEXT_MEMORY_LIMIT)
.map(|item| {
let label = format!("m{}", item.display_id.unwrap_or(0));
let snippet: String = item
.summary
.clone()
.unwrap_or_else(|| item.body.clone())
.chars()
.take(160)
.collect();
(label, snippet.trim().to_string())
})
.collect()
}
pub fn next_value(conn: &Connection, id: &str) -> Result<serde_json::Value> {
let task = db::resolve_task(conn, id)?;
let steps = db::get_steps(conn, &task.uuid, db::STEP_KIND_STEP)?;
let next = steps.iter().enumerate().find(|(_, s)| !s.done);
let relevant: Vec<serde_json::Value> = relevant_memories(conn, &task)
.into_iter()
.map(|(label, snippet)| json!({ "label": label, "snippet": snippet }))
.collect();
let mut value = match next {
Some((i, s)) => json!({
"task": task.id,
"index": i + 1,
"total": steps.len(),
"text": s.text,
"intent": s.intent,
"verify_cmd": s.verify_cmd,
"source": s.source,
}),
None => json!({ "task": task.id, "done": true, "total": steps.len() }),
};
if !relevant.is_empty()
&& let Some(obj) = value.as_object_mut()
{
obj.insert(
"relevant_memories".to_string(),
serde_json::Value::Array(relevant),
);
}
Ok(value)
}
pub fn next(conn: &Connection, _cfg: &Config, id: &str, as_json: bool) -> Result<()> {
if as_json {
println!("{}", serde_json::to_string_pretty(&next_value(conn, id)?)?);
return Ok(());
}
let task = db::resolve_task(conn, id)?;
let steps = db::get_steps(conn, &task.uuid, db::STEP_KIND_STEP)?;
let next = steps.iter().enumerate().find(|(_, s)| !s.done);
match next {
Some((i, s)) => {
println!("Next step {}/{}: {}", i + 1, steps.len(), s.text);
if let Some(intent) = &s.intent {
println!(" intent: {intent}");
}
if let Some(v) = &s.verify_cmd {
println!(" verify: {v}");
}
}
None if steps.is_empty() => println!("No steps defined for task {}.", task.id.unwrap_or(0)),
None => println!("All steps complete for task {}.", task.id.unwrap_or(0)),
}
let relevant = relevant_memories(conn, &task);
if !relevant.is_empty() {
println!(
"\nRelevant memory ({}) — recall before you act:",
relevant.len()
);
for (label, snippet) in &relevant {
println!(" {label}: {snippet}");
}
}
Ok(())
}
pub fn steps_value(conn: &Connection, id: &str, until: Option<usize>) -> Result<serde_json::Value> {
let task = db::resolve_task(conn, id)?;
let mut steps = db::get_steps(conn, &task.uuid, db::STEP_KIND_STEP)?;
if let Some(n) = until {
steps.truncate(n);
}
let arr: Vec<_> = steps
.iter()
.enumerate()
.map(|(i, s)| {
json!({
"index": i + 1,
"text": s.text,
"intent": s.intent,
"done": s.done,
"source": s.source,
"verify_cmd": s.verify_cmd,
"result": s.result,
})
})
.collect();
Ok(json!({ "task": task.id, "steps": arr }))
}
pub fn steps(
conn: &Connection,
_cfg: &Config,
id: &str,
until: Option<usize>,
as_json: bool,
) -> Result<()> {
if as_json {
println!(
"{}",
serde_json::to_string_pretty(&steps_value(conn, id, until)?)?
);
return Ok(());
}
let task = db::resolve_task(conn, id)?;
let mut steps = db::get_steps(conn, &task.uuid, db::STEP_KIND_STEP)?;
let acceptance = db::get_steps(conn, &task.uuid, db::STEP_KIND_ACCEPTANCE)?;
if let Some(n) = until {
steps.truncate(n);
}
if steps.is_empty() && acceptance.is_empty() {
println!("No steps defined for task {}.", task.id.unwrap_or(0));
return Ok(());
}
if !steps.is_empty() {
println!("Steps (tick with `step done <id> N`):");
for (i, s) in steps.iter().enumerate() {
let mark = if s.done { "[x]" } else { "[ ]" };
let badge = if s.source == "ai" { " (ai)" } else { "" };
println!(" {} {}. {}{}", mark, i + 1, s.text, badge);
if let Some(intent) = &s.intent {
println!(" {intent}");
}
}
}
if !acceptance.is_empty() {
println!("Acceptance criteria (tick with `step done <id> N --kind acceptance`):");
for (i, a) in acceptance.iter().enumerate() {
let mark = if a.done { "[x]" } else { "[ ]" };
let badge = if a.source == "ai" { " (ai)" } else { "" };
println!(" {} {}. {}{}", mark, i + 1, a.text, badge);
if let Some(intent) = &a.intent {
println!(" {intent}");
}
}
}
Ok(())
}
pub fn step_done_value(
conn: &Connection,
id: &str,
n: usize,
result: Option<&str>,
kind: Option<&str>,
) -> Result<serde_json::Value> {
let task = db::resolve_task(conn, id)?;
let kind = kind_arg(kind);
let step_id = db::step_id_by_index(conn, &task.uuid, kind, n)?;
let commit = project_head(conn, &task.project);
db::set_step_done(conn, step_id, true, result, commit.as_deref())?;
let activated = db::ensure_started(conn, &task.uuid)?;
let related = match result {
Some(r) if !r.trim().is_empty() => {
crate::commands::insight::related_findings(conn, &task.uuid, r, None)
}
_ => Vec::new(),
};
Ok(json!({
"task": task.id,
"uuid": task.uuid.to_string(),
"kind": kind,
"index": n,
"done": true,
"commit": commit,
"activated": activated,
"related_findings": crate::commands::insight::related_findings_json(&related),
}))
}
pub fn step_done_by_id_value(
conn: &Connection,
step_id: i64,
result: Option<&str>,
) -> Result<serde_json::Value> {
let (uuid, kind, index) = db::locate_step(conn, step_id)?;
step_done_value(conn, &uuid.to_string(), index, result, Some(&kind))
}
pub fn step_done(
conn: &Connection,
_cfg: &Config,
id: &str,
n: usize,
result: Option<&str>,
kind: Option<&str>,
as_json: bool,
) -> Result<()> {
let v = step_done_value(conn, id, n, result, kind)?;
if as_json {
println!("{}", serde_json::to_string_pretty(&v)?);
return Ok(());
}
let commit_suffix = v
.get("commit")
.and_then(|c| c.as_str())
.map(|c| format!(" @ {c}"))
.unwrap_or_default();
println!(
"Marked {} {} of task {} done{}.",
v.get("kind").and_then(|k| k.as_str()).unwrap_or("step"),
n,
v.get("task").and_then(|t| t.as_i64()).unwrap_or(0),
commit_suffix
);
if let Some(related) = v.get("related_findings").and_then(|r| r.as_array())
&& !related.is_empty()
{
eprintln!("⟳ reconsider — related prior finding(s) on this task:");
for r in related {
eprintln!(
" (~{:.2}) #{}: {}",
r.get("cosine").and_then(|c| c.as_f64()).unwrap_or(0.0),
r.get("annotation_id").and_then(|i| i.as_i64()).unwrap_or(0),
r.get("text").and_then(|t| t.as_str()).unwrap_or("")
);
}
}
Ok(())
}
pub fn step_undone_value(
conn: &Connection,
id: &str,
n: usize,
kind: Option<&str>,
) -> Result<serde_json::Value> {
let task = db::resolve_task(conn, id)?;
let kind = kind_arg(kind);
let step_id = db::step_id_by_index(conn, &task.uuid, kind, n)?;
db::set_step_done(conn, step_id, false, None, None)?;
Ok(json!({
"task": task.id,
"uuid": task.uuid.to_string(),
"kind": kind,
"index": n,
"done": false,
}))
}
pub fn step_undone_by_id_value(conn: &Connection, step_id: i64) -> Result<serde_json::Value> {
let (uuid, kind, index) = db::locate_step(conn, step_id)?;
step_undone_value(conn, &uuid.to_string(), index, Some(&kind))
}
pub fn step_undone(
conn: &Connection,
_cfg: &Config,
id: &str,
n: usize,
kind: Option<&str>,
as_json: bool,
) -> Result<()> {
let v = step_undone_value(conn, id, n, kind)?;
if as_json {
println!("{}", serde_json::to_string_pretty(&v)?);
return Ok(());
}
println!(
"Reopened {} {} of task {}.",
v["kind"].as_str().unwrap_or("step"),
n,
v["task"].as_i64().unwrap_or(0)
);
Ok(())
}
pub fn step_remove_value(
conn: &Connection,
id: &str,
n: usize,
kind: Option<&str>,
) -> Result<serde_json::Value> {
let task = db::resolve_task(conn, id)?;
let kind = kind_arg(kind);
let steps = db::get_steps(conn, &task.uuid, kind)?;
let idx = n
.checked_sub(1)
.ok_or_else(|| anyhow::anyhow!("{kind} index is 1-based; got 0"))?;
let item = steps
.get(idx)
.ok_or_else(|| anyhow::anyhow!("No {kind} #{n} on this task"))?;
let text = item.text.clone();
db::delete_step(conn, item.id)?;
Ok(json!({
"task": task.id,
"uuid": task.uuid.to_string(),
"kind": kind,
"index": n,
"removed": text,
}))
}
pub fn step_remove_by_id_value(conn: &Connection, step_id: i64) -> Result<serde_json::Value> {
let (uuid, kind, index) = db::locate_step(conn, step_id)?;
step_remove_value(conn, &uuid.to_string(), index, Some(&kind))
}
pub fn step_remove(
conn: &Connection,
_cfg: &Config,
id: &str,
n: usize,
kind: Option<&str>,
as_json: bool,
) -> Result<()> {
let v = step_remove_value(conn, id, n, kind)?;
if as_json {
println!("{}", serde_json::to_string_pretty(&v)?);
return Ok(());
}
println!(
"Removed {} {} of task {}: {}",
v["kind"].as_str().unwrap_or("step"),
n,
v["task"].as_i64().unwrap_or(0),
v["removed"].as_str().unwrap_or_default()
);
Ok(())
}
pub fn check_value(
conn: &Connection,
id: &str,
text: &str,
intent: Option<&str>,
kind: Option<&str>,
source: Option<&str>,
verify: Option<&str>,
) -> Result<serde_json::Value> {
let task = db::resolve_task(conn, id)?;
let kind = kind_arg(kind);
let source = source.unwrap_or("human");
let step_id = db::add_step(conn, &task.uuid, text, intent, kind, source, verify)?;
let index = db::get_steps(conn, &task.uuid, kind)?.len();
Ok(json!({
"task": task.id,
"uuid": task.uuid.to_string(),
"kind": kind,
"text": text,
"step_id": step_id,
"index": index,
}))
}
pub fn verify(
conn: &Connection,
_cfg: &Config,
id: &str,
step: Option<usize>,
run: bool,
tick_on_pass: bool,
) -> Result<()> {
let task = db::resolve_task(conn, id)?;
let steps = db::get_steps(conn, &task.uuid, db::STEP_KIND_STEP)?;
let acceptance = db::get_steps(conn, &task.uuid, db::STEP_KIND_ACCEPTANCE)?;
let meta = db::get_guide_fields(conn, &task.uuid)?.meta_json;
let working_dir = db::get_project(conn, &task.project)
.ok()
.flatten()
.and_then(|p| p.path);
if tick_on_pass {
let commit = project_head(conn, &task.project);
let targets: Vec<&_> = if let Some(n) = step {
let idx = n
.checked_sub(1)
.ok_or_else(|| anyhow::anyhow!("step index is 1-based; got 0"))?;
let s = steps
.get(idx)
.ok_or_else(|| anyhow::anyhow!("No step #{n}"))?;
vec![s]
} else {
steps.iter().chain(acceptance.iter()).collect()
};
let (mut ran, mut passed) = (0usize, 0usize);
let mut started_noted = false;
for s in targets {
let Some(cmd) = &s.verify_cmd else { continue };
ran += 1;
if !started_noted {
if db::ensure_started(conn, &task.uuid)? {
println!("Task {} is now active.", task.id.unwrap_or(0));
}
started_noted = true;
}
println!("$ {cmd}");
let mut command = std::process::Command::new("sh");
command.arg("-c").arg(cmd);
if let Some(dir) = &working_dir {
command.current_dir(dir);
}
match command.status() {
Ok(st) if st.success() => {
let note = format!("verify passed: {cmd}");
db::set_step_done(conn, s.id, true, Some(¬e), commit.as_deref())?;
passed += 1;
println!(" ✓ passed — ticked \"{}\"", s.text);
}
Ok(st) => {
let code = st.code().unwrap_or(-1);
println!(" ✗ exit {code} — left unticked: \"{}\"", s.text);
}
Err(e) => {
println!(" ✗ failed to run ({e}) — left unticked: \"{}\"", s.text);
}
}
}
if ran == 0 {
println!("No steps or acceptance criteria have a verify command to run.");
} else {
println!("Ticked {passed}/{ran} on pass.");
}
return Ok(());
}
let mut cmds: Vec<String> = vec![];
if let Some(n) = step {
let idx = n
.checked_sub(1)
.ok_or_else(|| anyhow::anyhow!("step index is 1-based; got 0"))?;
if let Some(s) = steps.get(idx) {
if let Some(v) = &s.verify_cmd {
cmds.push(v.clone());
} else {
println!("Step {n} has no verify command.");
}
} else {
anyhow::bail!("No step #{n}");
}
} else {
let pc = db::get_project_commands(conn, &task.project)?;
for c in [&pc.setup_cmd, &pc.test_cmd, &pc.lint_cmd]
.into_iter()
.flatten()
{
cmds.push(c.clone());
}
for s in steps.iter().chain(acceptance.iter()) {
if let Some(v) = &s.verify_cmd {
cmds.push(v.clone());
}
}
if let Some(meta) = meta
.as_deref()
.and_then(|m| serde_json::from_str::<serde_json::Value>(m).ok())
{
for key in ["test_cmd", "lint_cmd"] {
if let Some(c) = meta.get(key).and_then(|v| v.as_str()) {
cmds.push(c.to_string());
}
}
}
}
if !acceptance.is_empty() && step.is_none() {
println!("Acceptance criteria:");
for (i, a) in acceptance.iter().enumerate() {
let mark = if a.done { "[x]" } else { "[ ]" };
println!(" {} {}. {}", mark, i + 1, a.text);
}
}
if cmds.is_empty() {
println!("No verification commands found.");
return Ok(());
}
if run && db::ensure_started(conn, &task.uuid)? {
println!("Task {} is now active.", task.id.unwrap_or(0));
}
for cmd in &cmds {
if run {
println!("$ {cmd}");
let mut command = std::process::Command::new("sh");
command.arg("-c").arg(cmd);
if let Some(dir) = &working_dir {
command.current_dir(dir);
}
let status = command.status();
match status {
Ok(s) if s.success() => println!(" ok: passed"),
Ok(s) => println!(" exited with {}", s.code().unwrap_or(-1)),
Err(e) => println!(" failed to run: {e}"),
}
} else {
println!("{cmd}");
}
}
Ok(())
}
pub fn verify_value(conn: &Connection, id: &str, step: Option<usize>) -> Result<serde_json::Value> {
let task = db::resolve_task(conn, id)?;
let steps = db::get_steps(conn, &task.uuid, db::STEP_KIND_STEP)?;
let acceptance = db::get_steps(conn, &task.uuid, db::STEP_KIND_ACCEPTANCE)?;
let meta = db::get_guide_fields(conn, &task.uuid)?.meta_json;
let mut cmds: Vec<String> = vec![];
if let Some(n) = step {
let idx = n
.checked_sub(1)
.ok_or_else(|| anyhow::anyhow!("step index is 1-based; got 0"))?;
let s = steps
.get(idx)
.ok_or_else(|| anyhow::anyhow!("No step #{n}"))?;
if let Some(v) = &s.verify_cmd {
cmds.push(v.clone());
}
} else {
let pc = db::get_project_commands(conn, &task.project)?;
for c in [&pc.setup_cmd, &pc.test_cmd, &pc.lint_cmd]
.into_iter()
.flatten()
{
cmds.push(c.clone());
}
for s in steps.iter().chain(acceptance.iter()) {
if let Some(v) = &s.verify_cmd {
cmds.push(v.clone());
}
}
if let Some(meta) = meta
.as_deref()
.and_then(|m| serde_json::from_str::<serde_json::Value>(m).ok())
{
for key in ["test_cmd", "lint_cmd"] {
if let Some(c) = meta.get(key).and_then(|v| v.as_str()) {
cmds.push(c.to_string());
}
}
}
}
let acc: Vec<_> = acceptance
.iter()
.enumerate()
.map(|(i, a)| {
json!({
"index": i + 1,
"text": a.text,
"done": a.done,
"verify_cmd": a.verify_cmd,
})
})
.collect();
Ok(json!({ "task": task.id, "commands": cmds, "acceptance": acc }))
}
pub fn assignment_value(conn: &Connection, id: &str, text: &str) -> Result<serde_json::Value> {
let task = db::resolve_task(conn, id)?;
db::set_assignment(conn, &task.uuid, text)?;
Ok(json!({ "task": task.id, "uuid": task.uuid.to_string(), "assignment": text }))
}
pub fn assignment(conn: &Connection, id: &str, text: &str) -> Result<()> {
let v = assignment_value(conn, id, text)?;
println!(
"Set assignment for task {}.",
v["task"].as_i64().unwrap_or(0)
);
Ok(())
}
pub fn rationale_value(conn: &Connection, id: &str, text: &str) -> Result<serde_json::Value> {
let task = db::resolve_task(conn, id)?;
db::set_rationale(conn, &task.uuid, text)?;
Ok(json!({ "task": task.id, "uuid": task.uuid.to_string(), "rationale": text }))
}
pub fn rationale(conn: &Connection, id: &str, text: &str) -> Result<()> {
let v = rationale_value(conn, id, text)?;
println!(
"Set rationale for task {}.",
v["task"].as_i64().unwrap_or(0)
);
Ok(())
}
pub struct AcceptanceGate {
pub total: usize,
pub ran: usize,
pub passed: usize,
pub failures: Vec<String>,
pub missing_verify: Vec<String>,
pub transcript: Vec<GateRun>,
}
impl AcceptanceGate {
pub fn is_green(&self) -> bool {
self.total > 0 && self.failures.is_empty() && self.missing_verify.is_empty()
}
pub fn reason(&self) -> String {
if self.total == 0 {
return "no acceptance criteria to prove — add one with `sara check <id> \"…\" --kind acceptance --verify \"<cmd>\"`".to_string();
}
let mut parts = Vec::new();
if !self.missing_verify.is_empty() {
parts.push(format!(
"{} acceptance criterion/criteria have no verify command: {}",
self.missing_verify.len(),
self.missing_verify.join("; ")
));
}
if !self.failures.is_empty() {
parts.push(format!(
"{} acceptance verify command(s) failed: {}",
self.failures.len(),
self.failures.join("; ")
));
}
parts.join(" | ")
}
pub fn failure_detail(&self) -> String {
let mut out = String::new();
for r in self.transcript.iter().filter(|r| !r.passed) {
if r.output.trim().is_empty() {
continue;
}
let code = r
.exit_code
.map(|c| c.to_string())
.unwrap_or_else(|| "n/a".into());
out.push_str(&format!("\n$ {} (exit {})\n{}", r.cmd, code, r.output));
}
out
}
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum GateOutput {
Stream,
Capture,
}
impl GateOutput {
fn say(self, line: impl std::fmt::Display) {
if self == GateOutput::Stream {
println!("{line}");
}
}
}
#[derive(Debug, Clone)]
pub struct GateRun {
pub text: String,
pub cmd: String,
pub passed: bool,
pub exit_code: Option<i32>,
pub output: String,
}
fn tail_limited(s: &str, max_chars: usize) -> String {
let total = s.chars().count();
if total <= max_chars {
return s.to_string();
}
let skip = total - max_chars;
let start = s
.char_indices()
.nth(skip)
.map(|(i, _)| i)
.unwrap_or(s.len());
format!("…[{skip} chars truncated]\n{}", &s[start..])
}
pub fn run_acceptance_gate(
conn: &Connection,
task_id_or_uuid: &str,
mode: GateOutput,
) -> Result<AcceptanceGate> {
let task = db::resolve_task(conn, task_id_or_uuid)?;
let acceptance = db::get_steps(conn, &task.uuid, db::STEP_KIND_ACCEPTANCE)?;
let working_dir = db::get_project(conn, &task.project)
.ok()
.flatten()
.and_then(|p| p.path);
let commit = project_head(conn, &task.project);
let mut gate = AcceptanceGate {
total: acceptance.len(),
ran: 0,
passed: 0,
failures: Vec::new(),
missing_verify: Vec::new(),
transcript: Vec::new(),
};
for s in &acceptance {
let Some(cmd) = &s.verify_cmd else {
gate.missing_verify.push(s.text.clone());
continue;
};
gate.ran += 1;
mode.say(format!("$ {cmd}"));
let mut command = std::process::Command::new("sh");
command.arg("-c").arg(cmd);
if let Some(dir) = &working_dir {
command.current_dir(dir);
}
let outcome = match mode {
GateOutput::Stream => command.status().map(|st| (st, String::new())),
GateOutput::Capture => command.output().map(|o| {
let mut buf = String::from_utf8_lossy(&o.stdout).into_owned();
let err = String::from_utf8_lossy(&o.stderr);
if !err.is_empty() {
if !buf.is_empty() && !buf.ends_with('\n') {
buf.push('\n');
}
buf.push_str(&err);
}
(o.status, tail_limited(&buf, 4000))
}),
};
match outcome {
Ok((st, out)) if st.success() => {
let note = format!("verify passed: {cmd}");
db::set_step_done(conn, s.id, true, Some(¬e), commit.as_deref())?;
gate.passed += 1;
mode.say(format!(" ✓ passed — ticked \"{}\"", s.text));
gate.transcript.push(GateRun {
text: s.text.clone(),
cmd: cmd.clone(),
passed: true,
exit_code: st.code(),
output: out,
});
}
Ok((st, out)) => {
let code = st.code().unwrap_or(-1);
mode.say(format!(" ✗ exit {code} — \"{}\"", s.text));
gate.failures.push(s.text.clone());
gate.transcript.push(GateRun {
text: s.text.clone(),
cmd: cmd.clone(),
passed: false,
exit_code: st.code(),
output: out,
});
}
Err(e) => {
mode.say(format!(" ✗ failed to run ({e}) — \"{}\"", s.text));
gate.failures.push(s.text.clone());
gate.transcript.push(GateRun {
text: s.text.clone(),
cmd: cmd.clone(),
passed: false,
exit_code: None,
output: format!("failed to run: {e}"),
});
}
}
}
Ok(gate)
}
pub fn validate_value(
conn: &Connection,
id: &str,
skip_gate: bool,
mode: GateOutput,
) -> Result<serde_json::Value> {
let task = db::resolve_task(conn, id)?;
guard_branch_mutation(conn, id, &task, false)?;
let head = project_head(conn, &task.project)
.ok_or_else(|| anyhow::anyhow!("task's project is not in a git repo"))?;
if !skip_gate {
let gate = run_acceptance_gate(conn, id, mode)?;
if !gate.is_green() {
anyhow::bail!(
"validate refused — acceptance gate is red: {}.{} \
Fix and re-run, or `validate --no-run` to stamp without proof (discouraged).",
gate.reason(),
gate.failure_detail()
);
}
}
db::set_validated(conn, &task.uuid, &head)?;
let open_steps = db::get_steps(conn, &task.uuid, db::STEP_KIND_STEP)?
.iter()
.filter(|s| !s.done)
.count();
Ok(json!({
"task": task.id,
"uuid": task.uuid.to_string(),
"validated_commit": head,
"gate_skipped": skip_gate,
"open_steps": open_steps,
}))
}
pub fn validate(conn: &Connection, id: &str, no_run: bool) -> Result<()> {
if no_run {
eprintln!(
"⚠ validate --no-run: stamping WITHOUT running the acceptance gate — \
'validated' will not be backed by a passing command."
);
}
let v = validate_value(conn, id, no_run, GateOutput::Stream)?;
println!(
"Stamped task {} validated @ {}.",
v["task"].as_i64().unwrap_or(0),
v["validated_commit"].as_str().unwrap_or_default()
);
let open = v["open_steps"].as_u64().unwrap_or(0);
if open > 0 {
println!(
" advisory: {open} checklist step(s) still open — acceptance is green, \
but the plan isn't fully ticked (see `sara steps {}`).",
v["task"].as_i64().unwrap_or(0)
);
}
Ok(())
}
pub fn feedback_value(conn: &Connection, id: &str) -> Result<serde_json::Value> {
let task = db::resolve_task(conn, id)?;
let fb = db::get_open_feedback(conn, &task.uuid)?;
let arr: Vec<_> = fb
.iter()
.map(|a| {
json!({
"id": a.id,
"text": a.text,
"target_kind": a.target_kind,
"target_id": a.target_id,
"request_revision": a.request_revision,
})
})
.collect();
Ok(json!({ "task": task.id, "open_feedback": arr }))
}
pub fn feedback(conn: &Connection, id: &str, as_json: bool) -> Result<()> {
if as_json {
println!(
"{}",
serde_json::to_string_pretty(&feedback_value(conn, id)?)?
);
return Ok(());
}
let task = db::resolve_task(conn, id)?;
let fb = db::get_open_feedback(conn, &task.uuid)?;
if fb.is_empty() {
println!("No open feedback for task {}.", task.id.unwrap_or(0));
return Ok(());
}
for a in &fb {
let target = match (&a.target_kind, &a.target_id) {
(Some(k), Some(idv)) => format!(" [{k}:{idv}]"),
_ => String::new(),
};
let flag = if a.request_revision { " ⟳" } else { "" };
println!("#{}{}{}: {}", a.id, target, flag, a.text);
}
Ok(())
}
pub fn resolve_value(
conn: &Connection,
feedback_id: i64,
run_id: Option<i64>,
) -> Result<serde_json::Value> {
if !db::resolve_annotation(conn, feedback_id, run_id)? {
anyhow::bail!("No feedback with id {feedback_id}");
}
Ok(json!({ "feedback_id": feedback_id, "resolved": true, "run_id": run_id }))
}
pub fn resolve(conn: &Connection, feedback_id: i64, run_id: Option<i64>) -> Result<()> {
resolve_value(conn, feedback_id, run_id)?;
println!("Resolved feedback #{feedback_id}.");
Ok(())
}
#[allow(clippy::too_many_arguments)]
pub fn record_run_value(
conn: &Connection,
id: &str,
kind: &str,
model: Option<&str>,
provider: Option<&str>,
prompt: Option<&str>,
response: Option<&str>,
prompt_tokens: Option<i64>,
completion_tokens: Option<i64>,
total_tokens: Option<i64>,
) -> Result<serde_json::Value> {
anyhow::ensure!(!kind.trim().is_empty(), "kind cannot be empty");
let task = db::resolve_task(conn, id)?;
let run_id = db::record_ai_run(
conn,
&task.uuid,
kind,
model,
provider,
prompt,
response,
prompt_tokens,
completion_tokens,
total_tokens,
)?;
Ok(json!({
"task": task.id,
"run_id": run_id,
"kind": kind,
"model": model,
"provider": provider,
"prompt_tokens": prompt_tokens,
"completion_tokens": completion_tokens,
"total_tokens": total_tokens,
}))
}
#[allow(clippy::too_many_arguments)]
pub fn record_run(
conn: &Connection,
id: &str,
kind: &str,
model: Option<&str>,
provider: Option<&str>,
prompt: Option<&str>,
response: Option<&str>,
) -> Result<()> {
let v = record_run_value(
conn, id, kind, model, provider, prompt, response, None, None, None,
)?;
println!(
"Recorded {} run #{} on task {}.",
v["kind"].as_str().unwrap_or_default(),
v["run_id"].as_i64().unwrap_or(0),
v["task"].as_i64().unwrap_or(0),
);
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::infrastructure::config::Config;
use crate::infrastructure::model::Task;
fn cfg() -> Config {
Config::default()
}
#[test]
fn next_surfaces_a_strong_relevant_memory() {
use crate::infrastructure::model::Item;
let conn = db::open_in_memory_for_test();
let mut src = Task::new("source work".into(), "proj".into());
src.status = crate::infrastructure::model::Status::Completed;
db::insert_task(&conn, &mut src).unwrap();
let mut mem = Item::new_memory(
"dependabot bump restore pattern".into(),
"dependabot bump broke restore; align versions".into(),
Some(src.uuid),
);
mem.tags = vec!["dependabot".into()];
mem.path = Some(String::new());
db::insert_item(&conn, &mut mem).unwrap();
db::set_item_tags(&conn, &mem.uuid, &["dependabot".into()]).unwrap();
let mut task = Task::new("do the dependabot bump".into(), "proj".into());
task.tags = vec!["dependabot".into()];
db::insert_task(&conn, &mut task).unwrap();
db::add_step(
&conn,
&task.uuid,
"step one",
None,
db::STEP_KIND_STEP,
"human",
None,
)
.unwrap();
let v = next_value(&conn, &task.uuid.to_string()).unwrap();
let mems = v["relevant_memories"]
.as_array()
.expect("relevant_memories present when a Strong memory matches");
assert_eq!(mems.len(), 1);
assert!(mems[0]["label"].as_str().unwrap().starts_with('m'));
assert!(!mems[0]["snippet"].as_str().unwrap().is_empty());
}
#[test]
fn next_omits_the_block_when_no_memory_matches() {
let conn = db::open_in_memory_for_test();
let mut task = Task::new("unrelated task".into(), "proj".into());
task.tags = vec!["nothing-matches-this".into()];
db::insert_task(&conn, &mut task).unwrap();
db::add_step(
&conn,
&task.uuid,
"step one",
None,
db::STEP_KIND_STEP,
"human",
None,
)
.unwrap();
let v = next_value(&conn, &task.uuid.to_string()).unwrap();
assert!(
v.get("relevant_memories").is_none(),
"no relevant_memories key when nothing Strong matches"
);
}
#[test]
fn tick_on_pass_ticks_passing_criteria_and_activates_task() {
let conn = db::open_in_memory_for_test();
let mut task = Task::new("demo".into(), "proj".into());
db::insert_task(&conn, &mut task).unwrap();
db::add_step(
&conn,
&task.uuid,
"passes",
None,
db::STEP_KIND_ACCEPTANCE,
"human",
Some("true"),
)
.unwrap();
db::add_step(
&conn,
&task.uuid,
"fails",
None,
db::STEP_KIND_ACCEPTANCE,
"human",
Some("false"),
)
.unwrap();
let id = task.uuid.to_string();
verify(&conn, &cfg(), &id, None, false, true).unwrap();
let acc = db::get_steps(&conn, &task.uuid, db::STEP_KIND_ACCEPTANCE).unwrap();
assert!(acc[0].done, "criterion with a passing verify_cmd is ticked");
assert!(acc[0].result.as_deref().unwrap_or("").contains("passed"));
assert!(
!acc[1].done,
"criterion whose verify_cmd fails stays unticked"
);
let reloaded = db::get_task_by_uuid_prefix(&conn, &id).unwrap().unwrap();
assert!(reloaded.started_at.is_some());
}
fn task_with_acceptance(conn: &Connection, verify: Option<&str>) -> Task {
let mut task = Task::new("gate demo".into(), "proj".into());
db::insert_task(conn, &mut task).unwrap();
db::add_step(
conn,
&task.uuid,
"criterion",
None,
db::STEP_KIND_ACCEPTANCE,
"human",
verify,
)
.unwrap();
task
}
#[test]
fn gate_is_green_only_when_every_criterion_has_a_passing_verify() {
let conn = db::open_in_memory_for_test();
let task = task_with_acceptance(&conn, Some("true"));
let gate = run_acceptance_gate(&conn, &task.uuid.to_string(), GateOutput::Capture).unwrap();
assert!(gate.is_green(), "one criterion, verify passes → green");
assert_eq!(gate.passed, 1);
}
#[test]
fn gate_red_when_verify_command_fails() {
let conn = db::open_in_memory_for_test();
let task = task_with_acceptance(&conn, Some("false"));
let gate = run_acceptance_gate(&conn, &task.uuid.to_string(), GateOutput::Capture).unwrap();
assert!(!gate.is_green(), "failing verify → red");
assert_eq!(gate.failures.len(), 1);
}
#[test]
fn gate_red_when_a_criterion_has_no_verify_command() {
let conn = db::open_in_memory_for_test();
let task = task_with_acceptance(&conn, None);
let gate = run_acceptance_gate(&conn, &task.uuid.to_string(), GateOutput::Capture).unwrap();
assert!(!gate.is_green(), "unprovable criterion → red");
assert_eq!(gate.missing_verify.len(), 1);
}
#[test]
fn gate_red_when_no_acceptance_criteria_exist() {
let conn = db::open_in_memory_for_test();
let mut task = Task::new("no criteria".into(), "proj".into());
db::insert_task(&conn, &mut task).unwrap();
let gate = run_acceptance_gate(&conn, &task.uuid.to_string(), GateOutput::Capture).unwrap();
assert!(!gate.is_green(), "no definition of done → red");
assert_eq!(gate.total, 0);
}
#[test]
fn capture_mode_collects_command_output_instead_of_printing_it() {
let conn = db::open_in_memory_for_test();
let task = task_with_acceptance(&conn, Some("echo MARKER_ON_STDOUT; exit 1"));
let gate = run_acceptance_gate(&conn, &task.uuid.to_string(), GateOutput::Capture).unwrap();
assert!(!gate.is_green(), "exit 1 → red");
assert_eq!(gate.transcript.len(), 1);
assert!(
gate.transcript[0].output.contains("MARKER_ON_STDOUT"),
"child stdout must be captured, got {:?}",
gate.transcript[0].output
);
assert_eq!(gate.transcript[0].exit_code, Some(1));
assert!(
gate.failure_detail().contains("MARKER_ON_STDOUT"),
"failure detail must surface the captured output to the agent"
);
}
#[test]
fn capture_mode_records_stderr_too() {
let conn = db::open_in_memory_for_test();
let task = task_with_acceptance(&conn, Some("echo OOPS 1>&2; exit 3"));
let gate = run_acceptance_gate(&conn, &task.uuid.to_string(), GateOutput::Capture).unwrap();
assert_eq!(gate.transcript[0].exit_code, Some(3));
assert!(gate.transcript[0].output.contains("OOPS"));
}
#[test]
fn captured_output_is_truncated_on_a_char_boundary() {
let long = "é".repeat(5000);
let out = tail_limited(&long, 4000);
assert!(out.contains("truncated"));
assert!(out.chars().count() < 4100);
assert_eq!(tail_limited("short", 4000), "short");
}
#[test]
fn step_done_value_reports_activation_on_first_work() {
let conn = db::open_in_memory_for_test();
let mut task = Task::new("demo".into(), "proj".into());
db::insert_task(&conn, &mut task).unwrap();
db::add_step(
&conn,
&task.uuid,
"do it",
None,
db::STEP_KIND_STEP,
"human",
None,
)
.unwrap();
let id = task.uuid.to_string();
let v = step_done_value(&conn, &id, 1, None, None).unwrap();
assert_eq!(
v["activated"], true,
"first recorded work activates the task"
);
db::add_step(
&conn,
&task.uuid,
"again",
None,
db::STEP_KIND_STEP,
"human",
None,
)
.unwrap();
let v2 = step_done_value(&conn, &id, 2, None, None).unwrap();
assert_eq!(v2["activated"], false);
}
}