Skip to main content

speccoder_runtime/hooks/
events.rs

1//! `task.ticked` and `task.stopped`, read back from git: a tick commit on a
2//! unit's branch and an attempt saved under `refs/speccoder/attempts/…`.
3//! The same record gives the same event, with the same id, in any process;
4//! the projection carries ids, counts and words speccoder wrote, never test
5//! names, file contents, model words or tool arguments.
6
7use std::path::Path;
8
9use serde_json::{Value, json};
10use speccoder_core::hooks::{
11    Count, GateMark, GateState, TaskStopped, TaskTicked, stopped_summary, ticked_summary,
12};
13
14use crate::git::Git;
15
16/// Where the published schemas live.
17const SCHEMAS: &str = "https://hupe1980.github.io/speccoder/schemas/v2/events";
18
19/// One event, ready to deliver.
20#[derive(Debug, Clone)]
21pub struct Event {
22    /// `tick:<commit>` or `attempt:<commit>`.
23    pub id: String,
24    /// `task.ticked` or `task.stopped`.
25    pub kind: &'static str,
26    /// The CloudEvents 1.0 envelope (structured mode).
27    pub envelope: Value,
28}
29
30/// A commit's message.
31/// A commit's trailers only: free text above them (a reason, a model's
32/// words) never becomes a field.
33fn message(git: &Git, rev: &str) -> Option<String> {
34    git.run(&[
35        "log",
36        "-1",
37        "--format=%(trailers:only=true,unfold=true)",
38        rev,
39    ])
40    .ok()
41    .map(|b| String::from_utf8_lossy(&b).into_owned())
42}
43
44fn trailer<'a>(message: &'a str, key: &str) -> Option<&'a str> {
45    let prefix = format!("{key}: ");
46    message
47        .lines()
48        .rev()
49        .find_map(|l| l.strip_prefix(prefix.as_str()))
50        .map(str::trim)
51}
52
53fn gates(message: &str) -> Vec<GateMark> {
54    message
55        .lines()
56        .filter_map(|l| l.strip_prefix("Gate: "))
57        .filter_map(|v| {
58            let (name, outcome) = v.trim().rsplit_once(' ')?;
59            Some(GateMark {
60                name: name.to_owned(),
61                outcome: match outcome {
62                    "passed" => GateState::Passed,
63                    "blocked" => GateState::Blocked,
64                    _ => GateState::NotRun,
65                },
66            })
67        })
68        .collect()
69}
70
71fn spec_task(message: &str) -> Option<(String, String)> {
72    let (feature, task) = trailer(message, "Spec-Task")?.split_once('/')?;
73    Some((feature.to_owned(), task.to_owned()))
74}
75
76/// `task.ticked` for the tick commit `commit`, if it is one.
77#[must_use]
78pub fn ticked(git: &Git, commit: &str) -> Option<TaskTicked> {
79    let m = message(git, commit)?;
80    let (feature, task) = spec_task(&m)?;
81    let verdict = trailer(&m, "Verdict")?.to_owned();
82    let held_out = trailer(&m, "Held-Out")
83        .and_then(|v| v.strip_prefix("passed="))
84        .and_then(|v| v.split_whitespace().next())
85        .and_then(|v| v.split_once('/'))
86        .and_then(|(p, t)| {
87            Some(Count {
88                passed: p.parse().ok()?,
89                total: t.parse().ok()?,
90            })
91        });
92    let scenarios = trailer(&m, "Scenarios")
93        .map(|v| {
94            v.split(',')
95                .map(|s| s.trim().split('@').next().unwrap_or_default().to_owned())
96                .filter(|s| !s.is_empty())
97                .collect()
98        })
99        .unwrap_or_default();
100    let mut t = TaskTicked {
101        feature,
102        task,
103        verdict,
104        commit: commit.to_owned(),
105        scenarios,
106        held_out,
107        acceptance_strength: trailer(&m, "Acceptance-Strength")
108            .and_then(|v| v.split_whitespace().next())
109            .and_then(|v| v.parse().ok()),
110        gates: gates(&m),
111        executor: trailer(&m, "Executor").map(str::to_owned),
112        run: trailer(&m, "Run").map(str::to_owned),
113        summary: String::new(),
114    };
115    t.summary = ticked_summary(&t);
116    Some(t)
117}
118
119/// The code a saved attempt's outcome stands for.
120fn code_of(outcome: &str) -> Option<&'static str> {
121    Some(match outcome {
122        "verification_failed" => "VERIFICATION-FAILED",
123        "needs_approval" => "NEEDS-APPROVAL",
124        "blocked" => "BLOCKED",
125        "incomplete" => "ATTEMPT-INCOMPLETE",
126        "budget_exhausted" | "denied" => "BUDGET-EXHAUSTED",
127        "suspected_gaming" => "SUSPECTED-GAMING",
128        "acceptance_weak" => "ACCEPTANCE-WEAK",
129        "blocked_by_gate" => "HOOK-BLOCKED",
130        "interrupted" => "INTERRUPTED",
131        _ => return None,
132    })
133}
134
135/// `task.stopped` for the attempt saved under `reference` at `commit`.
136#[must_use]
137pub fn stopped(git: &Git, reference: &str, commit: &str) -> Option<TaskStopped> {
138    let m = message(git, commit)?;
139    let (feature, task) = spec_task(&m)?;
140    let outcome = trailer(&m, "Outcome")?.to_owned();
141    let mut s = TaskStopped {
142        feature,
143        task,
144        code: code_of(&outcome).map(str::to_owned),
145        outcome,
146        attempt_ref: reference.to_owned(),
147        gates: gates(&m),
148        run: trailer(&m, "Run").map(str::to_owned),
149        summary: String::new(),
150    };
151    s.summary = stopped_summary(&s);
152    Some(s)
153}
154
155/// The CloudEvents 1.0 envelope of an event about `feature`'s `task`.
156#[must_use]
157pub fn envelope(
158    project: &str,
159    kind: &str,
160    id: &str,
161    feature: &str,
162    task: &str,
163    data: &Value,
164) -> Value {
165    json!({
166        "specversion": "1.0",
167        "id": id,
168        "source": format!("speccoder://{project}/{feature}"),
169        "type": format!("dev.speccoder.{kind}"),
170        "subject": task,
171        "time": crate::attest::now_rfc3339(),
172        "datacontenttype": "application/json",
173        "dataschema": format!("{SCHEMAS}/{kind}.json"),
174        "data": data,
175    })
176}
177
178/// A record's event, if `commit` on `reference` is one.
179#[must_use]
180pub fn of(git: &Git, project: &str, reference: &str, commit: &str) -> Option<Event> {
181    if reference.starts_with(crate::attempts::PREFIX) {
182        let s = stopped(git, reference, commit)?;
183        let id = format!("attempt:{commit}");
184        let data = serde_json::to_value(&s).ok()?;
185        Some(Event {
186            envelope: envelope(project, "task.stopped", &id, &s.feature, &s.task, &data),
187            id,
188            kind: "task.stopped",
189        })
190    } else {
191        let t = ticked(git, commit)?;
192        let id = format!("tick:{commit}");
193        let data = serde_json::to_value(&t).ok()?;
194        Some(Event {
195            envelope: envelope(project, "task.ticked", &id, &t.feature, &t.task, &data),
196            id,
197            kind: "task.ticked",
198        })
199    }
200}
201
202/// The refs whose records are events: every unit's branch, the branch the
203/// checkout is on (in-place ticks), and every saved attempt; each with its
204/// commit.
205#[must_use]
206pub fn tips(root: &Path) -> Vec<(String, String)> {
207    let git = Git::at(root);
208    let mut out: Vec<(String, String)> = git
209        .run(&[
210            "for-each-ref",
211            "--format=%(refname) %(objectname)",
212            "refs/heads/speccoder/",
213            crate::attempts::PREFIX,
214        ])
215        .map(|b| String::from_utf8_lossy(&b).into_owned())
216        .unwrap_or_default()
217        .lines()
218        .filter_map(|l| l.split_once(' '))
219        .map(|(r, c)| (r.to_owned(), c.to_owned()))
220        .collect();
221    if let (Ok(head), Ok(commit)) = (
222        git.line(&["symbolic-ref", "-q", "HEAD"]),
223        git.line(&["rev-parse", "HEAD"]),
224    ) && !out.iter().any(|(r, _)| *r == head)
225    {
226        out.push((head.trim().to_owned(), commit.trim().to_owned()));
227    }
228    out
229}
230
231/// The tick commits in `old..new` on a branch, oldest first (all of `new`'s
232/// history when `old` is gone or not its ancestor — a rewrite invents
233/// nothing: it is the caller's to start such a branch at its tip).
234#[must_use]
235pub fn ticks_between(git: &Git, old: &str, new: &str) -> Vec<String> {
236    git.run(&[
237        "log",
238        "--reverse",
239        "--format=%H",
240        "--grep=^Verdict: ",
241        &format!("{old}..{new}"),
242    ])
243    .map(|b| String::from_utf8_lossy(&b).into_owned())
244    .unwrap_or_default()
245    .lines()
246    .map(str::to_owned)
247    .collect()
248}
249
250/// Whether `old` is an ancestor of `new`.
251#[must_use]
252pub fn descends(git: &Git, old: &str, new: &str) -> bool {
253    git.succeeds(&["merge-base", "--is-ancestor", old, new])
254        .unwrap_or(false)
255}
256
257/// A sample `task.ticked` marked `test: true`, for `speccoder hooks test`.
258#[must_use]
259pub fn sample(project: &str) -> Value {
260    let mut t = TaskTicked {
261        feature: "TEST".to_owned(),
262        task: "TEST".to_owned(),
263        verdict: "verified".to_owned(),
264        commit: "0".repeat(40),
265        scenarios: vec!["US1-1".to_owned()],
266        held_out: None,
267        acceptance_strength: None,
268        gates: Vec::new(),
269        executor: None,
270        run: None,
271        summary: String::new(),
272    };
273    t.summary = format!("A test from speccoder: {}", ticked_summary(&t));
274    let data = serde_json::to_value(&t).unwrap_or_default();
275    let mut e = envelope(project, "task.ticked", "test:sample", "TEST", "TEST", &data);
276    e["test"] = json!(true);
277    e
278}
279
280#[cfg(test)]
281mod tests {
282    use super::*;
283
284    fn git(dir: &Path, args: &[&str]) {
285        let ok = std::process::Command::new("git")
286            .args([
287                "-c",
288                "user.name=t",
289                "-c",
290                "user.email=t@x",
291                "-c",
292                "commit.gpgsign=false",
293            ])
294            .args(args)
295            .current_dir(dir)
296            .status()
297            .unwrap()
298            .success();
299        assert!(ok, "{args:?}");
300    }
301
302    #[test]
303    fn a_tick_commit_projects_ids_counts_and_a_sentence_only() {
304        let d = tempfile::tempdir().unwrap();
305        git(d.path(), &["init", "-q"]);
306        let msg = "T004 Add login\n\nAdd login in src/login.rs, asserting `secret_name()` returns 42\n\nSpec-Task: 001-md2csv/T004\nVerdict: verified\nHeld-Out: passed=2/2 manifest=sha256:ab\nScenarios: US1-1@aaa, US1-2@bbb\nAcceptance-Strength: 0.83 near-miss=1/1\nGate: licence passed\nRun: run_01\n";
307        git(d.path(), &["commit", "-q", "--allow-empty", "-m", msg]);
308        let g = Git::at(d.path());
309        let head = g.line(&["rev-parse", "HEAD"]).unwrap();
310        let head = head.trim();
311        let e = of(&g, "9a3b", "refs/heads/speccoder/001-md2csv", head).unwrap();
312        assert_eq!(e.id, format!("tick:{head}"));
313        assert_eq!(e.envelope["type"], "dev.speccoder.task.ticked");
314        assert_eq!(e.envelope["source"], "speccoder://9a3b/001-md2csv");
315        assert_eq!(e.envelope["subject"], "T004");
316        let data = &e.envelope["data"];
317        assert_eq!(data["scenarios"], json!(["US1-1", "US1-2"]));
318        assert_eq!(data["held_out"], json!({"passed": 2, "total": 2}));
319        assert_eq!(data["gates"][0]["name"], "licence");
320        assert_eq!(
321            data["summary"],
322            "T004 verified in 001-md2csv — 2 scenarios, held-out 2 of 2"
323        );
324        // The task's words, its test names and the model's never leave.
325        let text = e.envelope.to_string();
326        for leak in ["Add login", "secret_name", "src/login.rs"] {
327            assert!(!text.contains(leak), "{leak} in {text}");
328        }
329    }
330
331    #[test]
332    fn a_saved_attempt_is_a_stop_with_its_code() {
333        let d = tempfile::tempdir().unwrap();
334        git(d.path(), &["init", "-q"]);
335        let msg = "speccoder attempt: T005 blocked_by_gate\n\nBlocked by licence — GPL in foo\n\nSpec-Task: 001-md2csv/T005\nOutcome: blocked_by_gate\nRun: run_02\nGate: licence blocked\nEarned-Verdict: verified\n";
336        git(d.path(), &["commit", "-q", "--allow-empty", "-m", msg]);
337        let g = Git::at(d.path());
338        let head = g.line(&["rev-parse", "HEAD"]).unwrap();
339        let r = format!("{}/001-md2csv/T005/run_02", crate::attempts::PREFIX);
340        let e = of(&g, "9a3b", &r, head.trim()).unwrap();
341        assert_eq!(e.kind, "task.stopped");
342        assert_eq!(e.envelope["data"]["code"], "HOOK-BLOCKED");
343        assert_eq!(
344            e.envelope["data"]["summary"],
345            "T005 blocked by licence in 001-md2csv"
346        );
347        assert!(!e.envelope.to_string().contains("GPL in foo"));
348    }
349}