feat(rust): hanzo-actions, Kai Actions as a light crate

An action is one saved, typed, verified operation: its inputs and what
each may be, the preconditions read again just before it runs, what it
does to the world (which decides whether the user must approve it), what
its result depends on, and the check that its effect happened. A request
is matched to an action or workflow by trigger, its inputs are bound
from the request and from values read out of the repository, and Kai is
asked only when several actions or values remain; it may abstain and it
never writes a command. Every run leaves a ledger record.

The module began as hanzo-actions in Dev (hanzoai/dev branch actions)
and lives here now, as its own workspace crate for the reason hanzo-
trigram is one: Dev links it without taking this crate's ZAP router,
browser and parser dependencies. hanzo_mcp::actions re-exports it.
Paths inside it are crate:: paths; nothing else changed in the move.

34 tests: binding, authorization, the proof gate, the cache, redaction,
and the git-backed actions run against real repositories.
This commit is contained in:
2026-10-14 14:54:47 -07:00
parent ce3c6bfd4a
commit a8388f381a
35 changed files with 5947 additions and 1 deletions
+13
View File
@@ -1034,6 +1034,18 @@ dependencies = [
"tracing",
]
[[package]]
name = "hanzo-actions"
version = "0.1.0"
dependencies = [
"regex",
"reqwest",
"serde",
"serde_json",
"sha2 0.10.9",
"tempfile",
]
[[package]]
name = "hanzo-mcp"
version = "1.1.29"
@@ -1048,6 +1060,7 @@ dependencies = [
"dirs 5.0.1",
"env_logger",
"glob",
"hanzo-actions",
"hanzo-trigram",
"jsonrpc-core",
"jsonrpc-derive",
+3 -1
View File
@@ -1,5 +1,5 @@
[workspace]
members = ["trigram"]
members = ["trigram", "actions"]
[package]
name = "hanzo-mcp"
@@ -60,6 +60,8 @@ zapd = "1.1.5"
# The in-process text, symbol and document search over tgrep-core; light enough for
# Dev to link on its own, re-exported here as `hanzo_mcp::trigram`.
hanzo-trigram = { path = "trigram", version = "0.1.0" }
# Kai Actions, likewise its own crate; `hanzo_mcp::actions` is this.
hanzo-actions = { path = "actions", version = "0.1.0" }
tree-sitter = "0.20"
tree-sitter-rust = "0.20"
tree-sitter-javascript = "0.20"
+18
View File
@@ -0,0 +1,18 @@
[package]
name = "hanzo-actions"
version = "0.1.0"
edition = "2021"
license = "MIT"
repository = "https://github.com/hanzoai/mcp"
description = "Kai Actions: typed, verified operations run without a model call. Kai selects an action and binds its inputs over the Decisions API; the action's verifier, not the model, says it is done. Light enough for Dev to link."
[dependencies]
regex = "1"
# The Decisions API is asked from a blocking thread; the tool runs the executor under spawn_blocking.
reqwest = { version = "0.11", default-features = false, features = ["blocking", "json", "rustls-tls"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
sha2 = "0.10"
[dev-dependencies]
tempfile = "3"
+280
View File
@@ -0,0 +1,280 @@
//! An action: one saved, typed, verified operation.
//!
//! An action names its inputs and what each may be, the preconditions that must hold before it
//! runs, what it does to the world (which decides whether the user must approve it), what its
//! result depends on, and the check that its effect happened. Kai selects an action and binds its
//! inputs from values read out of the repository; the action never receives a shell string.
use crate::exec::{Out, Step, run};
use crate::state::State;
use serde::Serialize;
use serde_json::Value;
use std::collections::BTreeMap;
use std::path::PathBuf;
use std::time::Duration;
/// Bound inputs, by name. A single value is a string; a set is an array of strings.
pub type Args = BTreeMap<String, Value>;
/// What an action does to the world.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum Effect {
/// Reads the workspace only.
Read,
/// Writes inside the workspace.
Write,
/// Writes outside the workspace.
Outside,
/// Reaches a remote: a push, a fetch, a request to another host.
Remote,
/// Stops a process or deletes data.
Destroy,
}
impl Effect {
/// Why the user must approve an action with this effect; `None` when no approval is needed.
pub fn reason(self) -> Option<&'static str> {
match self {
Effect::Read | Effect::Write => None,
Effect::Outside => Some("writes outside the workspace"),
Effect::Remote => Some("reaches a remote"),
Effect::Destroy => Some("stops a process or deletes data"),
}
}
}
/// Where the values an input may take are read from.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Source {
Branches,
Remotes,
Commits,
Files,
Dirty,
Suites,
Docs,
/// The headings of the file bound to `path`.
Headings,
/// Result files: JSON lines or CSV with one row an attempt.
Datasets,
}
/// How an input is bound.
#[derive(Debug, Clone, Copy)]
pub enum Kind {
/// The first capture of a pattern over the request.
Literal(&'static str),
/// One of the source's values: the one the request names, the only one there is, or Kai's pick.
Pick(Source),
/// The nth distinct value of the source the request names, in the order it names them.
Nth(Source, usize),
/// Every value of the source the request names.
Named(Source),
}
#[derive(Debug, Clone, Copy)]
pub struct Input {
pub name: &'static str,
/// The question Kai is asked when this input is a choice.
pub ask: &'static str,
pub kind: Kind,
/// May be left unbound: the action has a default.
pub optional: bool,
}
#[derive(Debug)]
pub struct Spec {
pub id: &'static str,
pub version: u32,
/// The sentence Kai reads to tell this action from the others.
pub summary: &'static str,
/// Patterns over the lowercased request; an action is a candidate when one matches.
pub triggers: &'static [&'static str],
pub inputs: &'static [Input],
pub effect: Effect,
/// Programs whose versions the action depends on.
pub tools: &'static [&'static str],
/// The result is a function of what it reads: it may be reused while that is unchanged.
pub pure: bool,
}
/// What a result depends on in the workspace.
#[derive(Debug, Clone)]
pub enum Reads {
/// These files, as they are now (uncommitted changes included).
Files(Vec<String>),
/// Every file.
Tree,
/// The commit and the refs.
Git,
}
#[derive(Debug, Clone, Serialize)]
pub struct Check {
pub name: String,
pub ok: bool,
pub detail: String,
}
impl Check {
pub fn new(name: &str, ok: bool, detail: impl Into<String>) -> Check {
Check {
name: name.to_string(),
ok,
detail: detail.into(),
}
}
}
#[derive(Debug, Clone, Serialize)]
pub struct Verdict {
pub ok: bool,
pub detail: String,
}
impl Verdict {
pub fn pass(detail: impl Into<String>) -> Verdict {
Verdict {
ok: true,
detail: detail.into(),
}
}
pub fn fail(detail: impl Into<String>) -> Verdict {
Verdict {
ok: false,
detail: detail.into(),
}
}
}
/// What an action produced: the text it reports, and fields later steps of a workflow read.
#[derive(Debug, Clone, Default, Serialize)]
pub struct Output {
pub text: String,
pub data: Value,
}
/// Where an action runs, and the commands it ran.
pub struct Cx {
/// Where the action works (a workflow may move a later step into a new worktree).
pub root: PathBuf,
/// Where the request was made: reports are written relative to it.
pub home: PathBuf,
pub request: String,
pub steps: Vec<Step>,
}
impl Cx {
pub fn new(root: PathBuf, home: PathBuf, request: &str) -> Cx {
Cx {
root,
home,
request: request.to_string(),
steps: Vec::new(),
}
}
pub fn state(&self) -> State {
State::new(&self.root, &self.request)
}
/// Runs a command here, keeping a record of it.
pub fn exec(&mut self, argv: &[&str], limit: Duration) -> Result<Out, String> {
let out = run(&self.root, argv, limit)?;
self.steps.push(out.step.clone());
Ok(out)
}
/// Runs a command that must succeed; its stdout.
pub fn must(&mut self, argv: &[&str]) -> Result<String, String> {
let out = self.exec(argv, Duration::from_secs(600))?;
if out.ok() {
Ok(out.stdout)
} else {
Err(format!("{}: {}", argv.join(" "), out.stderr.trim()))
}
}
pub fn path(&self, rel: &str) -> PathBuf {
let p = std::path::Path::new(rel);
if p.is_absolute() {
p.to_path_buf()
} else {
self.root.join(p)
}
}
}
pub trait Action: Sync {
fn spec(&self) -> &'static Spec;
/// What it does with these arguments (a path outside the workspace makes a write `Outside`).
fn effect(&self, _state: &State, _args: &Args) -> Effect {
self.spec().effect
}
/// Whether `cand` is a value `input` may take given the inputs bound so far: a dependent constraint
/// (a results file must hold both arms the request names) that narrows the choice before anyone is asked.
fn admits(
&self,
_state: &State,
_args: &Args,
_input: &str,
_cand: &crate::state::Cand,
) -> bool {
true
}
/// The preconditions, read now. Every one must hold for the action to run.
fn check(&self, state: &State, args: &Args) -> Vec<Check>;
/// What its result depends on.
fn reads(&self, _state: &State, _args: &Args) -> Reads {
Reads::Tree
}
fn run(&self, cx: &mut Cx, args: &Args) -> Result<Output, String>;
/// Whether the effect the action promises happened.
fn verify(&self, cx: &Cx, args: &Args, out: &Output) -> Verdict;
}
/// A string argument.
pub fn text<'a>(args: &'a Args, name: &str) -> Option<&'a str> {
args.get(name).and_then(Value::as_str)
}
/// A set argument.
pub fn list(args: &Args, name: &str) -> Vec<String> {
args.get(name)
.and_then(Value::as_array)
.map(|a| {
a.iter()
.filter_map(|v| v.as_str().map(str::to_string))
.collect()
})
.unwrap_or_default()
}
/// Writes a requested report next to the work; the text is redacted first.
pub fn report(cx: &Cx, args: &Args, body: &str) -> Result<(), String> {
if let Some(path) = text(args, "report") {
let p = std::path::Path::new(path);
let target = if p.is_absolute() {
p.to_path_buf()
} else {
cx.home.join(p)
};
if let Some(parent) = target.parent() {
std::fs::create_dir_all(parent).map_err(|e| format!("{}: {e}", parent.display()))?;
}
let mut body = crate::redact::redact(body);
if !body.ends_with('\n') {
body.push('\n');
}
std::fs::write(&target, body).map_err(|e| format!("{}: {e}", target.display()))?;
}
Ok(())
}
+201
View File
@@ -0,0 +1,201 @@
//! Asking Kai a choice over the Decisions API.
//!
//! One request carries every question that is open at once; each is a `choice` whose options are
//! the candidate values (or actions), and one more option, [`ABSTAIN`], that Kai may take when
//! none fits or the request does not say. Kai never writes a command: it only chooses among what
//! the caller read out of the repository.
use serde::Serialize;
use serde_json::{Value, json};
use std::time::{Duration, Instant};
/// The option that means none of the others.
pub const ABSTAIN: &str = "abstain";
/// A choice put to Kai.
#[derive(Debug, Clone)]
pub struct Question {
pub id: String,
pub ask: String,
/// `(key, description)` in the order Kai is shown them.
pub options: Vec<(String, String)>,
}
/// Kai's distribution over a question's options, abstain last.
#[derive(Debug, Clone, Serialize)]
pub struct Answer {
pub id: String,
pub probs: Vec<(String, f64)>,
}
impl Answer {
/// The probability Kai gives to abstaining.
pub fn abstain(&self) -> f64 {
self.probs
.iter()
.find(|(k, _)| k == ABSTAIN)
.map_or(0.0, |(_, p)| *p)
}
/// The real options (abstain left out) with their probabilities renormalized to sum to one,
/// likeliest first.
pub fn ranked(&self) -> Vec<(String, f64)> {
let total: f64 = self
.probs
.iter()
.filter(|(k, _)| k != ABSTAIN)
.map(|(_, p)| *p)
.sum();
let mut v: Vec<(String, f64)> = self
.probs
.iter()
.filter(|(k, _)| k != ABSTAIN)
.map(|(k, p)| (k.clone(), if total > 0.0 { p / total } else { 0.0 }))
.collect();
v.sort_by(|a, b| b.1.total_cmp(&a.1));
v
}
/// The likeliest real option, its renormalized probability, and its lead over the next.
pub fn best(&self) -> (String, f64, f64) {
match self.ranked().as_slice() {
[] => (String::new(), 0.0, 0.0),
[only] => (only.0.clone(), only.1, only.1),
[first, second, ..] => (first.0.clone(), first.1, first.1 - second.1),
}
}
}
/// What one request to Kai returned.
#[derive(Debug, Clone, Serialize)]
pub struct Asked {
pub answers: Vec<Answer>,
pub ms: f64,
pub checkpoint: String,
}
/// Anything that can answer choices: Kai over HTTP, or a script in a test.
pub trait Chooser: Sync {
fn decide(&self, request: &str, questions: &[Question]) -> Result<Asked, String>;
}
/// Kai at the Decisions API.
pub struct Kai {
url: String,
model: String,
key: Option<String>,
client: reqwest::blocking::Client,
}
impl Kai {
/// Kai at `url` (`https://api.hanzo.ai/v1`, or a local `http://127.0.0.1:18977/v1`) as `model`.
pub fn new(url: &str, model: &str, key: Option<String>) -> Kai {
Kai {
url: format!("{}/decisions", url.trim_end_matches('/')),
model: model.to_string(),
key,
client: reqwest::blocking::Client::builder()
.connect_timeout(Duration::from_secs(2))
.timeout(Duration::from_secs(10))
.build()
.unwrap_or_default(),
}
}
}
impl Chooser for Kai {
fn decide(&self, request: &str, questions: &[Question]) -> Result<Asked, String> {
let mut qs = serde_json::Map::new();
for q in questions {
let mut criteria = serde_json::Map::new();
for (k, d) in &q.options {
criteria.insert(k.clone(), Value::String(d.clone()));
}
criteria.insert(
ABSTAIN.into(),
Value::String("none of these fits, or the request does not say which".into()),
);
qs.insert(q.id.clone(), json!({ "type": "choice", "instructions": q.ask, "criteria": Value::Object(criteria) }));
}
let body = json!({ "model": self.model, "state": request, "questions": Value::Object(qs) });
let started = Instant::now();
let mut req = self.client.post(&self.url).json(&body);
if let Some(key) = &self.key {
req = req.bearer_auth(key);
}
let resp = req.send().map_err(|e| e.without_url().to_string())?;
let status = resp.status();
let text = resp.text().map_err(|e| e.without_url().to_string())?;
if !status.is_success() {
return Err(format!(
"kai answered {status}: {}",
crate::redact::cut(&text, 200)
));
}
let v: Value = serde_json::from_str(&text).map_err(|e| format!("kai: not JSON: {e}"))?;
let mut answers = Vec::new();
for q in questions {
let probs = v["answers"][&q.id]["probabilities"]
.as_object()
.ok_or_else(|| format!("kai: no distribution for {}", q.id))?;
let mut keys: Vec<&str> = q.options.iter().map(|(k, _)| k.as_str()).collect();
keys.push(ABSTAIN);
let all: Option<Vec<(String, f64)>> = keys
.iter()
.map(|k| {
probs
.get(*k)
.and_then(Value::as_f64)
.map(|p| ((*k).to_string(), p))
})
.collect();
answers.push(Answer {
id: q.id.clone(),
probs: all
.ok_or_else(|| format!("kai: a distribution for {} misses an option", q.id))?,
});
}
Ok(Asked {
answers,
ms: started.elapsed().as_secs_f64() * 1000.0,
checkpoint: v["routing"]["checkpoint"]
.as_str()
.or_else(|| v["model"].as_str())
.unwrap_or("")
.to_string(),
})
}
}
/// A chooser that answers from a table, for tests: `(question id, key)` gets probability 0.9.
pub struct Script(pub Vec<(String, String)>);
impl Chooser for Script {
fn decide(&self, _request: &str, questions: &[Question]) -> Result<Asked, String> {
let answers = questions
.iter()
.map(|q| {
let want = self
.0
.iter()
.find(|(id, _)| *id == q.id)
.map_or(ABSTAIN, |(_, k)| k.as_str());
let mut keys: Vec<&str> = q.options.iter().map(|(k, _)| k.as_str()).collect();
keys.push(ABSTAIN);
let rest = 0.1 / (keys.len().max(2) - 1) as f64;
Answer {
id: q.id.clone(),
probs: keys
.iter()
.map(|k| ((*k).to_string(), if *k == want { 0.9 } else { rest }))
.collect(),
}
})
.collect();
Ok(Asked {
answers,
ms: 0.0,
checkpoint: "script".into(),
})
}
}
+819
View File
@@ -0,0 +1,819 @@
//! From a request to a plan: which action (or workflow), with which inputs.
//!
//! An action is a candidate when one of its trigger patterns matches the request. Its inputs are
//! bound from the request and from values read out of the repository: a literal is captured by
//! its pattern; a choice takes the one value the request names, or the only value there is; only
//! when several values (or several actions) remain does Kai choose, and Kai may abstain. A pick
//! of Kai's must be grounded in the request, or it is refused. Anything unbound, ambiguous or
//! ungrounded is declined, and the request goes to the normal agent.
use crate::action::{Action, Args, Check, Input, Kind, Source};
use crate::ask::{Answer, Chooser, Question};
use crate::catalog;
use crate::ledger::{Ask, Origin};
use crate::state::{Cand, State};
use crate::text::{grounded, mention, overlap};
use crate::workflow::{self, Guard, Workflow};
use regex::Regex;
use serde_json::{Value, json};
use std::collections::HashMap;
use std::fmt;
/// How sure Kai must be, and how much of the repository it is shown.
#[derive(Debug, Clone)]
pub struct Config {
/// The most probability Kai may give to abstaining.
pub max_abstain: f64,
/// The least probability of the option Kai picks, among the real options.
pub min_p: f64,
/// The least lead of the pick over the next real option.
pub min_margin: f64,
/// The most options put to Kai in one question.
pub options: usize,
/// Run an action only when a proof of it is recorded.
pub require_proof: bool,
}
impl Default for Config {
fn default() -> Config {
Config {
max_abstain: 0.6,
min_p: 0.0,
min_margin: 0.1,
options: 24,
require_proof: true,
}
}
}
/// Why a request was not run as an action.
#[derive(Debug, Clone, PartialEq)]
pub enum Decline {
/// No action's trigger fits the request, or none that can be bound.
NoAction(String),
/// Several fit and Kai did not pick one with enough lead (or abstained).
Ambiguous(String),
/// An input has no value.
Missing(String),
/// Kai picked a value the request does not ground.
Ungrounded(String),
/// A precondition does not hold.
Precondition(String),
/// The action has no proof in this environment.
Unproven(String),
/// The workspace changed between binding and running.
Stale(String),
/// The user did not approve a consequential action.
Denied(String),
/// Kai could not be asked, and the request needed it.
Kai(String),
}
impl fmt::Display for Decline {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let (kind, why) = match self {
Decline::NoAction(w) => ("no action", w),
Decline::Ambiguous(w) => ("ambiguous", w),
Decline::Missing(w) => ("missing input", w),
Decline::Ungrounded(w) => ("ungrounded", w),
Decline::Precondition(w) => ("precondition", w),
Decline::Unproven(w) => ("unproven", w),
Decline::Stale(w) => ("stale", w),
Decline::Denied(w) => ("not approved", w),
Decline::Kai(w) => ("kai", w),
};
write!(f, "{kind}: {why}")
}
}
/// What a request may run.
#[derive(Clone, Copy)]
pub enum Target {
Action(&'static dyn Action),
Workflow(&'static Workflow),
}
impl Target {
pub fn id(&self) -> &'static str {
match self {
Target::Action(a) => a.spec().id,
Target::Workflow(w) => w.id,
}
}
pub fn summary(&self) -> &'static str {
match self {
Target::Action(a) => a.spec().summary,
Target::Workflow(w) => w.summary,
}
}
fn triggers(&self) -> &'static [&'static str] {
match self {
Target::Action(a) => a.spec().triggers,
Target::Workflow(w) => w.triggers,
}
}
}
/// Whether the request asks for something to be written (a review, a fix, a test, a section): no action does that,
/// whatever words it shares with one. Quoted text is a value, not the request's own verb.
pub fn authoring(request: &str) -> bool {
let mut bare = request.to_string();
for q in crate::text::quoted(request) {
bare = bare.replace(&q, " ");
}
let bare = bare
.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
.to_lowercase();
Regex::new(r"\b(review|fix|refactor|implement|rewrite|debug|investigate|explain|summari[sz]e|documents?|(add|write) (a|an|one|the) (failing )?(test|section|paragraph|function|class))\b").is_ok_and(|r| r.is_match(&bare))
}
/// The actions and workflows whose triggers match the request.
pub fn triggered(request: &str) -> Vec<Target> {
let lower = request.to_lowercase();
let all = catalog::all()
.iter()
.map(|a| Target::Action(*a))
.chain(workflow::all().iter().map(|w| Target::Workflow(w)));
all.filter(|t| {
t.triggers()
.iter()
.any(|p| Regex::new(p).is_ok_and(|r| r.is_match(&lower)))
})
.collect()
}
/// One step of a plan: an action, its inputs, what it takes from earlier steps, and when it runs.
pub struct PlanPart {
pub action: &'static dyn Action,
pub args: Args,
pub inherit: Vec<(String, String)>,
pub when: Option<Guard>,
}
pub struct Plan {
pub target: &'static str,
pub origin: Origin,
pub parts: Vec<PlanPart>,
}
/// What asking Kai cost while a plan was made, kept whether or not the plan came out.
#[derive(Debug, Default, Clone)]
pub struct Trace {
pub asks: Vec<Ask>,
pub kai_ms: f64,
pub kai_calls: u32,
}
/// Values of `source`, each with the sentence Kai reads.
pub fn list(source: Source, state: &State, args: &Args) -> Vec<Cand> {
match source {
Source::Branches => state.branches(),
Source::Remotes => state.remotes(),
Source::Commits => state.commits(40),
Source::Suites => state.suites(),
Source::Files => with_names(
state
.files()
.into_iter()
.take(500)
.map(|f| Cand::new(&f, &f))
.collect(),
),
Source::Dirty => with_names(
state
.dirty()
.into_iter()
.map(|f| Cand::new(&f, &f))
.collect(),
),
Source::Docs => with_names(
state
.with_ext(&[".md"])
.into_iter()
.map(|f| {
let first = std::fs::read_to_string(state.root.join(&f))
.unwrap_or_default()
.lines()
.find(|l| l.starts_with('#'))
.unwrap_or("")
.trim_start_matches('#')
.trim()
.to_string();
Cand::new(&f, &format!("{f}: {first}"))
})
.collect(),
),
Source::Headings => {
let Some(path) = args.get("path").and_then(Value::as_str) else {
return Vec::new();
};
std::fs::read_to_string(state.root.join(path))
.unwrap_or_default()
.lines()
.filter_map(crate::catalog::note::heading)
.map(|(_, t)| Cand::new(&t, &format!("section titled {t}")))
.collect()
}
Source::Datasets => state
.with_ext(&[".jsonl", ".csv"])
.into_iter()
.filter(|f| !crate::catalog::pairs::rows(&state.root.join(f)).is_empty())
.map(|f| {
Cand::new(
&f,
&format!(
"{f}: {}",
crate::catalog::pairs::describe(&state.root.join(&f))
),
)
})
.collect(),
}
}
/// The values of `source` the request may mean: not the report it writes to.
fn candidates(
act: &dyn Action,
input: &Input,
source: Source,
state: &State,
bound: &Args,
) -> Vec<Cand> {
let report = bound.get("report").and_then(Value::as_str);
let mut cands = list(source, state, bound);
cands.retain(|c| Some(c.value.as_str()) != report && act.admits(state, bound, input.name, c));
cands
}
/// A file's name alone is another way to say it, when no other file has that name.
fn with_names(mut cands: Vec<Cand>) -> Vec<Cand> {
let names: Vec<String> = cands
.iter()
.map(|c| c.value.rsplit('/').next().unwrap_or(&c.value).to_string())
.collect();
for (i, c) in cands.iter_mut().enumerate() {
if names[i] != c.value && names.iter().filter(|n| **n == names[i]).count() == 1 {
c.aliases.push(names[i].clone());
}
}
cands
}
/// Where the request names the value, if it does.
fn position(request: &str, cand: &Cand) -> Option<usize> {
let named = std::iter::once(&cand.value)
.chain(cand.aliases.iter())
.filter_map(|n| mention(request, n))
.min();
named.or_else(|| {
// A commit may be named by any prefix of its hash of seven or more digits.
let hex = Regex::new(r"\b[0-9a-f]{7,40}\b").ok()?;
let found = hex
.find_iter(request)
.find(|m| cand.value.starts_with(m.as_str()))
.map(|m| m.start());
found
})
}
fn mentioned(request: &str, cands: &[Cand]) -> Vec<(usize, Cand)> {
let mut found: Vec<(usize, Cand)> = cands
.iter()
.filter_map(|c| position(request, c).map(|p| (p, c.clone())))
.collect();
found.sort_by_key(|(p, _)| *p);
found
}
fn literal(request: &str, pattern: &str) -> Option<String> {
let re = Regex::new(pattern).ok()?;
let caps = re.captures(request)?;
(1..caps.len())
.find_map(|i| caps.get(i))
.map(|m| m.as_str().trim().to_string())
.filter(|s| !s.is_empty())
}
/// What binding one target needs next.
pub enum Bound {
Done(Args),
Need(Vec<Question>),
Decline(Decline),
}
fn accept(ans: &Answer, cfg: &Config) -> Option<(String, f64, f64)> {
let (key, p, margin) = ans.best();
(!key.is_empty()
&& ans.abstain() <= cfg.max_abstain
&& p >= cfg.min_p
&& margin >= cfg.min_margin)
.then_some((key, p, margin))
}
fn record(ans: &Answer, accepted: bool, options: usize, ms: f64) -> Ask {
let (key, p, margin) = ans.best();
Ask {
question: ans.id.clone(),
options,
pick: key,
p,
margin,
abstain: ans.abstain(),
accepted,
ms,
top: ans.ranked().into_iter().take(3).collect(),
}
}
/// Binds one action's inputs against the request and the answers Kai has given so far.
pub fn bind_action(
action: &dyn Action,
state: &State,
answers: &HashMap<String, (Answer, f64)>,
cfg: &Config,
skip: &[&str],
) -> Bound {
let spec = action.spec();
let mut args = Args::new();
let mut need = Vec::new();
// The report is bound first: the file a request writes to is not one of the files it talks about.
// Literals come next: a dependent constraint (a file that holds the arms the request names) reads them.
let mut inputs: Vec<&Input> = spec.inputs.iter().collect();
inputs.sort_by_key(|i| {
if i.name == "report" {
0
} else if matches!(i.kind, Kind::Literal(_)) {
1
} else {
2
}
});
for input in inputs {
if skip.contains(&input.name) {
continue;
}
match bind_input(action, input, state, &args, answers, cfg) {
Ok(Some(v)) => {
args.insert(input.name.to_string(), v);
}
Ok(None) => {}
Err(Pending::Question(q)) => {
need.push(q);
break;
}
Err(Pending::Decline(d)) => return Bound::Decline(d),
}
}
if !need.is_empty() {
return Bound::Need(need);
}
// A value that starts with a dash would be read as an option by the program it is handed to.
for (name, value) in &args {
let dashed = |s: &str| s.starts_with('-');
let bad = match value {
Value::String(s) => dashed(s),
Value::Array(a) => a.iter().filter_map(Value::as_str).any(dashed),
_ => false,
};
if bad && !matches!(name.as_str(), "message" | "text") {
return Bound::Decline(Decline::Ungrounded(format!(
"{}.{name}: looks like an option",
spec.id
)));
}
}
Bound::Done(args)
}
enum Pending {
Question(Question),
Decline(Decline),
}
type Bind = Result<Option<Value>, Pending>;
fn missing(action: &str, input: &Input) -> Pending {
Pending::Decline(Decline::Missing(format!("{action}.{}", input.name)))
}
fn bind_input(
act: &dyn Action,
input: &Input,
state: &State,
bound: &Args,
answers: &HashMap<String, (Answer, f64)>,
cfg: &Config,
) -> Bind {
let req = &state.request;
let action = act.spec().id;
match input.kind {
Kind::Literal(pattern) => match literal(req, pattern) {
Some(v) => Ok(Some(json!(v))),
None if input.optional => Ok(None),
None => Err(missing(action, input)),
},
Kind::Nth(source, n) => {
let cands = candidates(act, input, source, state, bound);
let named = mentioned(req, &cands);
let mut distinct: Vec<&Cand> = Vec::new();
for (_, c) in &named {
if !distinct.iter().any(|d| d.value == c.value) {
distinct.push(c);
}
}
match distinct.get(n) {
Some(c) => Ok(Some(json!(c.value))),
None if input.optional => Ok(None),
None => Err(missing(action, input)),
}
}
Kind::Named(source) => {
let cands = candidates(act, input, source, state, bound);
let mut values: Vec<String> = Vec::new();
for (_, c) in mentioned(req, &cands) {
if !values.contains(&c.value) {
values.push(c.value);
}
}
if values.is_empty() && !input.optional {
Err(missing(action, input))
} else if values.is_empty() {
Ok(None)
} else {
Ok(Some(json!(values)))
}
}
Kind::Pick(source) => {
let cands = candidates(act, input, source, state, bound);
if cands.is_empty() {
return if input.optional {
Ok(None)
} else {
Err(missing(action, input))
};
}
let named = mentioned(req, &cands);
if named.len() == 1 {
return Ok(Some(json!(named[0].1.value)));
}
if named.is_empty() && cands.len() == 1 {
return Ok(Some(json!(cands[0].value)));
}
if named.is_empty() && input.optional {
return Ok(None);
}
// None is named: the values the request describes are the plausible ones.
let described: Vec<Cand> = if named.is_empty() {
cands
.iter()
.filter(|c| grounded(req, &c.label))
.cloned()
.collect()
} else {
Vec::new()
};
if named.is_empty() && described.len() == 1 {
return Ok(Some(json!(described[0].value)));
}
if named.is_empty() && described.is_empty() {
return Err(Pending::Decline(Decline::Missing(format!(
"{action}.{}: no value is named or described",
input.name
))));
}
let qid = format!("{action}.{}", input.name);
let pool: Vec<Cand> = if named.len() > 1 {
named.into_iter().map(|(_, c)| c).collect()
} else {
shortlist(req, described, cfg.options)
};
match answers.get(&qid) {
None => Err(Pending::Question(Question {
id: qid,
ask: input.ask.to_string(),
options: pool
.iter()
.map(|c| (c.value.clone(), crate::redact::cut(&c.label, 200)))
.collect(),
})),
Some((ans, _)) => match accept(ans, cfg) {
Some((key, _, _)) => {
let Some(c) = pool.iter().find(|c| c.value == key) else {
return Err(Pending::Decline(Decline::Ambiguous(format!(
"{qid}: kai picked {key}, which was not offered"
))));
};
if position(req, c).is_none() && !grounded(req, &c.label) {
return Err(Pending::Decline(Decline::Ungrounded(format!(
"{qid}: {key}"
))));
}
Ok(Some(json!(c.value)))
}
None => Err(Pending::Decline(Decline::Ambiguous(format!(
"{qid}: {}",
describe(ans)
)))),
},
}
}
}
}
fn describe(ans: &Answer) -> String {
let (key, p, margin) = ans.best();
format!(
"kai leans to {key} at {p:.2}, lead {margin:.2}, abstains at {:.2}",
ans.abstain()
)
}
/// At most `n` candidates, the ones whose sentences share the most words with the request first.
fn shortlist(request: &str, mut cands: Vec<Cand>, n: usize) -> Vec<Cand> {
if cands.len() > n {
cands.sort_by_key(|c| std::cmp::Reverse(overlap(request, &c.label)));
cands.truncate(n);
}
cands
}
/// Binds a target: an action alone, or every step of a workflow.
pub fn bind_target(
target: Target,
state: &State,
answers: &HashMap<String, (Answer, f64)>,
cfg: &Config,
) -> Result<Vec<PlanPart>, Bound> {
let mut parts = Vec::new();
let mut need = Vec::new();
match target {
Target::Action(a) => match bind_action(a, state, answers, cfg, &[]) {
Bound::Done(args) => parts.push(PlanPart {
action: a,
args,
inherit: Vec::new(),
when: None,
}),
other => return Err(other),
},
Target::Workflow(w) => {
for part in w.parts {
let Some(a) = catalog::get(part.action) else {
return Err(Bound::Decline(Decline::NoAction(format!(
"{}: no action {}",
w.id, part.action
))));
};
let skip: Vec<&str> = part.inherit.iter().map(|(i, _)| *i).collect();
match bind_action(a, state, answers, cfg, &skip) {
Bound::Done(args) => parts.push(PlanPart {
action: a,
args,
inherit: part
.inherit
.iter()
.map(|(i, f)| ((*i).to_string(), (*f).to_string()))
.collect(),
when: part.when,
}),
Bound::Need(q) => need.extend(q),
d @ Bound::Decline(_) => return Err(d),
}
}
if !need.is_empty() {
return Err(Bound::Need(need));
}
}
}
Ok(parts)
}
/// Whether every precondition of every step that can be read now holds.
fn feasible(parts: &[PlanPart], state: &State) -> Result<(), Decline> {
for p in parts.iter().filter(|p| p.inherit.is_empty()) {
let bad: Vec<Check> = p
.action
.check(state, &p.args)
.into_iter()
.filter(|c| !c.ok)
.collect();
if let Some(c) = bad.first() {
return Err(Decline::Precondition(format!(
"{}: {} ({})",
p.action.spec().id,
c.name,
c.detail
)));
}
}
Ok(())
}
/// A target that can still run: its bound steps, or the questions it waits on.
type Viable = (Target, Result<Vec<PlanPart>, Vec<Question>>);
/// The plan for a request, asking Kai only when the request leaves a real choice.
pub fn plan(
state: &State,
chooser: Option<&dyn Chooser>,
cfg: &Config,
trace: &mut Trace,
) -> Result<Plan, Decline> {
if authoring(&state.request) {
return Err(Decline::NoAction(
"the request asks for something to be written".into(),
));
}
let targets = triggered(&state.request);
if targets.is_empty() {
return Err(Decline::NoAction("no trigger matches the request".into()));
}
let mut answers: HashMap<String, (Answer, f64)> = HashMap::new();
for _round in 0..4 {
let mut viable: Vec<Viable> = Vec::new();
let mut why: Vec<String> = Vec::new();
for t in &targets {
match bind_target(*t, state, &answers, cfg) {
Ok(parts) => match feasible(&parts, state) {
Ok(()) => viable.push((*t, Ok(parts))),
Err(d) => why.push(format!("{}: {d}", t.id())),
},
Err(Bound::Need(q)) => viable.push((*t, Err(q))),
Err(Bound::Decline(d)) => why.push(format!("{}: {d}", t.id())),
Err(Bound::Done(_)) => {}
}
}
if viable.is_empty() {
return Err(Decline::NoAction(why.join("; ")));
}
// A workflow whose steps all bind subsumes those steps alone.
let covered: Vec<&str> = viable
.iter()
.filter_map(|(t, _)| {
if let Target::Workflow(w) = t {
Some(w.parts.iter().map(|p| p.action))
} else {
None
}
})
.flatten()
.collect();
viable.retain(|(t, _)| !matches!(t, Target::Action(a) if covered.contains(&a.spec().id)));
// A part of the request that no viable action serves is not ours to drop.
let unserved: Vec<&String> = why
.iter()
.filter(|w| !covered.iter().any(|c| w.starts_with(&format!("{c}:"))))
.collect();
if !unserved.is_empty() {
return Err(Decline::Ambiguous(format!(
"the request also fits what cannot run: {}",
unserved
.iter()
.map(|w| w.as_str())
.collect::<Vec<_>>()
.join("; ")
)));
}
let mut chosen = None;
if viable.len() == 1 {
chosen = Some(0);
} else if let Some((ans, _)) = answers.get("action") {
let Some((key, _, _)) = accept(ans, cfg) else {
return Err(Decline::Ambiguous(format!("action: {}", describe(ans))));
};
chosen = viable.iter().position(|(t, _)| t.id() == key);
if chosen.is_none() {
return Err(Decline::Ambiguous(format!(
"action: kai picked {key}, which was not offered"
)));
}
}
let mut questions: Vec<Question> = Vec::new();
if viable.len() > 1 && chosen.is_none() {
questions.push(Question {
id: "action".into(),
ask: "Which action does the request ask for?".into(),
options: viable
.iter()
.map(|(t, _)| (t.id().to_string(), t.summary().to_string()))
.collect(),
});
}
match chosen {
Some(i) => {
if let Err(q) = &viable[i].1 {
questions.extend(q.iter().cloned());
}
}
None => {
for (_, r) in &viable {
if let Err(q) = r {
questions.extend(q.iter().cloned());
}
}
}
}
if questions.is_empty() {
let Some(i) = chosen else {
return Err(Decline::Ambiguous("several actions fit".into()));
};
let (target, parts) = viable.swap_remove(i);
let Ok(parts) = parts else {
return Err(Decline::Missing("unbound input".into()));
};
let origin = if trace.asks.iter().any(|a| a.accepted) {
Origin::Kai
} else {
Origin::Rule
};
return Ok(Plan {
target: target.id(),
origin,
parts,
});
}
let Some(chooser) = chooser else {
return Err(Decline::Kai(
"a choice is open and no chooser is configured".into(),
));
};
let asked = chooser
.decide(&state.request, &questions)
.map_err(Decline::Kai)?;
trace.kai_ms += asked.ms;
trace.kai_calls += 1;
for a in asked.answers {
let q = questions.iter().find(|q| q.id == a.id);
let accepted = accept(&a, cfg).is_some();
trace.asks.push(record(
&a,
accepted,
q.map_or(0, |q| q.options.len()),
asked.ms,
));
answers.insert(a.id.clone(), (a, asked.ms));
}
}
Err(Decline::Ambiguous(
"inputs stayed open after four rounds".into(),
))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_request_matches_the_triggers_of_its_action_and_of_others_that_could_fit() {
let ids = |r: &str| triggered(r).iter().map(Target::id).collect::<Vec<_>>();
assert!(
ids("Find all uses of `parse_header` and write them to answer.txt").contains(&"find")
);
assert!(
ids("Run the tests, and if they pass commit `a.py` with message \"x\" and push")
.contains(&"ship")
);
assert!(ids("Create a worktree for branch `x` at `../wt`").contains(&"worktree"));
assert!(!ids("Create a worktree for branch `x` at `../wt`").contains(&"suite"));
}
#[test]
fn writing_is_not_an_action_but_a_quoted_word_is_only_a_value() {
assert!(authoring(
"The tests in `tests/` are failing. Fix the source so they pass."
));
assert!(authoring(
"Review the diff of `feat`. Add one test file with a test that fails."
));
assert!(authoring(
"Add a \"## Usage\" section to `README.md` that documents the `--limit` flag."
));
assert!(!authoring(
"Commit `a.py` with the message \"Fix off-by-one in paging\" and push to origin `main`."
));
assert!(!authoring(
"Run the tests and write `passed=<n> failed=<m>` to `answer.txt`."
));
assert!(!authoring(
"Add the line \"Retries are capped at 3.\" at the end of the \"Notes\" section of `docs/NOTES.md`."
));
}
#[test]
fn literals_come_from_the_request() {
let p = r#"(?i)\bbranch(?:\s+named|\s+called)?\s+[`"']([\w./-]+)[`"']|\bworktree\s+(?:for|on)\s+[`"']([\w./-]+)[`"']"#;
assert_eq!(
literal("worktree for `exp-1` at `../wt`", p),
Some("exp-1".into())
);
assert_eq!(
literal("new branch `exp-2` off main", p),
Some("exp-2".into())
);
assert_eq!(literal("nothing here", p), None);
}
}
+283
View File
@@ -0,0 +1,283 @@
//! Kai Cache: what an action has been seen to do, and the results it can reuse.
//!
//! Two kinds of receipt share one file of JSON lines.
//!
//! A *proof* says an action, at this version and with these tools, ran in this environment and its
//! verifier passed. An action runs on its own only when a proof matches (an unproven or changed
//! action goes to the normal agent). A *memo* says a pure action, given these arguments, produced
//! this output while everything it reads was exactly as fingerprinted. A fingerprint covers only
//! what the action declares it reads (a file's content as it is on disk, uncommitted edits
//! included; or the commit and refs), so an edit to an unrelated file leaves a memo valid, and it
//! is computed again just before the action would run.
use crate::action::{Action, Args, Reads};
use crate::redact::{cut, redact};
use crate::state::{State, sha256};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::path::PathBuf;
use std::sync::{Mutex, OnceLock};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Receipt {
/// `proof` or `memo`.
pub kind: String,
pub key: String,
pub action: String,
pub version: u32,
pub at: String,
/// A memo's output (redacted) and data.
#[serde(default)]
pub text: String,
#[serde(default)]
pub data: serde_json::Value,
}
pub struct Store {
file: PathBuf,
}
fn now() -> String {
let secs = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
format!("{secs}")
}
impl Store {
/// The cache in `dir`, created if it is not there.
pub fn open(dir: &std::path::Path) -> Result<Store, String> {
std::fs::create_dir_all(dir).map_err(|e| format!("{}: {e}", dir.display()))?;
Ok(Store {
file: dir.join("receipts.jsonl"),
})
}
fn all(&self) -> Vec<Receipt> {
std::fs::read_to_string(&self.file)
.unwrap_or_default()
.lines()
.filter_map(|l| serde_json::from_str(l).ok())
.collect()
}
fn add(&self, r: &Receipt) -> Result<(), String> {
use std::io::Write;
let mut f = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&self.file)
.map_err(|e| format!("{}: {e}", self.file.display()))?;
let line = serde_json::to_string(r).map_err(|e| e.to_string())?;
writeln!(f, "{line}").map_err(|e| e.to_string())
}
pub fn proved(&self, key: &str) -> bool {
self.all().iter().any(|r| r.kind == "proof" && r.key == key)
}
pub fn prove(&self, action: &dyn Action, key: &str) -> Result<(), String> {
if self.proved(key) {
return Ok(());
}
let s = action.spec();
self.add(&Receipt {
kind: "proof".into(),
key: key.into(),
action: s.id.into(),
version: s.version,
at: now(),
text: String::new(),
data: serde_json::Value::Null,
})
}
pub fn memo(&self, key: &str) -> Option<Receipt> {
self.all()
.into_iter()
.rev()
.find(|r| r.kind == "memo" && r.key == key)
}
pub fn remember(
&self,
action: &dyn Action,
key: &str,
text: &str,
data: &serde_json::Value,
) -> Result<(), String> {
let s = action.spec();
self.add(&Receipt {
kind: "memo".into(),
key: key.into(),
action: s.id.into(),
version: s.version,
at: now(),
text: cut(&redact(text), 8192),
data: data.clone(),
})
}
}
fn tool_versions() -> &'static Mutex<BTreeMap<String, String>> {
static V: OnceLock<Mutex<BTreeMap<String, String>>> = OnceLock::new();
V.get_or_init(|| Mutex::new(BTreeMap::new()))
}
/// The first line of `tool --version`, or a marker for a program that is not there.
pub fn version(tool: &str) -> String {
let mut cache = tool_versions()
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if let Some(v) = cache.get(tool) {
return v.clone();
}
let args: &[&str] = if tool == "go" {
&["version"]
} else {
&["--version"]
};
let v = match tool {
"sh" | "kill" => "builtin".to_string(),
_ => std::process::Command::new(tool)
.args(args)
.stdin(std::process::Stdio::null())
.output()
.map(|o| {
String::from_utf8_lossy(if o.stdout.is_empty() {
&o.stderr
} else {
&o.stdout
})
.lines()
.next()
.unwrap_or("")
.to_string()
})
.unwrap_or_else(|_| "absent".to_string()),
};
cache.insert(tool.to_string(), v.clone());
v
}
fn tools_of(action: &dyn Action) -> String {
action
.spec()
.tools
.iter()
.map(|t| format!("{t}={}", version(t)))
.collect::<Vec<_>>()
.join(";")
}
/// The key of an action's proof: which action, which version, which tools.
pub fn proof_key(action: &dyn Action) -> String {
let s = action.spec();
sha256(format!("proof|{}|{}|{}", s.id, s.version, tools_of(action)).as_bytes())
}
/// What an action reads, as one digest: the content of the files it declares as they are on disk,
/// or the commit and refs.
fn reads_digest(state: &State, reads: &Reads) -> String {
match reads {
Reads::Files(paths) => {
let mut parts = Vec::new();
for p in paths {
parts.push(format!(
"{p}={}",
sha256(&std::fs::read(state.root.join(p)).unwrap_or_default())
));
}
parts.join("\n")
}
Reads::Tree => {
let mut parts = Vec::new();
for p in state.files() {
parts.push(format!(
"{p}={}",
sha256(&std::fs::read(state.root.join(&p)).unwrap_or_default())
));
}
parts.join("\n")
}
Reads::Git => {
let refs = state
.git(&["for-each-ref", "--format=%(refname) %(objectname)"])
.unwrap_or_default();
format!("{}\n{refs}", state.head().unwrap_or_default())
}
}
}
/// The key of a result: the action, its tools, its arguments, and what it reads.
pub fn memo_key(action: &dyn Action, state: &State, args: &Args) -> String {
let s = action.spec();
let canonical = serde_json::to_string(args).unwrap_or_default();
let reads = reads_digest(state, &action.reads(state, args));
sha256(
format!(
"memo|{}|{}|{}|{canonical}|{reads}",
s.id,
s.version,
tools_of(action)
)
.as_bytes(),
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::catalog;
use serde_json::json;
#[test]
fn a_memo_survives_an_unrelated_edit_and_dies_with_a_related_one() {
let dir = tempfile::tempdir().unwrap_or_else(|e| panic!("{e}"));
let root = dir.path();
std::fs::write(root.join("a.txt"), "one\ntwo\nthree\n").unwrap_or_default();
std::fs::write(root.join("b.txt"), "other\n").unwrap_or_default();
let action = catalog::get("slice").unwrap_or_else(|| panic!("no slice"));
let state = State::new(root, "");
let args: Args = [
("path".to_string(), json!("a.txt")),
("from".to_string(), json!("1")),
("to".to_string(), json!("2")),
]
.into_iter()
.collect();
let before = memo_key(action, &state, &args);
std::fs::write(root.join("b.txt"), "changed\n").unwrap_or_default();
assert_eq!(
before,
memo_key(action, &state, &args),
"an unrelated file moved the key"
);
std::fs::write(root.join("a.txt"), "one\ntwo\nthree\nfour\n").unwrap_or_default();
assert_ne!(
before,
memo_key(action, &state, &args),
"a read file did not move the key"
);
}
#[test]
fn proofs_and_memos_are_kept_apart() {
let dir = tempfile::tempdir().unwrap_or_else(|e| panic!("{e}"));
let store = Store::open(dir.path()).unwrap_or_else(|e| panic!("{e}"));
let action = catalog::get("find").unwrap_or_else(|| panic!("no find"));
let key = proof_key(action);
assert!(!store.proved(&key));
assert!(store.prove(action, &key).is_ok());
assert!(store.proved(&key));
assert!(store.memo(&key).is_none());
assert!(
store
.remember(action, "k", "token=abcdef123456 found", &json!({}))
.is_ok()
);
let got = store.memo("k").map(|r| r.text).unwrap_or_default();
assert!(!got.contains("abcdef123456"), "{got}");
}
}
+100
View File
@@ -0,0 +1,100 @@
//! The commits one branch has that another does not.
use crate::action::*;
use crate::catalog::{REPORT, lines};
use crate::state::State;
pub struct Ahead;
static SPEC: Spec = Spec {
id: "ahead",
version: 1,
summary: "List the commits on one branch that another branch does not have.",
triggers: &[
r"not (yet )?(on|in|merged)",
r"ahead of",
r"unmerged",
r"missing from",
r"does(n't| not) have",
r"not in\b",
],
inputs: &[
Input {
name: "branch",
ask: "",
kind: Kind::Nth(Source::Branches, 0),
optional: false,
},
Input {
name: "base",
ask: "",
kind: Kind::Nth(Source::Branches, 1),
optional: true,
},
REPORT,
],
effect: Effect::Read,
tools: &["git"],
pure: true,
};
/// The branch a request without a base means: main, master or trunk, whichever exists.
fn trunk(state: &State, not: &str) -> Option<String> {
let have = state.branches();
["main", "master", "trunk"]
.iter()
.find(|b| **b != not && have.iter().any(|c| c.value == **b))
.map(|b| (*b).to_string())
}
fn base(state: &State, args: &Args) -> Option<String> {
text(args, "base")
.map(str::to_string)
.or_else(|| trunk(state, text(args, "branch").unwrap_or("")))
}
impl Action for Ahead {
fn spec(&self) -> &'static Spec {
&SPEC
}
fn check(&self, state: &State, args: &Args) -> Vec<Check> {
let b = base(state, args);
vec![
Check::new("repository", state.is_repo(), ""),
Check::new("base", b.is_some(), b.unwrap_or_default()),
Check::new("distinct", text(args, "branch") != text(args, "base"), ""),
]
}
fn reads(&self, _state: &State, _args: &Args) -> Reads {
Reads::Git
}
fn run(&self, cx: &mut Cx, args: &Args) -> Result<Output, String> {
let branch = text(args, "branch").ok_or("no branch")?;
let base = base(&cx.state(), args).ok_or("no base branch")?;
let range = format!("{base}..{branch}");
let body = cx.must(&["git", "log", "--format=%h %s", &range])?;
let body = body.trim_end().to_string();
report(cx, args, &body)?;
Ok(Output {
text: body,
data: serde_json::Value::Null,
})
}
fn verify(&self, cx: &Cx, args: &Args, out: &Output) -> Verdict {
let (Some(branch), Some(base)) = (text(args, "branch"), base(&cx.state(), args)) else {
return Verdict::fail("no range");
};
let range = format!("{base}..{branch}");
match cx.state().git(&["rev-list", "--count", &range]) {
Ok(n) if n.parse::<usize>().ok() == Some(lines(&out.text).len()) => {
Verdict::pass(format!("{n} commits"))
}
Ok(n) => Verdict::fail(format!("{n} commits expected")),
Err(e) => Verdict::fail(e),
}
}
}
+86
View File
@@ -0,0 +1,86 @@
//! The files a commit changed.
use crate::action::*;
use crate::catalog::{REPORT, lines};
use crate::state::State;
pub struct Changed;
static SPEC: Spec = Spec {
id: "changed",
version: 1,
summary: "List the files a given commit changed.",
triggers: &[
r"files? (changed|touched|modified|edited) (in|by)",
r"what (files )?(did|does) .*commit",
r"changed in (the )?commit",
r"touched by",
],
inputs: &[
Input {
name: "commit",
ask: "Which commit does the request refer to?",
kind: Kind::Pick(Source::Commits),
optional: false,
},
REPORT,
],
effect: Effect::Read,
tools: &["git"],
pure: true,
};
impl Action for Changed {
fn spec(&self) -> &'static Spec {
&SPEC
}
fn check(&self, state: &State, args: &Args) -> Vec<Check> {
let c = text(args, "commit").unwrap_or("");
vec![Check::new(
"commit",
state
.git(&["cat-file", "-e", &format!("{c}^{{commit}}")])
.is_ok(),
c,
)]
}
fn reads(&self, _state: &State, _args: &Args) -> Reads {
Reads::Git
}
fn run(&self, cx: &mut Cx, args: &Args) -> Result<Output, String> {
let commit = text(args, "commit").ok_or("no commit")?;
let body = cx.must(&["git", "show", "--name-only", "--format=", commit])?;
let mut files = lines(&body);
files.retain(|f| !f.is_empty());
files.sort();
let body = files.join("\n");
report(cx, args, &body)?;
Ok(Output {
text: body,
data: serde_json::Value::Null,
})
}
fn verify(&self, cx: &Cx, args: &Args, out: &Output) -> Verdict {
let Some(commit) = text(args, "commit") else {
return Verdict::fail("no commit");
};
match cx.state().git(&[
"diff-tree",
"--no-commit-id",
"--name-only",
"-r",
"--root",
commit,
]) {
Ok(list) if list.lines().count() == lines(&out.text).len() && !out.text.is_empty() => {
Verdict::pass(format!("{} files", list.lines().count()))
}
Ok(list) => Verdict::fail(format!("{} files expected", list.lines().count())),
Err(e) => Verdict::fail(e),
}
}
}
+120
View File
@@ -0,0 +1,120 @@
//! Where a symbol is defined or used.
use crate::action::*;
use crate::catalog::{REPORT, lines};
use crate::state::State;
use regex::Regex;
pub struct Find;
static SPEC: Spec = Spec {
id: "find",
version: 1,
summary: "Find where a symbol or string is defined or used in the repository and list each place as path:line.",
triggers: &[
r"\b(uses?|used|usages? of|callers?|references?|occurrences?|mentions?|defined|definition|declared|where is|where are|who calls|grep|locate)\b",
],
inputs: &[
Input {
name: "symbol",
ask: "",
kind: Kind::Literal(r#"[`"]([A-Za-z_][\w.:$-]*)[`"]"#),
optional: false,
},
REPORT,
],
effect: Effect::Read,
tools: &[],
pure: true,
};
fn defining(request: &str) -> bool {
let r = request.to_lowercase();
r.contains("defin") || r.contains("declar")
}
fn pattern(symbol: &str, defs: bool) -> Result<Regex, String> {
let s = regex::escape(symbol);
let p = if defs {
format!(
r"^\s*(?:pub(?:\([^)]*\))?\s+)?(?:export\s+)?(?:async\s+)?(?:default\s+)?(?:def|fn|func|function|class|struct|enum|trait|type|const|let|var|interface|impl|static)\s+(?:\([^)]*\)\s*)?{s}\b"
)
} else {
format!(r"\b{s}\b")
};
Regex::new(&p).map_err(|e| e.to_string())
}
fn search(state: &State, symbol: &str) -> Result<Vec<String>, String> {
let re = pattern(symbol, defining(&state.request))?;
let mut hits = Vec::new();
for path in state.files() {
let Ok(meta) = std::fs::metadata(state.root.join(&path)) else {
continue;
};
if meta.len() > 2_000_000 {
continue;
}
let Ok(body) = std::fs::read_to_string(state.root.join(&path)) else {
continue;
};
for (i, line) in body.lines().enumerate() {
if re.is_match(line) {
hits.push(format!("{path}:{}", i + 1));
}
}
}
Ok(hits)
}
impl Action for Find {
fn spec(&self) -> &'static Spec {
&SPEC
}
fn check(&self, _state: &State, args: &Args) -> Vec<Check> {
let symbol = text(args, "symbol").unwrap_or("");
vec![Check::new(
"symbol",
!symbol.is_empty() && pattern(symbol, false).is_ok(),
symbol,
)]
}
fn run(&self, cx: &mut Cx, args: &Args) -> Result<Output, String> {
let state = cx.state();
let symbol = text(args, "symbol").ok_or("no symbol")?;
let hits = search(&state, symbol)?;
let body = hits.join("\n");
report(cx, args, &body)?;
Ok(Output {
text: body,
data: serde_json::json!({ "places": hits.len() }),
})
}
fn verify(&self, cx: &Cx, args: &Args, out: &Output) -> Verdict {
let Some(symbol) = text(args, "symbol") else {
return Verdict::fail("no symbol");
};
let places = lines(&out.text);
if places.is_empty() {
return Verdict::fail(format!("no place holds {symbol}"));
}
for place in &places {
let Some((path, line)) = place.rsplit_once(':') else {
return Verdict::fail(format!("{place}: not path:line"));
};
let n: usize = line.parse().unwrap_or(0);
let body = std::fs::read_to_string(cx.path(path)).unwrap_or_default();
if !body
.lines()
.nth(n.saturating_sub(1))
.is_some_and(|l| l.contains(symbol))
{
return Verdict::fail(format!("{place} does not hold {symbol}"));
}
}
Verdict::pass(format!("{} places", places.len()))
}
}
+120
View File
@@ -0,0 +1,120 @@
//! The process listening on a TCP port.
use crate::action::*;
use crate::catalog::REPORT;
use crate::state::State;
use serde_json::json;
pub struct Listen;
static SPEC: Spec = Spec {
id: "listen",
version: 1,
summary: "Report the process id of the process listening on a TCP port.",
triggers: &[
r"listening on",
r"listens on",
r"which process .*port",
r"pid .*port",
r"what .* port\b",
r"\bport \d+",
],
inputs: &[
Input {
name: "port",
ask: "",
kind: Kind::Literal(r"(?i)\bport\s+(\d{2,5})\b"),
optional: false,
},
REPORT,
],
effect: Effect::Read,
tools: &[],
pure: false,
};
/// Process ids with a listening socket on `port`.
pub fn listeners(port: u16) -> Vec<u32> {
let want = format!(":{port:04X}");
let mut inodes = Vec::new();
for table in ["/proc/net/tcp", "/proc/net/tcp6"] {
for line in std::fs::read_to_string(table)
.unwrap_or_default()
.lines()
.skip(1)
{
let f: Vec<&str> = line.split_whitespace().collect();
if f.len() > 9 && f[3] == "0A" && f[1].ends_with(&want) {
inodes.push(f[9].to_string());
}
}
}
let mut pids = Vec::new();
for entry in std::fs::read_dir("/proc").into_iter().flatten().flatten() {
let Ok(pid) = entry.file_name().to_string_lossy().parse::<u32>() else {
continue;
};
for fd in std::fs::read_dir(entry.path().join("fd"))
.into_iter()
.flatten()
.flatten()
{
let target = std::fs::read_link(fd.path())
.map(|t| t.to_string_lossy().into_owned())
.unwrap_or_default();
if inodes.iter().any(|i| target == format!("socket:[{i}]")) {
pids.push(pid);
break;
}
}
}
pids.sort_unstable();
pids
}
impl Action for Listen {
fn spec(&self) -> &'static Spec {
&SPEC
}
fn check(&self, _state: &State, args: &Args) -> Vec<Check> {
let ok = text(args, "port")
.and_then(|p| p.parse::<u16>().ok())
.is_some_and(|p| p > 0);
vec![Check::new("port", ok, text(args, "port").unwrap_or(""))]
}
fn run(&self, cx: &mut Cx, args: &Args) -> Result<Output, String> {
let port: u16 = text(args, "port")
.and_then(|p| p.parse().ok())
.ok_or("no port")?;
let pids = listeners(port);
let body = pids
.iter()
.map(|p| format!("pid={p}"))
.collect::<Vec<_>>()
.join("\n");
report(cx, args, &body)?;
Ok(Output {
text: body,
data: json!({ "pids": pids }),
})
}
fn verify(&self, _cx: &Cx, _args: &Args, out: &Output) -> Verdict {
let pids: Vec<u64> = out.data["pids"]
.as_array()
.map(|a| a.iter().filter_map(serde_json::Value::as_u64).collect())
.unwrap_or_default();
if pids.is_empty() {
Verdict::fail("nothing listens there")
} else if pids
.iter()
.all(|p| std::path::Path::new(&format!("/proc/{p}")).exists())
{
Verdict::pass(format!("{} process(es)", pids.len()))
} else {
Verdict::fail("a reported process is gone")
}
}
}
+113
View File
@@ -0,0 +1,113 @@
//! The actions Dev ships.
use crate::action::{Action, Input, Kind};
use std::path::{Component, Path, PathBuf};
pub mod ahead;
pub mod changed;
pub mod find;
pub mod listen;
pub mod note;
pub mod pairs;
pub mod publish;
pub mod rebuild;
pub mod slice;
pub mod stage;
pub mod stop;
pub mod suite;
pub mod sync;
pub mod touched;
pub mod wait;
pub mod worktree;
/// Every action, in the order Kai is shown them.
pub fn all() -> &'static [&'static dyn Action] {
&[
&find::Find,
&slice::Slice,
&touched::Touched,
&ahead::Ahead,
&changed::Changed,
&worktree::Worktree,
&publish::Publish,
&sync::Sync,
&suite::Suite,
&rebuild::Rebuild,
&wait::Wait,
&listen::Listen,
&stop::Stop,
&pairs::Pairs,
&note::Note,
&stage::Stage,
]
}
pub fn get(id: &str) -> Option<&'static dyn Action> {
all().iter().copied().find(|a| a.spec().id == id)
}
/// The report every answering action takes: a file the request names.
pub const REPORT: Input = Input {
name: "report",
ask: "",
kind: Kind::Literal(r#"(?i)\b(?:to|into|in)\s+[`"']?([\w./-]+\.txt)"#),
optional: true,
};
/// `path` resolved against `root`, `..` and `.` folded away without touching the disk.
pub fn fold(root: &Path, path: &str) -> PathBuf {
let p = Path::new(path);
let joined = if p.is_absolute() {
p.to_path_buf()
} else {
root.join(p)
};
let mut out = PathBuf::new();
for c in joined.components() {
match c {
Component::ParentDir => {
out.pop();
}
Component::CurDir => {}
other => out.push(other.as_os_str()),
}
}
out
}
/// Whether `path` lies outside `root`.
pub fn outside(root: &Path, path: &str) -> bool {
!fold(root, path).starts_with(fold(root, "."))
}
pub fn lines(text: &str) -> Vec<String> {
text.lines().map(str::to_string).collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn dotdot_leaves_the_root() {
let root = Path::new("/w/ws");
assert!(outside(root, "../wt"));
assert!(!outside(root, "sub/../x"));
assert!(outside(root, "/tmp/x"));
assert!(!outside(root, "build/pinned"));
}
#[test]
fn every_action_has_a_unique_id_and_a_version() {
let mut ids: Vec<&str> = all().iter().map(|a| a.spec().id).collect();
ids.sort_unstable();
let n = ids.len();
ids.dedup();
assert_eq!(ids.len(), n);
assert!(
all()
.iter()
.all(|a| a.spec().version >= 1 && !a.spec().summary.is_empty())
);
}
}
+178
View File
@@ -0,0 +1,178 @@
//! Add a line to a section of a markdown file.
use crate::action::*;
use crate::state::State;
use serde_json::json;
pub struct Note;
static SPEC: Spec = Spec {
id: "note",
version: 1,
summary: "Add one line of text at the end of a named section of a markdown file.",
triggers: &[
r"add (the |a )?(line|note|sentence|entry)",
r"\bappend\b",
r"under (the )?.*(heading|section)",
r"insert .* (line|note)",
r"at the end of the .* section",
],
inputs: &[
Input {
name: "path",
ask: "Which document does the request mean?",
kind: Kind::Pick(Source::Docs),
optional: false,
},
Input {
name: "heading",
ask: "Which section does the request mean?",
kind: Kind::Pick(Source::Headings),
optional: false,
},
Input {
name: "text",
ask: "",
kind: Kind::Literal(
r#"(?i)\b(?:line|text|note|sentence|entry)\s+[`"\u{201c}]([^`"\u{201d}]+)[`"\u{201d}]"#,
),
optional: false,
},
],
effect: Effect::Write,
tools: &[],
pure: false,
};
/// `(level, title)` of a heading line.
pub fn heading(line: &str) -> Option<(usize, String)> {
let level = line.chars().take_while(|c| *c == '#').count();
(1..=6)
.contains(&level)
.then_some(())
.and(line[level..].strip_prefix(' '))
.map(|t| (level, t.trim().to_string()))
}
/// Where the line goes: after the last non-blank line of the section `title`.
fn place(lines: &[String], title: &str) -> Option<usize> {
let mut found = lines
.iter()
.enumerate()
.filter(|(_, l)| heading(l).is_some_and(|(_, t)| t == title));
let (at, line) = found.next()?;
if found.next().is_some() {
return None;
}
let level = heading(line)?.0;
let end = lines[at + 1..]
.iter()
.position(|l| heading(l).is_some_and(|(n, _)| n <= level))
.map_or(lines.len(), |p| at + 1 + p);
let last = (at..end)
.rev()
.find(|i| !lines[*i].trim().is_empty())
.unwrap_or(at);
Some(last + 1)
}
impl Action for Note {
fn spec(&self) -> &'static Spec {
&SPEC
}
fn check(&self, state: &State, args: &Args) -> Vec<Check> {
let path = text(args, "path").unwrap_or("");
let body = std::fs::read_to_string(state.root.join(path)).unwrap_or_default();
let lines: Vec<String> = body.lines().map(str::to_string).collect();
let line = text(args, "text").unwrap_or("");
vec![
Check::new("document", state.root.join(path).is_file(), path),
Check::new(
"section is unique",
place(&lines, text(args, "heading").unwrap_or("")).is_some(),
text(args, "heading").unwrap_or(""),
),
Check::new(
"line is new",
!line.is_empty() && !lines.iter().any(|l| l == line),
line,
),
]
}
fn reads(&self, _state: &State, args: &Args) -> Reads {
Reads::Files(
text(args, "path")
.map(|p| vec![p.to_string()])
.unwrap_or_default(),
)
}
fn run(&self, cx: &mut Cx, args: &Args) -> Result<Output, String> {
let (path, title, line) = (
text(args, "path").ok_or("no document")?,
text(args, "heading").ok_or("no section")?,
text(args, "text").ok_or("no text")?,
);
let target = cx.path(path);
let body = std::fs::read_to_string(&target).map_err(|e| format!("{path}: {e}"))?;
let mut lines: Vec<String> = body.lines().map(str::to_string).collect();
let at = place(&lines, title).ok_or("the section is not unique")?;
lines.insert(at, line.to_string());
let mut out = lines.join("\n");
if body.ends_with('\n') {
out.push('\n');
}
std::fs::write(&target, out).map_err(|e| format!("{path}: {e}"))?;
Ok(Output {
text: format!("added a line under {title} in {path}"),
data: json!({ "path": path, "line": at + 1 }),
})
}
fn verify(&self, cx: &Cx, args: &Args, out: &Output) -> Verdict {
let (Some(path), Some(line), Some(title)) = (
text(args, "path"),
text(args, "text"),
text(args, "heading"),
) else {
return Verdict::fail("no note");
};
let now: Vec<String> = std::fs::read_to_string(cx.path(path))
.unwrap_or_default()
.lines()
.map(str::to_string)
.collect();
let at = out.data["line"].as_u64().unwrap_or(0) as usize;
let section = now
.iter()
.position(|l| heading(l).is_some_and(|(_, t)| t == title));
match (now.get(at.wrapping_sub(1)), section) {
(Some(l), Some(s)) if l == line && at > s + 1 => Verdict::pass(format!("{path}:{at}")),
_ => Verdict::fail("the line is not under its heading"),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn l(text: &str) -> Vec<String> {
text.lines().map(str::to_string).collect()
}
#[test]
fn the_line_goes_after_the_last_line_of_its_section() {
let doc = l("# T\n\n## Notes\n\n- a\n- b\n\n## Other\n\nx");
assert_eq!(place(&doc, "Notes"), Some(6));
assert_eq!(place(&doc, "Other"), Some(10));
assert_eq!(place(&doc, "Missing"), None);
}
#[test]
fn an_empty_section_takes_it_under_the_heading() {
assert_eq!(place(&l("## A\n\n## B"), "A"), Some(1));
}
}
+284
View File
@@ -0,0 +1,284 @@
//! Two arms of a results file, compared task by task.
use crate::action::*;
use crate::catalog::REPORT;
use crate::state::State;
use serde_json::{Value, json};
use std::collections::BTreeMap;
pub struct Pairs;
static SPEC: Spec = Spec {
id: "pairs",
version: 1,
summary: "Compare two arms of a results file task by task: wins, losses, the median speedup and an exact paired test on solves.",
triggers: &[
r"compare .*\barms?\b",
r"\barms?\b.*\b(against|over|vs\.?|versus)\b",
r"speed-?ups?",
r"paired",
r"\bwins?\b.*\blosses\b",
],
inputs: &[
Input {
name: "file",
ask: "Which results file does the request mean?",
kind: Kind::Pick(Source::Datasets),
optional: false,
},
Input {
name: "x",
ask: "",
kind: Kind::Literal(
r#"(?i)\barm\s+[`"']?([\w-]+)[`"']?\s+(?:over|against|vs\.?|versus)\b"#,
),
optional: false,
},
Input {
name: "y",
ask: "",
kind: Kind::Literal(
r#"(?i)\b(?:over|against|vs\.?|versus)\s+(?:the\s+)?(?:arm\s+)?[`"']?([\w-]+)[`"']?"#,
),
optional: false,
},
REPORT,
],
effect: Effect::Read,
tools: &[],
pure: true,
};
#[derive(Debug, Clone)]
pub struct Row {
pub task: String,
pub arm: String,
pub seconds: f64,
pub solve: bool,
}
/// The rows of a JSON-lines or CSV results file (columns task, arm, seconds, solve).
pub fn rows(path: &std::path::Path) -> Vec<Row> {
let body = std::fs::read_to_string(path).unwrap_or_default();
let csv = path.extension().is_some_and(|e| e == "csv");
let mut head: Vec<String> = Vec::new();
let mut out = Vec::new();
for (i, line) in body.lines().enumerate() {
let v: Option<Value> = if csv {
let cells: Vec<&str> = line.split(',').map(str::trim).collect();
if i == 0 {
head = cells.iter().map(|c| (*c).to_string()).collect();
continue;
}
Some(Value::Object(
head.iter()
.cloned()
.zip(cells.iter().map(|c| {
c.parse::<f64>()
.map(Value::from)
.unwrap_or_else(|_| match *c {
"true" | "True" => Value::Bool(true),
"false" | "False" => Value::Bool(false),
other => Value::String(other.to_string()),
})
}))
.collect(),
))
} else {
serde_json::from_str(line).ok()
};
let Some(v) = v else { continue };
if let (Some(task), Some(arm), Some(seconds), Some(solve)) = (
v["task"].as_str(),
v["arm"].as_str(),
v["seconds"].as_f64(),
v["solve"].as_bool(),
) {
out.push(Row {
task: task.to_string(),
arm: arm.to_string(),
seconds,
solve,
});
}
}
out
}
/// Exact two-sided McNemar p from the discordant counts.
pub fn mcnemar(b: u32, c: u32) -> f64 {
let n = b + c;
if n == 0 {
return 1.0;
}
let k = b.min(c);
let mut pmf = 0.5f64.powi(n as i32);
let mut cdf = pmf;
for i in 0..k {
pmf *= f64::from(n - i) / f64::from(i + 1);
cdf += pmf;
}
(2.0 * cdf).min(1.0)
}
fn median(mut v: Vec<f64>) -> f64 {
v.sort_by(f64::total_cmp);
match v.len() {
0 => 0.0,
n if n % 2 == 1 => v[n / 2],
n => (v[n / 2 - 1] + v[n / 2]) / 2.0,
}
}
fn compare(rows: &[Row], x: &str, y: &str) -> Option<(String, Value)> {
let take = |arm: &str| -> BTreeMap<&str, (f64, bool)> {
let mut m = BTreeMap::new();
for r in rows.iter().filter(|r| r.arm == arm) {
m.entry(r.task.as_str()).or_insert((r.seconds, r.solve));
}
m
};
let (a, b) = (take(x), take(y));
let mut faster = 0;
let mut slower = 0;
let mut ties = 0;
let (mut ratios, mut only_x, mut only_y, mut sx, mut sy) = (Vec::new(), 0u32, 0u32, 0, 0);
let mut n = 0;
for (task, (tx, solx)) in &a {
let Some((ty, soly)) = b.get(task) else {
continue;
};
n += 1;
match tx.partial_cmp(ty) {
Some(std::cmp::Ordering::Less) => faster += 1,
Some(std::cmp::Ordering::Greater) => slower += 1,
_ => ties += 1,
}
if *tx > 0.0 {
ratios.push(ty / tx);
}
sx += u32::from(*solx);
sy += u32::from(*soly);
only_x += u32::from(*solx && !*soly);
only_y += u32::from(!*solx && *soly);
}
if n == 0 {
return None;
}
let p = mcnemar(only_x, only_y);
let med = median(ratios);
let text = format!(
"n={n} faster={faster} slower={slower} ties={ties} median_speedup={med:.3} solved_x={sx} solved_y={sy} mcnemar_p={p:.4}"
);
Some((
text,
json!({ "n": n, "faster": faster, "slower": slower, "ties": ties, "median_speedup": med, "solved_x": sx, "solved_y": sy, "mcnemar_p": p }),
))
}
/// A one-line description of a results file for Kai: its size and its arms.
pub fn describe(path: &std::path::Path) -> String {
let r = rows(path);
let mut arms: Vec<&str> = r.iter().map(|x| x.arm.as_str()).collect();
arms.sort_unstable();
arms.dedup();
format!("{} rows, arms: {}", r.len(), arms.join(", "))
}
impl Action for Pairs {
fn spec(&self) -> &'static Spec {
&SPEC
}
fn admits(&self, state: &State, args: &Args, input: &str, cand: &crate::state::Cand) -> bool {
if input != "file" {
return true;
}
let r = rows(&state.root.join(&cand.value));
[text(args, "x"), text(args, "y")]
.iter()
.flatten()
.all(|arm| r.iter().any(|x| x.arm == *arm))
}
fn check(&self, state: &State, args: &Args) -> Vec<Check> {
let file = text(args, "file").unwrap_or("");
let r = rows(&state.root.join(file));
let has = |a: &str| r.iter().any(|x| x.arm == a);
let (x, y) = (text(args, "x").unwrap_or(""), text(args, "y").unwrap_or(""));
vec![
Check::new("rows", !r.is_empty(), file),
Check::new("arm x", has(x), x),
Check::new("arm y", has(y), y),
Check::new("two arms", x != y, ""),
]
}
fn reads(&self, _state: &State, args: &Args) -> Reads {
Reads::Files(
text(args, "file")
.map(|f| vec![f.to_string()])
.unwrap_or_default(),
)
}
fn run(&self, cx: &mut Cx, args: &Args) -> Result<Output, String> {
let (file, x, y) = (
text(args, "file").ok_or("no file")?,
text(args, "x").ok_or("no arm")?,
text(args, "y").ok_or("no arm")?,
);
let (body, data) = compare(&rows(&cx.path(file)), x, y).ok_or("the arms share no task")?;
report(cx, args, &body)?;
Ok(Output { text: body, data })
}
fn verify(&self, _cx: &Cx, _args: &Args, out: &Output) -> Verdict {
let n = out.data["n"].as_u64().unwrap_or(0);
let p = out.data["mcnemar_p"].as_f64().unwrap_or(f64::NAN);
let (f, s, t) = (
out.data["faster"].as_u64().unwrap_or(0),
out.data["slower"].as_u64().unwrap_or(0),
out.data["ties"].as_u64().unwrap_or(0),
);
if n > 0 && f + s + t == n && (0.0..=1.0).contains(&p) {
Verdict::pass(format!("{n} pairs"))
} else {
Verdict::fail("counts do not add up")
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn mcnemar_is_exact() {
assert!((mcnemar(0, 0) - 1.0).abs() < 1e-12);
assert!((mcnemar(5, 0) - 0.0625).abs() < 1e-12);
assert!((mcnemar(3, 3) - 1.0).abs() < 1e-12);
assert!((mcnemar(1, 4) - 0.375).abs() < 1e-12);
}
#[test]
fn arms_pair_by_task() {
let r = |t: &str, a: &str, s: f64, ok: bool| Row {
task: t.into(),
arm: a.into(),
seconds: s,
solve: ok,
};
let rows = vec![
r("a", "x", 1.0, true),
r("a", "y", 3.0, true),
r("b", "x", 2.0, true),
r("b", "y", 4.0, false),
r("c", "x", 5.0, true),
];
let (text, data) = compare(&rows, "x", "y").unwrap_or_default();
assert_eq!(data["n"], 2, "{text}");
assert!(text.contains("median_speedup=2.500"), "{text}");
assert!(text.contains("mcnemar_p=1.0000"), "{text}");
}
}
+162
View File
@@ -0,0 +1,162 @@
//! Commit named files and push the branch.
use crate::action::*;
use crate::state::State;
use serde_json::json;
pub struct Publish;
static SPEC: Spec = Spec {
id: "publish",
version: 1,
summary: "Commit the named files with a message and push the branch to a remote.",
triggers: &[
r"\bcommit\b.{0,200}\b(with|message|and push|then push)\b",
r"\bpush\b.{0,200}\b(to|origin|branch|remote)\b",
],
inputs: &[
Input {
name: "paths",
ask: "",
kind: Kind::Named(Source::Dirty),
optional: false,
},
Input {
name: "message",
ask: "",
kind: Kind::Literal(
r#"(?i)\b(?:message|msg|saying|titled)\s+[`"\u{201c}]([^`"\u{201d}]+)[`"\u{201d}]"#,
),
optional: false,
},
Input {
name: "remote",
ask: "",
kind: Kind::Nth(Source::Remotes, 0),
optional: true,
},
Input {
name: "branch",
ask: "",
kind: Kind::Nth(Source::Branches, 0),
optional: true,
},
],
effect: Effect::Remote,
tools: &["git"],
pure: false,
};
fn remote(state: &State, args: &Args) -> Option<String> {
text(args, "remote").map(str::to_string).or_else(|| {
let all = state.remotes();
all.iter()
.find(|r| r.value == "origin")
.or(all.first())
.map(|r| r.value.clone())
})
}
fn branch(state: &State, args: &Args) -> Option<String> {
text(args, "branch")
.map(str::to_string)
.or_else(|| state.branch())
}
impl Action for Publish {
fn spec(&self) -> &'static Spec {
&SPEC
}
fn check(&self, state: &State, args: &Args) -> Vec<Check> {
let paths = list(args, "paths");
let dirty = state.dirty();
let missing: Vec<&String> = paths.iter().filter(|p| !dirty.contains(p)).collect();
let (r, b) = (remote(state, args), branch(state, args));
vec![
Check::new("repository", state.is_repo(), ""),
Check::new("paths named", !paths.is_empty(), paths.join(",")),
Check::new("paths changed", missing.is_empty(), format!("{missing:?}")),
Check::new(
"message",
text(args, "message").is_some_and(|m| !m.trim().is_empty()),
"",
),
Check::new("remote", r.is_some(), r.unwrap_or_default()),
Check::new(
"branch is checked out",
b.is_some() && b == state.branch(),
b.unwrap_or_default(),
),
]
}
fn reads(&self, _state: &State, _args: &Args) -> Reads {
Reads::Git
}
fn run(&self, cx: &mut Cx, args: &Args) -> Result<Output, String> {
let state = cx.state();
let paths = list(args, "paths");
let message = text(args, "message").ok_or("no message")?;
let remote = remote(&state, args).ok_or("no remote")?;
let branch = branch(&state, args).ok_or("no branch")?;
let mut add = vec!["git", "add", "--"];
add.extend(paths.iter().map(String::as_str));
cx.must(&add)?;
let mut commit = vec!["git", "commit", "-q", "-m", message, "--"];
commit.extend(paths.iter().map(String::as_str));
cx.must(&commit)?;
cx.must(&[
"git",
"push",
"-q",
&remote,
&format!("{branch}:refs/heads/{branch}"),
])?;
let head = cx.must(&["git", "rev-parse", "HEAD"])?.trim().to_string();
Ok(Output {
text: format!("pushed {} to {remote}/{branch}", &head[..7.min(head.len())]),
data: json!({ "commit": head, "remote": remote, "branch": branch }),
})
}
fn verify(&self, cx: &Cx, args: &Args, out: &Output) -> Verdict {
let state = cx.state();
let (Some(commit), Some(remote), Some(branch)) = (
out.data["commit"].as_str(),
out.data["remote"].as_str(),
out.data["branch"].as_str(),
) else {
return Verdict::fail("no commit");
};
let tip = state
.git(&["ls-remote", remote, &format!("refs/heads/{branch}")])
.unwrap_or_default();
if tip.split_whitespace().next() != Some(commit) {
return Verdict::fail(format!("{remote}/{branch} is not at {commit}"));
}
let mut changed: Vec<String> = state
.git(&["show", "--name-only", "--format=", commit])
.unwrap_or_default()
.lines()
.map(str::to_string)
.collect();
changed.sort();
let mut want = list(args, "paths");
want.sort();
if changed != want {
return Verdict::fail(format!("commit changes {changed:?}, not {want:?}"));
}
let subject = state
.git(&["show", "-s", "--format=%s", commit])
.unwrap_or_default();
if Some(subject.as_str()) != text(args, "message") {
return Verdict::fail(format!("subject is {subject:?}"));
}
Verdict::pass(format!(
"{remote}/{branch} at {}",
&commit[..7.min(commit.len())]
))
}
}
+156
View File
@@ -0,0 +1,156 @@
//! Rebuild a pinned copy of the project from a commit.
use crate::action::*;
use crate::catalog::{REPORT, fold, outside};
use crate::state::State;
use serde_json::json;
pub struct Rebuild;
static SPEC: Spec = Spec {
id: "rebuild",
version: 1,
summary: "Rebuild a pinned copy of the project from one commit into a directory, with the project's own build script.",
triggers: &[
r"rebuild",
r"pinned (copy|build)",
r"build (it |the \w+ )?(from|at) (the )?commit",
r"\barchive\b",
],
inputs: &[
Input {
name: "commit",
ask: "Which commit should the copy be built from?",
kind: Kind::Pick(Source::Commits),
optional: false,
},
Input {
name: "dest",
ask: "",
kind: Kind::Literal(r#"(?i)\b(?:into|in|at|to)\s+[`"']([\w./-]+)[`"']"#),
optional: false,
},
REPORT,
],
effect: Effect::Write,
tools: &["git", "tar", "sh"],
pure: false,
};
fn builder(state: &State, commit: &str) -> Option<&'static [&'static str]> {
let listing = state.git(&["ls-tree", "--name-only", commit]).ok()?;
let has = |n: &str| listing.lines().any(|l| l == n);
if has("build.sh") {
Some(&["sh", "build.sh"])
} else if has("Makefile") {
Some(&["make"])
} else {
None
}
}
impl Action for Rebuild {
fn spec(&self) -> &'static Spec {
&SPEC
}
fn effect(&self, state: &State, args: &Args) -> Effect {
match text(args, "dest") {
Some(d) if outside(&state.root, d) => Effect::Outside,
_ => Effect::Write,
}
}
fn check(&self, state: &State, args: &Args) -> Vec<Check> {
let commit = text(args, "commit").unwrap_or("");
let dest = fold(&state.root, text(args, "dest").unwrap_or(""));
let empty =
!dest.exists() || std::fs::read_dir(&dest).is_ok_and(|mut d| d.next().is_none());
vec![
Check::new(
"commit",
state
.git(&["cat-file", "-e", &format!("{commit}^{{commit}}")])
.is_ok(),
commit,
),
Check::new("destination is free", empty, dest.display().to_string()),
Check::new(
"a build script",
builder(state, commit).is_some(),
"build.sh or Makefile at the commit",
),
]
}
fn reads(&self, _state: &State, _args: &Args) -> Reads {
Reads::Git
}
fn run(&self, cx: &mut Cx, args: &Args) -> Result<Output, String> {
let state = cx.state();
let commit = text(args, "commit").ok_or("no commit")?;
let dest = fold(&cx.root, text(args, "dest").ok_or("no destination")?);
let dest_s = dest.to_string_lossy().into_owned();
let build = builder(&state, commit).ok_or("no build script")?;
std::fs::create_dir_all(&dest).map_err(|e| format!("{dest_s}: {e}"))?;
let tar = format!("{dest_s}.tar");
cx.must(&["git", "archive", "--format=tar", "-o", &tar, commit])?;
let unpacked = cx.must(&["tar", "-x", "-f", &tar, "-C", &dest_s]);
let _ = std::fs::remove_file(&tar);
unpacked?;
let sha = state.git(&["rev-parse", commit])?;
let mut cx_dest = Cx::new(dest.clone(), cx.home.clone(), &cx.request);
cx_dest.must(build)?;
cx.steps.append(&mut cx_dest.steps);
std::fs::write(dest.join(".commit"), format!("{sha}\n")).map_err(|e| e.to_string())?;
let body = format!(
"built {} from {} into {dest_s}",
dest.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default(),
&sha[..7.min(sha.len())]
);
report(cx, args, &body)?;
Ok(Output {
text: body,
data: json!({ "path": dest_s, "commit": sha }),
})
}
fn verify(&self, cx: &Cx, _args: &Args, out: &Output) -> Verdict {
let (Some(path), Some(sha)) = (out.data["path"].as_str(), out.data["commit"].as_str())
else {
return Verdict::fail("no copy");
};
let dest = std::path::Path::new(path);
if std::fs::read_to_string(dest.join(".commit"))
.unwrap_or_default()
.trim()
!= sha
{
return Verdict::fail(".commit does not name the commit");
}
let built = std::fs::read_dir(dest.join("dist")).is_ok_and(|mut d| d.next().is_some());
if !built {
return Verdict::fail("dist/ is missing or empty");
}
match cx.state().git(&["show", &format!("{sha}:VERSION")]) {
Ok(v) => {
let carries = std::fs::read_dir(dest.join("dist"))
.map(|d| {
d.flatten().any(|e| {
std::fs::read_to_string(e.path()).is_ok_and(|b| b.contains(v.trim()))
})
})
.unwrap_or(false);
if carries {
Verdict::pass(format!("dist carries {}", v.trim()))
} else {
Verdict::fail(format!("no file of dist/ carries version {}", v.trim()))
}
}
Err(_) => Verdict::pass("dist built"),
}
}
}
+110
View File
@@ -0,0 +1,110 @@
//! A range of lines of a file.
use crate::action::*;
use crate::catalog::REPORT;
use crate::state::State;
pub struct Slice;
static SPEC: Spec = Spec {
id: "slice",
version: 1,
summary: "Show a range of lines of one file: lines N to M.",
triggers: &[
r"\blines?\s+\d+",
r"\bfirst\s+\d+\s+lines\b",
r"\bline range\b",
],
inputs: &[
Input {
name: "path",
ask: "Which file does the request refer to?",
kind: Kind::Pick(Source::Files),
optional: false,
},
Input {
name: "from",
ask: "",
kind: Kind::Literal(r"(?i)\blines?\s+(\d+)"),
optional: false,
},
Input {
name: "to",
ask: "",
kind: Kind::Literal(r"(?i)\blines?\s+\d+\s*(?:-|\u{2013}|to|through|and)\s*(\d+)"),
optional: false,
},
REPORT,
],
effect: Effect::Read,
tools: &[],
pure: true,
};
fn number(args: &Args, name: &str) -> Option<usize> {
text(args, name)?.parse().ok().filter(|n| *n >= 1)
}
impl Action for Slice {
fn spec(&self) -> &'static Spec {
&SPEC
}
fn check(&self, state: &State, args: &Args) -> Vec<Check> {
let path = text(args, "path").unwrap_or("");
let (from, to) = (number(args, "from"), number(args, "to"));
vec![
Check::new("file", state.root.join(path).is_file(), path),
Check::new(
"range",
matches!((from, to), (Some(a), Some(b)) if a <= b),
format!("{from:?}..{to:?}"),
),
]
}
fn reads(&self, _state: &State, args: &Args) -> Reads {
Reads::Files(
text(args, "path")
.map(|p| vec![p.to_string()])
.unwrap_or_default(),
)
}
fn run(&self, cx: &mut Cx, args: &Args) -> Result<Output, String> {
let path = text(args, "path").ok_or("no path")?;
let (from, to) = (
number(args, "from").ok_or("no start")?,
number(args, "to").ok_or("no end")?,
);
let body = std::fs::read_to_string(cx.path(path)).map_err(|e| format!("{path}: {e}"))?;
let got: Vec<&str> = body.lines().skip(from - 1).take(to - from + 1).collect();
// Every line ends in a newline, so a range that ends on a blank line keeps it.
let text_out: String = got.iter().map(|l| format!("{l}\n")).collect();
report(cx, args, &text_out)?;
Ok(Output {
text: text_out,
data: serde_json::json!({ "lines": got.len() }),
})
}
fn verify(&self, cx: &Cx, args: &Args, out: &Output) -> Verdict {
let (Some(path), Some(from), Some(to)) =
(text(args, "path"), number(args, "from"), number(args, "to"))
else {
return Verdict::fail("no range");
};
let total = std::fs::read_to_string(cx.path(path))
.map(|b| b.lines().count())
.unwrap_or(0);
let want = to.min(total).saturating_sub(from - 1);
let got = out.text.lines().count();
if want == 0 {
Verdict::fail(format!("{path} has {total} lines, fewer than {from}"))
} else if got == want {
Verdict::pass(format!("{got} lines"))
} else {
Verdict::fail(format!("{got} lines, want {want}"))
}
}
}
+171
View File
@@ -0,0 +1,171 @@
//! Copy files or a tree to another directory.
use crate::action::*;
use crate::catalog::{fold, outside};
use crate::state::{State, sha256};
use serde_json::json;
use std::path::{Path, PathBuf};
pub struct Stage;
static SPEC: Spec = Spec {
id: "stage",
version: 1,
summary: "Copy files, a glob or a directory tree into another directory, keeping their contents.",
triggers: &[
r"\bcopy (the )?(contents|files?|everything|all|tree)\b",
r"\bduplicate\b",
r"\bmirror\b",
],
inputs: &[
Input {
name: "from",
ask: "",
kind: Kind::Literal(r#"(?i)\bcopy\b[^`"'\n]*[`"']([\w./*-]+)[`"']"#),
optional: false,
},
Input {
name: "to",
ask: "",
kind: Kind::Literal(r#"(?i)\b(?:into|to)\s+[`"']([\w./-]+)[`"']"#),
optional: false,
},
],
effect: Effect::Write,
tools: &[],
pure: false,
};
/// Files a source names: a file, a `dir/*.ext` glob, or every file under a directory, as (path, name under the destination).
fn sources(root: &Path, from: &str) -> Vec<(PathBuf, String)> {
let base = fold(root, from);
if let Some((dir, pat)) = from.rsplit_once('/').filter(|(_, p)| p.contains('*')) {
let (pre, post) = pat.split_once('*').unwrap_or((pat, ""));
return std::fs::read_dir(fold(root, dir))
.into_iter()
.flatten()
.flatten()
.filter(|e| e.path().is_file() && !e.path().is_symlink())
.filter(|e| {
let n = e.file_name().to_string_lossy().into_owned();
n.starts_with(pre) && n.ends_with(post)
})
.map(|e| (e.path(), e.file_name().to_string_lossy().into_owned()))
.collect();
}
if base.is_symlink() {
return Vec::new();
}
if base.is_file() {
return base
.file_name()
.map(|n| vec![(base.clone(), n.to_string_lossy().into_owned())])
.unwrap_or_default();
}
let mut out = Vec::new();
fn walk(dir: &Path, prefix: &str, out: &mut Vec<(PathBuf, String)>) {
for e in std::fs::read_dir(dir).into_iter().flatten().flatten() {
let name = e.file_name().to_string_lossy().into_owned();
let rel = if prefix.is_empty() {
name
} else {
format!("{prefix}/{name}")
};
if e.path().is_symlink() {
continue;
}
if e.path().is_dir() {
walk(&e.path(), &rel, out);
} else {
out.push((e.path(), rel));
}
}
}
walk(&base, "", &mut out);
out
}
impl Action for Stage {
fn spec(&self) -> &'static Spec {
&SPEC
}
fn effect(&self, state: &State, args: &Args) -> Effect {
// Reading from outside the workspace is as consequential as writing outside it.
let paths = [text(args, "to"), text(args, "from")];
if paths.into_iter().flatten().any(|p| outside(&state.root, p)) {
Effect::Outside
} else {
Effect::Write
}
}
fn check(&self, state: &State, args: &Args) -> Vec<Check> {
let (from, to) = (
text(args, "from").unwrap_or(""),
text(args, "to").unwrap_or(""),
);
let found = sources(&state.root, from);
let clash: Vec<&str> = found
.iter()
.filter(|(_, n)| fold(&state.root, to).join(n).exists())
.map(|(_, n)| n.as_str())
.collect();
vec![
Check::new(
"something to copy",
!found.is_empty(),
format!("{} files", found.len()),
),
Check::new(
"nothing is overwritten",
clash.is_empty(),
format!("{clash:?}"),
),
]
}
fn reads(&self, _state: &State, _args: &Args) -> Reads {
Reads::Tree
}
fn run(&self, cx: &mut Cx, args: &Args) -> Result<Output, String> {
let (from, to) = (
text(args, "from").ok_or("no source")?,
text(args, "to").ok_or("no destination")?,
);
let dest = fold(&cx.root, to);
let mut n = 0;
for (src, name) in sources(&cx.root, from) {
let target = dest.join(&name);
if let Some(parent) = target.parent() {
std::fs::create_dir_all(parent)
.map_err(|e| format!("{}: {e}", parent.display()))?;
}
std::fs::copy(&src, &target).map_err(|e| format!("{}: {e}", src.display()))?;
n += 1;
}
Ok(Output {
text: format!("copied {n} files into {to}"),
data: json!({ "files": n, "dest": dest.to_string_lossy() }),
})
}
fn verify(&self, cx: &Cx, args: &Args, out: &Output) -> Verdict {
let (Some(from), Some(dest)) = (text(args, "from"), out.data["dest"].as_str()) else {
return Verdict::fail("no copy");
};
let found = sources(&cx.root, from);
for (src, name) in &found {
let (a, b) = (
std::fs::read(src),
std::fs::read(Path::new(dest).join(name)),
);
match (a, b) {
(Ok(a), Ok(b)) if sha256(&a) == sha256(&b) => {}
_ => return Verdict::fail(format!("{name} is missing or differs")),
}
}
Verdict::pass(format!("{} files equal", found.len()))
}
}
+116
View File
@@ -0,0 +1,116 @@
//! Stop the process whose id is in a file.
use crate::action::*;
use crate::catalog::REPORT;
use crate::state::State;
use serde_json::json;
use std::time::{Duration, Instant};
pub struct Stop;
static SPEC: Spec = Spec {
id: "stop",
version: 1,
summary: "Stop the process whose id is written in a pid file.",
triggers: &[
r"\b(stop|kill|terminate|shut ?down)\b.*\b(pid|process|server|daemon)\b",
r"pid file",
],
inputs: &[
Input {
name: "pidfile",
ask: "",
kind: Kind::Literal(r#"[`"']?([\w./-]+\.pid)\b"#),
optional: false,
},
REPORT,
],
effect: Effect::Destroy,
tools: &["kill"],
pure: false,
};
fn pid_of(state: &State, file: &str) -> Option<u32> {
std::fs::read_to_string(state.root.join(file))
.ok()?
.trim()
.parse()
.ok()
.filter(|p| *p > 1)
}
fn alive(pid: u32) -> bool {
std::fs::read_to_string(format!("/proc/{pid}/stat")).is_ok_and(|s| {
// The state letter follows the command, which is in parentheses and may hold spaces.
s.rsplit_once(')')
.and_then(|(_, rest)| rest.split_whitespace().next().map(|c| c != "Z" && c != "X"))
.unwrap_or(false)
})
}
/// Whether the process works inside the workspace: its current directory is under the root.
fn ours(state: &State, pid: u32) -> bool {
let (Ok(cwd), Ok(root)) = (
std::fs::read_link(format!("/proc/{pid}/cwd")),
std::fs::canonicalize(&state.root),
) else {
return false;
};
cwd.starts_with(root)
}
impl Action for Stop {
fn spec(&self) -> &'static Spec {
&SPEC
}
fn check(&self, state: &State, args: &Args) -> Vec<Check> {
let file = text(args, "pidfile").unwrap_or("");
let pid = pid_of(state, file);
vec![
Check::new("pid file", pid.is_some(), file),
Check::new(
"process is alive",
pid.is_some_and(alive),
format!("{pid:?}"),
),
Check::new(
"process works in this workspace",
pid.is_some_and(|p| ours(state, p)),
format!("{pid:?}"),
),
Check::new("not this process", pid != Some(std::process::id()), ""),
]
}
fn run(&self, cx: &mut Cx, args: &Args) -> Result<Output, String> {
let file = text(args, "pidfile").ok_or("no pid file")?;
let pid = pid_of(&cx.state(), file).ok_or("no pid")?;
let p = pid.to_string();
cx.must(&["kill", "-TERM", &p])?;
let started = Instant::now();
while alive(pid) && started.elapsed() < Duration::from_secs(5) {
std::thread::sleep(Duration::from_millis(20));
}
if alive(pid) {
cx.must(&["kill", "-KILL", &p])?;
while alive(pid) && started.elapsed() < Duration::from_secs(8) {
std::thread::sleep(Duration::from_millis(20));
}
}
let body = format!("stopped pid={pid}");
report(cx, args, &body)?;
Ok(Output {
text: body,
data: json!({ "pid": pid }),
})
}
fn verify(&self, _cx: &Cx, _args: &Args, out: &Output) -> Verdict {
match out.data["pid"].as_u64() {
Some(p) if !alive(p as u32) => Verdict::pass(format!("pid {p} is gone")),
Some(p) => Verdict::fail(format!("pid {p} is still running")),
None => Verdict::fail("no pid"),
}
}
}
+335
View File
@@ -0,0 +1,335 @@
//! Run a test suite and report how many tests passed and failed.
use crate::action::*;
use crate::catalog::REPORT;
use crate::state::{Cand, State};
use regex::Regex;
use serde_json::json;
use std::collections::BTreeMap;
use std::time::Duration;
pub struct Suite;
static SPEC: Spec = Spec {
id: "suite",
version: 1,
summary: "Run the project's test suite and report how many tests passed and failed.",
triggers: &[
r"\b(run|execute|rerun|re-run)\b.{0,100}\b(tests?|suite|specs?)\b",
r"\bhow many\b.{0,60}\b(tests?|specs?)\b.{0,40}\b(pass|fail)",
],
inputs: &[
Input {
name: "suite",
ask: "Which test suite does the request mean?",
kind: Kind::Pick(Source::Suites),
optional: false,
},
REPORT,
],
effect: Effect::Write,
tools: &["python3", "node", "go"],
pure: true,
};
#[derive(Debug, Default, PartialEq, Clone, Copy)]
pub struct Counts {
pub passed: u32,
pub failed: u32,
pub skipped: u32,
}
/// Test suites in the workspace, one per directory that holds tests.
pub fn detect(state: &State) -> Vec<Cand> {
let files = state.files();
let mut dirs: BTreeMap<(&str, String), Vec<String>> = BTreeMap::new();
let py = Regex::new(r"(^|/)(test_[^/]*|[^/]*_test)\.py$");
let js = Regex::new(r"\.(test|spec)\.(js|mjs|cjs)$");
for f in &files {
let dir = f.rsplit_once('/').map_or(".", |(d, _)| d).to_string();
let name = f.rsplit('/').next().unwrap_or(f).to_string();
if py.as_ref().is_ok_and(|r| r.is_match(f)) {
dirs.entry(("python", dir)).or_default().push(name);
} else if js.as_ref().is_ok_and(|r| r.is_match(f)) {
dirs.entry(("node", dir)).or_default().push(name);
}
}
let mut out: Vec<Cand> = dirs
.into_iter()
.map(|((kind, dir), names)| {
Cand::new(
&format!("{kind}:{dir}"),
&format!("{dir} ({kind} tests: {})", names.join(", ")),
)
})
.collect();
if state.root.join("go.mod").is_file() {
out.push(Cand::new("go:.", "all Go packages (go test)"));
}
out
}
fn first(re: &str, text: &str) -> u32 {
Regex::new(re)
.ok()
.and_then(|r| r.captures(text))
.and_then(|c| c[1].parse().ok())
.unwrap_or(0)
}
/// Counts from pytest's last line: `2 failed, 3 passed, 1 skipped in 0.1s`.
pub fn pytest(text: &str) -> Option<Counts> {
let line = text
.lines()
.rev()
.find(|l| l.contains(" passed") || l.contains(" failed") || l.contains("no tests ran"))?;
Some(Counts {
passed: first(r"(\d+) passed", line),
failed: first(r"(\d+) failed", line) + first(r"(\d+) error", line),
skipped: first(r"(\d+) skipped", line),
})
}
/// Counts from unittest's summary: `Ran 5 tests` and `FAILED (failures=1, errors=1, skipped=1)`.
pub fn unittest(text: &str) -> Option<Counts> {
let ran = first(r"Ran (\d+) tests?", text);
if ran == 0 {
return None;
}
let failed = first(r"failures=(\d+)", text) + first(r"errors=(\d+)", text);
let skipped = first(r"skipped=(\d+)", text);
Some(Counts {
passed: ran.saturating_sub(failed + skipped),
failed,
skipped,
})
}
/// Counts from node's TAP summary.
pub fn tap(text: &str) -> Option<Counts> {
if !text.contains("# pass") {
return None;
}
Some(Counts {
passed: first(r"# pass (\d+)", text),
failed: first(r"# fail (\d+)", text),
skipped: first(r"# skipped (\d+)", text),
})
}
/// Counts from `go test -json`: one event a finished test.
pub fn gotest(text: &str) -> Option<Counts> {
let mut c = Counts::default();
let mut seen = false;
for line in text.lines() {
let Ok(v) = serde_json::from_str::<serde_json::Value>(line) else {
continue;
};
if v["Test"].as_str().is_none() {
continue;
}
match v["Action"].as_str() {
Some("pass") => c.passed += 1,
Some("fail") => c.failed += 1,
Some("skip") => c.skipped += 1,
_ => continue,
}
seen = true;
}
seen.then_some(c)
}
fn has_pytest(cx: &mut Cx) -> bool {
cx.exec(&["python3", "-c", "import pytest"], Duration::from_secs(20))
.is_ok_and(|o| o.ok())
}
fn execute(cx: &mut Cx, suite: &str) -> Result<(Counts, Option<i32>), String> {
let (kind, dir) = suite.split_once(':').ok_or("a suite is kind:dir")?;
let limit = Duration::from_secs(600);
match kind {
"python" => {
let out = if has_pytest(cx) {
cx.exec(
&[
"env",
"PYTHONPATH=.",
"python3",
"-m",
"pytest",
"-q",
"-p",
"no:cacheprovider",
dir,
],
limit,
)?
} else {
cx.exec(
&[
"env",
"PYTHONPATH=.",
"python3",
"-m",
"unittest",
"discover",
"-s",
dir,
"-t",
".",
],
limit,
)?
};
let counts = pytest(&out.text())
.filter(|_| out.stdout.contains("passed") || out.stdout.contains("failed"))
.or_else(|| unittest(&out.text()));
Ok((
counts.ok_or_else(|| {
format!(
"no counts in: {}",
crate::redact::cut(out.text().trim(), 200)
)
})?,
out.step.code,
))
}
"node" => {
let files: Vec<String> = cx
.state()
.files()
.into_iter()
.filter(|f| {
f.rsplit_once('/').map_or(".", |(d, _)| d) == dir
&& (f.contains(".test.") || f.contains(".spec."))
})
.collect();
let mut argv = vec!["node", "--test", "--test-reporter=tap"];
argv.extend(files.iter().map(String::as_str));
let out = cx.exec(&argv, limit)?;
Ok((tap(&out.stdout).ok_or("no TAP summary")?, out.step.code))
}
"go" => {
let out = cx.exec(&["go", "test", "-json", "./..."], limit)?;
Ok((
gotest(&out.stdout).ok_or("no go test events")?,
out.step.code,
))
}
other => Err(format!("no runner for {other}")),
}
}
impl Action for Suite {
fn spec(&self) -> &'static Spec {
&SPEC
}
fn check(&self, state: &State, args: &Args) -> Vec<Check> {
let suite = text(args, "suite").unwrap_or("");
vec![Check::new(
"suite exists",
state.suites().iter().any(|s| s.value == suite),
suite,
)]
}
fn reads(&self, _state: &State, _args: &Args) -> Reads {
Reads::Tree
}
fn run(&self, cx: &mut Cx, args: &Args) -> Result<Output, String> {
let suite = text(args, "suite").ok_or("no suite")?;
let (c, code) = execute(cx, suite)?;
let body = format!("passed={} failed={}", c.passed, c.failed);
report(cx, args, &body)?;
Ok(Output {
text: body,
data: json!({ "passed": c.passed, "failed": c.failed, "skipped": c.skipped, "code": code }),
})
}
fn verify(&self, _cx: &Cx, _args: &Args, out: &Output) -> Verdict {
let (passed, failed) = (
out.data["passed"].as_u64().unwrap_or(0),
out.data["failed"].as_u64().unwrap_or(0),
);
let code = out.data["code"].as_i64();
if passed + failed == 0 {
Verdict::fail("no test ran")
} else if (failed > 0) == (code != Some(0)) {
Verdict::pass(format!("{passed} passed, {failed} failed"))
} else {
Verdict::fail(format!(
"exit {code:?} does not agree with {failed} failures"
))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn pytest_lines_are_read() {
assert_eq!(
pytest("..F\n1 failed, 2 passed, 1 skipped in 0.02s"),
Some(Counts {
passed: 2,
failed: 1,
skipped: 1
})
);
assert_eq!(
pytest("3 passed in 0.01s"),
Some(Counts {
passed: 3,
failed: 0,
skipped: 0
})
);
assert_eq!(pytest("nothing"), None);
}
#[test]
fn unittest_summaries_are_read() {
assert_eq!(
unittest("Ran 5 tests in 0.001s\n\nFAILED (failures=1, errors=1)"),
Some(Counts {
passed: 3,
failed: 2,
skipped: 0
})
);
assert_eq!(
unittest("Ran 2 tests in 0.0s\n\nOK"),
Some(Counts {
passed: 2,
failed: 0,
skipped: 0
})
);
}
#[test]
fn tap_and_go_are_read() {
assert_eq!(
tap("1..3\n# tests 3\n# pass 2\n# fail 1\n# skipped 0"),
Some(Counts {
passed: 2,
failed: 1,
skipped: 0
})
);
let go = "{\"Action\":\"run\",\"Test\":\"A\"}\n{\"Action\":\"pass\",\"Test\":\"A\"}\n{\"Action\":\"fail\",\"Test\":\"B\"}\n{\"Action\":\"pass\"}";
assert_eq!(
gotest(go),
Some(Counts {
passed: 1,
failed: 1,
skipped: 0
})
);
}
}
+119
View File
@@ -0,0 +1,119 @@
//! Fast-forward the current branch from its remote.
use crate::action::*;
use crate::catalog::REPORT;
use crate::state::State;
use serde_json::json;
pub struct Sync;
static SPEC: Spec = Spec {
id: "sync",
version: 1,
summary: "Fast-forward the current branch from its remote and report how many commits came in.",
triggers: &[
r"fast-?forward",
r"\bsync\b",
r"catch up",
r"bring .* up to date",
r"update .* from (origin|upstream|the remote)",
r"\bpull\b",
],
inputs: &[
Input {
name: "remote",
ask: "",
kind: Kind::Nth(Source::Remotes, 0),
optional: true,
},
REPORT,
],
effect: Effect::Remote,
tools: &["git"],
pure: false,
};
fn remote(state: &State, args: &Args) -> Option<String> {
text(args, "remote").map(str::to_string).or_else(|| {
let all = state.remotes();
all.iter()
.find(|r| r.value == "origin")
.or(all.first())
.map(|r| r.value.clone())
})
}
impl Action for Sync {
fn spec(&self) -> &'static Spec {
&SPEC
}
fn check(&self, state: &State, args: &Args) -> Vec<Check> {
let tracked_dirty = state
.git(&["status", "--porcelain", "--untracked-files=no"])
.map(|s| !s.is_empty())
.unwrap_or(true);
vec![
Check::new("repository", state.is_repo(), ""),
Check::new(
"on a branch",
state.branch().is_some(),
state.branch().unwrap_or_default(),
),
Check::new(
"remote",
remote(state, args).is_some(),
remote(state, args).unwrap_or_default(),
),
Check::new("tracked files clean", !tracked_dirty, ""),
]
}
fn reads(&self, _state: &State, _args: &Args) -> Reads {
Reads::Git
}
fn run(&self, cx: &mut Cx, args: &Args) -> Result<Output, String> {
let state = cx.state();
let remote = remote(&state, args).ok_or("no remote")?;
let branch = state.branch().ok_or("not on a branch")?;
let before = cx.must(&["git", "rev-parse", "HEAD"])?.trim().to_string();
cx.must(&["git", "fetch", "-q", &remote])?;
cx.must(&[
"git",
"merge",
"-q",
"--ff-only",
&format!("{remote}/{branch}"),
])?;
let after = cx.must(&["git", "rev-parse", "HEAD"])?.trim().to_string();
let count = cx
.must(&["git", "rev-list", "--count", &format!("{before}..{after}")])?
.trim()
.to_string();
let body = format!("new={count} head={}", &after[..7.min(after.len())]);
report(cx, args, &body)?;
Ok(Output {
text: body,
data: json!({ "new": count, "head": after, "remote": remote, "branch": branch }),
})
}
fn verify(&self, cx: &Cx, _args: &Args, out: &Output) -> Verdict {
let (Some(head), Some(remote), Some(branch)) = (
out.data["head"].as_str(),
out.data["remote"].as_str(),
out.data["branch"].as_str(),
) else {
return Verdict::fail("no head");
};
match cx
.state()
.git(&["rev-parse", &format!("{remote}/{branch}")])
{
Ok(tip) if tip == head => Verdict::pass(format!("at {remote}/{branch}")),
Ok(tip) => Verdict::fail(format!("{remote}/{branch} is at {tip}")),
Err(e) => Verdict::fail(e),
}
}
}
+74
View File
@@ -0,0 +1,74 @@
//! The last commit that changed a file.
use crate::action::*;
use crate::catalog::REPORT;
use crate::state::State;
pub struct Touched;
static SPEC: Spec = Spec {
id: "touched",
version: 1,
summary: "Report the last commit that changed one file: its short hash and subject.",
triggers: &[
r"last (commit|change|time)",
r"who (last )?(changed|modified|touched)",
r"last (touched|modified|changed)",
r"most recent commit",
],
inputs: &[
Input {
name: "path",
ask: "Which file does the request refer to?",
kind: Kind::Pick(Source::Files),
optional: false,
},
REPORT,
],
effect: Effect::Read,
tools: &["git"],
pure: true,
};
impl Action for Touched {
fn spec(&self) -> &'static Spec {
&SPEC
}
fn check(&self, state: &State, args: &Args) -> Vec<Check> {
let path = text(args, "path").unwrap_or("");
vec![
Check::new("repository", state.is_repo(), ""),
Check::new("file", state.root.join(path).is_file(), path),
]
}
fn reads(&self, _state: &State, _args: &Args) -> Reads {
Reads::Git
}
fn run(&self, cx: &mut Cx, args: &Args) -> Result<Output, String> {
let path = text(args, "path").ok_or("no path")?;
let line = cx.must(&["git", "log", "-1", "--format=%h %s", "--", path])?;
let line = line.trim().to_string();
report(cx, args, &line)?;
Ok(Output {
text: line,
data: serde_json::Value::Null,
})
}
fn verify(&self, cx: &Cx, args: &Args, out: &Output) -> Verdict {
let (Some(path), Some(sha)) = (text(args, "path"), out.text.split_whitespace().next())
else {
return Verdict::fail("no commit");
};
match cx.state().git(&["show", "--name-only", "--format=", sha]) {
Ok(files) if files.lines().any(|f| f == path) => {
Verdict::pass(format!("{sha} changes {path}"))
}
Ok(_) => Verdict::fail(format!("{sha} does not change {path}")),
Err(e) => Verdict::fail(e),
}
}
}
+218
View File
@@ -0,0 +1,218 @@
//! Wait for a deployment, pipeline or rollout to reach a final state.
use crate::action::*;
use crate::catalog::REPORT;
use crate::state::State;
use serde_json::{Value, json};
use std::time::{Duration, Instant};
pub struct Wait;
static SPEC: Spec = Spec {
id: "wait",
version: 1,
summary: "Wait for a deployment, pipeline or rollout at a URL to reach a final state and report that state.",
triggers: &[
r"\bwait\b",
r"\buntil\b",
r"\bpoll\b",
r"\bwatch\b",
r"\bblock until\b",
r"\bfinish(es|ed)?\b.*\b(deploy|rollout|pipeline|build|release)",
],
inputs: &[
Input {
name: "url",
ask: "",
kind: Kind::Literal(r#"(https?://[^\s`"')>]+)"#),
optional: false,
},
REPORT,
],
effect: Effect::Read,
tools: &[],
pure: false,
};
const SUCCESS: &[&str] = &[
"ready",
"success",
"succeeded",
"successful",
"complete",
"completed",
"healthy",
"available",
"passed",
"done",
"deployed",
"running-ready",
];
const FAILURE: &[&str] = &[
"failed",
"failure",
"error",
"errored",
"cancelled",
"canceled",
"timeout",
"rolledback",
"aborted",
];
const LIMIT: Duration = Duration::from_secs(240);
const EVERY: Duration = Duration::from_millis(500);
/// Whether the URL, parsed as a client would, points at this machine. A URL with credentials in it is
/// not trusted to: `http://[email protected]/` goes to example.com.
fn loopback(url: &str) -> bool {
reqwest::Url::parse(url).is_ok_and(|u| {
u.username().is_empty()
&& u.password().is_none()
&& matches!(u.host_str(), Some("127.0.0.1" | "localhost" | "[::1]"))
})
}
/// The state a status document reports: `Some((word, final))`.
pub fn status(body: &str) -> Option<(String, bool)> {
let v: Value = match serde_json::from_str(body) {
Ok(v) => v,
Err(_) => {
let word = body.trim().to_lowercase();
return Some((
word.clone(),
SUCCESS.contains(&word.as_str()) || FAILURE.contains(&word.as_str()),
));
}
};
for key in ["status", "state", "phase", "conclusion", "result"] {
if let Some(word) = v[key].as_str() {
let w = word.to_lowercase();
let done = SUCCESS.contains(&w.as_str()) || FAILURE.contains(&w.as_str());
if !done {
if let Some((have, want)) = replicas(&v) {
return Some((format!("{w} {have}/{want}"), have == want && want > 0));
}
}
return Some((w, done));
}
}
replicas(&v).map(|(have, want)| (format!("{have}/{want}"), have == want && want > 0))
}
fn replicas(v: &Value) -> Option<(u64, u64)> {
let have = ["updated", "ready", "available"]
.iter()
.find_map(|k| v[*k].as_u64())?;
let want = ["desired", "replicas", "total"]
.iter()
.find_map(|k| v[*k].as_u64())?;
Some((have, want))
}
impl Action for Wait {
fn spec(&self) -> &'static Spec {
&SPEC
}
fn effect(&self, _state: &State, args: &Args) -> Effect {
if text(args, "url").is_some_and(loopback) {
Effect::Read
} else {
Effect::Remote
}
}
fn check(&self, _state: &State, args: &Args) -> Vec<Check> {
let url = text(args, "url").unwrap_or("");
vec![Check::new("url", url.starts_with("http://"), url)]
}
fn reads(&self, _state: &State, _args: &Args) -> Reads {
Reads::Files(Vec::new())
}
fn run(&self, cx: &mut Cx, args: &Args) -> Result<Output, String> {
let url = text(args, "url").ok_or("no url")?;
let client = reqwest::blocking::Client::builder()
.timeout(Duration::from_secs(5))
.build()
.map_err(|e| e.to_string())?;
let started = Instant::now();
let mut polls = 0u32;
let mut last = String::from("unreachable");
while started.elapsed() < LIMIT {
polls += 1;
let polled = client.get(url).send().ok().and_then(|resp| resp.text().ok()).and_then(|body| status(&body));
if let Some((word, done)) = polled {
last = word;
if done {
break;
}
}
std::thread::sleep(EVERY);
}
let waited = started.elapsed().as_secs_f64();
let body = format!("final={last} waited={waited:.1}s polls={polls}");
cx.steps.push(crate::exec::Step {
argv: vec!["GET".into(), crate::redact::redact(url)],
code: Some(0),
ms: waited * 1000.0,
});
report(cx, args, &format!("final={last}"))?;
Ok(Output {
text: body,
data: json!({ "final": last, "polls": polls, "waited": waited }),
})
}
fn verify(&self, _cx: &Cx, _args: &Args, out: &Output) -> Verdict {
let word = out.data["final"].as_str().unwrap_or("");
let word = word.split_whitespace().next().unwrap_or("");
if SUCCESS.contains(&word)
|| FAILURE.contains(&word)
|| out.data["final"].as_str().is_some_and(|f| f.contains('/'))
{
Verdict::pass(format!("final state {word}"))
} else {
Verdict::fail(format!("no final state: {word}"))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn statuses_in_three_shapes() {
assert_eq!(
status(r#"{"status":"pending"}"#),
Some(("pending".into(), false))
);
assert_eq!(
status(r#"{"status":"ready"}"#),
Some(("ready".into(), true))
);
assert_eq!(
status(r#"{"state":"Failed"}"#),
Some(("failed".into(), true))
);
assert_eq!(
status(r#"{"phase":"RollingOut","updated":2,"desired":4}"#),
Some(("rollingout 2/4".into(), false))
);
assert_eq!(
status(r#"{"updated":4,"desired":4}"#),
Some(("4/4".into(), true))
);
}
#[test]
fn only_loopback_is_not_remote() {
assert!(loopback("http://127.0.0.1:8080/x"));
assert!(!loopback("http://example.com/x"));
assert!(!loopback("http://[email protected]/x"));
assert!(!loopback("http://127.0.0.1.example.com/x"));
assert!(loopback("http://localhost:8080/"));
}
}
+143
View File
@@ -0,0 +1,143 @@
//! A git worktree on a new branch.
use crate::action::*;
use crate::catalog::{fold, outside};
use crate::state::State;
use serde_json::json;
pub struct Worktree;
static SPEC: Spec = Spec {
id: "worktree",
version: 1,
summary: "Create a git worktree on a new branch at a path, optionally branched off a named base branch.",
triggers: &[r"\bworktrees?\b"],
inputs: &[
Input {
name: "branch",
ask: "",
kind: Kind::Literal(
r#"(?i)\bbranch(?:\s+named|\s+called)?\s+[`"']([\w./-]+)[`"']|\bworktree\s+(?:for|on)\s+[`"']([\w./-]+)[`"']"#,
),
optional: false,
},
Input {
name: "path",
ask: "",
kind: Kind::Literal(r#"(?i)\b(?:at|in|under|into)\s+[`"']([\w./-]+)[`"']"#),
optional: false,
},
Input {
name: "base",
ask: "Which branch should the new branch start from?",
kind: Kind::Nth(Source::Branches, 0),
optional: true,
},
],
effect: Effect::Write,
tools: &["git"],
pure: false,
};
impl Action for Worktree {
fn spec(&self) -> &'static Spec {
&SPEC
}
fn effect(&self, state: &State, args: &Args) -> Effect {
match text(args, "path") {
Some(p) if outside(&state.root, p) => Effect::Outside,
_ => Effect::Write,
}
}
fn check(&self, state: &State, args: &Args) -> Vec<Check> {
let branch = text(args, "branch").unwrap_or("");
let path = text(args, "path").unwrap_or("");
let target = fold(&state.root, path);
let base_ok = text(args, "base").is_none_or(|b| {
state
.git(&["rev-parse", "--verify", "-q", &format!("{b}^{{commit}}")])
.is_ok()
});
vec![
Check::new("repository", state.is_repo(), ""),
Check::new(
"branch name",
!branch.is_empty() && state.git(&["check-ref-format", "--branch", branch]).is_ok(),
branch,
),
Check::new(
"branch is new",
state
.git(&[
"rev-parse",
"--verify",
"-q",
&format!("refs/heads/{branch}"),
])
.is_err(),
branch,
),
Check::new(
"path is free",
!target.exists(),
target.display().to_string(),
),
Check::new("base", base_ok, text(args, "base").unwrap_or("HEAD")),
]
}
fn reads(&self, _state: &State, _args: &Args) -> Reads {
Reads::Git
}
fn run(&self, cx: &mut Cx, args: &Args) -> Result<Output, String> {
let (branch, path) = (
text(args, "branch").ok_or("no branch")?,
text(args, "path").ok_or("no path")?,
);
let target = fold(&cx.root, path);
let target_s = target.to_string_lossy().into_owned();
let mut argv = vec!["git", "worktree", "add", "-b", branch, &target_s];
if let Some(base) = text(args, "base") {
argv.push(base);
}
cx.must(&argv)?;
let head = cx
.must(&["git", "-C", &target_s, "rev-parse", "HEAD"])?
.trim()
.to_string();
Ok(Output {
text: format!(
"worktree {target_s} on {branch} at {}",
&head[..7.min(head.len())]
),
data: json!({ "path": target_s, "branch": branch, "head": head }),
})
}
fn verify(&self, cx: &Cx, args: &Args, out: &Output) -> Verdict {
let (Some(branch), Some(path)) = (text(args, "branch"), out.data["path"].as_str()) else {
return Verdict::fail("no worktree");
};
let list = match cx.state().git(&["worktree", "list", "--porcelain"]) {
Ok(l) => l,
Err(e) => return Verdict::fail(e),
};
let listed = list.split("\n\n").any(|block| {
block.lines().any(|l| l == format!("worktree {path}"))
&& block
.lines()
.any(|l| l == format!("branch refs/heads/{branch}"))
});
let want = text(args, "base").unwrap_or("HEAD");
let base = cx.state().git(&["rev-parse", want]).unwrap_or_default();
let head = out.data["head"].as_str().unwrap_or("");
match (listed, base == head) {
(true, true) => Verdict::pass(format!("{path} on {branch}")),
(false, _) => Verdict::fail("git does not list the worktree on that branch"),
(_, false) => Verdict::fail(format!("worktree starts at {head}, not {base}")),
}
}
}
+148
View File
@@ -0,0 +1,148 @@
//! `hanzo-mcp kai`: run a request as a Kai action, or list the catalog.
//!
//! ```text
//! hanzo-mcp kai run --cwd DIR (--request TEXT | --request-file FILE) [--kai URL] [--model kai] [--key-env NAME]
//! [--cache DIR] [--approve grant] [--prove] [--min-margin X] [--min-p X] [--max-abstain X]
//! hanzo-mcp kai plan (the same flags: binds the request and prints what would run, runs nothing)
//! hanzo-mcp kai list
//! hanzo-mcp kai triggers --request TEXT
//! ```
//!
//! `run` prints one JSON report: the outcome (`done`, `failed`, `declined`), the ledger records,
//! and what asking Kai cost. Exit status is 0 when the request was done, 2 when it was declined
//! (the normal agent should take it), 3 when an action ran and failed.
use crate::bind::Config;
use crate::ledger::Outcome;
use crate::run::{Grant, Refuse};
use crate::{Engine, Kai, Store, catalog, perform, workflow};
use std::path::PathBuf;
fn flag(args: &[String], name: &str) -> Option<String> {
args.iter()
.position(|a| a == name)
.and_then(|i| args.get(i + 1))
.cloned()
}
/// `hanzo-mcp kai ARGS`: the arguments after `kai`. Returns the exit status.
pub fn main(args: &[String]) -> i32 {
match args.first().map(String::as_str) {
Some("list") => {
list();
0
}
Some("triggers") => {
triggers(&args[1..]);
0
}
Some("run") => run(&args[1..], false),
Some("plan") => run(&args[1..], true),
_ => {
eprintln!("usage: hanzo-mcp kai run --cwd DIR --request TEXT [--kai URL] [--cache DIR] | hanzo-mcp kai list | hanzo-mcp kai triggers --request TEXT");
64
}
}
}
fn list() {
let actions: Vec<serde_json::Value> = catalog::all()
.iter()
.map(|a| {
let s = a.spec();
serde_json::json!({ "id": s.id, "version": s.version, "summary": s.summary, "effect": s.effect, "inputs": s.inputs.iter().map(|i| i.name).collect::<Vec<_>>() })
})
.collect();
let workflows: Vec<serde_json::Value> = workflow::all().iter().map(|w| serde_json::json!({ "id": w.id, "version": w.version, "summary": w.summary, "steps": w.parts.iter().map(|p| p.action).collect::<Vec<_>>() })).collect();
println!(
"{}",
serde_json::json!({ "actions": actions, "workflows": workflows })
);
}
/// The ids of the actions and workflows whose triggers fit a request, as a JSON array.
fn triggers(args: &[String]) {
let ids: Vec<&str> = crate::triggered(&flag(args, "--request").unwrap_or_default())
.iter()
.map(crate::bind::Target::id)
.collect();
println!("{}", serde_json::json!(ids));
}
fn run(args: &[String], plan_only: bool) -> i32 {
let Some(cwd) = flag(args, "--cwd").map(PathBuf::from) else {
eprintln!("--cwd is required");
return 64;
};
let request = match (flag(args, "--request"), flag(args, "--request-file")) {
(Some(r), _) => r,
(None, Some(f)) => match std::fs::read_to_string(&f) {
Ok(r) => r,
Err(e) => {
eprintln!("{f}: {e}");
return 64;
}
},
_ => {
eprintln!("--request or --request-file is required");
return 64;
}
};
let kai = flag(args, "--kai").map(|url| {
Kai::new(
&url,
&flag(args, "--model").unwrap_or_else(|| "kai".into()),
flag(args, "--key-env").and_then(|n| std::env::var(n).ok()),
)
});
let store = match flag(args, "--cache")
.map(|d| Store::open(&PathBuf::from(d)))
.transpose()
{
Ok(s) => s,
Err(e) => {
eprintln!("{e}");
return 64;
}
};
let mut cfg = Config::default();
if let Some(v) = flag(args, "--min-margin").and_then(|v| v.parse().ok()) {
cfg.min_margin = v;
}
if let Some(v) = flag(args, "--min-p").and_then(|v| v.parse().ok()) {
cfg.min_p = v;
}
if let Some(v) = flag(args, "--max-abstain").and_then(|v| v.parse().ok()) {
cfg.max_abstain = v;
}
let approver: &dyn crate::Approver =
if flag(args, "--approve").as_deref() == Some("grant") {
&Grant
} else {
&Refuse
};
let engine = Engine {
chooser: kai.as_ref().map(|k| k as &dyn crate::Chooser),
cache: store.as_ref(),
approver,
cfg,
prove: args.iter().any(|a| a == "--prove"),
};
if plan_only {
println!("{}", crate::run::plan_report(&engine, &cwd, &request));
return 0;
}
let report = perform(&engine, &cwd, &request);
match serde_json::to_string(&report) {
Ok(s) => println!("{s}"),
Err(e) => {
eprintln!("{e}");
return 70;
}
}
match report.outcome {
Outcome::Done | Outcome::Skipped(_) => 0,
Outcome::Declined(_) => 2,
Outcome::Failed(_) => 3,
}
}
+113
View File
@@ -0,0 +1,113 @@
//! Running a program with an argument vector, never a shell string, under a time limit.
//!
//! Credentials in the environment are not handed on (names holding KEY, TOKEN, SECRET, PASSWORD,
//! CREDENTIAL or AUTH), so a project's script cannot read the model's login.
use crate::redact::{redact, secret};
use serde::Serialize;
use std::io::Read;
use std::path::Path;
use std::process::{Command, Stdio};
use std::time::{Duration, Instant};
/// One command an action ran.
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct Step {
/// The argument vector, redacted.
pub argv: Vec<String>,
pub code: Option<i32>,
pub ms: f64,
}
/// What a command printed.
#[derive(Debug, Clone)]
pub struct Out {
pub step: Step,
pub stdout: String,
pub stderr: String,
}
impl Out {
pub fn ok(&self) -> bool {
self.step.code == Some(0)
}
/// Both streams, stdout first.
pub fn text(&self) -> String {
format!("{}{}", self.stdout, self.stderr)
}
}
/// Runs `argv` in `cwd`, killed after `limit`.
pub fn run(cwd: &Path, argv: &[&str], limit: Duration) -> Result<Out, String> {
let (program, args) = argv.split_first().ok_or("empty command")?;
let started = Instant::now();
let mut command = Command::new(program);
command
.args(args)
.current_dir(cwd)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
for (name, _) in std::env::vars() {
if secret(&name) {
command.env_remove(name);
}
}
command
.env("GIT_TERMINAL_PROMPT", "0")
.env("GIT_OPTIONAL_LOCKS", "0");
let mut child = command.spawn().map_err(|e| format!("{program}: {e}"))?;
let mut stdout = child.stdout.take().ok_or("no stdout")?;
let mut stderr = child.stderr.take().ok_or("no stderr")?;
let (out, err, code) = std::thread::scope(|scope| {
let a = scope.spawn(move || {
let mut s = Vec::new();
let _ = stdout.read_to_end(&mut s);
s
});
let b = scope.spawn(move || {
let mut s = Vec::new();
let _ = stderr.read_to_end(&mut s);
s
});
let code = loop {
match child.try_wait() {
Ok(Some(status)) => break status.code(),
Ok(None) if started.elapsed() > limit => {
let _ = child.kill();
let _ = child.wait();
break None;
}
Ok(None) => std::thread::sleep(Duration::from_millis(5)),
Err(_) => break None,
}
};
(
a.join().unwrap_or_default(),
b.join().unwrap_or_default(),
code,
)
});
Ok(Out {
step: Step {
argv: argv.iter().map(|a| redact(a)).collect(),
code,
ms: started.elapsed().as_secs_f64() * 1000.0,
},
stdout: String::from_utf8_lossy(&out).into_owned(),
stderr: String::from_utf8_lossy(&err).into_owned(),
})
}
/// `git` in `cwd`; its stdout trimmed when it succeeds.
pub fn git(cwd: &Path, args: &[&str]) -> Result<String, String> {
let mut argv = vec!["git"];
argv.extend_from_slice(args);
let out = run(cwd, &argv, Duration::from_secs(60))?;
if out.ok() {
Ok(out.stdout.trim_end().to_string())
} else {
Err(format!("git {}: {}", args.join(" "), out.stderr.trim()))
}
}
+101
View File
@@ -0,0 +1,101 @@
//! The record every action run leaves, for Dev's loop and for replay.
use crate::exec::Step;
use serde::Serialize;
use serde_json::Value;
/// Who chose the action and bound its inputs.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum Origin {
/// Exactly one action matched and every input was named or had one value: no model was asked.
Rule,
/// Kai chose among several actions or values.
Kai,
}
/// A precondition, as it read just before the action ran.
pub use crate::action::Check;
#[derive(Debug, Clone, Serialize)]
pub struct Authorization {
pub required: bool,
pub reason: Option<String>,
pub granted: bool,
/// `policy` when none was needed, `approver` when one was asked.
pub by: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct Cache {
/// The action had a recorded success in this environment.
pub proved: bool,
/// `hit` when the result was reused, `miss` when it was computed, `off` when the action is not pure or no cache is open.
pub memo: String,
pub fingerprint: String,
}
/// One Kai request that bore on this action.
#[derive(Debug, Clone, Serialize)]
pub struct Ask {
pub question: String,
pub options: usize,
/// The likeliest real option.
pub pick: String,
/// Its probability among the real options, and its lead over the next.
pub p: f64,
pub margin: f64,
/// What Kai gave to abstaining.
pub abstain: f64,
pub accepted: bool,
pub ms: f64,
/// The three likeliest real options with their renormalized probabilities.
pub top: Vec<(String, f64)>,
}
#[derive(Debug, Clone, Default, Serialize)]
pub struct Timing {
pub bind_ms: f64,
pub kai_ms: f64,
pub check_ms: f64,
pub run_ms: f64,
pub verify_ms: f64,
pub total_ms: f64,
}
#[derive(Debug, Clone, PartialEq, Serialize)]
#[serde(tag = "kind", content = "why", rename_all = "lowercase")]
pub enum Outcome {
/// The effect happened and the verifier says so.
Done,
/// It ran and the verifier (or the command) says it did not work.
Failed(String),
/// It did not run: no action fits, an input is unbound, a precondition fails, approval is refused.
Declined(String),
/// A workflow step whose guard did not hold.
Skipped(String),
}
/// One run of one action. `id` is derived from what ran and what it depended on, so a replay of the
/// same action on the same inputs and workspace has the same id.
#[derive(Debug, Clone, Serialize)]
pub struct Record {
pub id: String,
pub action: String,
pub version: u32,
pub source: Origin,
/// The workspace revision the action was bound against, and the one just before it ran.
pub revision: String,
pub revision_at_run: String,
pub args: Value,
pub preconditions: Vec<Check>,
pub authorization: Authorization,
pub cache: Cache,
pub asks: Vec<Ask>,
pub steps: Vec<Step>,
pub timing: Timing,
pub verdict: Option<crate::action::Verdict>,
pub outcome: Outcome,
/// What the action reported, credentials redacted and cut.
pub output: String,
}
+43
View File
@@ -0,0 +1,43 @@
//! Kai Actions.
//!
//! An *action* is one saved, typed, verified operation: its inputs and what each may be, the
//! preconditions that must hold, what it does to the world (which decides whether the user must
//! approve it), what its result depends on, and the check that its effect happened. A *workflow*
//! is a plan of several actions. The *cache* holds what an action has been seen to do (a proof,
//! keyed by its version and tools) and the results it can reuse (keyed by what they read).
//!
//! Dev asks this crate in four steps, each exposed:
//!
//! 1. match: [`bind::triggered`] gives the actions and workflows whose triggers fit a request;
//! 2. bind: [`bind::plan`] binds their inputs from the request and from values read out of the
//! repository, asking Kai (a [`Chooser`]) only when several actions or values remain;
//! 3. run: [`run::perform`] checks preconditions again just before each step, authorizes,
//! consults the cache and runs;
//! 4. verify: each step's own verifier decides, and every step leaves a [`ledger::Record`].
//!
//! Kai never writes a command. It chooses among actions and among values read from the repository;
//! a request nothing fits, or that stays ambiguous, is declined and goes to the normal agent.
pub mod action;
pub mod ask;
pub mod bind;
pub mod cache;
pub mod catalog;
pub mod cli;
pub mod exec;
pub mod ledger;
pub mod redact;
pub mod run;
pub mod state;
pub mod text;
pub mod workflow;
pub use action::{Action, Args, Effect};
pub use ask::{Chooser, Kai};
pub use bind::{Config, Decline, plan, triggered};
pub use cache::Store;
pub use ledger::{Outcome, Record};
pub use run::{Approver, Engine, Report, perform};
#[cfg(test)]
mod tests;
+82
View File
@@ -0,0 +1,82 @@
//! Secrets out of anything an action records: tokens, keys and passwords become `[redacted]`
//! before a ledger line, a cache entry or a step's argv is written.
use regex::Regex;
use std::sync::OnceLock;
const PATTERNS: &[&str] = &[
r"(?i)bearer\s+[A-Za-z0-9._~+/=-]{8,}",
r"\bsk-[A-Za-z0-9_-]{16,}",
r"\bhk-[A-Za-z0-9_-]{12,}",
r"\bgh[pousr]_[A-Za-z0-9]{20,}",
r"\bglpat-[A-Za-z0-9_-]{16,}",
r"\bAKIA[0-9A-Z]{16}\b",
r"\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}",
r"(?i)\b(password|passwd|secret|token|api[_-]?key|authorization)\b(\s*[=:]\s*)[^\s,;&]+",
r"-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----",
];
fn rules() -> &'static [Regex] {
static RULES: OnceLock<Vec<Regex>> = OnceLock::new();
RULES.get_or_init(|| PATTERNS.iter().filter_map(|p| Regex::new(p).ok()).collect())
}
/// `text` with every credential-shaped span replaced.
pub fn redact(text: &str) -> String {
let mut out = text.to_string();
for rule in rules() {
out = rule
.replace_all(&out, |caps: &regex::Captures| {
match (caps.get(1), caps.get(2)) {
(Some(name), Some(sep)) => {
format!("{}{}[redacted]", name.as_str(), sep.as_str())
}
_ => "[redacted]".to_string(),
}
})
.into_owned();
}
out
}
/// Whether an environment variable's name says it holds a credential.
pub fn secret(name: &str) -> bool {
let n = name.to_ascii_uppercase();
["KEY", "TOKEN", "SECRET", "PASSWORD", "CREDENTIAL", "AUTH"]
.iter()
.any(|w| n.contains(w))
}
/// `text` cut to at most `n` bytes on a character boundary.
pub fn cut(text: &str, n: usize) -> String {
if text.len() <= n {
return text.to_string();
}
let mut end = n;
while !text.is_char_boundary(end) {
end -= 1;
}
format!("{}...", &text[..end])
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn credentials_are_replaced_and_prose_is_not() {
let text = "token=abc123def Authorization: Bearer abcdefghijklmnop sk-abcdefghijklmnopqrst ok plain words";
let got = redact(text);
assert!(!got.contains("abc123def"), "{got}");
assert!(!got.contains("abcdefghijklmnop"), "{got}");
assert!(!got.contains("sk-abcdef"), "{got}");
assert!(got.contains("ok plain words"), "{got}");
}
#[test]
fn names_of_secrets_are_found() {
assert!(secret("HANZO_API_KEY"));
assert!(secret("claude_code_oauth_token"));
assert!(!secret("PATH"));
}
}
+430
View File
@@ -0,0 +1,430 @@
//! Running a plan: preconditions read again just before each step, authorization, the proof gate,
//! the cache, the run, the verifier, and a record of each.
use crate::action::{Action, Args, Check, Cx, Output, Verdict};
use crate::ask::Chooser;
use crate::bind::{Config, Decline, Plan, PlanPart, Trace, plan};
use crate::cache::{Store, memo_key, proof_key};
use crate::exec::Step;
use crate::ledger::{Ask, Authorization, Cache, Origin, Outcome, Record, Timing};
use crate::redact::{cut, redact};
use crate::state::{State, sha256};
use serde::Serialize;
use serde_json::Value;
use std::path::Path;
use std::time::Instant;
/// What a consequential action asks of the user.
#[derive(Debug, Clone)]
pub struct Approval {
pub action: String,
pub reason: String,
pub detail: String,
}
/// The user's side of authorization. The native client asks; a test or a sandbox answers.
pub trait Approver: Sync {
fn approve(&self, approval: &Approval) -> bool;
}
/// Grants every request. For a sandbox that counts them from the ledger.
pub struct Grant;
impl Approver for Grant {
fn approve(&self, _approval: &Approval) -> bool {
true
}
}
/// Refuses every request.
pub struct Refuse;
impl Approver for Refuse {
fn approve(&self, _approval: &Approval) -> bool {
false
}
}
pub struct Engine<'a> {
pub chooser: Option<&'a dyn Chooser>,
pub cache: Option<&'a Store>,
pub approver: &'a dyn Approver,
pub cfg: Config,
/// Record a proof for each action whose verifier passes, and do not require one.
pub prove: bool,
}
/// Everything a request produced.
#[derive(Debug, Serialize)]
pub struct Report {
pub outcome: Outcome,
pub target: Option<String>,
pub records: Vec<Record>,
pub asks: Vec<Ask>,
pub kai_calls: u32,
pub kai_ms: f64,
pub bind_ms: f64,
pub total_ms: f64,
}
fn ms(t: Instant) -> f64 {
t.elapsed().as_secs_f64() * 1000.0
}
fn scrub(v: &Value) -> Value {
match v {
Value::String(s) => Value::String(redact(s)),
Value::Array(a) => Value::Array(a.iter().map(scrub).collect()),
Value::Object(m) => Value::Object(m.iter().map(|(k, v)| (k.clone(), scrub(v))).collect()),
other => other.clone(),
}
}
fn declined(report: &mut Report, why: String) {
report.outcome = Outcome::Declined(why);
}
/// What a request would run, without running it: the plan's steps, who chose them, and what asking Kai cost.
pub fn plan_report(engine: &Engine, root: &Path, request: &str) -> Value {
let started = Instant::now();
let state = State::new(root, request);
let mut trace = Trace::default();
let triggered: Vec<&str> = crate::bind::triggered(request).iter().map(crate::bind::Target::id).collect();
let planned = plan(&state, engine.chooser, &engine.cfg, &mut trace);
let steps = |p: &Plan| -> Vec<Value> {
p.parts
.iter()
.map(|part| serde_json::json!({ "action": part.action.spec().id, "version": part.action.spec().version, "args": scrub(&serde_json::to_value(&part.args).unwrap_or_default()), "inherit": part.inherit, "effect": part.action.effect(&state, &part.args) }))
.collect()
};
let (outcome, target, source, steps) = match &planned {
Ok(p) => ("planned".to_string(), Some(p.target), Some(p.origin), steps(p)),
Err(d) => (format!("declined: {d}"), None, None, Vec::new()),
};
serde_json::json!({ "outcome": outcome, "target": target, "source": source, "steps": steps, "triggered": triggered, "asks": trace.asks, "kai_calls": trace.kai_calls, "kai_ms": trace.kai_ms, "total_ms": ms(started) })
}
/// Runs the request in `root`: plans it, then runs each step.
pub fn perform(engine: &Engine, root: &Path, request: &str) -> Report {
let started = Instant::now();
let state = State::new(root, request);
let revision = state.revision();
let mut trace = Trace::default();
let bind_started = Instant::now();
let planned = plan(&state, engine.chooser, &engine.cfg, &mut trace);
let bind_ms = (ms(bind_started) - trace.kai_ms).max(0.0);
let mut report = Report {
outcome: Outcome::Done,
target: None,
records: Vec::new(),
asks: trace.asks.clone(),
kai_calls: trace.kai_calls,
kai_ms: trace.kai_ms,
bind_ms,
total_ms: 0.0,
};
match planned {
Err(d) => declined(&mut report, d.to_string()),
Ok(p) => {
report.target = Some(p.target.to_string());
steps(engine, root, request, &revision, &p, &trace, &mut report);
}
}
report.total_ms = ms(started);
report
}
fn steps(
engine: &Engine,
root: &Path,
request: &str,
revision: &str,
plan: &Plan,
trace: &Trace,
report: &mut Report,
) {
let mut outputs: Vec<Option<Output>> = Vec::new();
for (i, part) in plan.parts.iter().enumerate() {
if let Some(g) = part.when {
let held = outputs
.get(g.step)
.and_then(Option::as_ref)
.and_then(|o| o.data[g.field].as_i64())
== Some(g.equals);
if !held {
outputs.push(None);
report.records.push(skipped(
part,
plan,
revision,
format!("{} of step {} is not {}", g.field, g.step + 1, g.equals),
));
continue;
}
}
let mut args = part.args.clone();
for (input, from) in &part.inherit {
let value = from
.strip_prefix('$')
.and_then(|r| r.split_once('.'))
.and_then(|(n, f)| {
n.parse::<usize>()
.ok()
.and_then(|n| outputs.get(n - 1))
.and_then(Option::as_ref)
.map(|o| o.data[f].clone())
});
match value {
Some(v) if !v.is_null() => {
args.insert(input.clone(), v);
}
_ => {
declined(
report,
format!("step {} cannot take {from} from an earlier step", i + 1),
);
return;
}
}
}
match step(engine, root, request, revision, plan, i, part, &args, trace) {
Ok((record, out)) => {
report.records.push(record);
outputs.push(Some(out));
}
Err(record) => {
report.outcome = record.outcome.clone();
report.records.push(*record);
return;
}
}
}
}
fn skipped(part: &PlanPart, plan: &Plan, revision: &str, why: String) -> Record {
let s = part.action.spec();
Record {
id: sha256(format!("{}|{}|skipped|{why}", s.id, s.version).as_bytes())[..16].to_string(),
action: s.id.into(),
version: s.version,
source: plan.origin,
revision: revision.into(),
revision_at_run: revision.into(),
args: scrub(&serde_json::to_value(&part.args).unwrap_or_default()),
preconditions: Vec::new(),
authorization: Authorization {
required: false,
reason: None,
granted: true,
by: "policy".into(),
},
cache: Cache {
proved: false,
memo: "off".into(),
fingerprint: String::new(),
},
asks: Vec::new(),
steps: Vec::new(),
timing: Timing::default(),
verdict: None,
outcome: Outcome::Skipped(why),
output: String::new(),
}
}
#[allow(clippy::too_many_arguments)]
fn step(
engine: &Engine,
root: &Path,
request: &str,
revision: &str,
plan: &Plan,
index: usize,
part: &PlanPart,
args: &Args,
trace: &Trace,
) -> Result<(Record, Output), Box<Record>> {
let action: &dyn Action = part.action;
let spec = action.spec();
let t0 = Instant::now();
let work = args
.get("root")
.and_then(Value::as_str)
.map_or_else(|| root.to_path_buf(), std::path::PathBuf::from);
let state = State::new(&work, request);
let mut timing = Timing::default();
let mut rec = Record {
id: String::new(),
action: spec.id.into(),
version: spec.version,
source: if index == 0 {
plan.origin
} else {
Origin::Rule
},
revision: revision.into(),
revision_at_run: String::new(),
args: scrub(&serde_json::to_value(args).unwrap_or_default()),
preconditions: Vec::new(),
authorization: Authorization {
required: false,
reason: None,
granted: true,
by: "policy".into(),
},
cache: Cache {
proved: false,
memo: "off".into(),
fingerprint: String::new(),
},
asks: if index == 0 {
trace.asks.clone()
} else {
Vec::new()
},
steps: Vec::new(),
timing: Timing::default(),
verdict: None,
outcome: Outcome::Done,
output: String::new(),
};
let stop = |mut r: Record, outcome: Outcome, timing: Timing, t0: Instant| -> Box<Record> {
r.outcome = outcome;
r.timing = Timing {
total_ms: ms(t0),
..timing
};
r.id = id_of(&r);
Box::new(r)
};
// Preconditions, read now.
let t = Instant::now();
let checks: Vec<Check> = action.check(&state, args);
timing.check_ms = ms(t);
rec.revision_at_run = state.revision();
rec.preconditions = checks.clone();
if let Some(bad) = checks.iter().find(|c| !c.ok) {
let why = Decline::Precondition(format!("{}: {} ({})", spec.id, bad.name, bad.detail))
.to_string();
return Err(stop(rec, Outcome::Declined(why), timing, t0));
}
if index == 0 && work == root && rec.revision_at_run != revision {
let why =
Decline::Stale(format!("bound at {revision}, now {}", rec.revision_at_run)).to_string();
return Err(stop(rec, Outcome::Declined(why), timing, t0));
}
// Authorization: a match is not permission.
let effect = action.effect(&state, args);
if let Some(reason) = effect.reason() {
let granted = engine.approver.approve(&Approval {
action: spec.id.into(),
reason: reason.into(),
detail: rec.args.to_string(),
});
rec.authorization = Authorization {
required: true,
reason: Some(reason.into()),
granted,
by: "approver".into(),
};
if !granted {
let why = Decline::Denied(format!("{}: {reason}", spec.id)).to_string();
return Err(stop(rec, Outcome::Declined(why), timing, t0));
}
}
// The proof gate.
let pkey = proof_key(action);
let proved = engine.cache.is_some_and(|c| c.proved(&pkey));
rec.cache.proved = proved;
if engine.cfg.require_proof && !engine.prove && !proved {
let why = Decline::Unproven(format!("{} v{}", spec.id, spec.version)).to_string();
return Err(stop(rec, Outcome::Declined(why), timing, t0));
}
// The cache: a pure action's result is reused while everything it reads is as it was.
let mut cx = Cx::new(work, root.to_path_buf(), request);
let memo = engine
.cache
.filter(|_| spec.pure)
.map(|c| (c, memo_key(action, &state, args)));
if let Some((_, key)) = &memo {
rec.cache.fingerprint.clone_from(key);
rec.cache.memo = "miss".into();
}
let t = Instant::now();
let hit = memo
.as_ref()
.and_then(|(c, key)| c.memo(key))
.map(|m| Output {
text: m.text,
data: m.data,
});
let out = match hit {
Some(out) => {
rec.cache.memo = "hit".into();
if let Err(e) = crate::action::report(&cx, args, &out.text) {
rec.output = cut(&redact(&e), 500);
return Err(stop(rec, Outcome::Failed(e), timing, t0));
}
out
}
None => match action.run(&mut cx, args) {
Ok(out) => out,
Err(e) => {
timing.run_ms = ms(t);
rec.steps = cx.steps;
rec.output = cut(&redact(&e), 500);
let why = redact(&e);
return Err(stop(rec, Outcome::Failed(why), timing, t0));
}
},
};
timing.run_ms = ms(t);
rec.steps = std::mem::take(&mut cx.steps);
// The verifier says whether the effect happened.
let t = Instant::now();
let verdict: Verdict = action.verify(&cx, args, &out);
timing.verify_ms = ms(t);
rec.output = cut(&redact(&out.text), 2000);
rec.verdict = Some(verdict.clone());
if !verdict.ok {
let why = format!("verify: {}", redact(&verdict.detail));
return Err(stop(rec, Outcome::Failed(why), timing, t0));
}
if let Some(c) = engine.cache {
if engine.prove {
let _ = c.prove(action, &pkey);
}
if let Some((_, key)) = &memo {
if rec.cache.memo == "miss" {
let _ = c.remember(action, key, &out.text, &out.data);
}
}
}
rec.timing = Timing {
total_ms: ms(t0),
..timing
};
rec.id = id_of(&rec);
Ok((rec, out))
}
/// A record's id: what ran, with what, against what. The same run of the same workspace has the same id.
fn id_of(r: &Record) -> String {
sha256(
format!(
"{}|{}|{}|{}|{}",
r.action, r.version, r.args, r.revision_at_run, r.cache.fingerprint
)
.as_bytes(),
)[..16]
.to_string()
}
/// A [`Step`] list as a flat summary, for logs.
pub fn summary(steps: &[Step]) -> String {
steps
.iter()
.map(|s| s.argv.join(" "))
.collect::<Vec<_>>()
.join(" ; ")
}
+246
View File
@@ -0,0 +1,246 @@
//! What an action reads of the workspace: the request, and the repository around it.
//!
//! Everything here is read-only. The revision it reports (HEAD, and the uncommitted changes)
//! is what an action's preconditions are checked against, and checked again just before it runs.
use crate::exec::{git, run, Out};
use serde::Serialize;
use sha2::{Digest, Sha256};
use std::path::{Path, PathBuf};
use std::time::Duration;
/// A value an input may take, and the sentence Kai reads to tell it from the others.
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct Cand {
pub value: String,
pub label: String,
/// Other spellings a request may use for it (a file's name without its directory, a commit's short hash).
pub aliases: Vec<String>,
}
impl Cand {
pub fn new(value: &str, label: &str) -> Cand {
Cand {
value: value.to_string(),
label: label.to_string(),
aliases: Vec::new(),
}
}
}
pub struct State {
/// The directory the request is made in.
pub root: PathBuf,
pub request: String,
}
const SKIP_DIRS: &[&str] = &[
".git",
"node_modules",
"target",
"dist",
"__pycache__",
".venv",
".pytest_cache",
];
impl State {
pub fn new(root: &Path, request: &str) -> State {
State {
root: root.to_path_buf(),
request: request.to_string(),
}
}
pub fn git(&self, args: &[&str]) -> Result<String, String> {
git(&self.root, args)
}
pub fn is_repo(&self) -> bool {
self.git(&["rev-parse", "--git-dir"]).is_ok()
}
pub fn head(&self) -> Option<String> {
self.git(&["rev-parse", "HEAD"]).ok()
}
pub fn branch(&self) -> Option<String> {
self.git(&["symbolic-ref", "--short", "-q", "HEAD"])
.ok()
.filter(|b| !b.is_empty())
}
/// The commit and the uncommitted work, as one string: it changes when either does.
pub fn revision(&self) -> String {
let Some(head) = self.head() else {
return format!("tree:{}", &self.tree_digest()[..12]);
};
let dirty = self.dirty();
if dirty.is_empty() {
return format!("git:{}", &head[..12.min(head.len())]);
}
let mut h = Sha256::new();
for path in &dirty {
h.update(path.as_bytes());
h.update(std::fs::read(self.root.join(path)).unwrap_or_default());
}
format!(
"git:{}+{}",
&head[..12.min(head.len())],
&hex(&h.finalize())[..8]
)
}
fn tree_digest(&self) -> String {
let mut h = Sha256::new();
for path in self.files() {
h.update(path.as_bytes());
h.update(std::fs::read(self.root.join(&path)).unwrap_or_default());
}
hex(&h.finalize())
}
/// Modified, staged and untracked paths (not ignored), relative to the root.
pub fn dirty(&self) -> Vec<String> {
let Ok(out) = run(
&self.root,
&[
"git",
"status",
"--porcelain=v1",
"-z",
"--untracked-files=all",
".",
],
Duration::from_secs(30),
) else {
return Vec::new();
};
if !out.ok() {
return Vec::new();
}
let mut paths = Vec::new();
let mut parts = out.stdout.split('\0').filter(|p| p.len() > 3);
while let Some(entry) = parts.next() {
let (code, path) = entry.split_at(3);
if code.starts_with('R') {
parts.next();
}
paths.push(path.to_string());
}
paths.sort();
paths
}
/// Tracked files and untracked files that are not ignored, relative to the root.
pub fn files(&self) -> Vec<String> {
let listed = run(
&self.root,
&["git", "ls-files", "-z", "--cached", "--others", "--exclude-standard"],
Duration::from_secs(30),
)
.ok()
.filter(Out::ok);
if let Some(out) = listed {
let mut v: Vec<String> = out
.stdout
.split('\0')
.filter(|p| !p.is_empty())
.map(str::to_string)
.collect();
v.retain(|p| self.root.join(p).is_file());
v.sort();
return v;
}
let mut v = Vec::new();
walk(&self.root, &self.root, &mut v);
v.sort();
v
}
pub fn branches(&self) -> Vec<Cand> {
let Ok(out) = self.git(&[
"for-each-ref",
"--format=%(refname:short)\t%(objectname:short)\t%(contents:subject)",
"refs/heads",
]) else {
return Vec::new();
};
out.lines()
.filter_map(|l| {
let mut f = l.splitn(3, '\t');
let (name, sha, subject) = (f.next()?, f.next()?, f.next().unwrap_or(""));
Some(Cand::new(
name,
&format!("branch {name}, at {sha}: {subject}"),
))
})
.collect()
}
pub fn remotes(&self) -> Vec<Cand> {
let Ok(out) = self.git(&["remote"]) else {
return Vec::new();
};
out.lines()
.filter(|l| !l.is_empty())
.map(|r| Cand::new(r, &format!("remote {r}")))
.collect()
}
/// The last `n` commits on HEAD, newest first.
pub fn commits(&self, n: usize) -> Vec<Cand> {
let limit = format!("-{n}");
let Ok(out) = self.git(&["log", &limit, "--format=%H\t%h\t%s"]) else {
return Vec::new();
};
out.lines()
.filter_map(|l| {
let mut f = l.splitn(3, '\t');
let (full, short, subject) = (f.next()?, f.next()?, f.next().unwrap_or(""));
let mut c = Cand::new(full, &format!("{short} {subject}"));
c.aliases.push(short.to_string());
Some(c)
})
.collect()
}
/// Files with a name ending in one of `exts`.
pub fn with_ext(&self, exts: &[&str]) -> Vec<String> {
self.files()
.into_iter()
.filter(|f| exts.iter().any(|e| f.ends_with(e)))
.collect()
}
/// Test suites the repository declares: a runner and the directory it runs.
pub fn suites(&self) -> Vec<Cand> {
crate::catalog::suite::detect(self)
}
}
fn walk(root: &Path, dir: &Path, out: &mut Vec<String>) {
let Ok(read) = std::fs::read_dir(dir) else {
return;
};
for entry in read.flatten() {
let path = entry.path();
let name = entry.file_name().to_string_lossy().into_owned();
if path.is_dir() {
if !SKIP_DIRS.contains(&name.as_str()) {
walk(root, &path, out);
}
} else if let Ok(rel) = path.strip_prefix(root) {
out.push(rel.to_string_lossy().into_owned());
}
}
}
pub fn hex(bytes: &[u8]) -> String {
bytes.iter().map(|b| format!("{b:02x}")).collect()
}
/// SHA-256 of `bytes`, lowercase hex.
pub fn sha256(bytes: &[u8]) -> String {
hex(&Sha256::digest(bytes))
}
+332
View File
@@ -0,0 +1,332 @@
use super::*;
use crate::ask::Script;
use crate::run::{Grant, Refuse};
use std::path::Path;
use std::process::Command;
fn sh(dir: &Path, args: &[&str]) {
let ok = Command::new(args[0])
.args(&args[1..])
.current_dir(dir)
.output()
.map(|o| o.status.success())
.unwrap_or(false);
assert!(ok, "{args:?} failed in {}", dir.display());
}
fn write(dir: &Path, name: &str, body: &str) {
let p = dir.join(name);
if let Some(parent) = p.parent() {
std::fs::create_dir_all(parent).unwrap_or_default();
}
std::fs::write(p, body).unwrap_or_default();
}
/// A repository with a bare remote beside it: `root/ws` and `root/remote.git`.
fn repo(root: &Path) -> std::path::PathBuf {
let ws = root.join("ws");
std::fs::create_dir_all(&ws).unwrap_or_default();
sh(root, &["git", "init", "-q", "--bare", "remote.git"]);
sh(&ws, &["git", "init", "-q", "-b", "main"]);
sh(&ws, &["git", "config", "user.email", "a@b"]);
sh(&ws, &["git", "config", "user.name", "a"]);
write(
&ws,
"src/lib.py",
"def parse_header(x):\n return x\n\ndef other():\n return parse_header(1)\n",
);
write(
&ws,
"README.md",
"# Title\n\n## Notes\n\n- one\n\n## Other\n\ntext\n",
);
sh(&ws, &["git", "add", "-A"]);
sh(&ws, &["git", "commit", "-q", "-m", "first"]);
sh(&ws, &["git", "remote", "add", "origin", "../remote.git"]);
sh(&ws, &["git", "push", "-q", "origin", "main"]);
ws
}
fn engine<'a>(
cache: Option<&'a Store>,
chooser: Option<&'a dyn Chooser>,
approver: &'a dyn Approver,
) -> Engine<'a> {
Engine {
chooser,
cache,
approver,
cfg: Config {
require_proof: false,
..Config::default()
},
prove: false,
}
}
#[test]
fn a_unique_request_runs_without_asking_kai() {
let dir = tempfile::tempdir().unwrap_or_else(|e| panic!("{e}"));
let ws = repo(dir.path());
let e = engine(None, None, &Grant);
let r = perform(
&e,
&ws,
"Find all uses of `parse_header` and write the path:line list to answer.txt",
);
assert_eq!(r.outcome, Outcome::Done, "{r:?}");
assert_eq!(r.kai_calls, 0);
assert_eq!(r.records[0].source, crate::ledger::Origin::Rule);
let got = std::fs::read_to_string(ws.join("answer.txt")).unwrap_or_default();
assert_eq!(
got.lines().collect::<Vec<_>>(),
vec!["src/lib.py:1", "src/lib.py:5"]
);
}
#[test]
fn publish_commits_only_the_named_paths_and_needs_approval() {
let dir = tempfile::tempdir().unwrap_or_else(|e| panic!("{e}"));
let ws = repo(dir.path());
write(&ws, "a.txt", "a\n");
write(&ws, "b.txt", "b\n");
let ask = "Commit `a.txt` with the message \"Add a\" and push it to origin";
let denied = perform(&engine(None, None, &Refuse), &ws, ask);
assert!(matches!(denied.outcome, Outcome::Declined(_)), "{denied:?}");
assert!(denied.records[0].authorization.required && !denied.records[0].authorization.granted);
let r = perform(&engine(None, None, &Grant), &ws, ask);
assert_eq!(r.outcome, Outcome::Done, "{r:?}");
assert!(r.records[0].authorization.required && r.records[0].authorization.granted);
let remote = Command::new("git")
.args([
"--git-dir",
"../remote.git",
"log",
"-1",
"--format=%s",
"main",
])
.current_dir(&ws)
.output()
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
.unwrap_or_default();
assert_eq!(remote, "Add a");
let status = Command::new("git")
.args(["status", "--porcelain"])
.current_dir(&ws)
.output()
.map(|o| String::from_utf8_lossy(&o.stdout).to_string())
.unwrap_or_default();
assert_eq!(status.trim(), "?? b.txt");
}
#[test]
fn a_request_with_a_part_nothing_serves_is_declined_not_trimmed() {
let dir = tempfile::tempdir().unwrap_or_else(|e| panic!("{e}"));
let ws = repo(dir.path());
write(&ws, "a.txt", "a\n");
// The tests come first, but this repository has none: commit and push alone would drop the condition.
let ask = "Run the tests, and if they pass commit `a.txt` with the message \"Add a\" and push";
let r = perform(&engine(None, None, &Grant), &ws, ask);
assert!(matches!(r.outcome, Outcome::Declined(_)), "{r:?}");
assert!(r.records.is_empty());
}
#[test]
fn an_ambiguous_request_asks_kai_once_and_abstaining_declines() {
let dir = tempfile::tempdir().unwrap_or_else(|e| panic!("{e}"));
let ws = repo(dir.path());
let ask =
"Show lines 1 to 2 of `src/lib.py` and find all uses of `parse_header`, writing to out.txt";
let none = Script(vec![]);
let r = perform(&engine(None, Some(&none), &Grant), &ws, ask);
assert!(matches!(r.outcome, Outcome::Declined(_)), "{r:?}");
assert_eq!(r.kai_calls, 1);
assert!(r.asks.iter().any(|a| a.question == "action" && !a.accepted));
let picks = Script(vec![("action".into(), "slice".into())]);
let r = perform(&engine(None, Some(&picks), &Grant), &ws, ask);
assert_eq!(r.outcome, Outcome::Done, "{r:?}");
assert_eq!(r.target.as_deref(), Some("slice"));
assert_eq!(r.records[0].source, crate::ledger::Origin::Kai);
}
#[test]
fn the_cache_reuses_a_pure_result_until_a_read_file_changes() {
let dir = tempfile::tempdir().unwrap_or_else(|e| panic!("{e}"));
let ws = repo(dir.path());
let store = Store::open(&dir.path().join("cache")).unwrap_or_else(|e| panic!("{e}"));
let e = engine(Some(&store), None, &Grant);
let ask = "Show lines 1 to 2 of `src/lib.py` in out.txt";
let first = perform(&e, &ws, ask);
assert_eq!(first.outcome, Outcome::Done, "{first:?}");
assert!(!first.records.is_empty(), "{first:?}");
assert_eq!(first.records[0].cache.memo, "miss");
write(&ws, "unrelated.txt", "x\n");
let second = perform(&e, &ws, ask);
assert!(!second.records.is_empty(), "{:?}", second.outcome);
assert_eq!(second.records[0].cache.memo, "hit", "{second:?}");
write(&ws, "src/lib.py", "def parse_header(x):\n return 2\n");
let third = perform(&e, &ws, ask);
assert_eq!(third.records[0].cache.memo, "miss");
}
#[test]
fn an_unproven_action_does_not_run() {
let dir = tempfile::tempdir().unwrap_or_else(|e| panic!("{e}"));
let ws = repo(dir.path());
let store = Store::open(&dir.path().join("cache")).unwrap_or_else(|e| panic!("{e}"));
let strict = Engine {
chooser: None,
cache: Some(&store),
approver: &Grant,
cfg: Config::default(),
prove: false,
};
let ask = "Find all uses of `parse_header`";
assert!(
matches!(perform(&strict, &ws, ask).outcome, Outcome::Declined(ref w) if w.starts_with("unproven"))
);
let proving = Engine {
prove: true,
..strict
};
assert_eq!(perform(&proving, &ws, ask).outcome, Outcome::Done);
let again = Engine {
chooser: None,
cache: Some(&store),
approver: &Grant,
cfg: Config::default(),
prove: false,
};
assert_eq!(perform(&again, &ws, ask).outcome, Outcome::Done);
}
#[test]
fn a_note_goes_under_its_heading_and_nothing_else_moves() {
let dir = tempfile::tempdir().unwrap_or_else(|e| panic!("{e}"));
let ws = repo(dir.path());
let e = engine(None, None, &Grant);
let r = perform(
&e,
&ws,
"Add the line \"- two\" at the end of the Notes section of `README.md`",
);
assert_eq!(r.outcome, Outcome::Done, "{r:?}");
let body = std::fs::read_to_string(ws.join("README.md")).unwrap_or_default();
assert_eq!(
body,
"# Title\n\n## Notes\n\n- one\n- two\n\n## Other\n\ntext\n"
);
}
#[test]
fn credentials_never_reach_a_record() {
let dir = tempfile::tempdir().unwrap_or_else(|e| panic!("{e}"));
let ws = repo(dir.path());
write(&ws, "env.txt", "token=abcdef0123456789 ok\n");
let e = engine(None, None, &Grant);
let r = perform(&e, &ws, "Show lines 1 to 1 of `env.txt` in out.txt");
let json = serde_json::to_string(&r).unwrap_or_default();
assert!(!json.contains("abcdef0123456789"), "{json}");
}
#[test]
fn a_commit_the_request_describes_is_bound_without_asking_kai() {
let dir = tempfile::tempdir().unwrap_or_else(|e| panic!("{e}"));
let ws = repo(dir.path());
write(&ws, "src/fetch.py", "retry = 3\n");
sh(&ws, &["git", "add", "-A"]);
sh(
&ws,
&["git", "commit", "-q", "-m", "add retry logic to fetch"],
);
write(&ws, "docs/guide.md", "fixed\n");
sh(&ws, &["git", "add", "-A"]);
sh(&ws, &["git", "commit", "-q", "-m", "fix typo in the docs"]);
let e = engine(None, None, &Grant);
let r = perform(
&e,
&ws,
"List the files changed by the commit that added the retry logic, one path per line, in files.txt",
);
assert_eq!(r.outcome, Outcome::Done, "{r:?}");
assert_eq!(r.kai_calls, 0);
assert_eq!(
std::fs::read_to_string(ws.join("files.txt"))
.unwrap_or_default()
.trim(),
"src/fetch.py"
);
}
#[test]
fn a_results_file_must_hold_the_arms_the_request_names() {
let dir = tempfile::tempdir().unwrap_or_else(|e| panic!("{e}"));
let ws = repo(dir.path());
let row = |t: &str, a: &str, s: f64| {
format!("{{\"task\":\"{t}\",\"arm\":\"{a}\",\"seconds\":{s},\"solve\":true}}\n")
};
write(
&ws,
"results/a.jsonl",
&(row("t1", "kai", 1.0)
+ &row("t1", "native", 4.0)
+ &row("t2", "kai", 2.0)
+ &row("t2", "native", 6.0)),
);
write(
&ws,
"results/b.jsonl",
&(row("t1", "lite", 1.0) + &row("t1", "native", 4.0)),
);
let e = engine(None, None, &Grant);
let r = perform(
&e,
&ws,
"Compare arm `kai` over arm `native` in the results file that has both arms, writing to out.txt",
);
assert_eq!(r.outcome, Outcome::Done, "{r:?}");
assert_eq!(r.kai_calls, 0);
let got = std::fs::read_to_string(ws.join("out.txt")).unwrap_or_default();
assert!(
got.contains("n=2 faster=2") && got.contains("median_speedup=3.500"),
"{got}"
);
}
#[test]
fn a_value_that_looks_like_an_option_is_declined() {
let dir = tempfile::tempdir().unwrap_or_else(|e| panic!("{e}"));
let ws = repo(dir.path());
let e = engine(None, None, &Grant);
let r = perform(
&e,
&ws,
"Create a git worktree for the new branch `--force` off `main` at `../wt`.",
);
assert!(matches!(r.outcome, Outcome::Declined(_)), "{r:?}");
assert!(!dir.path().join("wt").exists());
}
#[test]
fn staging_neither_follows_a_link_out_nor_copies_from_outside_unasked() {
let dir = tempfile::tempdir().unwrap_or_else(|e| panic!("{e}"));
let ws = repo(dir.path());
write(&ws, "fixtures/a.txt", "a\n");
write(dir.path(), "secret.txt", "s\n");
std::os::unix::fs::symlink(dir.path().join("secret.txt"), ws.join("fixtures/link.txt"))
.unwrap_or_default();
let ask = "Copy the contents of `fixtures/` into `staging/`";
let r = perform(&engine(None, None, &Grant), &ws, ask);
assert_eq!(r.outcome, Outcome::Done, "{r:?}");
assert!(ws.join("staging/a.txt").exists() && !ws.join("staging/link.txt").exists());
let outside = perform(
&engine(None, None, &Refuse),
&ws,
"Copy the contents of `../` into `staging2/`",
);
assert!(
matches!(outside.outcome, Outcome::Declined(_)),
"{outside:?}"
);
}
+139
View File
@@ -0,0 +1,139 @@
//! Reading a request against values: where a value is mentioned, and whether a description of it
//! is grounded in the request.
const STOP: &[&str] = &[
"the", "and", "for", "that", "this", "with", "from", "into", "onto", "then", "than", "them",
"its", "are", "was", "were", "has", "have", "had", "not", "but", "all", "any", "one", "two",
"out", "off", "per", "via", "use", "used", "using", "make", "made", "get", "got", "new", "old",
"add", "run", "file", "files", "line", "lines", "when", "what", "which", "who", "how", "where",
"whose", "also", "only", "just", "each", "every", "some", "more", "most", "less", "into",
"over", "under", "about", "after", "before", "should", "would", "could", "please", "show",
"tell", "give",
];
/// Lowercase words of at least three letters or digits, without the filler words, each once.
pub fn words(text: &str) -> Vec<String> {
let mut out = Vec::new();
let mut word = String::new();
for c in text.chars().chain(std::iter::once(' ')) {
if c.is_alphanumeric() {
word.extend(c.to_lowercase());
} else {
if word.chars().count() >= 3 && !STOP.contains(&word.as_str()) && !out.contains(&word) {
out.push(std::mem::take(&mut word));
}
word.clear();
}
}
out
}
fn boundary(c: Option<char>) -> bool {
c.is_none_or(|c| !(c.is_alphanumeric() || matches!(c, '_' | '.' | '/' | '-')))
}
/// Byte offset of the first place `needle` stands alone in `text` (not inside a longer name).
pub fn mention(text: &str, needle: &str) -> Option<usize> {
if needle.is_empty() {
return None;
}
let mut from = 0;
while let Some(at) = text[from..].find(needle) {
let start = from + at;
let end = start + needle.len();
let before = text[..start].chars().next_back();
let after = text[end..].chars().next();
// A sentence's full stop after the name is not part of it.
let after_ok =
boundary(after) || (after == Some('.') && boundary(text[end + 1..].chars().next()));
if boundary(before) && after_ok {
return Some(start);
}
from = start + needle.len().max(1);
}
None
}
/// Whether the words of `label` that the request also uses are enough to say the request means it.
pub fn grounded(request: &str, label: &str) -> bool {
let want = words(label);
if want.is_empty() {
return false;
}
let have = words(request);
let shared = want.iter().filter(|w| have.contains(w)).count();
shared >= 2.min(want.len())
}
/// How many of `label`'s words the request uses.
pub fn overlap(request: &str, label: &str) -> usize {
let have = words(request);
words(label).iter().filter(|w| have.contains(w)).count()
}
/// The first quoted or backticked spans of `text`, in order.
pub fn quoted(text: &str) -> Vec<String> {
let mut out = Vec::new();
let chars: Vec<char> = text.chars().collect();
let mut i = 0;
while i < chars.len() {
let open = chars[i];
if matches!(open, '`' | '"' | '\u{201c}') {
let close = if open == '\u{201c}' { '\u{201d}' } else { open };
if let Some(j) = chars[i + 1..].iter().position(|c| *c == close) {
out.push(chars[i + 1..i + 1 + j].iter().collect());
i += j + 2;
continue;
}
}
i += 1;
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_mention_stands_alone() {
assert_eq!(mention("push feat-x to origin", "feat-x"), Some(5));
assert_eq!(mention("push feat-x.", "feat-x"), Some(5));
assert_eq!(mention("push feat-x2 now", "feat-x"), None);
assert_eq!(mention("see src/a.py today", "src/a.py"), Some(4));
assert_eq!(mention("no main branch", "main"), Some(3));
assert_eq!(mention("domain", "main"), None);
}
#[test]
fn a_repeated_word_counts_once() {
assert!(!grounded(
"run the unit tests",
"tests/integration (python tests: test_integ_core.py)"
));
assert!(grounded(
"run the unit tests",
"tests/unit (python tests: test_unit_core.py)"
));
}
#[test]
fn grounding_needs_two_shared_words() {
assert!(grounded(
"rebuild from the commit that fixed cache eviction",
"fix cache eviction on shutdown"
));
assert!(!grounded(
"rebuild from the commit that fixed cache eviction",
"bump version to 2"
));
}
#[test]
fn quotes_come_in_order() {
assert_eq!(
quoted("add `a.txt` and \"hi there\" now"),
vec!["a.txt", "hi there"]
);
}
}
+88
View File
@@ -0,0 +1,88 @@
//! Workflows: a plan of several actions, each step an action with its own inputs, a later step
//! able to take a value from an earlier one and to run only if an earlier result allows it.
/// A step runs only when a field of an earlier step's output equals a number.
#[derive(Debug, Clone, Copy)]
pub struct Guard {
/// Zero-based index of the earlier step.
pub step: usize,
pub field: &'static str,
pub equals: i64,
}
#[derive(Debug)]
pub struct Part {
pub action: &'static str,
/// `(input, "$n.field")`: the input takes `field` of step n's output (n counts from 1).
pub inherit: &'static [(&'static str, &'static str)],
pub when: Option<Guard>,
}
#[derive(Debug)]
pub struct Workflow {
pub id: &'static str,
pub version: u32,
pub summary: &'static str,
pub triggers: &'static [&'static str],
pub parts: &'static [Part],
}
/// Create a worktree, then run the suite inside it.
pub static ISOLATE: Workflow = Workflow {
id: "isolate",
version: 1,
summary: "Create a git worktree on a new branch, then run the test suite inside that worktree and report the counts.",
triggers: &[
r"worktree.*\b(tests?|suite)\b",
r"\b(tests?|suite)\b.*worktree",
],
parts: &[
Part {
action: "worktree",
inherit: &[],
when: None,
},
Part {
action: "suite",
inherit: &[("root", "$1.path")],
when: None,
},
],
};
/// Run the suite; if nothing failed, commit the named files and push.
pub static SHIP: Workflow = Workflow {
id: "ship",
version: 1,
summary: "Run the test suite and, only if every test passes, commit the named files with a message and push the branch.",
triggers: &[
r"\b(tests?|suite)\b.*\b(commit|push)\b",
r"\b(commit|push)\b.*\b(tests?|suite)\b",
],
parts: &[
Part {
action: "suite",
inherit: &[],
when: None,
},
Part {
action: "publish",
inherit: &[],
when: Some(Guard {
step: 0,
field: "failed",
equals: 0,
}),
},
],
};
static ALL: [&Workflow; 2] = [&ISOLATE, &SHIP];
pub fn all() -> &'static [&'static Workflow] {
&ALL
}
pub fn get(id: &str) -> Option<&'static Workflow> {
all().iter().copied().find(|w| w.id == id)
}
+3
View File
@@ -28,6 +28,9 @@ pub mod zap;
/// links directly: a call, not an MCP hop. It is its own light crate so a caller need not
/// take this crate's browser, desktop and parser dependencies to search.
pub use hanzo_trigram as trigram;
/// Kai Actions: typed, verified operations run without a model call (the `kai` tool's executor).
/// Its own light crate, for the same reason as `trigram`.
pub use hanzo_actions as actions;
pub use config::Config;
pub use server::MCPServer;
pub use tools::{