@runbooks/graph 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 +17 -0
- package/dist/diff.d.ts +74 -0
- package/dist/diff.js +148 -0
- package/dist/diff.test.d.ts +1 -0
- package/dist/diff.test.js +140 -0
- package/dist/edit.d.ts +160 -0
- package/dist/edit.js +299 -0
- package/dist/edit.test.d.ts +1 -0
- package/dist/edit.test.js +231 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.js +16 -0
- package/dist/invariants.d.ts +33 -0
- package/dist/invariants.js +253 -0
- package/dist/invariants.test.d.ts +1 -0
- package/dist/invariants.test.js +149 -0
- package/dist/layout.d.ts +51 -0
- package/dist/layout.js +185 -0
- package/dist/layout.test.d.ts +1 -0
- package/dist/layout.test.js +181 -0
- package/dist/model.d.ts +106 -0
- package/dist/model.js +118 -0
- package/dist/paths.d.ts +47 -0
- package/dist/paths.js +84 -0
- package/dist/paths.test.d.ts +1 -0
- package/dist/paths.test.js +255 -0
- package/dist/relations.d.ts +118 -0
- package/dist/relations.js +213 -0
- package/dist/relations.test.d.ts +1 -0
- package/dist/relations.test.js +140 -0
- package/dist/simulate.d.ts +64 -0
- package/dist/simulate.js +115 -0
- package/dist/simulate.test.d.ts +1 -0
- package/dist/simulate.test.js +109 -0
- package/dist/subgraph.d.ts +69 -0
- package/dist/subgraph.js +119 -0
- package/dist/subgraph.test.d.ts +1 -0
- package/dist/subgraph.test.js +127 -0
- package/package.json +38 -0
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { checkRelations, relationCycles, compositionTrust, parseRef, inheritsUpstream, RELATION_KINDS, MUST_PIN, MAY_CROSS_PUBLISHER, } from "./relations.js";
|
|
3
|
+
const known = {
|
|
4
|
+
refs: new Set(["std/drain-node", "std/cordon-node", "acme/restart-db"]),
|
|
5
|
+
versions: {
|
|
6
|
+
"std/drain-node": ["1.0.0", "1.1.0"],
|
|
7
|
+
"std/cordon-node": ["1.0.0"],
|
|
8
|
+
"acme/restart-db": ["2.0.0"],
|
|
9
|
+
},
|
|
10
|
+
};
|
|
11
|
+
const self = (relations) => ({ publisher: "std", slug: "playbook", relations });
|
|
12
|
+
const check = (relations) => checkRelations(self(relations), known);
|
|
13
|
+
/** "Every edge kind has a validation rule and a fixture." */
|
|
14
|
+
describe("each kind is validated by what it asserts", () => {
|
|
15
|
+
it("has four kinds and a rule for each", () => {
|
|
16
|
+
expect([...RELATION_KINDS].sort()).toEqual(["escalates_to", "part_of", "requires", "rollback_of"]);
|
|
17
|
+
for (const kind of RELATION_KINDS) {
|
|
18
|
+
expect(MUST_PIN[kind], kind).toBeTypeOf("boolean");
|
|
19
|
+
expect(MAY_CROSS_PUBLISHER[kind], kind).toBeTypeOf("boolean");
|
|
20
|
+
}
|
|
21
|
+
});
|
|
22
|
+
it("accepts a pinned requires", () => {
|
|
23
|
+
expect(check([{ kind: "requires", ref: "std/cordon-node@1.0.0" }])).toEqual([]);
|
|
24
|
+
});
|
|
25
|
+
it("refuses an unpinned requires, because it asserts what another record does", () => {
|
|
26
|
+
const [problem] = check([{ kind: "requires", ref: "std/cordon-node" }]);
|
|
27
|
+
expect(problem.code).toBe("relations/must-pin");
|
|
28
|
+
expect(problem.message).toMatch(/only true of a version/);
|
|
29
|
+
});
|
|
30
|
+
it("refuses a pinned part_of, because a frozen structure rots", () => {
|
|
31
|
+
const [problem] = check([{ kind: "part_of", ref: "std/drain-node@1.0.0" }]);
|
|
32
|
+
expect(problem.code).toBe("relations/must-not-pin");
|
|
33
|
+
expect(problem.message).toMatch(/keep pointing at 1\.0\.0 while/);
|
|
34
|
+
});
|
|
35
|
+
it("accepts a structural escalates_to with no version", () => {
|
|
36
|
+
expect(check([{ kind: "escalates_to", ref: "std/drain-node" }])).toEqual([]);
|
|
37
|
+
});
|
|
38
|
+
it("accepts a cross-publisher requires and refuses a cross-publisher part_of", () => {
|
|
39
|
+
expect(check([{ kind: "requires", ref: "acme/restart-db@2.0.0" }])).toEqual([]);
|
|
40
|
+
const [problem] = check([{ kind: "part_of", ref: "acme/restart-db" }]);
|
|
41
|
+
expect(problem.code).toBe("relations/cross-publisher");
|
|
42
|
+
expect(problem.message).toMatch(/only they can make it/);
|
|
43
|
+
});
|
|
44
|
+
it("refuses a record relating to itself", () => {
|
|
45
|
+
expect(check([{ kind: "requires", ref: "std/playbook@1.0.0" }])[0].code).toBe("relations/self");
|
|
46
|
+
});
|
|
47
|
+
it("refuses a reference that is not a reference", () => {
|
|
48
|
+
expect(check([{ kind: "requires", ref: "not a ref" }])[0].code).toBe("relations/unparseable");
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
/** "A dangling relation is a broken record." */
|
|
52
|
+
describe("a relation that does not resolve", () => {
|
|
53
|
+
it("fails on a record the catalog does not hold", () => {
|
|
54
|
+
const [problem] = check([{ kind: "requires", ref: "std/nothing@1.0.0" }]);
|
|
55
|
+
expect(problem.code).toBe("relations/dangling");
|
|
56
|
+
expect(problem.message).toMatch(/a reader following it arrives nowhere/);
|
|
57
|
+
});
|
|
58
|
+
it("fails on a version that record does not have, and says which it does", () => {
|
|
59
|
+
const [problem] = check([{ kind: "requires", ref: "std/cordon-node@9.9.9" }]);
|
|
60
|
+
expect(problem.code).toBe("relations/dangling");
|
|
61
|
+
expect(problem.message).toMatch(/Pin one it has: 1\.0\.0/);
|
|
62
|
+
});
|
|
63
|
+
it("accepts any version that record does have", () => {
|
|
64
|
+
expect(check([{ kind: "requires", ref: "std/drain-node@1.1.0" }])).toEqual([]);
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
/** All four are acyclic, for reasons that differ by kind and agree in outcome. */
|
|
68
|
+
describe("cycles", () => {
|
|
69
|
+
const records = [
|
|
70
|
+
{ ref: "std/a", relations: [{ kind: "requires", ref: "std/b@1.0.0" }] },
|
|
71
|
+
{ ref: "std/b", relations: [{ kind: "requires", ref: "std/a@1.0.0" }] },
|
|
72
|
+
{ ref: "std/c", relations: [{ kind: "part_of", ref: "std/a" }] },
|
|
73
|
+
];
|
|
74
|
+
it("finds a requires cycle nobody can satisfy first", () => {
|
|
75
|
+
const cycles = relationCycles(records, "requires");
|
|
76
|
+
expect(cycles.length).toBeGreaterThan(0);
|
|
77
|
+
expect(cycles[0]).toContain("std/a");
|
|
78
|
+
expect(cycles[0]).toContain("std/b");
|
|
79
|
+
});
|
|
80
|
+
it("finds no cycle where there is none", () => {
|
|
81
|
+
expect(relationCycles(records, "part_of")).toEqual([]);
|
|
82
|
+
expect(relationCycles(records, "escalates_to")).toEqual([]);
|
|
83
|
+
});
|
|
84
|
+
it("finds an escalation loop, which is the failure escalation exists to prevent", () => {
|
|
85
|
+
const loop = [
|
|
86
|
+
{ ref: "std/a", relations: [{ kind: "escalates_to", ref: "std/b" }] },
|
|
87
|
+
{ ref: "std/b", relations: [{ kind: "escalates_to", ref: "std/a" }] },
|
|
88
|
+
];
|
|
89
|
+
expect(relationCycles(loop, "escalates_to").length).toBeGreaterThan(0);
|
|
90
|
+
});
|
|
91
|
+
it("finds a containment that contains itself", () => {
|
|
92
|
+
const loop = [
|
|
93
|
+
{ ref: "std/a", relations: [{ kind: "part_of", ref: "std/b" }] },
|
|
94
|
+
{ ref: "std/b", relations: [{ kind: "part_of", ref: "std/a" }] },
|
|
95
|
+
];
|
|
96
|
+
expect(relationCycles(loop, "part_of").length).toBeGreaterThan(0);
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
/** "The trust of a composition is defined, not left to intuition." */
|
|
100
|
+
describe("a composition is as trustworthy as its weakest member", () => {
|
|
101
|
+
it("takes the minimum", () => {
|
|
102
|
+
expect(compositionTrust(["T4", "T1"])).toBe("T1");
|
|
103
|
+
expect(compositionTrust(["T3", "T3", "T2"])).toBe("T2");
|
|
104
|
+
expect(compositionTrust(["T4"])).toBe("T4");
|
|
105
|
+
});
|
|
106
|
+
it("does not average, which would let one good member launder four bad ones", () => {
|
|
107
|
+
expect(compositionTrust(["T4", "T4", "T4", "T4", "T0"])).toBe("T0");
|
|
108
|
+
});
|
|
109
|
+
it("is T0 for an empty composition rather than undefined", () => {
|
|
110
|
+
expect(compositionTrust([])).toBe("T0");
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
/** "A relation to a `gone` upstream record has defined behaviour." */
|
|
114
|
+
describe("a member whose upstream is gone", () => {
|
|
115
|
+
it("is not a broken relation: the record is still here", () => {
|
|
116
|
+
expect(check([{ kind: "requires", ref: "acme/restart-db@2.0.0" }])).toEqual([]);
|
|
117
|
+
});
|
|
118
|
+
it("reads its upstream state rather than guessing one", () => {
|
|
119
|
+
expect(inheritsUpstream("gone")).toBe("gone");
|
|
120
|
+
expect(inheritsUpstream("changed")).toBe("changed");
|
|
121
|
+
expect(inheritsUpstream(undefined)).toBe("unknown");
|
|
122
|
+
expect(inheritsUpstream("something-else")).toBe("unknown");
|
|
123
|
+
});
|
|
124
|
+
it("shows up in the composition's trust through that member's own level", () => {
|
|
125
|
+
// Nothing special-cases `gone` here: the member's level already fell when its
|
|
126
|
+
// upstream did (I-03), and the composition takes the minimum.
|
|
127
|
+
expect(compositionTrust(["T3", "T1"])).toBe("T1");
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
describe("references", () => {
|
|
131
|
+
it("parses both forms", () => {
|
|
132
|
+
expect(parseRef("std/x")).toEqual({ publisher: "std", slug: "x" });
|
|
133
|
+
expect(parseRef("std/x@1.2.3")).toEqual({ publisher: "std", slug: "x", semver: "1.2.3" });
|
|
134
|
+
});
|
|
135
|
+
it("refuses a form that is neither", () => {
|
|
136
|
+
for (const bad of ["std", "std/", "/x", "std/x@1", "Std/X", "std/x@v1.0.0"]) {
|
|
137
|
+
expect(parseRef(bad), bad).toBeUndefined();
|
|
138
|
+
}
|
|
139
|
+
});
|
|
140
|
+
});
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The dry pass (W-15, §18.3 rule 6, NG1).
|
|
3
|
+
*
|
|
4
|
+
* A simulation walks the graph and stops at every point where the document does not say
|
|
5
|
+
* on its own which way a run goes. Nothing executes: this is a reading of the procedure,
|
|
6
|
+
* and it is the answer to the neighbours' playground that does not require us to become
|
|
7
|
+
* one — the questions a reader has before trusting a runbook are answerable from its
|
|
8
|
+
* structure, and this answers them.
|
|
9
|
+
*
|
|
10
|
+
* The walk is here rather than in the page because `paths.ts` already owns reachability
|
|
11
|
+
* and enumeration, and a second traversal in a component would be the same sweep written
|
|
12
|
+
* twice: agreeing until somebody changed what counts as an edge in one of them.
|
|
13
|
+
*
|
|
14
|
+
* Two edge kinds are deliberately not choices. An `approval` edge is a duplicate of the
|
|
15
|
+
* transition it gates, so following it would double every gated step; a `rollback` edge
|
|
16
|
+
* is not a route forward at all. A `retry` is a route, but not one anybody chooses — it
|
|
17
|
+
* is what the runtime does with a failure under a budget, and offering it as an option
|
|
18
|
+
* would invite a reader to simulate a decision no operator ever makes.
|
|
19
|
+
*/
|
|
20
|
+
import { type EndOutcome, type Graph, type GraphEdge } from "./model.js";
|
|
21
|
+
/** How a run leaves the procedure. `escalated` is a handoff, not a failure. */
|
|
22
|
+
export type Ending = EndOutcome | "escalated";
|
|
23
|
+
export interface Option {
|
|
24
|
+
/** Stable answer key: a branch label, or `next` / `on_fail` at an outcome fork. */
|
|
25
|
+
readonly id: string;
|
|
26
|
+
readonly label: string;
|
|
27
|
+
readonly to: string;
|
|
28
|
+
readonly kind: GraphEdge["kind"];
|
|
29
|
+
}
|
|
30
|
+
export interface Fork {
|
|
31
|
+
readonly stepId: string;
|
|
32
|
+
readonly question: string;
|
|
33
|
+
readonly options: readonly Option[];
|
|
34
|
+
}
|
|
35
|
+
export interface Walk {
|
|
36
|
+
/** Node ids in the order the run reaches them, starting at the first step. */
|
|
37
|
+
readonly path: readonly string[];
|
|
38
|
+
/** Where the walk stopped for an answer. Absent once the run has ended. */
|
|
39
|
+
readonly fork?: Fork;
|
|
40
|
+
readonly ending?: Ending;
|
|
41
|
+
/** Steps on the path an operator must release by hand before they run. */
|
|
42
|
+
readonly approvals: readonly string[];
|
|
43
|
+
/**
|
|
44
|
+
* How far back the nearest undo is from where the walk stopped, in steps.
|
|
45
|
+
*
|
|
46
|
+
* The question a reader asks at a destructive step is not "does a rollback exist"
|
|
47
|
+
* but "how much of this do I have to unwind". Zero means this step names its own
|
|
48
|
+
* rollback; absent means nothing on the path does, which is worth seeing plainly.
|
|
49
|
+
*/
|
|
50
|
+
readonly stepsToRollback?: number;
|
|
51
|
+
}
|
|
52
|
+
export type Answers = Readonly<Record<string, string>>;
|
|
53
|
+
/** The transitions a run may take from a node, in document order. */
|
|
54
|
+
export declare function continuations(graph: Graph, from: string): GraphEdge[];
|
|
55
|
+
/**
|
|
56
|
+
* Walk as far as the answers allow.
|
|
57
|
+
*
|
|
58
|
+
* A missing answer is a stop, never a default. The whole point of the exercise is that
|
|
59
|
+
* the reader says which way their environment goes; guessing for them would produce a
|
|
60
|
+
* confident picture of a run nobody is going to have.
|
|
61
|
+
*/
|
|
62
|
+
export declare function walk(graph: Graph, answers?: Answers, limit?: number): Walk;
|
|
63
|
+
/** Per-node state for the renderer: what has been passed, where the reader is now. */
|
|
64
|
+
export declare function statesFor(walkResult: Walk): Record<string, "current" | "passed">;
|
package/dist/simulate.js
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The dry pass (W-15, §18.3 rule 6, NG1).
|
|
3
|
+
*
|
|
4
|
+
* A simulation walks the graph and stops at every point where the document does not say
|
|
5
|
+
* on its own which way a run goes. Nothing executes: this is a reading of the procedure,
|
|
6
|
+
* and it is the answer to the neighbours' playground that does not require us to become
|
|
7
|
+
* one — the questions a reader has before trusting a runbook are answerable from its
|
|
8
|
+
* structure, and this answers them.
|
|
9
|
+
*
|
|
10
|
+
* The walk is here rather than in the page because `paths.ts` already owns reachability
|
|
11
|
+
* and enumeration, and a second traversal in a component would be the same sweep written
|
|
12
|
+
* twice: agreeing until somebody changed what counts as an edge in one of them.
|
|
13
|
+
*
|
|
14
|
+
* Two edge kinds are deliberately not choices. An `approval` edge is a duplicate of the
|
|
15
|
+
* transition it gates, so following it would double every gated step; a `rollback` edge
|
|
16
|
+
* is not a route forward at all. A `retry` is a route, but not one anybody chooses — it
|
|
17
|
+
* is what the runtime does with a failure under a budget, and offering it as an option
|
|
18
|
+
* would invite a reader to simulate a decision no operator ever makes.
|
|
19
|
+
*/
|
|
20
|
+
import { isTerminal, START } from "./model.js";
|
|
21
|
+
/** The transitions a run may take from a node, in document order. */
|
|
22
|
+
export function continuations(graph, from) {
|
|
23
|
+
return graph.edges.filter((edge) => edge.from === from && (edge.kind === "next" || edge.kind === "branch" || edge.kind === "on_fail"));
|
|
24
|
+
}
|
|
25
|
+
function question(graph, id, options) {
|
|
26
|
+
const node = graph.nodes.find((n) => n.id === id);
|
|
27
|
+
const asked = node?.step?.question;
|
|
28
|
+
if (asked)
|
|
29
|
+
return asked;
|
|
30
|
+
if (node?.kind === "decision")
|
|
31
|
+
return node.title;
|
|
32
|
+
// Not a decision, but the document still forks here: a postcondition that holds and one
|
|
33
|
+
// that does not are two runs, and which one a reader is looking at is their answer.
|
|
34
|
+
return options.some((option) => option.kind === "on_fail")
|
|
35
|
+
? `Did this step do what it expected? — ${node?.title ?? id}`
|
|
36
|
+
: (node?.title ?? id);
|
|
37
|
+
}
|
|
38
|
+
function optionsAt(graph, edges) {
|
|
39
|
+
return edges.map((edge) => ({
|
|
40
|
+
id: edge.kind === "branch" ? (edge.label ?? edge.to) : edge.kind,
|
|
41
|
+
label: edge.kind === "branch"
|
|
42
|
+
? (edge.label ?? edge.to)
|
|
43
|
+
: edge.kind === "on_fail"
|
|
44
|
+
? "it failed"
|
|
45
|
+
: "as expected",
|
|
46
|
+
to: edge.to,
|
|
47
|
+
kind: edge.kind,
|
|
48
|
+
}));
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Walk as far as the answers allow.
|
|
52
|
+
*
|
|
53
|
+
* A missing answer is a stop, never a default. The whole point of the exercise is that
|
|
54
|
+
* the reader says which way their environment goes; guessing for them would produce a
|
|
55
|
+
* confident picture of a run nobody is going to have.
|
|
56
|
+
*/
|
|
57
|
+
export function walk(graph, answers = {}, limit = 256) {
|
|
58
|
+
const path = [];
|
|
59
|
+
const approvals = [];
|
|
60
|
+
const byId = new Map(graph.nodes.map((node) => [node.id, node]));
|
|
61
|
+
let rollbackAt;
|
|
62
|
+
let current = continuations(graph, START)[0]?.to;
|
|
63
|
+
for (let guard = 0; current !== undefined && guard < limit; guard++) {
|
|
64
|
+
const node = byId.get(current);
|
|
65
|
+
if (!node) {
|
|
66
|
+
// A dangling transition. Invariant 2 blocks publication on it, so this only shows
|
|
67
|
+
// up in a draft — and stopping with the id named is more use than an empty walk.
|
|
68
|
+
return { path, approvals, ...(rollbackAt === undefined ? {} : { stepsToRollback: path.length - 1 - rollbackAt }) };
|
|
69
|
+
}
|
|
70
|
+
path.push(current);
|
|
71
|
+
if (node.requiresApproval)
|
|
72
|
+
approvals.push(current);
|
|
73
|
+
if (graph.edges.some((edge) => edge.from === current && edge.kind === "rollback")) {
|
|
74
|
+
rollbackAt = path.length - 1;
|
|
75
|
+
}
|
|
76
|
+
const distance = rollbackAt === undefined ? {} : { stepsToRollback: path.length - 1 - rollbackAt };
|
|
77
|
+
if (node.kind === "end") {
|
|
78
|
+
return { path, approvals, ...(node.outcome ? { ending: node.outcome } : {}), ...distance };
|
|
79
|
+
}
|
|
80
|
+
if (isTerminal(node.kind)) {
|
|
81
|
+
return { path, approvals, ending: "escalated", ...distance };
|
|
82
|
+
}
|
|
83
|
+
const edges = continuations(graph, current);
|
|
84
|
+
if (edges.length === 0) {
|
|
85
|
+
// Invariant 7 makes this impossible in a published record; a draft can still get
|
|
86
|
+
// here, and a walk that ends nowhere is exactly what the reader should see.
|
|
87
|
+
return { path, approvals, ...distance };
|
|
88
|
+
}
|
|
89
|
+
if (edges.length === 1) {
|
|
90
|
+
current = edges[0].to;
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
const options = optionsAt(graph, edges);
|
|
94
|
+
const answer = answers[current];
|
|
95
|
+
const chosen = options.find((option) => option.id === answer);
|
|
96
|
+
if (!chosen) {
|
|
97
|
+
return {
|
|
98
|
+
path,
|
|
99
|
+
approvals,
|
|
100
|
+
fork: { stepId: current, question: question(graph, current, options), options },
|
|
101
|
+
...distance,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
current = chosen.to;
|
|
105
|
+
}
|
|
106
|
+
return { path, approvals, ...(rollbackAt === undefined ? {} : { stepsToRollback: path.length - 1 - rollbackAt }) };
|
|
107
|
+
}
|
|
108
|
+
/** Per-node state for the renderer: what has been passed, where the reader is now. */
|
|
109
|
+
export function statesFor(walkResult) {
|
|
110
|
+
const states = {};
|
|
111
|
+
walkResult.path.forEach((id, index) => {
|
|
112
|
+
states[id] = index === walkResult.path.length - 1 ? "current" : "passed";
|
|
113
|
+
});
|
|
114
|
+
return states;
|
|
115
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { toGraph } from "./model.js";
|
|
3
|
+
import { walk, statesFor, continuations } from "./simulate.js";
|
|
4
|
+
const doc = (steps) => ({ runbook: { steps } });
|
|
5
|
+
const linear = toGraph(doc([
|
|
6
|
+
{ id: "s1", kind: "check", title: "Look", tool: "cli:df", expect: "it is there", on_fail: "escalate:s9", next: "s2" },
|
|
7
|
+
{ id: "s2", kind: "action", title: "Fix it", tool: "cli:rm", next: "end:success" },
|
|
8
|
+
{ id: "s9", kind: "escalate", title: "Escalate", to: "team:x" },
|
|
9
|
+
]));
|
|
10
|
+
const branching = toGraph(doc([
|
|
11
|
+
{ id: "s1", kind: "decision", title: "Which way", question: "Is the kubelet reachable?", branches: { yes: "s2", no: "s3" } },
|
|
12
|
+
{ id: "s2", kind: "action", title: "Left", tool: "cli:a", next: "end:success" },
|
|
13
|
+
{ id: "s3", kind: "action", title: "Right", tool: "cli:b", next: "end:failed" },
|
|
14
|
+
]));
|
|
15
|
+
const gated = toGraph(doc([
|
|
16
|
+
{ id: "s1", kind: "check", title: "Look", tool: "cli:df", next: "s2" },
|
|
17
|
+
{
|
|
18
|
+
id: "s2", kind: "action", title: "Drain", tool: "cli:kubectl", risk: "destructive",
|
|
19
|
+
requires_approval: true, rollback_ref: "s3", next: "end:success",
|
|
20
|
+
},
|
|
21
|
+
{ id: "s3", kind: "action", title: "Uncordon", tool: "cli:kubectl", next: "end:success" },
|
|
22
|
+
]));
|
|
23
|
+
/**
|
|
24
|
+
* A dry pass is a reading of the document, so it stops wherever the document stops
|
|
25
|
+
* saying which way a run goes — and nowhere else.
|
|
26
|
+
*/
|
|
27
|
+
describe("the walk goes as far as the document decides on its own", () => {
|
|
28
|
+
it("stops at the first fork with the question the author wrote", () => {
|
|
29
|
+
const result = walk(branching);
|
|
30
|
+
expect(result.path).toEqual(["s1"]);
|
|
31
|
+
expect(result.fork?.question).toBe("Is the kubelet reachable?");
|
|
32
|
+
expect(result.fork?.options.map((o) => o.id).sort()).toEqual(["no", "yes"]);
|
|
33
|
+
expect(result.ending).toBeUndefined();
|
|
34
|
+
});
|
|
35
|
+
it("takes the branch the reader chose", () => {
|
|
36
|
+
expect(walk(branching, { s1: "yes" }).path).toEqual(["s1", "s2", "__end_success"]);
|
|
37
|
+
expect(walk(branching, { s1: "no" }).ending).toBe("failed");
|
|
38
|
+
});
|
|
39
|
+
it("treats a postcondition as a fork too, since a failure is a different run", () => {
|
|
40
|
+
const result = walk(linear);
|
|
41
|
+
expect(result.fork?.stepId).toBe("s1");
|
|
42
|
+
expect(result.fork?.options.map((o) => o.id).sort()).toEqual(["next", "on_fail"]);
|
|
43
|
+
expect(result.fork?.question).toMatch(/Did this step do what it expected\?/);
|
|
44
|
+
});
|
|
45
|
+
it("reaches an ending down each answer", () => {
|
|
46
|
+
expect(walk(linear, { s1: "next" }).ending).toBe("success");
|
|
47
|
+
expect(walk(linear, { s1: "on_fail" }).ending).toBe("escalated");
|
|
48
|
+
});
|
|
49
|
+
it("never guesses an answer it was not given", () => {
|
|
50
|
+
// The reader's answer describes their production environment. A default would draw a
|
|
51
|
+
// confident picture of a run nobody is going to have.
|
|
52
|
+
expect(walk(branching, { s1: "maybe" }).fork?.stepId).toBe("s1");
|
|
53
|
+
});
|
|
54
|
+
it("terminates on a graph with a retry loop rather than following it forever", () => {
|
|
55
|
+
const retrying = toGraph(doc([
|
|
56
|
+
{ id: "s1", kind: "check", title: "Settled", tool: "cli:test", on_fail: "s2", next: "end:success" },
|
|
57
|
+
{ id: "s2", kind: "wait", title: "Wait", duration: "5s", retry: { max: 2, target: "s1" }, next: "end:failed" },
|
|
58
|
+
]));
|
|
59
|
+
expect(walk(retrying, { s1: "on_fail" }).ending).toBe("failed");
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
/** What the reader came for: the collected outputs along the path they chose. */
|
|
63
|
+
describe("what the chosen path collects", () => {
|
|
64
|
+
it("names where a person has to release the run by hand", () => {
|
|
65
|
+
expect(walk(gated).approvals).toEqual(["s2"]);
|
|
66
|
+
});
|
|
67
|
+
it("counts how far back the nearest undo is", () => {
|
|
68
|
+
// s2 names its own rollback; the walk runs on to the ending, one step past it.
|
|
69
|
+
expect(walk(gated).stepsToRollback).toBe(1);
|
|
70
|
+
expect(walk(gated, {}).path.indexOf("s2")).toBe(1);
|
|
71
|
+
expect(walk(gated).path).toEqual(["s1", "s2", "__end_success"]);
|
|
72
|
+
});
|
|
73
|
+
it("says nothing about a rollback where the path has none, rather than zero", () => {
|
|
74
|
+
expect(walk(branching, { s1: "yes" }).stepsToRollback).toBeUndefined();
|
|
75
|
+
});
|
|
76
|
+
it("marks where the reader is and what is behind them, and nothing else", () => {
|
|
77
|
+
const states = statesFor(walk(branching, { s1: "yes" }));
|
|
78
|
+
expect(states["s1"]).toBe("passed");
|
|
79
|
+
expect(states["__end_success"]).toBe("current");
|
|
80
|
+
expect(states["s3"]).toBeUndefined();
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
/**
|
|
84
|
+
* An approval edge is a duplicate of the transition it gates and a rollback is not a
|
|
85
|
+
* route forward. Following either would double a gated step or walk the undo as though
|
|
86
|
+
* it were the procedure.
|
|
87
|
+
*/
|
|
88
|
+
describe("the walk follows routes a run can take", () => {
|
|
89
|
+
it("offers no approval or rollback edge as a way forward", () => {
|
|
90
|
+
for (const node of gated.nodes) {
|
|
91
|
+
expect(continuations(gated, node.id).every((e) => e.kind !== "approval" && e.kind !== "rollback")).toBe(true);
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
it("passes a gated step once", () => {
|
|
95
|
+
expect(walk(gated).path.filter((id) => id === "s2").length).toBe(1);
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
/** A draft is where a broken graph lives. Stopping with the id named beats an empty walk. */
|
|
99
|
+
describe("a graph that does not hold up still walks", () => {
|
|
100
|
+
it("stops at a transition to a step that is not there", () => {
|
|
101
|
+
const dangling = toGraph(doc([{ id: "s1", kind: "check", title: "Look", next: "s404" }]));
|
|
102
|
+
const result = walk(dangling);
|
|
103
|
+
expect(result.path).toEqual(["s1"]);
|
|
104
|
+
expect(result.ending).toBeUndefined();
|
|
105
|
+
});
|
|
106
|
+
it("walks an empty document without throwing", () => {
|
|
107
|
+
expect(walk(toGraph(doc([]))).path).toEqual([]);
|
|
108
|
+
});
|
|
109
|
+
});
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Subgraphs (W-21, §18.6, Q7).
|
|
3
|
+
*
|
|
4
|
+
* §18.6 caps a graph at twelve nodes and sends the rest into a subgraph linked by
|
|
5
|
+
* `part_of`. Because `part_of` is an edge between **records** (S-13), a subgraph already
|
|
6
|
+
* has a publisher, a trust level, a provenance chain and a page — so nothing expands in
|
|
7
|
+
* place, and the parent shows the aggregate instead
|
|
8
|
+
* (docs/decisions/Q7-subgraph-expansion.md).
|
|
9
|
+
*
|
|
10
|
+
* The aggregate is the part a reader cannot afford to have hidden: **a collapsed subgraph
|
|
11
|
+
* that hides a destructive step is a correctness bug, not a display choice.** So it is
|
|
12
|
+
* computed transitively — a destructive step two levels down marks the node at the top —
|
|
13
|
+
* while what is *shown* stays one level deep.
|
|
14
|
+
*/
|
|
15
|
+
import { type RunbookDocument } from "./model.js";
|
|
16
|
+
import { type Relation } from "./relations.js";
|
|
17
|
+
import type { Risk } from "@runbooks/schema";
|
|
18
|
+
/** How deep aggregation walks before it decides the graph of records is malformed. */
|
|
19
|
+
export declare const MAX_ROLLUP_DEPTH = 16;
|
|
20
|
+
export interface SubgraphRecord {
|
|
21
|
+
readonly ref: string;
|
|
22
|
+
readonly document: RunbookDocument & {
|
|
23
|
+
runbook?: {
|
|
24
|
+
relations?: readonly Relation[];
|
|
25
|
+
capabilities?: readonly string[];
|
|
26
|
+
};
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
export interface Aggregate {
|
|
30
|
+
readonly ref: string;
|
|
31
|
+
/** The step of the parent this record expands, when it says which. */
|
|
32
|
+
readonly step?: string;
|
|
33
|
+
readonly nodes: number;
|
|
34
|
+
/** The worst risk anywhere inside, including inside its own subgraphs. */
|
|
35
|
+
readonly highestRisk: Risk;
|
|
36
|
+
/** Everything it needs, transitively: a reader granting this grants all of it. */
|
|
37
|
+
readonly capabilities: readonly string[];
|
|
38
|
+
/** True when something inside forces an approval. Shown, never inferred by the reader. */
|
|
39
|
+
readonly containsDangerous: boolean;
|
|
40
|
+
/** How deep the walk actually went, so a cut-off is visible rather than silent. */
|
|
41
|
+
readonly depth: number;
|
|
42
|
+
/** Set when the walk stopped early, with why. */
|
|
43
|
+
readonly truncated?: string;
|
|
44
|
+
}
|
|
45
|
+
/** The children that decompose a record: the ones declaring `part_of` against it. */
|
|
46
|
+
export declare function childrenOf(parentRef: string, records: readonly SubgraphRecord[]): SubgraphRecord[];
|
|
47
|
+
/**
|
|
48
|
+
* What a collapsed node stands for, computed all the way down.
|
|
49
|
+
*
|
|
50
|
+
* The cycle guard is not defensive programming: `part_of` is acyclic by S-13's rule and
|
|
51
|
+
* the gate refuses a cycle, so reaching one here means the catalog moved underneath us —
|
|
52
|
+
* and hanging on it would be a worse answer than a truncated one that says so.
|
|
53
|
+
*/
|
|
54
|
+
export declare function aggregate(child: SubgraphRecord, parentRef: string, records: readonly SubgraphRecord[], seen?: ReadonlySet<string>): Aggregate;
|
|
55
|
+
/**
|
|
56
|
+
* The decomposition of one record, one level deep and summarised all the way down.
|
|
57
|
+
*
|
|
58
|
+
* Ordered by the parent's own steps, so following them with a keyboard walks the procedure
|
|
59
|
+
* in the order it runs rather than in the order the children happen to be filed.
|
|
60
|
+
*/
|
|
61
|
+
export declare function decomposition(parent: SubgraphRecord, records: readonly SubgraphRecord[]): Aggregate[];
|
|
62
|
+
/**
|
|
63
|
+
* §18.6 rule 2, as a question about one record: is this graph over the cap and undecomposed?
|
|
64
|
+
*
|
|
65
|
+
* A warning rather than an error (§6), and it stays one: twelve is a rule about what a
|
|
66
|
+
* reader can take in, and a procedure that genuinely needs thirteen steps is not invalid.
|
|
67
|
+
*/
|
|
68
|
+
export declare const NODE_CAP = 12;
|
|
69
|
+
export declare function overCap(record: SubgraphRecord, records: readonly SubgraphRecord[]): boolean;
|
package/dist/subgraph.js
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Subgraphs (W-21, §18.6, Q7).
|
|
3
|
+
*
|
|
4
|
+
* §18.6 caps a graph at twelve nodes and sends the rest into a subgraph linked by
|
|
5
|
+
* `part_of`. Because `part_of` is an edge between **records** (S-13), a subgraph already
|
|
6
|
+
* has a publisher, a trust level, a provenance chain and a page — so nothing expands in
|
|
7
|
+
* place, and the parent shows the aggregate instead
|
|
8
|
+
* (docs/decisions/Q7-subgraph-expansion.md).
|
|
9
|
+
*
|
|
10
|
+
* The aggregate is the part a reader cannot afford to have hidden: **a collapsed subgraph
|
|
11
|
+
* that hides a destructive step is a correctness bug, not a display choice.** So it is
|
|
12
|
+
* computed transitively — a destructive step two levels down marks the node at the top —
|
|
13
|
+
* while what is *shown* stays one level deep.
|
|
14
|
+
*/
|
|
15
|
+
import { toGraph } from "./model.js";
|
|
16
|
+
import { parseRef } from "./relations.js";
|
|
17
|
+
import { isDangerous } from "@runbooks/schema";
|
|
18
|
+
const RISK_ORDER = ["read-only", "reversible-write", "destructive", "irreversible"];
|
|
19
|
+
/** How deep aggregation walks before it decides the graph of records is malformed. */
|
|
20
|
+
export const MAX_ROLLUP_DEPTH = 16;
|
|
21
|
+
function riskOf(document) {
|
|
22
|
+
const steps = (document.runbook?.steps ?? []);
|
|
23
|
+
return steps.reduce((worst, step) => step.risk && RISK_ORDER.indexOf(step.risk) > RISK_ORDER.indexOf(worst) ? step.risk : worst, "read-only");
|
|
24
|
+
}
|
|
25
|
+
/** The children that decompose a record: the ones declaring `part_of` against it. */
|
|
26
|
+
export function childrenOf(parentRef, records) {
|
|
27
|
+
return records.filter((record) => (record.document.runbook?.relations ?? []).some((relation) => {
|
|
28
|
+
if (relation.kind !== "part_of")
|
|
29
|
+
return false;
|
|
30
|
+
const parsed = parseRef(relation.ref);
|
|
31
|
+
return parsed !== undefined && `${parsed.publisher}/${parsed.slug}` === parentRef;
|
|
32
|
+
}));
|
|
33
|
+
}
|
|
34
|
+
function stepOf(child, parentRef) {
|
|
35
|
+
const relation = (child.document.runbook?.relations ?? []).find((candidate) => {
|
|
36
|
+
if (candidate.kind !== "part_of")
|
|
37
|
+
return false;
|
|
38
|
+
const parsed = parseRef(candidate.ref);
|
|
39
|
+
return parsed !== undefined && `${parsed.publisher}/${parsed.slug}` === parentRef;
|
|
40
|
+
});
|
|
41
|
+
return relation?.step;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* What a collapsed node stands for, computed all the way down.
|
|
45
|
+
*
|
|
46
|
+
* The cycle guard is not defensive programming: `part_of` is acyclic by S-13's rule and
|
|
47
|
+
* the gate refuses a cycle, so reaching one here means the catalog moved underneath us —
|
|
48
|
+
* and hanging on it would be a worse answer than a truncated one that says so.
|
|
49
|
+
*/
|
|
50
|
+
export function aggregate(child, parentRef, records, seen = new Set()) {
|
|
51
|
+
const visited = new Set([...seen, child.ref]);
|
|
52
|
+
const graph = toGraph(child.document);
|
|
53
|
+
const own = graph.nodes.filter((node) => !node.synthetic);
|
|
54
|
+
let nodes = own.length;
|
|
55
|
+
let highestRisk = riskOf(child.document);
|
|
56
|
+
const capabilities = new Set(child.document.runbook?.capabilities ?? []);
|
|
57
|
+
let depth = 1;
|
|
58
|
+
let truncated;
|
|
59
|
+
if (visited.size <= MAX_ROLLUP_DEPTH) {
|
|
60
|
+
for (const grandchild of childrenOf(child.ref, records)) {
|
|
61
|
+
if (visited.has(grandchild.ref)) {
|
|
62
|
+
truncated = `${grandchild.ref} is already on this path: \`part_of\` has a cycle, which the relations gate refuses to publish.`;
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
const inner = aggregate(grandchild, child.ref, records, visited);
|
|
66
|
+
nodes += inner.nodes;
|
|
67
|
+
if (RISK_ORDER.indexOf(inner.highestRisk) > RISK_ORDER.indexOf(highestRisk)) {
|
|
68
|
+
highestRisk = inner.highestRisk;
|
|
69
|
+
}
|
|
70
|
+
for (const capability of inner.capabilities)
|
|
71
|
+
capabilities.add(capability);
|
|
72
|
+
depth = Math.max(depth, inner.depth + 1);
|
|
73
|
+
truncated = truncated ?? inner.truncated;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
else {
|
|
77
|
+
truncated = `Stopped at ${MAX_ROLLUP_DEPTH} levels. A decomposition this deep is not a decomposition.`;
|
|
78
|
+
}
|
|
79
|
+
const step = stepOf(child, parentRef);
|
|
80
|
+
return {
|
|
81
|
+
ref: child.ref,
|
|
82
|
+
...(step ? { step } : {}),
|
|
83
|
+
nodes,
|
|
84
|
+
highestRisk,
|
|
85
|
+
capabilities: [...capabilities].sort(),
|
|
86
|
+
// The one thing that must never be hidden by collapsing.
|
|
87
|
+
containsDangerous: isDangerous(highestRisk),
|
|
88
|
+
depth,
|
|
89
|
+
...(truncated ? { truncated } : {}),
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* The decomposition of one record, one level deep and summarised all the way down.
|
|
94
|
+
*
|
|
95
|
+
* Ordered by the parent's own steps, so following them with a keyboard walks the procedure
|
|
96
|
+
* in the order it runs rather than in the order the children happen to be filed.
|
|
97
|
+
*/
|
|
98
|
+
export function decomposition(parent, records) {
|
|
99
|
+
const order = (parent.document.runbook?.steps ?? []).map((step) => step.id);
|
|
100
|
+
return childrenOf(parent.ref, records)
|
|
101
|
+
.map((child) => aggregate(child, parent.ref, records))
|
|
102
|
+
.sort((a, b) => {
|
|
103
|
+
const left = a.step ? order.indexOf(a.step) : Number.MAX_SAFE_INTEGER;
|
|
104
|
+
const right = b.step ? order.indexOf(b.step) : Number.MAX_SAFE_INTEGER;
|
|
105
|
+
return left !== right ? left - right : a.ref < b.ref ? -1 : 1;
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* §18.6 rule 2, as a question about one record: is this graph over the cap and undecomposed?
|
|
110
|
+
*
|
|
111
|
+
* A warning rather than an error (§6), and it stays one: twelve is a rule about what a
|
|
112
|
+
* reader can take in, and a procedure that genuinely needs thirteen steps is not invalid.
|
|
113
|
+
*/
|
|
114
|
+
export const NODE_CAP = 12;
|
|
115
|
+
export function overCap(record, records) {
|
|
116
|
+
const graph = toGraph(record.document);
|
|
117
|
+
const own = graph.nodes.filter((node) => !node.synthetic).length;
|
|
118
|
+
return own > NODE_CAP && childrenOf(record.ref, records).length === 0;
|
|
119
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|