@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.
- package/LICENSE +21 -0
- package/README.md +47 -0
- package/dist/approval.d.ts +132 -0
- package/dist/approval.js +148 -0
- package/dist/approval.test.d.ts +1 -0
- package/dist/approval.test.js +190 -0
- package/dist/budgets.test.d.ts +1 -0
- package/dist/budgets.test.js +148 -0
- package/dist/contract.d.ts +38 -0
- package/dist/contract.js +77 -0
- package/dist/contract.test.d.ts +1 -0
- package/dist/contract.test.js +131 -0
- package/dist/deviations.d.ts +77 -0
- package/dist/deviations.js +81 -0
- package/dist/deviations.test.d.ts +1 -0
- package/dist/deviations.test.js +241 -0
- package/dist/emit.d.ts +97 -0
- package/dist/emit.js +184 -0
- package/dist/emit.test.d.ts +1 -0
- package/dist/emit.test.js +187 -0
- package/dist/enforce.d.ts +29 -0
- package/dist/enforce.js +100 -0
- package/dist/enforce.test.d.ts +1 -0
- package/dist/enforce.test.js +112 -0
- package/dist/expect.d.ts +67 -0
- package/dist/expect.js +187 -0
- package/dist/expect.test.d.ts +1 -0
- package/dist/expect.test.js +120 -0
- package/dist/index.d.ts +33 -0
- package/dist/index.js +34 -0
- package/dist/index.test.d.ts +1 -0
- package/dist/index.test.js +30 -0
- package/dist/observe.test.d.ts +1 -0
- package/dist/observe.test.js +246 -0
- package/dist/policy.d.ts +122 -0
- package/dist/policy.js +147 -0
- package/dist/policy.test.d.ts +1 -0
- package/dist/policy.test.js +124 -0
- package/dist/purity.test.d.ts +1 -0
- package/dist/purity.test.js +191 -0
- package/dist/report.d.ts +72 -0
- package/dist/report.js +67 -0
- package/dist/run.d.ts +216 -0
- package/dist/run.js +445 -0
- package/dist/run.test.d.ts +1 -0
- package/dist/run.test.js +198 -0
- package/package.json +42 -0
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { loadCorpus } from "@runbooks/fixtures";
|
|
3
|
+
import { derivePolicy } from "./policy.js";
|
|
4
|
+
import { startRun, propose, advance, observe, conclude } from "./run.js";
|
|
5
|
+
import { proposeCommand } from "./enforce.js";
|
|
6
|
+
import { DEFAULT_ESCALATION, capabilityClassOf, countByClass, countByStep, recordDeviation, } from "./deviations.js";
|
|
7
|
+
import { report } from "./report.js";
|
|
8
|
+
function envelopeFor(name) {
|
|
9
|
+
const record = loadCorpus().find((r) => r.name.startsWith(name));
|
|
10
|
+
return derivePolicy(record.doc);
|
|
11
|
+
}
|
|
12
|
+
function begin(envelope, options = {}) {
|
|
13
|
+
const started = startRun(envelope, { agentIdentity: "agent-7", ...options });
|
|
14
|
+
if (!started.ok)
|
|
15
|
+
throw new Error(JSON.stringify(started.refusals));
|
|
16
|
+
return started.state;
|
|
17
|
+
}
|
|
18
|
+
const disk = () => envelopeFor("disk-pressure");
|
|
19
|
+
const k8s = () => envelopeFor("k8s-node");
|
|
20
|
+
const flaky = () => envelopeFor("flaky-check");
|
|
21
|
+
const permissive = {
|
|
22
|
+
responses: Object.fromEntries(Object.keys(DEFAULT_ESCALATION.responses).map((c) => [c, "block-and-continue"])),
|
|
23
|
+
maxTotal: 99,
|
|
24
|
+
};
|
|
25
|
+
/**
|
|
26
|
+
* Every class in the taxonomy, each produced by a run doing the thing that class names.
|
|
27
|
+
* A taxonomy with a member nothing can produce is a taxonomy that has drifted from the
|
|
28
|
+
* supervisor it describes.
|
|
29
|
+
*/
|
|
30
|
+
describe("every deviation class has a fixture that produces exactly it", () => {
|
|
31
|
+
const produced = {
|
|
32
|
+
"undeclared-tool": () => {
|
|
33
|
+
const state = begin(disk(), { inputs: { mount: "/tmp" }, escalation: permissive });
|
|
34
|
+
return propose(disk(), state, { tool: "cli:nothing-declares-this" })
|
|
35
|
+
.state.deviations.map((d) => d.class);
|
|
36
|
+
},
|
|
37
|
+
"out-of-scope-tool": () => {
|
|
38
|
+
const envelope = disk();
|
|
39
|
+
const state = begin(envelope, { inputs: { mount: "/tmp" }, escalation: permissive });
|
|
40
|
+
const later = Object.values(envelope.steps)
|
|
41
|
+
.flatMap((s) => s.tools)
|
|
42
|
+
.find((t) => !envelope.steps[state.current].tools.includes(t));
|
|
43
|
+
return propose(envelope, state, { tool: later }).state.deviations.map((d) => d.class);
|
|
44
|
+
},
|
|
45
|
+
"out-of-order-step": () => {
|
|
46
|
+
const state = begin(k8s(), { inputs: { node: "node-1" }, escalation: permissive });
|
|
47
|
+
return advance(k8s(), state, "s5").state.deviations.map((d) => d.class);
|
|
48
|
+
},
|
|
49
|
+
"unchecked-advance": () => {
|
|
50
|
+
const state = begin(k8s(), {
|
|
51
|
+
inputs: { node: "node-1" }, escalation: permissive, evaluatePostconditions: true,
|
|
52
|
+
});
|
|
53
|
+
return advance(k8s(), state, "s2").state.deviations.map((d) => d.class);
|
|
54
|
+
},
|
|
55
|
+
"budget-exhausted": () => {
|
|
56
|
+
const envelope = flaky();
|
|
57
|
+
const [from, to] = Object.keys(envelope.budgets)[0].split("->");
|
|
58
|
+
let state = begin(envelope, { escalation: permissive });
|
|
59
|
+
for (let i = 0; i < 20 && state.status === "running"; i++) {
|
|
60
|
+
state = advance(envelope, state, state.current === to ? from : to).state;
|
|
61
|
+
}
|
|
62
|
+
return state.deviations.map((d) => d.class);
|
|
63
|
+
},
|
|
64
|
+
"unrouted-failure": () => {
|
|
65
|
+
const envelope = disk();
|
|
66
|
+
const { onFail: _none, ...unrouted } = envelope.steps[envelope.entry];
|
|
67
|
+
const patched = {
|
|
68
|
+
...envelope,
|
|
69
|
+
steps: { ...envelope.steps, [envelope.entry]: { ...unrouted, assert: { exit_code: 0 } } },
|
|
70
|
+
};
|
|
71
|
+
const state = begin(patched, { inputs: { mount: "/tmp" }, escalation: permissive });
|
|
72
|
+
return observe(patched, state, { exitCode: 1 }).state.deviations.map((d) => d.class);
|
|
73
|
+
},
|
|
74
|
+
"terminal-not-reached": () => {
|
|
75
|
+
const state = begin(disk(), { inputs: { mount: "/tmp" }, escalation: permissive });
|
|
76
|
+
return conclude(disk(), state).state.deviations.map((d) => d.class);
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
it("covers the taxonomy with no member left over", () => {
|
|
80
|
+
expect(Object.keys(produced).sort()).toEqual(Object.keys(DEFAULT_ESCALATION.responses).sort());
|
|
81
|
+
});
|
|
82
|
+
it.each(Object.keys(produced))("%s", (cls) => {
|
|
83
|
+
expect(produced[cls]()).toEqual([cls]);
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
/**
|
|
87
|
+
* §14's rule applies to everything that might leave the operator's environment, and a
|
|
88
|
+
* deviation record is exactly that. The class, the step, the capability class — never
|
|
89
|
+
* the payload.
|
|
90
|
+
*/
|
|
91
|
+
describe("a deviation record carries no payload", () => {
|
|
92
|
+
const secrets = [
|
|
93
|
+
"cli:psql --password=hunter2",
|
|
94
|
+
"cli:curl https://api.example.com?token=sk-live-4242424242",
|
|
95
|
+
"cli:kubectl --token=eyJhbGciOiJIUzI1NiJ9.secret",
|
|
96
|
+
"cli:aws --secret-access-key wJalrXUtnFEMI/K7MDENG",
|
|
97
|
+
];
|
|
98
|
+
it.each(secrets)("keeps nothing of %s but its class", (tool) => {
|
|
99
|
+
const state = begin(disk(), { inputs: { mount: "/tmp" }, escalation: permissive });
|
|
100
|
+
const decided = propose(disk(), state, { tool });
|
|
101
|
+
const serialized = JSON.stringify(decided.state.deviations);
|
|
102
|
+
expect(serialized).not.toMatch(/hunter2|sk-live|eyJ|wJalr/);
|
|
103
|
+
expect(serialized).not.toContain(tool);
|
|
104
|
+
expect(decided.state.deviations[0].capabilityClass).toBe("cli");
|
|
105
|
+
});
|
|
106
|
+
it("keeps nothing of a command that could not be attributed", () => {
|
|
107
|
+
const state = begin(disk(), { inputs: { mount: "/tmp" }, escalation: permissive });
|
|
108
|
+
const decided = proposeCommand(disk(), state, {
|
|
109
|
+
command: "df -h $(cat /etc/shadow) --password=hunter2",
|
|
110
|
+
});
|
|
111
|
+
expect(JSON.stringify(decided.state.deviations)).not.toMatch(/shadow|hunter2|df/);
|
|
112
|
+
});
|
|
113
|
+
it("reduces a capability to its class and nothing finer", () => {
|
|
114
|
+
expect(capabilityClassOf("cli:kubectl")).toBe("cli");
|
|
115
|
+
expect(capabilityClassOf("mcp:github/create_issue")).toBe("mcp");
|
|
116
|
+
expect(capabilityClassOf("Bearer sk-live-1")).toBeUndefined();
|
|
117
|
+
expect(capabilityClassOf(undefined)).toBeUndefined();
|
|
118
|
+
});
|
|
119
|
+
it("orders records without reading a clock", () => {
|
|
120
|
+
let state = begin(disk(), { inputs: { mount: "/tmp" }, escalation: permissive });
|
|
121
|
+
state = propose(disk(), state, { tool: "cli:a" }).state;
|
|
122
|
+
state = propose(disk(), state, { tool: "cli:b" }).state;
|
|
123
|
+
expect(state.deviations.map((d) => d.sequence)).toEqual([0, 1]);
|
|
124
|
+
expect(JSON.stringify(state.deviations)).not.toMatch(/\d{10}/);
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
/** A refusal that a second attempt turns into a permission is not a refusal. */
|
|
128
|
+
describe("a blocked deviation never becomes a permitted call", () => {
|
|
129
|
+
it("blocks the same call however many times it is proposed", () => {
|
|
130
|
+
let state = begin(disk(), { inputs: { mount: "/tmp" }, escalation: permissive });
|
|
131
|
+
for (let attempt = 0; attempt < 5; attempt++) {
|
|
132
|
+
const decided = propose(disk(), state, { tool: "cli:nothing-declares-this" });
|
|
133
|
+
expect(decided.authorization.verdict).toBe("block");
|
|
134
|
+
state = decided.state;
|
|
135
|
+
}
|
|
136
|
+
expect(state.deviations).toHaveLength(5);
|
|
137
|
+
});
|
|
138
|
+
it("does not let a blocked tool arrive through the command path either", () => {
|
|
139
|
+
const state = begin(disk(), { inputs: { mount: "/tmp" }, escalation: permissive });
|
|
140
|
+
const decided = proposeCommand(disk(), state, { command: "rm -rf /var/lib/postgresql" });
|
|
141
|
+
expect(decided.authorization.verdict).toBe("block");
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
/**
|
|
145
|
+
* Escalation is the operator's policy with a conservative default: anything that breaks
|
|
146
|
+
* control flow stops the run, because every later authorization would otherwise be
|
|
147
|
+
* decided against the wrong step.
|
|
148
|
+
*/
|
|
149
|
+
describe("escalation policy", () => {
|
|
150
|
+
it("aborts the run on a control-flow deviation by default", () => {
|
|
151
|
+
const state = begin(k8s(), { inputs: { node: "node-1" } });
|
|
152
|
+
const moved = advance(k8s(), state, "s5");
|
|
153
|
+
expect(moved.state.status).toBe("ended");
|
|
154
|
+
expect(moved.events.some((e) => e.kind === "escalated")).toBe(true);
|
|
155
|
+
});
|
|
156
|
+
it("blocks and continues on a tool the runbook declares for another step", () => {
|
|
157
|
+
const envelope = disk();
|
|
158
|
+
const state = begin(envelope, { inputs: { mount: "/tmp" } });
|
|
159
|
+
const later = Object.values(envelope.steps)
|
|
160
|
+
.flatMap((s) => s.tools)
|
|
161
|
+
.find((t) => !envelope.steps[state.current].tools.includes(t));
|
|
162
|
+
const decided = propose(envelope, state, { tool: later });
|
|
163
|
+
expect(decided.authorization.verdict).toBe("block");
|
|
164
|
+
expect(decided.state.status).toBe("running");
|
|
165
|
+
});
|
|
166
|
+
it("stops once a run has accumulated more deviations than the operator allows", () => {
|
|
167
|
+
const envelope = disk();
|
|
168
|
+
const later = Object.values(envelope.steps).flatMap((s) => s.tools).find((t) => !envelope.steps[envelope.entry].tools.includes(t));
|
|
169
|
+
let state = begin(envelope, { inputs: { mount: "/tmp" } });
|
|
170
|
+
for (let i = 0; i <= DEFAULT_ESCALATION.maxTotal; i++) {
|
|
171
|
+
state = propose(envelope, state, { tool: later }).state;
|
|
172
|
+
}
|
|
173
|
+
expect(state.status).toBe("ended");
|
|
174
|
+
expect(state.deviations.length).toBe(DEFAULT_ESCALATION.maxTotal + 1);
|
|
175
|
+
});
|
|
176
|
+
it("lets an operator say otherwise", () => {
|
|
177
|
+
const state = begin(k8s(), { inputs: { node: "node-1" }, escalation: permissive });
|
|
178
|
+
expect(advance(k8s(), state, "s5").state.status).toBe("running");
|
|
179
|
+
});
|
|
180
|
+
it("names what stopped the run without naming what was being attempted", () => {
|
|
181
|
+
const state = begin(k8s(), { inputs: { node: "node-1" } });
|
|
182
|
+
const escalated = advance(k8s(), state, "s5").events.find((e) => e.kind === "escalated");
|
|
183
|
+
expect(escalated.message).toMatch(/out-of-order-step/);
|
|
184
|
+
expect(escalated.at).toBe("s1");
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
/** An escalation the runbook anticipated is where the procedure meant to end. */
|
|
188
|
+
describe("an anticipated escalation is not a deviation", () => {
|
|
189
|
+
it("records nothing when a failed check routes to the escalate node the author wrote", () => {
|
|
190
|
+
const state = begin(k8s(), { inputs: { node: "node-1" }, evaluatePostconditions: true });
|
|
191
|
+
const observed = observe(k8s(), state, { exitCode: 0, stdout: "True" });
|
|
192
|
+
expect(observed.state.current).toBe("s9");
|
|
193
|
+
expect(observed.state.deviations).toEqual([]);
|
|
194
|
+
});
|
|
195
|
+
it("concludes a run sitting on that escalate node as ended, not as incomplete", () => {
|
|
196
|
+
const state = begin(k8s(), { inputs: { node: "node-1" }, evaluatePostconditions: true });
|
|
197
|
+
const escalated = observe(k8s(), state, { exitCode: 0, stdout: "True" }).state;
|
|
198
|
+
// The escalate node is a terminal, so the run already ended there — handing off to a
|
|
199
|
+
// person is where this procedure meant to go, and it cost no deviation.
|
|
200
|
+
expect(escalated.status).toBe("ended");
|
|
201
|
+
const concluded = conclude(k8s(), escalated);
|
|
202
|
+
expect(concluded.state.deviations).toEqual([]);
|
|
203
|
+
expect(report(concluded.state).complete).toBe(true);
|
|
204
|
+
});
|
|
205
|
+
it("records terminal-not-reached only when the run stopped somewhere else", () => {
|
|
206
|
+
const state = begin(k8s(), { inputs: { node: "node-1" } });
|
|
207
|
+
expect(conclude(k8s(), state).state.deviations.map((d) => d.class))
|
|
208
|
+
.toEqual(["terminal-not-reached"]);
|
|
209
|
+
});
|
|
210
|
+
});
|
|
211
|
+
/**
|
|
212
|
+
* The diagnostic half. A procedure that constantly provokes deviations is a procedure
|
|
213
|
+
* whose graph does not match reality, and an author needs that per step.
|
|
214
|
+
*/
|
|
215
|
+
describe("the aggregate is what an author reads", () => {
|
|
216
|
+
it("counts by class and by step", () => {
|
|
217
|
+
const records = [
|
|
218
|
+
{ class: "out-of-scope-tool", stepId: "s4", sequence: 0 },
|
|
219
|
+
{ class: "out-of-scope-tool", stepId: "s4", sequence: 1 },
|
|
220
|
+
{ class: "undeclared-tool", stepId: "s1", sequence: 2 },
|
|
221
|
+
];
|
|
222
|
+
expect(countByClass(records)).toEqual({ "out-of-scope-tool": 2, "undeclared-tool": 1 });
|
|
223
|
+
expect(countByStep(records)).toEqual({ s4: 2, s1: 1 });
|
|
224
|
+
});
|
|
225
|
+
it("surfaces the count and both breakdowns in the run report", () => {
|
|
226
|
+
let state = begin(disk(), { inputs: { mount: "/tmp" }, escalation: permissive });
|
|
227
|
+
state = propose(disk(), state, { tool: "cli:nope" }).state;
|
|
228
|
+
state = propose(disk(), state, { tool: "cli:nope" }).state;
|
|
229
|
+
const r = report(state);
|
|
230
|
+
expect(r.deviations.count).toBe(2);
|
|
231
|
+
expect(r.deviations.byClass["undeclared-tool"]).toBe(2);
|
|
232
|
+
expect(Object.values(r.deviations.byStep)[0]).toBe(2);
|
|
233
|
+
expect(r.caveats.join(" ")).toMatch(/graph may not match reality/);
|
|
234
|
+
});
|
|
235
|
+
it("records a deviation even once the run has ended, without resurrecting it", () => {
|
|
236
|
+
const ended = { ...begin(disk(), { inputs: { mount: "/tmp" } }), status: "ended" };
|
|
237
|
+
const after = recordDeviation(ended, { class: "out-of-order-step" });
|
|
238
|
+
expect(after.state.status).toBe("ended");
|
|
239
|
+
expect(after.state.deviations).toHaveLength(1);
|
|
240
|
+
});
|
|
241
|
+
});
|
package/dist/emit.d.ts
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The §14 run report, assembled (R-07).
|
|
3
|
+
*
|
|
4
|
+
* Not to be confused with `./report.js`, which is the operator's own account of a run
|
|
5
|
+
* and stays on their machine. This is the thing that might leave, and everything about
|
|
6
|
+
* it is shaped by that: a closed vocabulary, a bucketed duration, and no field into
|
|
7
|
+
* which a message could be written.
|
|
8
|
+
*
|
|
9
|
+
* Assembly is pure, so it can be reasoned about; validation against the published schema
|
|
10
|
+
* happens at the emitting edge, where the filesystem lives.
|
|
11
|
+
*/
|
|
12
|
+
import type { RunEvent, RunState } from "./run.js";
|
|
13
|
+
export type Outcome = "success" | "failed" | "aborted" | "partial";
|
|
14
|
+
export type FailureClass = "precondition_unmet" | "tool_missing" | "permission_denied" | "expectation_mismatch" | "timeout" | "human_abort" | "upstream_changed";
|
|
15
|
+
export type ExecutorKind = "human" | "agent-assisted" | "autonomous";
|
|
16
|
+
export type DurationBucket = "<1m" | "1-5m" | "5-30m" | ">30m";
|
|
17
|
+
export type EnvClass = "local" | "ci" | "staging" | "prod";
|
|
18
|
+
export interface RunReportV1 {
|
|
19
|
+
readonly report_schema: "runbook-run-report/v1";
|
|
20
|
+
readonly schema_version: "v1";
|
|
21
|
+
readonly runbook: string;
|
|
22
|
+
readonly version: string;
|
|
23
|
+
readonly content_hash: string;
|
|
24
|
+
readonly profile: "P0" | "P1";
|
|
25
|
+
readonly outcome: Outcome;
|
|
26
|
+
readonly failed_step_id?: string;
|
|
27
|
+
readonly failure_class?: FailureClass;
|
|
28
|
+
readonly executor_kind: ExecutorKind;
|
|
29
|
+
readonly runtime_profile: "R0" | "R1" | "R2";
|
|
30
|
+
readonly deviation_count: number;
|
|
31
|
+
readonly duration_bucket: DurationBucket;
|
|
32
|
+
readonly env_class: EnvClass;
|
|
33
|
+
readonly reported_at: string;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Which §14 class an internal reason belongs to.
|
|
37
|
+
*
|
|
38
|
+
* `null` means "this reason is not a failure": the run was stopped, or it ended cleanly,
|
|
39
|
+
* and inventing a failure class for it would put a cause into an aggregate for runs that
|
|
40
|
+
* did not fail. Control-flow deviations are the interesting case — a run that went off
|
|
41
|
+
* its graph did not fail at a step, it was halted, and §14's vocabulary has no member for
|
|
42
|
+
* "the run left the procedure" because that is not a fact about the procedure.
|
|
43
|
+
*/
|
|
44
|
+
export declare const FAILURE_CLASS_BY_CODE: Readonly<Record<string, FailureClass | null>>;
|
|
45
|
+
export declare class UnclassifiableFailureError extends Error {
|
|
46
|
+
constructor(stepId: string | undefined);
|
|
47
|
+
}
|
|
48
|
+
export declare class UnmappedReasonError extends Error {
|
|
49
|
+
constructor(code: string);
|
|
50
|
+
}
|
|
51
|
+
/** Loudly, per R-07: an unmapped reason throws rather than picking something adjacent. */
|
|
52
|
+
export declare function failureClassFor(code: string): FailureClass | null;
|
|
53
|
+
/** Bucketed, never exact (§14). An unknown duration is not a short one. */
|
|
54
|
+
export declare function durationBucket(seconds: number | undefined): DurationBucket;
|
|
55
|
+
export interface ReportContext {
|
|
56
|
+
/** publisher/slug, from the record that ran. */
|
|
57
|
+
readonly runbook: string;
|
|
58
|
+
readonly version: string;
|
|
59
|
+
readonly contentHash: string;
|
|
60
|
+
readonly profile: "P0" | "P1";
|
|
61
|
+
readonly runtimeProfile: "R0" | "R1" | "R2";
|
|
62
|
+
/**
|
|
63
|
+
* Both of these are the operator's to state and the supervisor cannot infer either.
|
|
64
|
+
* There is no default: guessing `local` for a run that was in production would put a
|
|
65
|
+
* prod failure into the wrong aggregate, and an aggregate is the whole point.
|
|
66
|
+
*/
|
|
67
|
+
readonly executorKind: ExecutorKind;
|
|
68
|
+
readonly envClass: EnvClass;
|
|
69
|
+
/** The day, not the moment (§14). */
|
|
70
|
+
readonly reportedAt: string;
|
|
71
|
+
/** For the duration bucket. The core has no clock. */
|
|
72
|
+
readonly now?: number;
|
|
73
|
+
/**
|
|
74
|
+
* The operator's own classification, for a failure the run did not explain.
|
|
75
|
+
*
|
|
76
|
+
* A runbook that routes to `end:failed` has declared a failing path without saying
|
|
77
|
+
* why, and at R0 or R1 there may be no recorded cause at all. Rather than file it
|
|
78
|
+
* under something adjacent, assembly refuses and asks — the person who was there
|
|
79
|
+
* knows, and a wrong class is not a smaller error than a missing report.
|
|
80
|
+
*/
|
|
81
|
+
readonly failureClass?: FailureClass;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Assemble the report from what the run actually recorded.
|
|
85
|
+
*
|
|
86
|
+
* Every value here is derived from run state or supplied by the operator. Nothing is
|
|
87
|
+
* copied out of a message, because there is nowhere in the format for a message to go.
|
|
88
|
+
*/
|
|
89
|
+
export declare function buildRunReport(state: RunState, events: readonly RunEvent[], context: ReportContext): RunReportV1;
|
|
90
|
+
/**
|
|
91
|
+
* How a run ended, in §14's four words.
|
|
92
|
+
*
|
|
93
|
+
* Exported because a run attestation (R-09) states the same fact and must state it the
|
|
94
|
+
* same way: a report saying `failed` and an attestation saying `aborted` about one run
|
|
95
|
+
* would be two claims about the same thing, and whichever a reader saw first would win.
|
|
96
|
+
*/
|
|
97
|
+
export declare function outcomeOf(state: RunState): Outcome;
|
package/dist/emit.js
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import { SCHEMA_VERSION } from "@runbooks/schema";
|
|
2
|
+
/**
|
|
3
|
+
* Which §14 class an internal reason belongs to.
|
|
4
|
+
*
|
|
5
|
+
* `null` means "this reason is not a failure": the run was stopped, or it ended cleanly,
|
|
6
|
+
* and inventing a failure class for it would put a cause into an aggregate for runs that
|
|
7
|
+
* did not fail. Control-flow deviations are the interesting case — a run that went off
|
|
8
|
+
* its graph did not fail at a step, it was halted, and §14's vocabulary has no member for
|
|
9
|
+
* "the run left the procedure" because that is not a fact about the procedure.
|
|
10
|
+
*/
|
|
11
|
+
export const FAILURE_CLASS_BY_CODE = {
|
|
12
|
+
// Nothing went wrong.
|
|
13
|
+
"run/started": null,
|
|
14
|
+
"run/entered": null,
|
|
15
|
+
"run/permitted": null,
|
|
16
|
+
"run/authorized": null,
|
|
17
|
+
"run/approved": null,
|
|
18
|
+
"run/adjudicated": null,
|
|
19
|
+
"run/ended": null,
|
|
20
|
+
"run/expectation-passed": null,
|
|
21
|
+
"run/approval-required": null,
|
|
22
|
+
"run/nothing-to-adjudicate": null,
|
|
23
|
+
// The procedure did not do what it said it would.
|
|
24
|
+
"run/expectation-failed": "expectation_mismatch",
|
|
25
|
+
"run/expectation-unevaluable": "expectation_mismatch",
|
|
26
|
+
"run/unrouted-failure": "expectation_mismatch",
|
|
27
|
+
// The supervisor refused.
|
|
28
|
+
"run/undeclared-tool": "permission_denied",
|
|
29
|
+
"run/out-of-scope-tool": "permission_denied",
|
|
30
|
+
"run/unanalyzable-command": "permission_denied",
|
|
31
|
+
"runtime/coverage-insufficient": "permission_denied",
|
|
32
|
+
"runtime/risk-above-ceiling": "permission_denied",
|
|
33
|
+
"runtime/profile-too-low": "permission_denied",
|
|
34
|
+
"runtime/autonomous-unsupervised": "permission_denied",
|
|
35
|
+
// The environment could not supply what the procedure needs.
|
|
36
|
+
"runtime/capability-unavailable": "tool_missing",
|
|
37
|
+
// The run was not set up to start.
|
|
38
|
+
"run/input-missing": "precondition_unmet",
|
|
39
|
+
"run/input-type": "precondition_unmet",
|
|
40
|
+
"run/input-undeclared": "precondition_unmet",
|
|
41
|
+
"run/no-entry": "precondition_unmet",
|
|
42
|
+
/**
|
|
43
|
+
* A budget is a statement about time the operator was willing to spend. A loop that
|
|
44
|
+
* exhausted its retries did not settle inside the allowance it was given, which is the
|
|
45
|
+
* same fact as a wall clock running out, told from the other end.
|
|
46
|
+
*/
|
|
47
|
+
"run/budget-exhausted": "timeout",
|
|
48
|
+
"run/wall-clock-exhausted": "timeout",
|
|
49
|
+
"run/deviation-limit": null,
|
|
50
|
+
// A person stopped it.
|
|
51
|
+
"run/aborted-by-human": "human_abort",
|
|
52
|
+
"run/approval-denied": "human_abort",
|
|
53
|
+
// What ran is not what was published.
|
|
54
|
+
"run/content-hash-mismatch": "upstream_changed",
|
|
55
|
+
// Control flow: halted, not failed.
|
|
56
|
+
"run/out-of-order-step": null,
|
|
57
|
+
"run/unchecked-advance": null,
|
|
58
|
+
"run/terminal-not-reached": null,
|
|
59
|
+
"run/after-end": null,
|
|
60
|
+
"run/escalated-undeclared-tool": null,
|
|
61
|
+
"run/escalated-out-of-scope-tool": null,
|
|
62
|
+
"run/escalated-out-of-order-step": null,
|
|
63
|
+
"run/escalated-unchecked-advance": null,
|
|
64
|
+
"run/escalated-budget-exhausted": null,
|
|
65
|
+
"run/escalated-unrouted-failure": null,
|
|
66
|
+
"run/escalated-terminal-not-reached": null,
|
|
67
|
+
};
|
|
68
|
+
export class UnclassifiableFailureError extends Error {
|
|
69
|
+
constructor(stepId) {
|
|
70
|
+
super(`This run failed${stepId ? ` at ${stepId}` : ""} and recorded no cause a §14 class ` +
|
|
71
|
+
`covers. Supply failureClass explicitly: the person who was there knows, and a ` +
|
|
72
|
+
`wrong class is not a smaller error than a missing report.`);
|
|
73
|
+
this.name = "UnclassifiableFailureError";
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
export class UnmappedReasonError extends Error {
|
|
77
|
+
constructor(code) {
|
|
78
|
+
super(`${code} has no §14 failure class. Refusing to emit rather than choosing a bucket: ` +
|
|
79
|
+
`a report that files an unknown cause under a plausible heading is worse than no ` +
|
|
80
|
+
`report, because the aggregate then reads as evidence.`);
|
|
81
|
+
this.name = "UnmappedReasonError";
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
/** Loudly, per R-07: an unmapped reason throws rather than picking something adjacent. */
|
|
85
|
+
export function failureClassFor(code) {
|
|
86
|
+
const known = FAILURE_CLASS_BY_CODE[code];
|
|
87
|
+
if (known === undefined)
|
|
88
|
+
throw new UnmappedReasonError(code);
|
|
89
|
+
return known;
|
|
90
|
+
}
|
|
91
|
+
/** Bucketed, never exact (§14). An unknown duration is not a short one. */
|
|
92
|
+
export function durationBucket(seconds) {
|
|
93
|
+
if (seconds === undefined || !Number.isFinite(seconds))
|
|
94
|
+
return ">30m";
|
|
95
|
+
if (seconds < 60)
|
|
96
|
+
return "<1m";
|
|
97
|
+
if (seconds < 300)
|
|
98
|
+
return "1-5m";
|
|
99
|
+
if (seconds < 1800)
|
|
100
|
+
return "5-30m";
|
|
101
|
+
return ">30m";
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Assemble the report from what the run actually recorded.
|
|
105
|
+
*
|
|
106
|
+
* Every value here is derived from run state or supplied by the operator. Nothing is
|
|
107
|
+
* copied out of a message, because there is nowhere in the format for a message to go.
|
|
108
|
+
*/
|
|
109
|
+
export function buildRunReport(state, events, context) {
|
|
110
|
+
const outcome = outcomeOf(state);
|
|
111
|
+
const derived = failureOf(state, events, outcome);
|
|
112
|
+
const failure = derived && context.failureClass
|
|
113
|
+
? { ...derived, failureClass: context.failureClass }
|
|
114
|
+
: derived;
|
|
115
|
+
if (outcome === "failed" && !failure?.failureClass) {
|
|
116
|
+
throw new UnclassifiableFailureError(failure?.stepId);
|
|
117
|
+
}
|
|
118
|
+
const elapsed = context.now !== undefined && state.startedAt !== undefined
|
|
119
|
+
? context.now - state.startedAt
|
|
120
|
+
: undefined;
|
|
121
|
+
return {
|
|
122
|
+
report_schema: "runbook-run-report/v1",
|
|
123
|
+
schema_version: SCHEMA_VERSION,
|
|
124
|
+
runbook: context.runbook,
|
|
125
|
+
version: context.version,
|
|
126
|
+
content_hash: context.contentHash,
|
|
127
|
+
profile: context.profile,
|
|
128
|
+
outcome,
|
|
129
|
+
...(failure?.stepId ? { failed_step_id: failure.stepId } : {}),
|
|
130
|
+
...(failure?.failureClass ? { failure_class: failure.failureClass } : {}),
|
|
131
|
+
executor_kind: context.executorKind,
|
|
132
|
+
runtime_profile: context.runtimeProfile,
|
|
133
|
+
deviation_count: state.deviations.length,
|
|
134
|
+
duration_bucket: durationBucket(elapsed),
|
|
135
|
+
env_class: context.envClass,
|
|
136
|
+
reported_at: context.reportedAt,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* How a run ended, in §14's four words.
|
|
141
|
+
*
|
|
142
|
+
* Exported because a run attestation (R-09) states the same fact and must state it the
|
|
143
|
+
* same way: a report saying `failed` and an attestation saying `aborted` about one run
|
|
144
|
+
* would be two claims about the same thing, and whichever a reader saw first would win.
|
|
145
|
+
*/
|
|
146
|
+
export function outcomeOf(state) {
|
|
147
|
+
if (state.status !== "ended")
|
|
148
|
+
return "partial";
|
|
149
|
+
switch (state.outcome) {
|
|
150
|
+
case "success":
|
|
151
|
+
return "success";
|
|
152
|
+
case "failed":
|
|
153
|
+
return "failed";
|
|
154
|
+
case "timed-out":
|
|
155
|
+
case "budget-exhausted":
|
|
156
|
+
return "failed";
|
|
157
|
+
default:
|
|
158
|
+
return "aborted";
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* What failed and why, taken from the last event that carries a cause.
|
|
163
|
+
*
|
|
164
|
+
* The last, not the first: a run that recovered from one failure and then hit another
|
|
165
|
+
* failed at the second, and reporting the first would point an author at the step that
|
|
166
|
+
* worked.
|
|
167
|
+
*/
|
|
168
|
+
function failureOf(state, events, outcome) {
|
|
169
|
+
if (outcome === "success")
|
|
170
|
+
return undefined;
|
|
171
|
+
for (let i = events.length - 1; i >= 0; i--) {
|
|
172
|
+
const event = events[i];
|
|
173
|
+
const failureClass = failureClassFor(event.code);
|
|
174
|
+
if (failureClass === null)
|
|
175
|
+
continue;
|
|
176
|
+
return { ...(event.at ? { stepId: event.at } : {}), failureClass };
|
|
177
|
+
}
|
|
178
|
+
// A run stopped without a cause anyone recorded: where it stopped, and nothing more.
|
|
179
|
+
// Not for a run still going — a step it happens to be sitting on has not failed, and
|
|
180
|
+
// reporting it as the failed step would point an author at a step that was working.
|
|
181
|
+
if (outcome === "partial" || !state.current)
|
|
182
|
+
return undefined;
|
|
183
|
+
return { stepId: state.current };
|
|
184
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|