1use std::collections::{BTreeMap, HashMap};
8use std::path::{Path, PathBuf};
9use std::sync::{Mutex, OnceLock, mpsc};
10use std::time::{Duration, Instant};
11
12use serde::{Deserialize, Serialize};
13use serde_json::Value;
14use speccoder_core::hooks::{HookDef, Runner, TrustState};
15
16use super::HookRun;
17use super::events::{self, Event};
18use crate::git::Git;
19use crate::verify::process::{Invocation, OUTPUT_CAP};
20
21const CURSORS: &str = "cursors.json";
22const LOCK: &str = "deliver.lock";
23
24const TRIES: u32 = 3;
26
27#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
30pub struct Cursor {
31 pub tips: BTreeMap<String, String>,
34 pub attempts: Vec<String>,
36}
37
38fn cursors(dir: &Path) -> BTreeMap<String, Cursor> {
39 std::fs::read_to_string(dir.join(CURSORS))
40 .ok()
41 .and_then(|t| serde_json::from_str(&t).ok())
42 .unwrap_or_default()
43}
44
45#[must_use]
47pub fn here(root: &Path) -> Cursor {
48 let mut c = Cursor::default();
49 for (r, sha) in events::tips(root) {
50 if r.starts_with(crate::attempts::PREFIX) {
51 c.attempts.push(format!("attempt:{sha}"));
52 } else {
53 c.tips.insert(r, sha);
54 }
55 }
56 c
57}
58
59#[must_use]
61pub fn has_cursor(dir: &Path, name: &str) -> bool {
62 cursors(dir).contains_key(name)
63}
64
65pub fn start_here(dir: &Path, root: &Path, name: &str) {
67 let _ = std::fs::create_dir_all(dir);
70 let lock = std::fs::File::create(dir.join(LOCK));
71 let _held = lock.as_ref().map(std::fs::File::lock);
72 let mut all = cursors(dir);
73 all.insert(name.to_owned(), here(root));
74 super::write_json(dir, CURSORS, &all);
75}
76
77#[must_use]
79pub fn pending(root: &Path, project: &str, cursor: &Cursor) -> Vec<(String, Event)> {
80 let git = Git::at(root);
81 let seen: Vec<&str> = cursor
84 .tips
85 .values()
86 .map(String::as_str)
87 .filter(|sha| {
88 git.succeeds(&["cat-file", "-e", &format!("{sha}^{{commit}}")])
89 .unwrap_or(false)
90 })
91 .collect();
92 let mut out = Vec::new();
93 let tips = events::tips(root);
94 for (r, sha) in tips
95 .iter()
96 .filter(|(r, _)| !r.starts_with(crate::attempts::PREFIX))
97 {
98 if cursor.tips.get(r) == Some(sha) {
99 continue;
100 }
101 let mut args = vec![
102 "log".to_owned(),
103 "--reverse".to_owned(),
104 "--format=%H".to_owned(),
105 "--grep=^Verdict: ".to_owned(),
106 sha.clone(),
107 ];
108 if !seen.is_empty() {
109 args.push("--not".to_owned());
110 args.extend(seen.iter().map(|s| (*s).to_owned()));
111 }
112 let args: Vec<&str> = args.iter().map(String::as_str).collect();
113 let commits = git
114 .run(&args)
115 .map(|b| String::from_utf8_lossy(&b).into_owned())
116 .unwrap_or_default();
117 for c in commits.lines().filter(|c| !c.is_empty()) {
118 if let Some(e) = events::of(&git, project, r, c) {
119 out.push((r.clone(), e));
120 }
121 }
122 }
123 for (r, sha) in tips
124 .iter()
125 .filter(|(r, _)| r.starts_with(crate::attempts::PREFIX))
126 {
127 let id = format!("attempt:{sha}");
128 if !cursor.attempts.contains(&id)
129 && let Some(e) = events::of(&git, project, r, sha)
130 {
131 out.push((r.clone(), e));
132 }
133 }
134 out
135}
136
137fn advance(cursor: &mut Cursor, reference: &str, event: &Event) {
138 if let Some(commit) = event.id.strip_prefix("tick:") {
139 cursor.tips.insert(reference.to_owned(), commit.to_owned());
140 } else {
141 cursor.attempts.push(event.id.clone());
142 }
143}
144
145pub fn send(def: &HookDef, envelope: &Value, root: &Path, state: &Path) -> Result<String, String> {
151 match &def.runner {
152 Runner::Url { url, secret_env } => post(def, url, secret_env.as_deref(), envelope),
153 Runner::Argv(argv) => {
154 let folder = def.extension.as_ref().map(|(_, f)| Path::new(f));
156 let argv = crate::extensions::resolve(argv, folder, &[]);
157 command(def, &argv, envelope, root, state)
158 }
159 }
160}
161
162fn post(
163 def: &HookDef,
164 url: &str,
165 secret_env: Option<&str>,
166 envelope: &Value,
167) -> Result<String, String> {
168 use agentplane::push::{
169 Delivered, Destination, PushConfig, PushMessage, PushSender, PushTransport,
170 };
171 speccoder_core::hooks::url_allowed(url)?;
172 let event = agentplane::core::CloudEvent::from_json(envelope.to_string().as_bytes())
173 .map_err(|e| format!("the event is not a CloudEvent: {e}"))?;
174 let mut destination = Destination::new(&def.name, url);
175 if let Some(var) = secret_env {
176 let key = std::env::var(var).map_err(|_| format!("${var} is not set"))?;
177 destination = destination
178 .try_signed_with(&agentplane::core::Secret::new(key))
179 .map_err(|e| format!("${var}: {e}"))?;
180 }
181 let sender = PushSender::for_operator_destinations(std::slice::from_ref(&destination))
182 .timeout(Duration::from_secs(def.timeout));
183 let config = PushConfig {
184 id: destination.registration_id(),
185 task: agentplane::core::RunId::generate(),
186 url: url.to_owned(),
187 token: None,
188 authentication: None,
189 };
190 let at = std::time::SystemTime::now()
191 .duration_since(std::time::UNIX_EPOCH)
192 .map_or(0, |d| d.as_secs());
193 let rt = crate::pipeline::tokio_runtime()?;
194 match rt.block_on(sender.deliver(&config, &PushMessage::cloudevent(&event), at)) {
195 Ok(Delivered::Accepted) => Ok("accepted".to_owned()),
196 Ok(Delivered::Rejected { status, .. }) => Err(format!("answered {status}")),
197 Ok(Delivered::Unreachable(why)) => Err(why),
198 Err(e) => Err(e.to_string()),
199 }
200}
201
202fn command(
203 def: &HookDef,
204 argv: &[String],
205 envelope: &Value,
206 root: &Path,
207 state: &Path,
208) -> Result<String, String> {
209 let (runner, _) = crate::sandbox::runner_at(root)?;
210 if !runner.is_sandboxed() {
211 return Err("no sandbox is enforced here, and an observer runs only in one".to_owned());
212 }
213 let (runner, _) = runner.carve(
215 &[root.to_path_buf()],
216 &super::gate::hidden_state(state, root),
217 );
218 if !runner.hides() {
219 return Err("no sandbox is enforced here that hides speccoder's state, and an observer runs only in one".to_owned());
220 }
221 let scratch = runner
222 .scratch()
223 .ok_or_else(|| "the sandbox has no scratch directory".to_owned())?;
224 let file = scratch.join(format!("event-{}.json", std::process::id()));
225 std::fs::write(&file, format!("{envelope}\n")).map_err(|e| e.to_string())?;
226 let mut env = crate::verify::process::scrubbed_env(std::env::vars());
227 for name in &def.env {
228 if let Ok(v) = std::env::var(name) {
229 env.retain(|(k, _)| k != name);
230 env.push((name.clone(), v));
231 }
232 }
233 env.push((
234 "SPECCODER_EVENT".to_owned(),
235 envelope["type"]
236 .as_str()
237 .unwrap_or_default()
238 .trim_start_matches("dev.speccoder.")
239 .to_owned(),
240 ));
241 let mut full = vec![
243 "sh".to_owned(),
244 "-c".to_owned(),
245 "exec \"$@\" < \"$0\"".to_owned(),
246 file.to_string_lossy().into_owned(),
247 ];
248 full.extend(argv.iter().cloned());
249 let inv = Invocation {
250 argv: full,
251 cwd: root.to_path_buf(),
252 env,
253 timeout: Duration::from_secs(def.timeout),
254 output_cap: OUTPUT_CAP,
255 };
256 let ended = runner.run_with(&inv, true);
257 let _ = std::fs::remove_file(&file);
258 if let Some(e) = ended.spawn_error {
259 return Err(format!("could not start: {e}"));
260 }
261 if ended.timed_out {
262 return Err(format!("timed out after {} s", def.timeout));
263 }
264 match ended.exit_code {
265 Some(0) => Ok("exit 0".to_owned()),
266 code => {
267 let stderr = String::from_utf8_lossy(&ended.stderr.kept);
268 let line = stderr.lines().map(str::trim).rfind(|l| !l.is_empty());
269 Err(line.map_or_else(
270 || code.map_or_else(|| "was killed".to_owned(), |c| format!("exit {c}")),
271 speccoder_core::hooks::clean,
272 ))
273 }
274 }
275}
276
277#[derive(Debug, Clone, Default, PartialEq, Eq)]
279pub struct Delivery {
280 pub delivered: u32,
282 pub pending: u32,
284 pub untrusted: Vec<String>,
286 pub failed: Vec<(String, String)>,
288 pub busy: bool,
290}
291
292fn tries() -> &'static Mutex<HashMap<String, u32>> {
295 static TRIED: OnceLock<Mutex<HashMap<String, u32>>> = OnceLock::new();
296 TRIED.get_or_init(Mutex::default)
297}
298
299#[must_use]
302pub fn pass(root: &Path, state: &Path, deadline: Option<Instant>) -> Delivery {
303 let mut out = Delivery::default();
304 let Ok(hooks) = super::load(root) else {
305 return out;
306 };
307 let observers: Vec<&HookDef> = hooks.iter().filter(|h| h.at().is_none()).collect();
308 if observers.is_empty() {
309 return out;
310 }
311 let dir = super::dir(state, root);
312 let _ = std::fs::create_dir_all(&dir);
313 let Ok(lock) = std::fs::File::create(dir.join(LOCK)) else {
314 return out;
315 };
316 if lock.try_lock().is_err() {
317 out.busy = true;
318 return out;
319 }
320 let project = super::project_id(state, root);
321 let mut all = cursors(&dir);
322 for def in observers {
323 if super::digest(def, root).trust(&dir) != TrustState::Trusted {
324 out.untrusted.push(def.name.clone());
325 continue;
326 }
327 let Some(cursor) = all.get_mut(&def.name) else {
328 all.insert(def.name.clone(), here(root));
330 continue;
331 };
332 let present: std::collections::HashSet<String> = events::tips(root)
334 .into_iter()
335 .filter(|(r, _)| r.starts_with(crate::attempts::PREFIX))
336 .map(|(_, sha)| format!("attempt:{sha}"))
337 .collect();
338 cursor.attempts.retain(|id| present.contains(id));
339 for (reference, event) in pending(root, &project, cursor) {
340 if !def.hears(event.kind) {
341 advance(cursor, &reference, &event);
342 continue;
343 }
344 if deadline.is_some_and(|d| Instant::now() >= d) {
346 out.pending += 1;
347 break;
348 }
349 let key = format!("{}/{}", def.name, event.id);
350 let tried = tries()
351 .lock()
352 .map_or(0, |t| t.get(&key).copied().unwrap_or(0));
353 if tried >= TRIES {
354 out.pending += 1;
355 break;
356 }
357 let began = Instant::now();
358 let sent = send(def, &event.envelope, root, state);
359 let duration_ms = u64::try_from(began.elapsed().as_millis()).unwrap_or(u64::MAX);
360 super::record_run(
361 &dir,
362 &def.name,
363 HookRun {
364 at: crate::attest::now_rfc3339(),
365 what: event.id.clone(),
366 outcome: if sent.is_ok() { "delivered" } else { "failed" }.to_owned(),
367 duration_ms,
368 error: sent.as_ref().err().cloned(),
369 findings: Vec::new(),
370 findings_error: None,
371 },
372 );
373 match sent {
374 Ok(_) => {
375 advance(cursor, &reference, &event);
376 out.delivered += 1;
377 }
378 Err(e) => {
379 if let Ok(mut t) = tries().lock() {
380 *t.entry(key).or_default() += 1;
381 }
382 out.failed.push((def.name.clone(), e));
383 out.pending += 1;
384 break;
386 }
387 }
388 }
389 }
390 super::write_json(&dir, CURSORS, &all);
391 out
392}
393
394#[derive(Debug)]
398pub struct Deliverer {
399 stop: mpsc::Sender<()>,
400 done: mpsc::Receiver<()>,
401}
402
403pub const GRACE: Duration = Duration::from_secs(5);
405
406static ACTIVE: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
407
408impl Deliverer {
409 #[must_use]
412 pub fn start(root: &Path, state: &Path) -> Option<Self> {
413 use std::sync::atomic::Ordering;
414 let has_observers = super::load(root)
415 .map(|h| h.iter().any(|d| d.at().is_none()))
416 .unwrap_or(false);
417 if !has_observers || ACTIVE.swap(true, Ordering::SeqCst) {
418 return None;
419 }
420 let (stop, stopped) = mpsc::channel::<()>();
421 let (finished, done) = mpsc::channel::<()>();
422 let (root, state): (PathBuf, PathBuf) = (root.to_path_buf(), state.to_path_buf());
423 std::thread::spawn(move || {
424 loop {
425 let _ = pass(&root, &state, None);
426 match stopped.recv_timeout(Duration::from_secs(1)) {
427 Err(mpsc::RecvTimeoutError::Timeout) => {}
428 Ok(()) | Err(mpsc::RecvTimeoutError::Disconnected) => break,
429 }
430 }
431 let _ = pass(&root, &state, Some(Instant::now() + GRACE));
432 let _ = finished.send(());
433 });
434 Some(Self { stop, done })
435 }
436}
437
438impl Drop for Deliverer {
439 fn drop(&mut self) {
440 let _ = self.stop.send(());
441 let _ = self.done.recv_timeout(GRACE + Duration::from_secs(1));
442 ACTIVE.store(false, std::sync::atomic::Ordering::SeqCst);
443 }
444}
445
446#[cfg(test)]
447mod tests {
448 use super::*;
449
450 #[test]
451 fn a_cursor_moves_per_event() {
452 let mut c = Cursor::default();
453 let tick = Event {
454 id: "tick:abc".into(),
455 kind: "task.ticked",
456 envelope: Value::Null,
457 };
458 advance(&mut c, "refs/heads/speccoder/x", &tick);
459 assert_eq!(c.tips["refs/heads/speccoder/x"], "abc");
460 let stop = Event {
461 id: "attempt:def".into(),
462 kind: "task.stopped",
463 envelope: Value::Null,
464 };
465 advance(&mut c, "refs/speccoder/attempts/x/T1/r", &stop);
466 assert_eq!(c.attempts, ["attempt:def"]);
467 }
468}