@runbooks/supervise 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +47 -0
  3. package/dist/approval.d.ts +132 -0
  4. package/dist/approval.js +148 -0
  5. package/dist/approval.test.d.ts +1 -0
  6. package/dist/approval.test.js +190 -0
  7. package/dist/budgets.test.d.ts +1 -0
  8. package/dist/budgets.test.js +148 -0
  9. package/dist/contract.d.ts +38 -0
  10. package/dist/contract.js +77 -0
  11. package/dist/contract.test.d.ts +1 -0
  12. package/dist/contract.test.js +131 -0
  13. package/dist/deviations.d.ts +77 -0
  14. package/dist/deviations.js +81 -0
  15. package/dist/deviations.test.d.ts +1 -0
  16. package/dist/deviations.test.js +241 -0
  17. package/dist/emit.d.ts +97 -0
  18. package/dist/emit.js +184 -0
  19. package/dist/emit.test.d.ts +1 -0
  20. package/dist/emit.test.js +187 -0
  21. package/dist/enforce.d.ts +29 -0
  22. package/dist/enforce.js +100 -0
  23. package/dist/enforce.test.d.ts +1 -0
  24. package/dist/enforce.test.js +112 -0
  25. package/dist/expect.d.ts +67 -0
  26. package/dist/expect.js +187 -0
  27. package/dist/expect.test.d.ts +1 -0
  28. package/dist/expect.test.js +120 -0
  29. package/dist/index.d.ts +33 -0
  30. package/dist/index.js +34 -0
  31. package/dist/index.test.d.ts +1 -0
  32. package/dist/index.test.js +30 -0
  33. package/dist/observe.test.d.ts +1 -0
  34. package/dist/observe.test.js +246 -0
  35. package/dist/policy.d.ts +122 -0
  36. package/dist/policy.js +147 -0
  37. package/dist/policy.test.d.ts +1 -0
  38. package/dist/policy.test.js +124 -0
  39. package/dist/purity.test.d.ts +1 -0
  40. package/dist/purity.test.js +191 -0
  41. package/dist/report.d.ts +72 -0
  42. package/dist/report.js +67 -0
  43. package/dist/run.d.ts +216 -0
  44. package/dist/run.js +445 -0
  45. package/dist/run.test.d.ts +1 -0
  46. package/dist/run.test.js +198 -0
  47. package/package.json +42 -0
