use crate::paths::Paths;
use crate::session;
use crate::store::{FileStore, Key, StateStore};
use anyhow::{Context, Result, bail};
use serde::Deserialize;
use std::fs;
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 store = FileStore::new(paths);
let mut map: serde_json::Map<String, serde_json::Value> = store
.read(&Key::GoalActivity)
.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 _ = store.write_atomic(
&Key::GoalActivity,
&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 _ = FileStore::new(paths).write_atomic(&Key::ActionWal, &body);
}
fn clear_intent(paths: &Paths) {
let _ = FileStore::new(paths).remove(&Key::ActionWal);
}
pub fn warn_if_interrupted(paths: &Paths) -> bool {
let store = FileStore::new(paths);
let Some(raw) = store.read(&Key::ActionWal) else {
return false;
};
let _ = store.remove(&Key::ActionWal); 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)?;
FileStore::new(paths)
.write_atomic(&Key::Goal(id.clone()), &with_trailing_newline(body))?;
Ok(format!("write-goal {id}"))
}
Action::ArchiveGoal { id } => {
safe_segment("goal", id)?;
FileStore::new(paths)
.archive(&Key::Goal(id.clone()))
.with_context(|| format!("archive_goal {id:?}"))?;
Ok(format!("archive-goal {id}"))
}
Action::WriteSensor { name, script } => {
safe_segment("sensor", name)?;
FileStore::new(paths)
.write_atomic(&Key::Sensor(name.clone()), &with_trailing_newline(script))?;
Ok(format!("write-sensor {name}"))
}
Action::WritePlaybook { body } => {
FileStore::new(paths).write_atomic(&Key::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");
FileStore::new(paths).append_line(&Key::Journal, &format!("- {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 resolve_body(words: &[String]) -> Result<String> {
if words.is_empty() || (words.len() == 1 && words[0] == "-") {
use std::io::Read;
let mut buf = String::new();
std::io::stdin()
.read_to_string(&mut buf)
.context("reading body from stdin")?;
return Ok(buf);
}
Ok(words.join(" "))
}
fn ok(summary: String) -> Result<ExitCode> {
println!("{summary}");
Ok(ExitCode::SUCCESS)
}
pub fn write_goal(
paths: &Paths,
id: &str,
body: &[String],
journal: Option<&str>,
) -> Result<ExitCode> {
let body = resolve_body(body)?;
ok(run_action(
paths,
&Action::WriteGoal {
id: id.to_string(),
body,
},
journal,
)?)
}
pub fn archive_goal(paths: &Paths, id: &str, journal: Option<&str>) -> Result<ExitCode> {
ok(run_action(
paths,
&Action::ArchiveGoal { id: id.to_string() },
journal,
)?)
}
pub fn write_sensor(
paths: &Paths,
name: &str,
script: &[String],
journal: Option<&str>,
) -> Result<ExitCode> {
let script = resolve_body(script)?;
ok(run_action(
paths,
&Action::WriteSensor {
name: name.to_string(),
script,
},
journal,
)?)
}
pub fn write_playbook(paths: &Paths, body: &[String], journal: Option<&str>) -> Result<ExitCode> {
let body = resolve_body(body)?;
ok(run_action(paths, &Action::WritePlaybook { body }, journal)?)
}
pub fn cmd_run(paths: &Paths, args: &crate::cli::RunArgs) -> Result<ExitCode> {
let cmd = args.cmd.join(" ");
if cmd.trim().is_empty() {
eprintln!("usage: looop _ run [--reason TEXT] <cmd…>");
return Ok(ExitCode::from(1));
}
ok(run_action(
paths,
&Action::RunShell {
cmd,
reason: args.reason.clone().unwrap_or_default(),
},
args.journal.journal.as_deref(),
)?)
}
pub fn start_worker(
paths: &Paths,
id: &str,
prompt: &[String],
journal: Option<&str>,
) -> Result<ExitCode> {
let prompt = resolve_body(prompt)?;
if prompt.trim().is_empty() {
eprintln!("usage: looop _ worker start <id> <prompt…|->");
return Ok(ExitCode::from(1));
}
ok(run_action(
paths,
&Action::StartWorker {
id: id.to_string(),
prompt,
},
journal,
)?)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn resolve_body_joins_words_and_keeps_inner_dash() {
assert_eq!(
resolve_body(&["hello".to_string(), "world".to_string()]).unwrap(),
"hello world"
);
assert_eq!(
resolve_body(&["a".to_string(), "-".to_string(), "b".to_string()]).unwrap(),
"a - b"
);
}
#[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
);
}
}