use crate::paths::Paths;
use crate::session;
use anyhow::{Context, Result, bail};
use serde::Deserialize;
use std::fs;
use std::fs::OpenOptions;
use std::io::Write;
use std::process::ExitCode;
#[derive(Debug, Deserialize, PartialEq)]
#[serde(tag = "action", rename_all = "snake_case")]
pub enum Action {
Noop {
#[serde(default)]
reason: String,
},
RunShell {
cmd: String,
#[serde(default)]
reason: String,
},
WriteGoal { id: String, body: String },
ArchiveGoal { id: String },
WriteSensor { name: String, script: String },
WritePlaybook { body: String },
StartWorker { id: String, prompt: String },
}
fn safe_segment(kind: &str, id: &str) -> Result<()> {
if id.is_empty() || id.contains('/') || id.contains('\\') || id.starts_with('.') || id == ".." {
bail!("invalid {kind} id {id:?}");
}
Ok(())
}
pub fn kind(action: &Action) -> &'static str {
match action {
Action::Noop { .. } => "noop",
Action::RunShell { .. } => "shell",
Action::WriteGoal { .. } => "goal",
Action::ArchiveGoal { .. } => "archive",
Action::WriteSensor { .. } => "sensor",
Action::WritePlaybook { .. } => "playbook",
Action::StartWorker { .. } => "worker",
}
}
fn goal_of(action: &Action) -> Option<String> {
match action {
Action::WriteGoal { id, .. } => Some(id.clone()),
Action::ArchiveGoal { id } => Some(id.clone()),
Action::StartWorker { id, .. } => Some(id.clone()),
_ => None,
}
}
fn record_goal_activity(paths: &Paths, id: &str) {
let path = paths.goal_activity();
let mut map: serde_json::Map<String, serde_json::Value> = fs::read_to_string(&path)
.ok()
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_default();
map.insert(id.to_string(), serde_json::json!(crate::util::now_unix()));
let _ = fs::write(&path, serde_json::Value::Object(map).to_string());
}
fn is_non_idempotent(action: &Action) -> bool {
matches!(action, Action::RunShell { .. })
}
fn action_fingerprint(action: &Action) -> String {
let canon = match action {
Action::RunShell { cmd, .. } => format!("run_shell\n{cmd}"),
_ => kind(action).to_string(),
};
crate::util::content_hash(canon.as_bytes())
}
fn begin_intent(paths: &Paths, action: &Action) {
let body = serde_json::json!({
"kind": kind(action),
"fingerprint": action_fingerprint(action),
"ts": crate::util::now_unix(),
})
.to_string();
let _ = fs::write(paths.action_wal(), body);
}
fn clear_intent(paths: &Paths) {
let _ = fs::remove_file(paths.action_wal());
}
pub fn warn_if_interrupted(paths: &Paths) -> bool {
let wal = paths.action_wal();
let Ok(raw) = fs::read_to_string(&wal) else {
return false;
};
let _ = fs::remove_file(&wal); let v: serde_json::Value = serde_json::from_str(&raw).unwrap_or_default();
let akind = v.get("kind").and_then(|x| x.as_str()).unwrap_or("?");
let fp = v.get("fingerprint").and_then(|x| x.as_str()).unwrap_or("?");
crate::util::event(
crate::util::Level::Warn,
"tick.interrupted",
&format!(
"previous beat died mid '{akind}' (a non-idempotent action) before committing \
— NOT retried automatically; verify it didn't half-run (fp {fp})"
),
&[
("action", serde_json::json!(akind)),
("fingerprint", serde_json::json!(fp)),
],
);
crate::events::emit(
paths,
"tick_interrupted",
serde_json::json!({ "action": akind, "fingerprint": fp }),
);
true
}
fn with_trailing_newline(body: &str) -> String {
if body.ends_with('\n') {
body.to_string()
} else {
format!("{body}\n")
}
}
pub fn execute(paths: &Paths, action: &Action) -> Result<String> {
session::suppress_stdout(|| execute_inner(paths, action))
}
fn execute_inner(paths: &Paths, action: &Action) -> Result<String> {
match action {
Action::Noop { reason } => Ok(if reason.trim().is_empty() {
"noop".to_string()
} else {
format!("noop · {}", reason.trim())
}),
Action::RunShell { cmd, reason } => {
let out = std::process::Command::new("bash")
.arg("-c")
.arg(cmd)
.current_dir(&paths.data_dir)
.output()
.with_context(|| format!("run_shell: {cmd}"))?;
let code = out.status.code().unwrap_or(-1);
let why = if reason.is_empty() { cmd } else { reason };
if out.status.success() {
Ok(format!("run-shell · {why}"))
} else {
bail!("run_shell exited {code}: {why}");
}
}
Action::WriteGoal { id, body } => {
safe_segment("goal", id)?;
fs::create_dir_all(paths.goals_dir())?;
fs::write(
paths.goals_dir().join(format!("{id}.md")),
with_trailing_newline(body),
)?;
Ok(format!("write-goal {id}"))
}
Action::ArchiveGoal { id } => {
safe_segment("goal", id)?;
let from = paths.goals_dir().join(format!("{id}.md"));
let archive = paths.goals_dir().join("archive");
fs::create_dir_all(&archive)?;
fs::rename(&from, archive.join(format!("{id}.md")))
.with_context(|| format!("archive_goal {id:?}"))?;
Ok(format!("archive-goal {id}"))
}
Action::WriteSensor { name, script } => {
safe_segment("sensor", name)?;
fs::create_dir_all(paths.sensors_dir())?;
let p = paths.sensors_dir().join(format!("{name}.sh"));
fs::write(&p, with_trailing_newline(script))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perm = fs::metadata(&p)?.permissions();
perm.set_mode(0o755);
fs::set_permissions(&p, perm)?;
}
Ok(format!("write-sensor {name}"))
}
Action::WritePlaybook { body } => {
fs::write(paths.playbook(), with_trailing_newline(body))?;
Ok("write-playbook".into())
}
Action::StartWorker { id, prompt } => {
let code = session::cmd_start_session(paths, &[id.clone(), prompt.clone()])?;
if code != std::process::ExitCode::SUCCESS {
bail!("start_worker {id:?} failed");
}
Ok(format!("start-worker {id}"))
}
}
}
fn append_journal(paths: &Paths, line: &str) -> Result<()> {
let stamp = crate::util::date_fmt("%Y-%m-%d %H:%M");
let mut f = OpenOptions::new()
.create(true)
.append(true)
.open(paths.journal())?;
writeln!(f, "- {stamp} {line}")?;
Ok(())
}
pub const DECISION_FILE: &str = ".decision.json";
#[derive(Debug, PartialEq)]
pub struct Decision {
pub action: Action,
pub journal: String,
pub next_interval_s: Option<u64>,
}
impl Decision {
pub fn parse(json: &str) -> Result<Decision> {
let v: serde_json::Value =
serde_json::from_str(json.trim()).context("decision is not valid JSON")?;
let journal = v
.get("journal")
.and_then(|x| x.as_str())
.unwrap_or_default()
.to_string();
let next_interval_s = v.get("next_interval_s").and_then(|x| x.as_u64());
let action: Action =
serde_json::from_value(v).context("decision has no/unknown \"action\"")?;
Ok(Decision {
action,
journal,
next_interval_s,
})
}
}
#[derive(Debug, PartialEq)]
pub struct Decided {
pub kind: &'static str,
pub summary: String,
pub journal: String,
pub next_interval_s: Option<u64>,
}
pub fn consume_decision(paths: &Paths) -> Option<Result<Decided>> {
let path = paths.data_dir.join(DECISION_FILE);
let raw = fs::read_to_string(&path).ok()?; let _ = fs::remove_file(&path); Some((|| {
let decision = Decision::parse(&raw)?;
let journal = if decision.journal.trim().is_empty() {
None
} else {
Some(decision.journal.as_str())
};
let summary = run_action(paths, &decision.action, journal)?;
let journal_line = if decision.journal.trim().is_empty() {
summary.clone()
} else {
decision.journal.clone()
};
Ok(Decided {
kind: kind(&decision.action),
summary,
journal: journal_line,
next_interval_s: decision.next_interval_s,
})
})())
}
pub fn run_action(paths: &Paths, action: &Action, journal: Option<&str>) -> Result<String> {
let guarded = is_non_idempotent(action);
if guarded {
begin_intent(paths, action);
}
let exec_result = execute(paths, action);
if guarded {
clear_intent(paths);
}
let summary = exec_result?;
if let Some(id) = goal_of(action) {
record_goal_activity(paths, &id);
}
let line = match journal {
Some(j) if !j.trim().is_empty() => j.trim().to_string(),
_ => summary.clone(),
};
append_journal(paths, &line)?;
Ok(summary)
}
fn body_or_stdin(rest: &[String]) -> Result<String> {
if !rest.is_empty() {
return Ok(rest.join(" "));
}
use std::io::Read;
let mut buf = String::new();
std::io::stdin()
.read_to_string(&mut buf)
.context("reading body from stdin")?;
Ok(buf)
}
fn take_journal(args: &[String]) -> (Option<String>, Vec<String>) {
let mut journal = None;
let mut rest = Vec::new();
let mut it = args.iter();
while let Some(a) = it.next() {
if a == "--journal" {
journal = it.next().cloned();
} else {
rest.push(a.clone());
}
}
(journal, rest)
}
fn ok(summary: String) -> Result<ExitCode> {
println!("{summary}");
Ok(ExitCode::SUCCESS)
}
pub fn cmd_goal(paths: &Paths, args: &[String]) -> Result<ExitCode> {
let (journal, rest) = take_journal(args);
match rest.first().map(String::as_str) {
Some("write") => {
let Some(id) = rest.get(1).cloned() else {
eprintln!("usage: looop _ goal write <id> [body…|stdin]");
return Ok(ExitCode::from(1));
};
let body = body_or_stdin(&rest[2.min(rest.len())..])?;
ok(run_action(
paths,
&Action::WriteGoal { id, body },
journal.as_deref(),
)?)
}
Some("archive") => {
let Some(id) = rest.get(1).cloned() else {
eprintln!("usage: looop _ goal archive <id>");
return Ok(ExitCode::from(1));
};
ok(run_action(
paths,
&Action::ArchiveGoal { id },
journal.as_deref(),
)?)
}
_ => {
eprintln!("usage: looop _ goal write <id> [body…] | looop _ goal archive <id>");
Ok(ExitCode::from(1))
}
}
}
pub fn cmd_sensor(paths: &Paths, args: &[String]) -> Result<ExitCode> {
let (journal, rest) = take_journal(args);
if rest.first().map(String::as_str) != Some("write") {
eprintln!("usage: looop _ sensor write <name> [script…|stdin]");
return Ok(ExitCode::from(1));
}
let Some(name) = rest.get(1).cloned() else {
eprintln!("usage: looop _ sensor write <name> [script…|stdin]");
return Ok(ExitCode::from(1));
};
let script = body_or_stdin(&rest[2.min(rest.len())..])?;
ok(run_action(
paths,
&Action::WriteSensor { name, script },
journal.as_deref(),
)?)
}
pub fn cmd_playbook(paths: &Paths, args: &[String]) -> Result<ExitCode> {
let (journal, rest) = take_journal(args);
if rest.first().map(String::as_str) != Some("write") {
eprintln!("usage: looop _ playbook write [body…|stdin]");
return Ok(ExitCode::from(1));
}
let body = body_or_stdin(&rest[1.min(rest.len())..])?;
ok(run_action(
paths,
&Action::WritePlaybook { body },
journal.as_deref(),
)?)
}
pub fn cmd_run(paths: &Paths, args: &[String]) -> Result<ExitCode> {
let (journal, mut rest) = take_journal(args);
let mut reason = String::new();
if let Some(i) = rest.iter().position(|a| a == "--reason") {
rest.remove(i);
if i < rest.len() {
reason = rest.remove(i);
}
}
let cmd = rest.join(" ");
if cmd.trim().is_empty() {
eprintln!("usage: looop _ run <cmd…> [--reason TEXT]");
return Ok(ExitCode::from(1));
}
ok(run_action(
paths,
&Action::RunShell { cmd, reason },
journal.as_deref(),
)?)
}
pub fn cmd_worker_start(paths: &Paths, args: &[String]) -> Result<ExitCode> {
let (journal, rest) = take_journal(args);
let Some(id) = rest.first().cloned() else {
eprintln!("usage: looop _ worker start <id> <prompt…>");
return Ok(ExitCode::from(1));
};
let prompt = body_or_stdin(&rest[1.min(rest.len())..])?;
if prompt.trim().is_empty() {
eprintln!("usage: looop _ worker start <id> <prompt…>");
return Ok(ExitCode::from(1));
}
ok(run_action(
paths,
&Action::StartWorker { id, prompt },
journal.as_deref(),
)?)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn safe_segment_blocks_traversal() {
assert!(safe_segment("goal", "ok").is_ok());
for bad in ["", "..", "a/b", ".hidden", "a\\b"] {
assert!(safe_segment("goal", bad).is_err(), "should reject {bad:?}");
}
}
#[test]
fn run_action_write_and_archive_goal_round_trip() {
let p = Paths::temp();
let body = "goal: ship it\nnotes here";
run_action(
&p,
&Action::WriteGoal {
id: "ship".into(),
body: body.into(),
},
None,
)
.unwrap();
let written = fs::read_to_string(p.goals_dir().join("ship.md")).unwrap();
assert_eq!(written, format!("{body}\n"), "trailing newline normalized");
run_action(&p, &Action::ArchiveGoal { id: "ship".into() }, None).unwrap();
assert!(!p.goals_dir().join("ship.md").exists());
assert!(p.goals_dir().join("archive").join("ship.md").exists());
}
#[test]
fn run_action_journals_and_stamps_goal_activity() {
let p = Paths::temp();
run_action(
&p,
&Action::WriteGoal {
id: "triage".into(),
body: "do it".into(),
},
Some("made triage"),
)
.unwrap();
let journal = fs::read_to_string(p.journal()).unwrap();
assert!(journal.contains("made triage"), "journal line appended");
assert!(journal.starts_with("- "), "canonical journal prefix");
let act: serde_json::Value =
serde_json::from_str(&fs::read_to_string(p.goal_activity()).unwrap()).unwrap();
assert!(
act.get("triage").and_then(|v| v.as_u64()).is_some(),
"acting on a goal stamps its activity time"
);
}
#[test]
fn only_run_shell_is_guarded() {
assert!(is_non_idempotent(&Action::RunShell {
cmd: "gh pr comment".into(),
reason: String::new()
}));
assert!(!is_non_idempotent(&Action::WriteGoal {
id: "g".into(),
body: "b".into()
}));
assert!(!is_non_idempotent(&Action::StartWorker {
id: "w".into(),
prompt: "p".into()
}));
}
#[test]
fn run_action_clears_wal_around_a_guarded_action() {
let p = Paths::temp();
run_action(
&p,
&Action::RunShell {
cmd: "true".into(),
reason: "noop check".into(),
},
Some("ran a guarded command"),
)
.unwrap();
assert!(
!p.action_wal().exists(),
"the write-ahead intent is cleared once execute returns"
);
assert!(!warn_if_interrupted(&p), "no interrupted action to report");
}
#[test]
fn warn_if_interrupted_detects_and_clears_a_stale_intent() {
let p = Paths::temp();
begin_intent(
&p,
&Action::RunShell {
cmd: "gh pr comment 1 -b hi".into(),
reason: String::new(),
},
);
assert!(p.action_wal().exists(), "intent written before the effect");
assert!(
warn_if_interrupted(&p),
"a leftover intent is reported as an interrupted beat"
);
assert!(!p.action_wal().exists(), "the report is one-shot");
assert!(!warn_if_interrupted(&p));
}
#[test]
fn fingerprint_is_stable_and_payload_sensitive() {
let a = Action::RunShell {
cmd: "echo a".into(),
reason: "r1".into(),
};
let a2 = Action::RunShell {
cmd: "echo a".into(),
reason: "r2-ignored".into(),
};
let b = Action::RunShell {
cmd: "echo b".into(),
reason: "r1".into(),
};
assert_eq!(action_fingerprint(&a), action_fingerprint(&a2));
assert_ne!(action_fingerprint(&a), action_fingerprint(&b));
}
#[test]
fn goal_of_maps_only_goal_targeting_actions() {
assert_eq!(
goal_of(&Action::StartWorker {
id: "triage".into(),
prompt: "p".into()
}),
Some("triage".into())
);
assert_eq!(
goal_of(&Action::WriteGoal {
id: "ship".into(),
body: "b".into()
}),
Some("ship".into())
);
assert_eq!(
goal_of(&Action::RunShell {
cmd: "echo hi".into(),
reason: String::new(),
}),
None
);
}
}