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;
pub const DECISION_FILE: &str = ".decision.json";
#[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 },
SteerSession { id: String, input: String },
SendKey { id: String, keys: Vec<String> },
RestartSession { id: String },
}
#[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,
})
}
}
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(())
}
fn worker_target(id: &str) -> Result<String> {
let s = id.strip_prefix("looop-").unwrap_or(id);
if s.is_empty() {
bail!("empty session id");
}
if s == session::PULSE_SESSION {
bail!("'{s}' is the pulse (the control loop), not a worker");
}
Ok(s.to_string())
}
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",
Action::SteerSession { .. } => "steer",
Action::SendKey { .. } => "key",
Action::RestartSession { .. } => "restart",
}
}
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.is_empty() {
"noop".into()
} else {
format!("noop · {reason}")
}),
Action::RunShell { cmd, reason } => {
let out = std::process::Command::new("bash")
.arg("-lc")
.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}"))
}
Action::SteerSession { id, input } => {
let s = worker_target(id)?;
session::send(paths, &s, input.clone(), true, false)?;
Ok(format!("steer {s}"))
}
Action::SendKey { id, keys } => {
let s = worker_target(id)?;
session::key(paths, &s, keys.clone(), false)?;
Ok(format!("key {s} · {}", keys.join(" ")))
}
Action::RestartSession { id } => {
let s = worker_target(id)?;
session::restart(paths, &s, false)?;
Ok(format!("restart {s}"))
}
}
}
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 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 summary = execute(paths, &decision.action)?;
let journal = if decision.journal.trim().is_empty() {
summary.clone()
} else {
decision.journal.clone()
};
append_journal(paths, &journal)?;
if let Some(secs) = decision.next_interval_s {
let _ = fs::write(paths.data_dir.join(".next-interval"), format!("{secs}\n"));
}
Ok(Decided {
kind: kind(&decision.action),
summary,
journal,
})
})())
}
#[derive(Debug, PartialEq)]
pub struct Decided {
pub kind: &'static str,
pub summary: String,
pub journal: String,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_noop_with_journal() {
let d = Decision::parse(r#"{"action":"noop","reason":"quiet","journal":"nothing to do"}"#)
.unwrap();
assert_eq!(
d.action,
Action::Noop {
reason: "quiet".into()
}
);
assert_eq!(d.journal, "nothing to do");
assert_eq!(d.next_interval_s, None);
}
#[test]
fn parses_start_worker_and_lifts_metadata() {
let d = Decision::parse(
r#"{"action":"start_worker","id":"triage","prompt":"do it","journal":"started triage","next_interval_s":15}"#,
)
.unwrap();
assert_eq!(
d.action,
Action::StartWorker {
id: "triage".into(),
prompt: "do it".into()
}
);
assert_eq!(d.journal, "started triage");
assert_eq!(d.next_interval_s, Some(15));
}
#[test]
fn parses_run_shell_escape_hatch() {
let d = Decision::parse(r#"{"action":"run_shell","cmd":"gh pr list","reason":"check"}"#)
.unwrap();
assert_eq!(
d.action,
Action::RunShell {
cmd: "gh pr list".into(),
reason: "check".into()
}
);
}
#[test]
fn parses_all_remaining_variants() {
for (json, want) in [
(
r#"{"action":"write_goal","id":"g","body":"b"}"#,
Action::WriteGoal {
id: "g".into(),
body: "b".into(),
},
),
(
r#"{"action":"archive_goal","id":"g"}"#,
Action::ArchiveGoal { id: "g".into() },
),
(
r#"{"action":"write_sensor","name":"n","script":"s"}"#,
Action::WriteSensor {
name: "n".into(),
script: "s".into(),
},
),
(
r#"{"action":"write_playbook","body":"pb"}"#,
Action::WritePlaybook { body: "pb".into() },
),
(
r#"{"action":"steer_session","id":"w","input":"y"}"#,
Action::SteerSession {
id: "w".into(),
input: "y".into(),
},
),
(
r#"{"action":"send_key","id":"w","keys":["Enter"]}"#,
Action::SendKey {
id: "w".into(),
keys: vec!["Enter".into()],
},
),
(
r#"{"action":"restart_session","id":"w"}"#,
Action::RestartSession { id: "w".into() },
),
] {
assert_eq!(Decision::parse(json).unwrap().action, want, "json: {json}");
}
}
#[test]
fn rejects_garbage_and_unknown_actions() {
assert!(Decision::parse("not json").is_err());
assert!(Decision::parse(r#"{"action":"frobnicate"}"#).is_err());
assert!(Decision::parse(r#"{"reason":"no action tag"}"#).is_err());
}
#[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 worker_target_refuses_pulse_and_strips_prefix() {
assert_eq!(worker_target("triage").unwrap(), "triage");
assert_eq!(worker_target("looop-triage").unwrap(), "triage");
assert!(worker_target("pulse").is_err());
assert!(worker_target("").is_err());
}
#[test]
fn execute_write_and_archive_goal_round_trip() {
let p = Paths::temp();
let body = "goal: ship it\nnotes here";
execute(
&p,
&Action::WriteGoal {
id: "ship".into(),
body: body.into(),
},
)
.unwrap();
let written = fs::read_to_string(p.goals_dir().join("ship.md")).unwrap();
assert_eq!(written, format!("{body}\n"), "trailing newline normalized");
execute(&p, &Action::ArchiveGoal { id: "ship".into() }).unwrap();
assert!(!p.goals_dir().join("ship.md").exists());
assert!(p.goals_dir().join("archive").join("ship.md").exists());
}
#[test]
fn consume_decision_executes_journals_and_clears_file() {
let p = Paths::temp();
let path = p.data_dir.join(DECISION_FILE);
fs::write(
&path,
r#"{"action":"noop","reason":"all quiet","journal":"did nothing","next_interval_s":30}"#,
)
.unwrap();
let d = consume_decision(&p)
.expect("a decision was present")
.unwrap();
assert_eq!(d.kind, "noop");
assert_eq!(d.summary, "noop · all quiet");
assert_eq!(d.journal, "did nothing");
assert!(!path.exists(), "decision file is one-shot");
let journal = fs::read_to_string(p.journal()).unwrap();
assert!(journal.contains("did nothing"), "journal line appended");
assert!(journal.starts_with("- "), "canonical journal prefix");
let next = fs::read_to_string(p.data_dir.join(".next-interval")).unwrap();
assert_eq!(next.trim(), "30");
}
#[test]
fn consume_decision_absent_is_none() {
let p = Paths::temp();
assert!(consume_decision(&p).is_none());
}
}