@@ -0,0 +1,187 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { readFileSync, readdirSync } from "node:fs";
3
+ import { dirname, join } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { loadCorpus } from "@runbooks/fixtures";
6
+ import { derivePolicy } from "./policy.js";
7
+ import { startRun, propose, advance, observe } from "./run.js";
8
+ import { buildRunReport, durationBucket, failureClassFor, FAILURE_CLASS_BY_CODE, UnmappedReasonError, UnclassifiableFailureError, } from "./emit.js";
9
+ const context = {
10
+ runbook: "std/k8s-node-not-ready-drain",
11
+ version: "1.0.0",
12
+ contentHash: `sha256:${"a".repeat(64)}`,
13
+ profile: "P1",
14
+ runtimeProfile: "R2",
15
+ executorKind: "autonomous",
16
+ envClass: "staging",
17
+ reportedAt: "2026-09-03",
18
+ };
19
+ function envelopeFor(name) {
20
+ return derivePolicy(loadCorpus().find((r) => r.name.startsWith(name)).doc);
21
+ }
22
+ function begin(name, inputs, options = {}) {
23
+ const started = startRun(envelopeFor(name), { agentIdentity: "agent-7", inputs, ...options });
24
+ if (!started.ok)
25
+ throw new Error(JSON.stringify(started.refusals));
26
+ return started.state;
27
+ }
28
+ describe("the report says what the run recorded", () => {
29
+ it("reports a run still going as partial rather than as anything decided", () => {
30
+ const state = begin("k8s-node", { node: "node-1" });
31
+ expect(buildRunReport(state, [], context).outcome).toBe("partial");
32
+ });
33
+ it("carries the deviation count, which is the field no other surface provides", () => {
34
+ let state = begin("disk-pressure", { mount: "/tmp" }, {
35
+ escalation: {
36
+ responses: Object.fromEntries(Object.keys(FAILURE_CLASS_BY_CODE).length > 0
37
+ ? [["undeclared-tool", "block-and-continue"], ["out-of-scope-tool", "block-and-continue"],
38
+ ["out-of-order-step", "block-and-continue"], ["unchecked-advance", "block-and-continue"],
39
+ ["budget-exhausted", "block-and-continue"], ["unrouted-failure", "block-and-continue"],
40
+ ["terminal-not-reached", "block-and-continue"]]
41
+ : []),
42
+ maxTotal: 99,
43
+ },
44
+ });
45
+ state = propose(envelopeFor("disk-pressure"), state, { tool: "cli:nope" }).state;
46
+ expect(buildRunReport(state, [], context).deviation_count).toBe(1);
47
+ });
48
+ it("names the step a failure happened at", () => {
49
+ const envelope = envelopeFor("k8s-node");
50
+ const state = begin("k8s-node", { node: "node-1" }, { evaluatePostconditions: true });
51
+ const observed = observe(envelope, state, { exitCode: 0, stdout: "True" });
52
+ const report = buildRunReport(observed.state, observed.events, context);
53
+ expect(report.failed_step_id).toBe("s1");
54
+ expect(report.failure_class).toBe("expectation_mismatch");
55
+ });
56
+ /** A run that recovered and then failed elsewhere failed at the second place. */
57
+ it("takes the last cause, not the first", () => {
58
+ const events = [
59
+ { kind: "deviation", code: "run/expectation-failed", message: "", at: "s1" },
60
+ { kind: "deviation", code: "run/wall-clock-exhausted", message: "", at: "s4" },
61
+ ];
62
+ const state = { ...begin("k8s-node", { node: "node-1" }), status: "ended", outcome: "timed-out" };
63
+ const report = buildRunReport(state, events, context);
64
+ expect(report.failed_step_id).toBe("s4");
65
+ expect(report.failure_class).toBe("timeout");
66
+ });
67
+ });
68
+ /**
69
+ * The rule R-07 states outright: map onto the closed vocabulary, and fail loudly where
70
+ * there is no mapping rather than inventing a bucket.
71
+ */
72
+ describe("every internal reason maps, or the emission stops", () => {
73
+ /**
74
+ * Against the source rather than against a list someone maintains by hand: a code
75
+ * added next month is one this test fails on until somebody decides what it means.
76
+ */
77
+ const codes = (() => {
78
+ const dir = dirname(fileURLToPath(import.meta.url));
79
+ const found = new Set();
80
+ for (const file of readdirSync(dir).filter((f) => f.endsWith(".ts") && !f.endsWith(".test.ts"))) {
81
+ const source = readFileSync(join(dir, file), "utf8");
82
+ for (const [, code] of source.matchAll(/code: "((?:run|runtime)\/[a-z-]+)"/g))
83
+ found.add(code);
84
+ }
85
+ return [...found].sort();
86
+ })();
87
+ it("finds the codes the supervisor actually emits", () => {
88
+ expect(codes.length).toBeGreaterThan(15);
89
+ });
90
+ it.each(codes)("%s is classified", (code) => {
91
+ expect(() => failureClassFor(code)).not.toThrow();
92
+ });
93
+ it("throws on a reason nobody has classified", () => {
94
+ expect(() => failureClassFor("run/something-new")).toThrow(UnmappedReasonError);
95
+ });
96
+ it("says why it would rather stop than guess", () => {
97
+ expect(() => failureClassFor("run/something-new")).toThrow(/reads as evidence/);
98
+ });
99
+ it("refuses to build a failed report it cannot classify", () => {
100
+ const state = {
101
+ ...begin("k8s-node", { node: "node-1" }),
102
+ status: "ended",
103
+ outcome: "failed",
104
+ };
105
+ expect(() => buildRunReport(state, [], context)).toThrow(UnclassifiableFailureError);
106
+ });
107
+ it("takes the operator's classification when the run did not explain itself", () => {
108
+ const state = {
109
+ ...begin("k8s-node", { node: "node-1" }),
110
+ status: "ended",
111
+ outcome: "failed",
112
+ };
113
+ const report = buildRunReport(state, [], { ...context, failureClass: "human_abort" });
114
+ expect(report.failure_class).toBe("human_abort");
115
+ });
116
+ });
117
+ /** Second-level precision plus submission time plus a rare runbook identifies a company. */
118
+ describe("the duration is bucketed and never exact", () => {
119
+ it.each([
120
+ [0, "<1m"], [59, "<1m"], [60, "1-5m"], [299, "1-5m"],
121
+ [300, "5-30m"], [1799, "5-30m"], [1800, ">30m"], [86_400, ">30m"],
122
+ ])("%is is %s", (seconds, bucket) => {
123
+ expect(durationBucket(seconds)).toBe(bucket);
124
+ });
125
+ it("does not treat an unknown duration as a short one", () => {
126
+ expect(durationBucket(undefined)).toBe(">30m");
127
+ expect(durationBucket(Number.NaN)).toBe(">30m");
128
+ });
129
+ it("puts no precise duration in the report by any name", () => {
130
+ const state = { ...begin("k8s-node", { node: "node-1" }, { now: 100 }) };
131
+ const report = buildRunReport(state, [], { ...context, now: 100 + 137 });
132
+ expect(JSON.stringify(report)).not.toContain("137");
133
+ expect(report.duration_bucket).toBe("1-5m");
134
+ });
135
+ });
136
+ /**
137
+ * The property that matters most. Runs whose commands, tools and errors are
138
+ * secret-shaped, and a report that contains none of it — because there is nowhere in the
139
+ * format for it to go.
140
+ */
141
+ describe("no free text reaches the report by any path", () => {
142
+ const secrets = [
143
+ "hunter2", "sk-live-4242424242", "eyJhbGciOiJIUzI1NiJ9", "wJalrXUtnFEMI",
144
+ "db-3.prod.internal", "postgres://user:pass@host/db",
145
+ ];
146
+ const permissive = {
147
+ responses: {
148
+ "undeclared-tool": "block-and-continue", "out-of-scope-tool": "block-and-continue",
149
+ "out-of-order-step": "block-and-continue", "unchecked-advance": "block-and-continue",
150
+ "budget-exhausted": "block-and-continue", "unrouted-failure": "block-and-continue",
151
+ "terminal-not-reached": "block-and-continue",
152
+ },
153
+ maxTotal: 999,
154
+ };
155
+ it.each(secrets)("keeps %s out of a report from a run that saw it", (secret) => {
156
+ const envelope = envelopeFor("disk-pressure");
157
+ let state = begin("disk-pressure", { mount: "/tmp" }, { escalation: permissive });
158
+ const events = [];
159
+ for (const attempt of [`cli:${secret}`, `mcp:${secret}`]) {
160
+ const decided = propose(envelope, state, { tool: attempt });
161
+ state = decided.state;
162
+ events.push(...decided.events);
163
+ }
164
+ // The events themselves carry the reason, which is local. The report must not.
165
+ expect(JSON.stringify(events)).toContain(secret);
166
+ const report = buildRunReport(state, events, context);
167
+ expect(JSON.stringify(report)).not.toContain(secret);
168
+ });
169
+ it("keeps a secret-shaped step title out, since only the id is reported", () => {
170
+ const envelope = envelopeFor("k8s-node");
171
+ const state = begin("k8s-node", { node: "node-1" }, { evaluatePostconditions: true });
172
+ const observed = observe(envelope, state, { exitCode: 0, stdout: "password=hunter2" });
173
+ const report = buildRunReport(observed.state, observed.events, context);
174
+ expect(JSON.stringify(report)).not.toContain("hunter2");
175
+ });
176
+ it("emits only the fields §14 declares, whatever the run did", () => {
177
+ const envelope = envelopeFor("disk-pressure");
178
+ let state = begin("disk-pressure", { mount: "/tmp" }, { escalation: permissive });
179
+ state = advance(envelope, state, "s2").state;
180
+ const report = buildRunReport(state, [], context);
181
+ expect(Object.keys(report).sort()).toEqual([
182
+ "content_hash", "deviation_count", "duration_bucket", "env_class", "executor_kind",
183
+ "outcome", "profile", "report_schema", "reported_at", "runbook", "runtime_profile",
184
+ "schema_version", "version",
185
+ ]);
186
+ });
187
+ });
@@ -0,0 +1,29 @@
1
+ import type { PolicyEnvelope } from "./policy.js";
2
+ import type { Decided, RunState } from "./run.js";
3
+ export interface StartCheck {
4
+ readonly ok: boolean;
5
+ readonly missing: readonly string[];
6
+ readonly message?: string;
7
+ }
8
+ /**
9
+ * Whether the client can satisfy the whole allowlist before the run begins.
10
+ *
11
+ * Refusing up front rather than failing at the step that needs the missing capability
12
+ * is normative (§13.4): a run that stops halfway has already done part of the work, and
13
+ * part of a procedure is a state nobody designed.
14
+ */
15
+ export declare function canSatisfyAllowlist(envelope: PolicyEnvelope, available: readonly string[]): StartCheck;
16
+ export interface CommandProposal {
17
+ readonly command: string;
18
+ }
19
+ export interface CommandDecision extends Decided {
20
+ /** What the command was found to need. */
21
+ readonly required: readonly string[];
22
+ }
23
+ /**
24
+ * Authorize a command by what it actually invokes, not by what the step claims.
25
+ *
26
+ * A step declaring `cli:kubectl` and running `psql` is the mismatch this exists to
27
+ * catch, and it is the runtime half of AST03/AST04.
28
+ */
29
+ export declare function proposeCommand(envelope: PolicyEnvelope, state: RunState, proposal: CommandProposal): CommandDecision;
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Allowlist and per-step scope enforcement.
3
+ *
4
+ * Where `capabilities[]` stops being documentation and becomes an allowlist. The rule
5
+ * that does the most work is that scope is **per step, not per run**: granting an agent
6
+ * the union of a procedure's capabilities for the whole run is how a diagnostic session
7
+ * ends in a deletion.
8
+ *
9
+ * Capability extraction is imported from `@runbooks/schema`, the same code the linter's
10
+ * consistency gate uses. A second parser here would mean a document that passes T2 fails
11
+ * at run time.
12
+ */
13
+ import { capabilitiesInCommand } from "@runbooks/schema";
14
+ import { recordDeviation } from "./deviations.js";
15
+ import { propose } from "./run.js";
16
+ /**
17
+ * Whether the client can satisfy the whole allowlist before the run begins.
18
+ *
19
+ * Refusing up front rather than failing at the step that needs the missing capability
20
+ * is normative (§13.4): a run that stops halfway has already done part of the work, and
21
+ * part of a procedure is a state nobody designed.
22
+ */
23
+ export function canSatisfyAllowlist(envelope, available) {
24
+ const have = new Set(available);
25
+ const missing = envelope.allowlist.filter((c) => !have.has(c));
26
+ if (missing.length === 0)
27
+ return { ok: true, missing: [] };
28
+ return {
29
+ ok: false,
30
+ missing,
31
+ message: `This runbook requires ${missing.join(", ")}, which this client cannot provide. Connect them, or pick a procedure that does not need them.`,
32
+ };
33
+ }
34
+ /**
35
+ * Authorize a command by what it actually invokes, not by what the step claims.
36
+ *
37
+ * A step declaring `cli:kubectl` and running `psql` is the mismatch this exists to
38
+ * catch, and it is the runtime half of AST03/AST04.
39
+ */
40
+ export function proposeCommand(envelope, state, proposal) {
41
+ const extraction = capabilitiesInCommand(proposal.command);
42
+ if (extraction.unanalyzable) {
43
+ // A gate that treats "I could not tell" as "consistent" is decoration.
44
+ const authorization = {
45
+ verdict: "block",
46
+ reason: extraction.reason ??
47
+ "This command cannot be attributed to a capability, so it cannot be bounded.",
48
+ deviation: "undeclared-tool",
49
+ };
50
+ const events = [
51
+ {
52
+ kind: "blocked",
53
+ code: "run/unanalyzable-command",
54
+ message: authorization.reason,
55
+ ...(state.current ? { at: state.current } : {}),
56
+ },
57
+ ];
58
+ const recorded = recordDeviation(state, { class: "undeclared-tool" });
59
+ return {
60
+ state: recorded.state,
61
+ authorization,
62
+ events: [...events, ...recorded.events],
63
+ required: [],
64
+ };
65
+ }
66
+ if (extraction.capabilities.length === 0) {
67
+ return {
68
+ state,
69
+ authorization: { verdict: "permit", reason: "The command invokes nothing." },
70
+ events: [],
71
+ required: [],
72
+ };
73
+ }
74
+ // Every capability the command needs must pass. The first refusal decides, because a
75
+ // partially authorized command is not a narrower command.
76
+ let current = state;
77
+ const events = [];
78
+ for (const capability of extraction.capabilities) {
79
+ const decided = propose(envelope, current, { tool: capability });
80
+ current = decided.state;
81
+ events.push(...decided.events);
82
+ if (decided.authorization.verdict !== "permit") {
83
+ return {
84
+ state: current,
85
+ authorization: decided.authorization,
86
+ events,
87
+ required: extraction.capabilities,
88
+ };
89
+ }
90
+ }
91
+ return {
92
+ state: current,
93
+ authorization: {
94
+ verdict: "permit",
95
+ reason: `Step ${state.current} declares ${extraction.capabilities.join(", ")}.`,
96
+ },
97
+ events,
98
+ required: extraction.capabilities,
99
+ };
100
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,112 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { readdirSync, readFileSync } from "node:fs";
3
+ import { join, dirname, resolve } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { derivePolicy, scopeFor, dangerousSteps } from "./policy.js";
6
+ import { startRun, propose, applyGrant } from "./run.js";
7
+ import { canSatisfyAllowlist, proposeCommand } from "./enforce.js";
8
+ const CORPUS = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..", "schema", "fixtures", "p1-valid");
9
+ const corpus = readdirSync(CORPUS).map((name) => ({
10
+ name,
11
+ doc: JSON.parse(readFileSync(join(CORPUS, name), "utf8")),
12
+ }));
13
+ const runbook = (r) => ({ runbook: r });
14
+ const twoStep = runbook({
15
+ capabilities: ["cli:kubectl", "mcp:postgres"],
16
+ steps: [
17
+ { id: "s3", kind: "check", title: "Look", risk: "read-only", tool: "cli:kubectl", expect: "ok", on_fail: "s5", next: "s5" },
18
+ { id: "s5", kind: "action", title: "Write", risk: "reversible-write", tool: "mcp:postgres", next: "end:success" },
19
+ ],
20
+ });
21
+ function start(doc, inputs = {}) {
22
+ const env = derivePolicy(doc);
23
+ const r = startRun(env, inputs);
24
+ if (!r.ok)
25
+ throw new Error("expected a start");
26
+ return { env, state: r.state };
27
+ }
28
+ describe("the allowlist is checked before the run begins", () => {
29
+ it("refuses when the client cannot provide a declared capability", () => {
30
+ const { env } = start(twoStep);
31
+ const check = canSatisfyAllowlist(env, ["cli:kubectl"]);
32
+ expect(check.ok).toBe(false);
33
+ expect(check.missing).toEqual(["mcp:postgres"]);
34
+ expect(check.message).toMatch(/Connect them/);
35
+ });
36
+ it("accepts a client that provides everything", () => {
37
+ const { env } = start(twoStep);
38
+ expect(canSatisfyAllowlist(env, ["cli:kubectl", "mcp:postgres", "cli:extra"]).ok).toBe(true);
39
+ });
40
+ });
41
+ describe("a tool permitted at one step is blocked at another", () => {
42
+ it("blocks s5's capability while the run is at s3", () => {
43
+ const { env, state } = start(twoStep);
44
+ const decided = propose(env, state, { tool: "mcp:postgres" });
45
+ expect(decided.authorization.verdict).toBe("block");
46
+ expect(decided.authorization.deviation).toBe("out-of-scope-tool");
47
+ expect(decided.authorization.reason).toMatch(/not the union/);
48
+ });
49
+ });
50
+ describe("commands are authorized by what they invoke", () => {
51
+ it("permits a command matching the step's declared tool", () => {
52
+ const { env, state } = start(twoStep);
53
+ const decided = proposeCommand(env, state, { command: "kubectl get pods" });
54
+ expect(decided.authorization.verdict).toBe("permit");
55
+ expect(decided.required).toEqual(["cli:kubectl"]);
56
+ });
57
+ // A step declaring kubectl and running psql is the runtime half of AST03/AST04.
58
+ it("blocks a command the step did not declare, whatever the step says", () => {
59
+ const { env, state } = start(twoStep);
60
+ const decided = proposeCommand(env, state, { command: "psql -c 'drop table x'" });
61
+ expect(decided.authorization.verdict).toBe("block");
62
+ expect(decided.authorization.deviation).toBe("undeclared-tool");
63
+ });
64
+ it("blocks a pipeline whose second stage is undeclared", () => {
65
+ const { env, state } = start(twoStep);
66
+ expect(proposeCommand(env, state, { command: "kubectl get pods | psql" }).authorization.verdict).toBe("block");
67
+ });
68
+ it("blocks rather than guessing when a command cannot be analysed", () => {
69
+ const { env, state } = start(twoStep);
70
+ const decided = proposeCommand(env, state, {
71
+ command: "kubectl delete pod $(kubectl get pods -o name)",
72
+ });
73
+ expect(decided.authorization.verdict).toBe("block");
74
+ expect(decided.events[0].code).toBe("run/unanalyzable-command");
75
+ });
76
+ });
77
+ /**
78
+ * R-02's property: across every corpus record and every reachable state, a destructive
79
+ * capability is never in scope before its gate has been passed. Checked over the state
80
+ * machine rather than the envelope alone, because the envelope is a description and the
81
+ * run is what actually happens.
82
+ */
83
+ describe("no reachable state grants a destructive capability before its gate", () => {
84
+ /** Guards the guard: with no corpus this property holds over nothing, and passes. */
85
+ it("has records to check the property over", () => {
86
+ expect(corpus.length).toBeGreaterThan(0);
87
+ });
88
+ it.each(corpus)("holds for $name", ({ doc }) => {
89
+ const env = derivePolicy(doc);
90
+ const inputs = {};
91
+ for (const [name, decl] of Object.entries(env.inputs)) {
92
+ inputs[name] = decl.type === "boolean" ? true : decl.type === "number" ? 1 : "x";
93
+ }
94
+ const started = startRun(env, inputs);
95
+ expect(started.ok).toBe(true);
96
+ if (!started.ok)
97
+ return;
98
+ for (const scope of dangerousSteps(env)) {
99
+ // Ungated, the capability must not exist...
100
+ expect(scopeFor(env, scope.id, new Set())).toEqual([]);
101
+ const at = { ...started.state, current: scope.id };
102
+ for (const tool of scope.tools) {
103
+ expect(propose(env, at, { tool }).authorization.verdict).not.toBe("permit");
104
+ }
105
+ // ...and it must appear only once a decision is recorded.
106
+ const approved = applyGrant(at, { requestId: "r1", stepId: scope.id, decidedBy: "operator" }).state;
107
+ for (const tool of scope.tools) {
108
+ expect(propose(env, approved, { tool }).authorization.verdict).toBe("permit");
109
+ }
110
+ }
111
+ });
112
+ });
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Postcondition evaluation (R-04).
3
+ *
4
+ * R2's distinguishing behaviour: `expect` is not a suggestion the model grades itself
5
+ * against. The supervisor decides whether the step did what the procedure said it would,
6
+ * and takes the `on_fail` route itself.
7
+ *
8
+ * Q12 settled how: prose is never the thing evaluated. `expect` stays prose for the
9
+ * reader; a step an R2 supervisor must decide carries `assert`, a small closed set of
10
+ * predicates over the observation. A step with only prose is **unevaluable** — a first
11
+ * class outcome, distinct from pass and from fail, in which the supervisor halts and
12
+ * asks a person rather than guessing. A supervisor that reads "I could not tell" as
13
+ * "passed" is worse than one that does not check at all.
14
+ *
15
+ * Nothing here interprets meaning. Every predicate is decidable by inspection, which is
16
+ * the property that makes a supervised claim worth more than a plausible one.
17
+ */
18
+ /** What an adapter reports back after a step ran. Fields it did not capture are absent. */
19
+ export interface Observation {
20
+ readonly exitCode?: number;
21
+ readonly stdout?: string;
22
+ readonly stderr?: string;
23
+ }
24
+ export interface Assertion {
25
+ readonly exit_code?: number;
26
+ readonly stdout_matches?: string;
27
+ readonly stdout_not_matches?: string;
28
+ readonly stderr_empty?: boolean;
29
+ readonly json_path?: {
30
+ readonly path: string;
31
+ readonly equals: unknown;
32
+ };
33
+ }
34
+ export type PredicateName = keyof Assertion;
35
+ export interface PredicateResult {
36
+ readonly predicate: PredicateName;
37
+ readonly verdict: "pass" | "fail" | "unevaluable";
38
+ /** Why, in the terms a person needs to act: what was expected, what was seen. */
39
+ readonly detail: string;
40
+ }
41
+ export type Evaluation = {
42
+ readonly outcome: "pass";
43
+ readonly predicates: readonly PredicateResult[];
44
+ } | {
45
+ readonly outcome: "fail";
46
+ readonly predicates: readonly PredicateResult[];
47
+ readonly why: string;
48
+ } | {
49
+ readonly outcome: "unevaluable";
50
+ readonly predicates: readonly PredicateResult[];
51
+ readonly why: string;
52
+ };
53
+ /**
54
+ * Beyond this, a regular expression over the output is not something we are willing to
55
+ * run inside a decision path. Refusing to decide is the honest answer; deciding slowly
56
+ * enough to hang an incident is not.
57
+ */
58
+ export declare const MAX_OUTPUT_BYTES = 1048576;
59
+ /**
60
+ * Evaluate a step's postcondition.
61
+ *
62
+ * Every predicate is evaluated rather than short-circuited, so a person sees everything
63
+ * that was wrong instead of the first thing. A definite failure outranks an undecided
64
+ * predicate — the step demonstrably did not do what it said — but nothing undecided ever
65
+ * becomes a pass.
66
+ */
67
+ export declare function evaluate(assertion: Assertion | undefined, observation: Observation): Evaluation;