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) => command(def, argv, envelope, root, state),
154 }
155}
156
157fn post(
158 def: &HookDef,
159 url: &str,
160 secret_env: Option<&str>,
161 envelope: &Value,
162) -> Result<String, String> {
163 use agentplane::push::{
164 Delivered, Destination, PushConfig, PushMessage, PushSender, PushTransport,
165 };
166 speccoder_core::hooks::url_allowed(url)?;
167 let event = agentplane::core::CloudEvent::from_json(envelope.to_string().as_bytes())
168 .map_err(|e| format!("the event is not a CloudEvent: {e}"))?;
169 let mut destination = Destination::new(&def.name, url);
170 if let Some(var) = secret_env {
171 let key = std::env::var(var).map_err(|_| format!("${var} is not set"))?;
172 destination = destination
173 .try_signed_with(&agentplane::core::Secret::new(key))
174 .map_err(|e| format!("${var}: {e}"))?;
175 }
176 let sender = PushSender::for_operator_destinations(std::slice::from_ref(&destination))
177 .timeout(Duration::from_secs(def.timeout));
178 let config = PushConfig {
179 id: destination.registration_id(),
180 task: agentplane::core::RunId::generate(),
181 url: url.to_owned(),
182 token: None,
183 authentication: None,
184 };
185 let at = std::time::SystemTime::now()
186 .duration_since(std::time::UNIX_EPOCH)
187 .map_or(0, |d| d.as_secs());
188 let rt = crate::pipeline::tokio_runtime()?;
189 match rt.block_on(sender.deliver(&config, &PushMessage::cloudevent(&event), at)) {
190 Ok(Delivered::Accepted) => Ok("accepted".to_owned()),
191 Ok(Delivered::Rejected { status, .. }) => Err(format!("answered {status}")),
192 Ok(Delivered::Unreachable(why)) => Err(why),
193 Err(e) => Err(e.to_string()),
194 }
195}
196
197fn command(
198 def: &HookDef,
199 argv: &[String],
200 envelope: &Value,
201 root: &Path,
202 state: &Path,
203) -> Result<String, String> {
204 let (runner, _) = crate::sandbox::runner_at(root)?;
205 if !runner.is_sandboxed() {
206 return Err("no sandbox is enforced here, and an observer runs only in one".to_owned());
207 }
208 let (runner, _) = runner.carve(
210 &[root.to_path_buf()],
211 &super::gate::hidden_state(state, root),
212 );
213 if !runner.hides() {
214 return Err("no sandbox is enforced here that hides speccoder's state, and an observer runs only in one".to_owned());
215 }
216 let scratch = runner
217 .scratch()
218 .ok_or_else(|| "the sandbox has no scratch directory".to_owned())?;
219 let file = scratch.join(format!("event-{}.json", std::process::id()));
220 std::fs::write(&file, format!("{envelope}\n")).map_err(|e| e.to_string())?;
221 let mut env = crate::verify::process::scrubbed_env(std::env::vars());
222 for name in &def.env {
223 if let Ok(v) = std::env::var(name) {
224 env.retain(|(k, _)| k != name);
225 env.push((name.clone(), v));
226 }
227 }
228 env.push((
229 "SPECCODER_EVENT".to_owned(),
230 envelope["type"]
231 .as_str()
232 .unwrap_or_default()
233 .trim_start_matches("dev.speccoder.")
234 .to_owned(),
235 ));
236 let mut full = vec![
238 "sh".to_owned(),
239 "-c".to_owned(),
240 "exec \"$@\" < \"$0\"".to_owned(),
241 file.to_string_lossy().into_owned(),
242 ];
243 full.extend(argv.iter().cloned());
244 let inv = Invocation {
245 argv: full,
246 cwd: root.to_path_buf(),
247 env,
248 timeout: Duration::from_secs(def.timeout),
249 output_cap: OUTPUT_CAP,
250 };
251 let ended = runner.run_with(&inv, true);
252 let _ = std::fs::remove_file(&file);
253 if let Some(e) = ended.spawn_error {
254 return Err(format!("could not start: {e}"));
255 }
256 if ended.timed_out {
257 return Err(format!("timed out after {} s", def.timeout));
258 }
259 match ended.exit_code {
260 Some(0) => Ok("exit 0".to_owned()),
261 code => {
262 let stderr = String::from_utf8_lossy(&ended.stderr.kept);
263 let line = stderr.lines().map(str::trim).rfind(|l| !l.is_empty());
264 Err(line.map_or_else(
265 || code.map_or_else(|| "was killed".to_owned(), |c| format!("exit {c}")),
266 speccoder_core::hooks::clean,
267 ))
268 }
269 }
270}
271
272#[derive(Debug, Clone, Default, PartialEq, Eq)]
274pub struct Delivery {
275 pub delivered: u32,
277 pub pending: u32,
279 pub untrusted: Vec<String>,
281 pub failed: Vec<(String, String)>,
283 pub busy: bool,
285}
286
287fn tries() -> &'static Mutex<HashMap<String, u32>> {
290 static TRIED: OnceLock<Mutex<HashMap<String, u32>>> = OnceLock::new();
291 TRIED.get_or_init(Mutex::default)
292}
293
294#[must_use]
297pub fn pass(root: &Path, state: &Path, deadline: Option<Instant>) -> Delivery {
298 let mut out = Delivery::default();
299 let Ok(hooks) = super::load(root) else {
300 return out;
301 };
302 let observers: Vec<&HookDef> = hooks.iter().filter(|h| h.at().is_none()).collect();
303 if observers.is_empty() {
304 return out;
305 }
306 let dir = super::dir(state, root);
307 let _ = std::fs::create_dir_all(&dir);
308 let Ok(lock) = std::fs::File::create(dir.join(LOCK)) else {
309 return out;
310 };
311 if lock.try_lock().is_err() {
312 out.busy = true;
313 return out;
314 }
315 let project = super::project_id(state, root);
316 let mut all = cursors(&dir);
317 for def in observers {
318 let digest = super::digest(def, root).digest;
319 if super::trust::state(&dir, &def.name, &digest) != TrustState::Trusted {
320 out.untrusted.push(def.name.clone());
321 continue;
322 }
323 let Some(cursor) = all.get_mut(&def.name) else {
324 all.insert(def.name.clone(), here(root));
326 continue;
327 };
328 let present: std::collections::HashSet<String> = events::tips(root)
330 .into_iter()
331 .filter(|(r, _)| r.starts_with(crate::attempts::PREFIX))
332 .map(|(_, sha)| format!("attempt:{sha}"))
333 .collect();
334 cursor.attempts.retain(|id| present.contains(id));
335 for (reference, event) in pending(root, &project, cursor) {
336 if !def.hears(event.kind) {
337 advance(cursor, &reference, &event);
338 continue;
339 }
340 if deadline.is_some_and(|d| Instant::now() >= d) {
342 out.pending += 1;
343 break;
344 }
345 let key = format!("{}/{}", def.name, event.id);
346 let tried = tries()
347 .lock()
348 .map_or(0, |t| t.get(&key).copied().unwrap_or(0));
349 if tried >= TRIES {
350 out.pending += 1;
351 break;
352 }
353 let began = Instant::now();
354 let sent = send(def, &event.envelope, root, state);
355 let duration_ms = u64::try_from(began.elapsed().as_millis()).unwrap_or(u64::MAX);
356 super::record_run(
357 &dir,
358 &def.name,
359 HookRun {
360 at: crate::attest::now_rfc3339(),
361 what: event.id.clone(),
362 outcome: if sent.is_ok() { "delivered" } else { "failed" }.to_owned(),
363 duration_ms,
364 error: sent.as_ref().err().cloned(),
365 },
366 );
367 match sent {
368 Ok(_) => {
369 advance(cursor, &reference, &event);
370 out.delivered += 1;
371 }
372 Err(e) => {
373 if let Ok(mut t) = tries().lock() {
374 *t.entry(key).or_default() += 1;
375 }
376 out.failed.push((def.name.clone(), e));
377 out.pending += 1;
378 break;
380 }
381 }
382 }
383 }
384 super::write_json(&dir, CURSORS, &all);
385 out
386}
387
388#[derive(Debug)]
392pub struct Deliverer {
393 stop: mpsc::Sender<()>,
394 done: mpsc::Receiver<()>,
395}
396
397pub const GRACE: Duration = Duration::from_secs(5);
399
400static ACTIVE: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
401
402impl Deliverer {
403 #[must_use]
406 pub fn start(root: &Path, state: &Path) -> Option<Self> {
407 use std::sync::atomic::Ordering;
408 let has_observers = super::load(root)
409 .map(|h| h.iter().any(|d| d.at().is_none()))
410 .unwrap_or(false);
411 if !has_observers || ACTIVE.swap(true, Ordering::SeqCst) {
412 return None;
413 }
414 let (stop, stopped) = mpsc::channel::<()>();
415 let (finished, done) = mpsc::channel::<()>();
416 let (root, state): (PathBuf, PathBuf) = (root.to_path_buf(), state.to_path_buf());
417 std::thread::spawn(move || {
418 loop {
419 let _ = pass(&root, &state, None);
420 match stopped.recv_timeout(Duration::from_secs(1)) {
421 Err(mpsc::RecvTimeoutError::Timeout) => {}
422 Ok(()) | Err(mpsc::RecvTimeoutError::Disconnected) => break,
423 }
424 }
425 let _ = pass(&root, &state, Some(Instant::now() + GRACE));
426 let _ = finished.send(());
427 });
428 Some(Self { stop, done })
429 }
430}
431
432impl Drop for Deliverer {
433 fn drop(&mut self) {
434 let _ = self.stop.send(());
435 let _ = self.done.recv_timeout(GRACE + Duration::from_secs(1));
436 ACTIVE.store(false, std::sync::atomic::Ordering::SeqCst);
437 }
438}
439
440#[cfg(test)]
441mod tests {
442 use super::*;
443
444 #[test]
445 fn a_cursor_moves_per_event() {
446 let mut c = Cursor::default();
447 let tick = Event {
448 id: "tick:abc".into(),
449 kind: "task.ticked",
450 envelope: Value::Null,
451 };
452 advance(&mut c, "refs/heads/speccoder/x", &tick);
453 assert_eq!(c.tips["refs/heads/speccoder/x"], "abc");
454 let stop = Event {
455 id: "attempt:def".into(),
456 kind: "task.stopped",
457 envelope: Value::Null,
458 };
459 advance(&mut c, "refs/speccoder/attempts/x/T1/r", &stop);
460 assert_eq!(c.attempts, ["attempt:def"]);
461 }
462}