@abelxiaoxing/cadence 1.1.0 → 1.2.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/README.md +19 -3
- package/agents/implementation-worker.md +17 -11
- package/config/routes.example.json +0 -2
- package/package.json +2 -2
- package/prompts/abel-design.md +77 -17
- package/prompts/abel-diagnose.md +2 -0
- package/prompts/abel-implement.md +23 -11
- package/skills/abel-workflow/SKILL.md +42 -5
- package/src/agent-registry.ts +0 -1
- package/src/candidate-patch.ts +250 -0
- package/src/child-session.ts +27 -24
- package/src/contracts.ts +19 -10
- package/src/control-contracts.ts +15 -62
- package/src/delivery-compiler.ts +47 -17
- package/src/design-control.ts +1053 -0
- package/src/design-journal.ts +1616 -0
- package/src/index.ts +626 -196
- package/src/packet-runtime.ts +6 -6
- package/src/route-policy.ts +45 -17
- package/src/run-state.ts +43 -4
- package/src/run-store.ts +103 -15
- package/src/subagent-activity.ts +1 -1
- package/src/submit-tool.ts +149 -49
- package/src/verification-capability.ts +43 -15
- package/src/worker-broker.ts +0 -1
- package/src/workflow-engine.ts +416 -250
- package/agents/contract-reviewer.md +0 -11
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
import { lstatSync, readFileSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { generateUnifiedPatch } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
|
|
5
|
+
import { isValidRelativePath } from "./contracts.ts";
|
|
6
|
+
import { observeSafePath } from "./safe-path.ts";
|
|
7
|
+
|
|
8
|
+
export type CandidatePatchOperation =
|
|
9
|
+
| {
|
|
10
|
+
kind: "replace";
|
|
11
|
+
path: string;
|
|
12
|
+
oldText: string;
|
|
13
|
+
newText: string;
|
|
14
|
+
}
|
|
15
|
+
| { kind: "rewrite"; path: string; content: string }
|
|
16
|
+
| {
|
|
17
|
+
kind: "create";
|
|
18
|
+
path: string;
|
|
19
|
+
content: string;
|
|
20
|
+
mode: "regular" | "executable";
|
|
21
|
+
}
|
|
22
|
+
| { kind: "delete"; path: string };
|
|
23
|
+
|
|
24
|
+
interface CandidateFileState {
|
|
25
|
+
path: string;
|
|
26
|
+
base: string | null;
|
|
27
|
+
current: string | null;
|
|
28
|
+
mode: "100644" | "100755";
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function exactKeys(value: object, expected: readonly string[]): boolean {
|
|
32
|
+
const actual = Object.keys(value).sort();
|
|
33
|
+
return (
|
|
34
|
+
actual.length === expected.length &&
|
|
35
|
+
[...expected].sort().every((key, index) => actual[index] === key)
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function safeText(value: unknown, maxBytes: number): value is string {
|
|
40
|
+
if (typeof value !== "string" || value.includes("\0")) return false;
|
|
41
|
+
const encoded = Buffer.from(value, "utf8");
|
|
42
|
+
return encoded.byteLength <= maxBytes && encoded.toString("utf8") === value;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function renderFileDiff(state: CandidateFileState): string {
|
|
46
|
+
if (state.base === state.current) return "";
|
|
47
|
+
if (state.base === null && state.current === "") {
|
|
48
|
+
throw new Error("candidate-empty-file-create-unsupported");
|
|
49
|
+
}
|
|
50
|
+
if (state.current === null && state.base === "") {
|
|
51
|
+
throw new Error("candidate-empty-file-delete-unsupported");
|
|
52
|
+
}
|
|
53
|
+
const output: string[] = [`diff --git a/${state.path} b/${state.path}`];
|
|
54
|
+
if (state.base === null) output.push(`new file mode ${state.mode}`);
|
|
55
|
+
if (state.current === null) output.push(`deleted file mode ${state.mode}`);
|
|
56
|
+
const generated = generateUnifiedPatch(
|
|
57
|
+
state.path,
|
|
58
|
+
state.base ?? "",
|
|
59
|
+
state.current ?? "",
|
|
60
|
+
3,
|
|
61
|
+
);
|
|
62
|
+
const generatedHeaders = `--- ${state.path}\n+++ ${state.path}\n`;
|
|
63
|
+
if (!generated.startsWith(generatedHeaders)) {
|
|
64
|
+
throw new Error("candidate-patch-diff-invalid");
|
|
65
|
+
}
|
|
66
|
+
output.push(state.base === null ? "--- /dev/null" : `--- a/${state.path}`);
|
|
67
|
+
output.push(state.current === null ? "+++ /dev/null" : `+++ b/${state.path}`);
|
|
68
|
+
return `${output.join("\n")}\n${generated.slice(generatedHeaders.length)}`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function occurrenceCount(value: string, search: string): number {
|
|
72
|
+
let count = 0;
|
|
73
|
+
let offset = 0;
|
|
74
|
+
for (;;) {
|
|
75
|
+
const found = value.indexOf(search, offset);
|
|
76
|
+
if (found < 0) return count;
|
|
77
|
+
count += 1;
|
|
78
|
+
if (count > 1) return count;
|
|
79
|
+
offset = found + 1;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function compileCandidatePatch(input: {
|
|
84
|
+
root: string;
|
|
85
|
+
writePaths: readonly string[];
|
|
86
|
+
deletePaths: readonly string[];
|
|
87
|
+
operations: readonly CandidatePatchOperation[];
|
|
88
|
+
maxBytes: number;
|
|
89
|
+
}): string {
|
|
90
|
+
if (
|
|
91
|
+
!path.isAbsolute(input.root) ||
|
|
92
|
+
!Number.isSafeInteger(input.maxBytes) ||
|
|
93
|
+
input.maxBytes < 1 ||
|
|
94
|
+
!Array.isArray(input.operations) ||
|
|
95
|
+
input.operations.length < 1 ||
|
|
96
|
+
input.operations.length > 128
|
|
97
|
+
) {
|
|
98
|
+
throw new Error("candidate-patch-invalid");
|
|
99
|
+
}
|
|
100
|
+
const writePaths = new Set(input.writePaths);
|
|
101
|
+
const deletePaths = new Set(input.deletePaths);
|
|
102
|
+
const states = new Map<string, CandidateFileState>();
|
|
103
|
+
|
|
104
|
+
const requirePath = (relative: unknown): string => {
|
|
105
|
+
if (
|
|
106
|
+
!isValidRelativePath(relative) ||
|
|
107
|
+
relative === "." ||
|
|
108
|
+
relative.trim() !== relative ||
|
|
109
|
+
/[\r\n\t]/u.test(relative)
|
|
110
|
+
) {
|
|
111
|
+
throw new Error("candidate-patch-path-invalid");
|
|
112
|
+
}
|
|
113
|
+
return relative;
|
|
114
|
+
};
|
|
115
|
+
const load = (relative: string): CandidateFileState => {
|
|
116
|
+
const existing = states.get(relative);
|
|
117
|
+
if (existing) return existing;
|
|
118
|
+
const observation = observeSafePath(input.root, relative);
|
|
119
|
+
if (observation.kind === "directory" || observation.kind === "unsafe") {
|
|
120
|
+
throw new Error("candidate-patch-path-unsafe");
|
|
121
|
+
}
|
|
122
|
+
let base: string | null = null;
|
|
123
|
+
let mode: CandidateFileState["mode"] = "100644";
|
|
124
|
+
if (observation.kind === "file") {
|
|
125
|
+
const target = path.join(input.root, relative);
|
|
126
|
+
const stat = lstatSync(target);
|
|
127
|
+
if (stat.size > input.maxBytes) {
|
|
128
|
+
throw new Error("candidate-patch-source-too-large");
|
|
129
|
+
}
|
|
130
|
+
const bytes = readFileSync(target);
|
|
131
|
+
try {
|
|
132
|
+
base = new TextDecoder("utf-8", {
|
|
133
|
+
fatal: true,
|
|
134
|
+
ignoreBOM: true,
|
|
135
|
+
}).decode(bytes);
|
|
136
|
+
} catch {
|
|
137
|
+
throw new Error("candidate-patch-source-invalid");
|
|
138
|
+
}
|
|
139
|
+
if (base.includes("\0")) {
|
|
140
|
+
throw new Error("candidate-patch-source-invalid");
|
|
141
|
+
}
|
|
142
|
+
mode = stat.mode & 0o111 ? "100755" : "100644";
|
|
143
|
+
}
|
|
144
|
+
const state = { path: relative, base, current: base, mode };
|
|
145
|
+
states.set(relative, state);
|
|
146
|
+
return state;
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
for (const operation of input.operations) {
|
|
150
|
+
if (
|
|
151
|
+
!operation ||
|
|
152
|
+
typeof operation !== "object" ||
|
|
153
|
+
Array.isArray(operation)
|
|
154
|
+
) {
|
|
155
|
+
throw new Error("candidate-patch-operation-invalid");
|
|
156
|
+
}
|
|
157
|
+
switch (operation.kind) {
|
|
158
|
+
case "replace": {
|
|
159
|
+
if (
|
|
160
|
+
!exactKeys(operation, ["kind", "path", "oldText", "newText"]) ||
|
|
161
|
+
!safeText(operation.oldText, input.maxBytes) ||
|
|
162
|
+
operation.oldText.length === 0 ||
|
|
163
|
+
!safeText(operation.newText, input.maxBytes)
|
|
164
|
+
) {
|
|
165
|
+
throw new Error("candidate-patch-replace-invalid");
|
|
166
|
+
}
|
|
167
|
+
const relative = requirePath(operation.path);
|
|
168
|
+
if (!writePaths.has(relative)) {
|
|
169
|
+
throw new Error("candidate-patch-replace-invalid");
|
|
170
|
+
}
|
|
171
|
+
const state = load(relative);
|
|
172
|
+
if (
|
|
173
|
+
state.current === null ||
|
|
174
|
+
occurrenceCount(state.current, operation.oldText) !== 1
|
|
175
|
+
) {
|
|
176
|
+
throw new Error("candidate-patch-replace-invalid");
|
|
177
|
+
}
|
|
178
|
+
state.current = state.current.replace(
|
|
179
|
+
operation.oldText,
|
|
180
|
+
operation.newText,
|
|
181
|
+
);
|
|
182
|
+
break;
|
|
183
|
+
}
|
|
184
|
+
case "rewrite": {
|
|
185
|
+
if (
|
|
186
|
+
!exactKeys(operation, ["kind", "path", "content"]) ||
|
|
187
|
+
!safeText(operation.content, input.maxBytes)
|
|
188
|
+
) {
|
|
189
|
+
throw new Error("candidate-patch-rewrite-invalid");
|
|
190
|
+
}
|
|
191
|
+
const relative = requirePath(operation.path);
|
|
192
|
+
if (!writePaths.has(relative)) {
|
|
193
|
+
throw new Error("candidate-patch-rewrite-invalid");
|
|
194
|
+
}
|
|
195
|
+
const state = load(relative);
|
|
196
|
+
if (state.current === null) {
|
|
197
|
+
throw new Error("candidate-patch-rewrite-invalid");
|
|
198
|
+
}
|
|
199
|
+
state.current = operation.content;
|
|
200
|
+
break;
|
|
201
|
+
}
|
|
202
|
+
case "create": {
|
|
203
|
+
if (
|
|
204
|
+
!exactKeys(operation, ["kind", "path", "content", "mode"]) ||
|
|
205
|
+
!safeText(operation.content, input.maxBytes) ||
|
|
206
|
+
operation.content.length === 0 ||
|
|
207
|
+
(operation.mode !== "regular" && operation.mode !== "executable")
|
|
208
|
+
) {
|
|
209
|
+
throw new Error("candidate-patch-create-invalid");
|
|
210
|
+
}
|
|
211
|
+
const relative = requirePath(operation.path);
|
|
212
|
+
if (!writePaths.has(relative)) {
|
|
213
|
+
throw new Error("candidate-patch-create-invalid");
|
|
214
|
+
}
|
|
215
|
+
const state = load(relative);
|
|
216
|
+
if (state.base !== null || state.current !== null) {
|
|
217
|
+
throw new Error("candidate-patch-create-invalid");
|
|
218
|
+
}
|
|
219
|
+
state.current = operation.content;
|
|
220
|
+
state.mode = operation.mode === "executable" ? "100755" : "100644";
|
|
221
|
+
break;
|
|
222
|
+
}
|
|
223
|
+
case "delete": {
|
|
224
|
+
if (!exactKeys(operation, ["kind", "path"])) {
|
|
225
|
+
throw new Error("candidate-patch-delete-invalid");
|
|
226
|
+
}
|
|
227
|
+
const relative = requirePath(operation.path);
|
|
228
|
+
if (!deletePaths.has(relative)) {
|
|
229
|
+
throw new Error("candidate-patch-delete-invalid");
|
|
230
|
+
}
|
|
231
|
+
const state = load(relative);
|
|
232
|
+
if (state.base === null || state.current === null) {
|
|
233
|
+
throw new Error("candidate-patch-delete-invalid");
|
|
234
|
+
}
|
|
235
|
+
state.current = null;
|
|
236
|
+
break;
|
|
237
|
+
}
|
|
238
|
+
default:
|
|
239
|
+
throw new Error("candidate-patch-operation-invalid");
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const diff = [...states.values()]
|
|
244
|
+
.filter((state) => state.base !== state.current)
|
|
245
|
+
.sort((left, right) => left.path.localeCompare(right.path))
|
|
246
|
+
.map(renderFileDiff)
|
|
247
|
+
.join("");
|
|
248
|
+
if (diff.length === 0) throw new Error("candidate-patch-empty");
|
|
249
|
+
return diff;
|
|
250
|
+
}
|
package/src/child-session.ts
CHANGED
|
@@ -200,14 +200,8 @@ function structuralSubmitCount(message: AssistantMessage | undefined) {
|
|
|
200
200
|
).length;
|
|
201
201
|
}
|
|
202
202
|
|
|
203
|
-
function
|
|
204
|
-
|
|
205
|
-
return (
|
|
206
|
-
message?.role === "assistant" &&
|
|
207
|
-
content.length === 1 &&
|
|
208
|
-
content[0]?.type === "toolCall" &&
|
|
209
|
-
content[0].name === "abel_submit_result"
|
|
210
|
-
);
|
|
203
|
+
function hasOneStructuralSubmit(message: AssistantMessage | undefined) {
|
|
204
|
+
return message?.role === "assistant" && structuralSubmitCount(message) === 1;
|
|
211
205
|
}
|
|
212
206
|
|
|
213
207
|
function safeChildError(failure: ChildFailure): string {
|
|
@@ -317,7 +311,7 @@ export async function runChildSession(input: {
|
|
|
317
311
|
onStreamStart?: () => void;
|
|
318
312
|
onStreamProgress?: () => void;
|
|
319
313
|
}): Promise<ChildSessionResult> {
|
|
320
|
-
const
|
|
314
|
+
const candidateProtocol = input.candidateArtifact !== undefined;
|
|
321
315
|
const submit = input.candidateArtifact
|
|
322
316
|
? createCandidateArtifactTool(input.candidateArtifact)
|
|
323
317
|
: createSubmitTool({
|
|
@@ -402,14 +396,19 @@ export async function runChildSession(input: {
|
|
|
402
396
|
const assistants =
|
|
403
397
|
session?.messages.filter((m) => m.role === "assistant") ?? [];
|
|
404
398
|
const last = assistants.at(-1);
|
|
399
|
+
const lastSubmit = assistants.findLast((message) =>
|
|
400
|
+
hasStructuralSubmit(message),
|
|
401
|
+
);
|
|
402
|
+
const structuralAttempts = assistants.reduce(
|
|
403
|
+
(count, message) => count + structuralSubmitCount(message),
|
|
404
|
+
0,
|
|
405
|
+
);
|
|
405
406
|
let finalCategory: FinalCategory;
|
|
406
407
|
if (!last) {
|
|
407
408
|
finalCategory = "no-final-assistant";
|
|
408
|
-
} else if (
|
|
409
|
+
} else if (hasOneStructuralSubmit(lastSubmit)) {
|
|
409
410
|
finalCategory =
|
|
410
|
-
|
|
411
|
-
? "multiple-submit"
|
|
412
|
-
: "single-submit-only";
|
|
411
|
+
structuralAttempts > 1 ? "multiple-submit" : "single-submit-only";
|
|
413
412
|
} else {
|
|
414
413
|
const content = finalDeliveryContent(last);
|
|
415
414
|
finalCategory =
|
|
@@ -504,13 +503,17 @@ export async function runChildSession(input: {
|
|
|
504
503
|
if (childAgent) {
|
|
505
504
|
const previousShouldStopAfterTurn = childAgent.shouldStopAfterTurn;
|
|
506
505
|
childAgent.shouldStopAfterTurn = async (turn, signal) => {
|
|
507
|
-
if (
|
|
508
|
-
segmentedCandidate
|
|
509
|
-
? submit.getResult() !== undefined
|
|
510
|
-
: hasStructuralSubmit(turn.message)
|
|
511
|
-
) {
|
|
506
|
+
if (submit.getResult() !== undefined) {
|
|
512
507
|
return true;
|
|
513
508
|
}
|
|
509
|
+
const structuralAttempts =
|
|
510
|
+
session?.messages
|
|
511
|
+
.filter((message) => message.role === "assistant")
|
|
512
|
+
.reduce(
|
|
513
|
+
(count, message) => count + structuralSubmitCount(message),
|
|
514
|
+
0,
|
|
515
|
+
) ?? 0;
|
|
516
|
+
if (structuralAttempts >= 2) return true;
|
|
514
517
|
return (await previousShouldStopAfterTurn?.(turn, signal)) ?? false;
|
|
515
518
|
};
|
|
516
519
|
}
|
|
@@ -560,11 +563,11 @@ export async function runChildSession(input: {
|
|
|
560
563
|
const result = submit.getResult();
|
|
561
564
|
const attempts = submit.getAttempts();
|
|
562
565
|
const classification = classifySession();
|
|
563
|
-
const
|
|
564
|
-
|
|
566
|
+
const candidateComplete =
|
|
567
|
+
candidateProtocol &&
|
|
565
568
|
result !== undefined &&
|
|
566
569
|
(result.kind === "sealed-candidate" || result.kind === "context-request");
|
|
567
|
-
if (!result || (
|
|
570
|
+
if (!result || (candidateProtocol && !candidateComplete)) {
|
|
568
571
|
const failure = withSubmitDetails(
|
|
569
572
|
submit.getFailure() ??
|
|
570
573
|
input.failureOverride?.() ??
|
|
@@ -589,9 +592,9 @@ export async function runChildSession(input: {
|
|
|
589
592
|
const assistants = session.messages.filter((m) => m.role === "assistant");
|
|
590
593
|
const final = assistants.at(-1);
|
|
591
594
|
if (
|
|
592
|
-
!(
|
|
593
|
-
?
|
|
594
|
-
:
|
|
595
|
+
!(candidateProtocol
|
|
596
|
+
? candidateComplete && structuralSubmitCount(final) === 1
|
|
597
|
+
: hasOneStructuralSubmit(final))
|
|
595
598
|
) {
|
|
596
599
|
const failure = {
|
|
597
600
|
kind: "artifact",
|
package/src/contracts.ts
CHANGED
|
@@ -10,7 +10,6 @@ export type {
|
|
|
10
10
|
export {
|
|
11
11
|
assertControlCommand,
|
|
12
12
|
CONTROL_COMMANDS,
|
|
13
|
-
CONTROL_PROTOCOL_VERSION,
|
|
14
13
|
CONTROL_STAGES,
|
|
15
14
|
controlRunKey,
|
|
16
15
|
validateControlCommand,
|
|
@@ -23,18 +22,11 @@ export const STAGES = [
|
|
|
23
22
|
] as const;
|
|
24
23
|
export const ROLES = [
|
|
25
24
|
"design-explorer",
|
|
26
|
-
"contract-reviewer",
|
|
27
25
|
"implementation-worker",
|
|
28
26
|
"diagnosis-worker",
|
|
29
27
|
] as const;
|
|
30
28
|
export const OUTPUT_KINDS = ["evidence", "diff"] as const;
|
|
31
|
-
export const PHASES = [
|
|
32
|
-
"evidence",
|
|
33
|
-
"red",
|
|
34
|
-
"green",
|
|
35
|
-
"refactor",
|
|
36
|
-
"review",
|
|
37
|
-
] as const;
|
|
29
|
+
export const PHASES = ["evidence", "red", "green", "refactor"] as const;
|
|
38
30
|
|
|
39
31
|
export const AGENTS_IMPACTS = [
|
|
40
32
|
"none",
|
|
@@ -146,8 +138,10 @@ export const APPROVAL_BOUNDARY_CODES = [
|
|
|
146
138
|
"behavior-contract-insufficient",
|
|
147
139
|
"architecture-contract-insufficient",
|
|
148
140
|
"task-scope-insufficient",
|
|
141
|
+
"conflict-resource-authority-insufficient",
|
|
149
142
|
"verification-contract-insufficient",
|
|
150
143
|
"agents-contract-insufficient",
|
|
144
|
+
"irreversible-scope-insufficient",
|
|
151
145
|
] as const;
|
|
152
146
|
export const VERIFICATION_ADAPTER_CODES = [
|
|
153
147
|
"input-missing",
|
|
@@ -439,6 +433,7 @@ export type ImplementGraphReadinessDiagnostic =
|
|
|
439
433
|
export interface PacketEnvelope {
|
|
440
434
|
stage: "abel-design" | "abel-diagnose";
|
|
441
435
|
role: "design-explorer" | "diagnosis-worker";
|
|
436
|
+
runId?: string;
|
|
442
437
|
id: string;
|
|
443
438
|
phase: string;
|
|
444
439
|
objective: string;
|
|
@@ -1230,7 +1225,7 @@ export function validatePacketEnvelope(
|
|
|
1230
1225
|
return { ok: false, reason: "missing request envelope" };
|
|
1231
1226
|
}
|
|
1232
1227
|
const packet = value as Record<string, unknown>;
|
|
1233
|
-
const
|
|
1228
|
+
const baseRequired = [
|
|
1234
1229
|
"stage",
|
|
1235
1230
|
"role",
|
|
1236
1231
|
"id",
|
|
@@ -1241,6 +1236,14 @@ export function validatePacketEnvelope(
|
|
|
1241
1236
|
"declared",
|
|
1242
1237
|
"output",
|
|
1243
1238
|
] as const;
|
|
1239
|
+
const required =
|
|
1240
|
+
packet.stage === "abel-design"
|
|
1241
|
+
? ([
|
|
1242
|
+
...baseRequired.slice(0, 2),
|
|
1243
|
+
"runId",
|
|
1244
|
+
...baseRequired.slice(2),
|
|
1245
|
+
] as const)
|
|
1246
|
+
: baseRequired;
|
|
1244
1247
|
for (const field of required) {
|
|
1245
1248
|
if (packet[field] === undefined) {
|
|
1246
1249
|
return { ok: false, reason: `missing required field: ${field}` };
|
|
@@ -1262,6 +1265,12 @@ export function validatePacketEnvelope(
|
|
|
1262
1265
|
) {
|
|
1263
1266
|
return { ok: false, reason: "packet role does not match stage" };
|
|
1264
1267
|
}
|
|
1268
|
+
if (
|
|
1269
|
+
(packet.stage === "abel-design" && !validIdentifier(packet.runId)) ||
|
|
1270
|
+
(packet.stage === "abel-diagnose" && packet.runId !== undefined)
|
|
1271
|
+
) {
|
|
1272
|
+
return { ok: false, reason: "invalid Design run identity" };
|
|
1273
|
+
}
|
|
1265
1274
|
if (
|
|
1266
1275
|
typeof packet.id !== "string" ||
|
|
1267
1276
|
packet.id.length === 0 ||
|
package/src/control-contracts.ts
CHANGED
|
@@ -2,8 +2,6 @@
|
|
|
2
2
|
// Mechanical graph, snapshot, task, and apply identities are intentionally not
|
|
3
3
|
// part of this surface: the control plane derives them from approved delivery.
|
|
4
4
|
|
|
5
|
-
export const CONTROL_PROTOCOL_VERSION = 2 as const;
|
|
6
|
-
|
|
7
5
|
export const CONTROL_COMMANDS = [
|
|
8
6
|
"start",
|
|
9
7
|
"status",
|
|
@@ -13,7 +11,7 @@ export const CONTROL_COMMANDS = [
|
|
|
13
11
|
"discard",
|
|
14
12
|
] as const;
|
|
15
13
|
|
|
16
|
-
export const CONTROL_STAGES = ["abel-
|
|
14
|
+
export const CONTROL_STAGES = ["abel-implement"] as const;
|
|
17
15
|
|
|
18
16
|
export type ControlCommandName = (typeof CONTROL_COMMANDS)[number];
|
|
19
17
|
export type ControlStage = (typeof CONTROL_STAGES)[number];
|
|
@@ -23,55 +21,39 @@ const CHANGE_NAME = /^[a-z0-9](?:[a-z0-9-]{0,126}[a-z0-9])?$/u;
|
|
|
23
21
|
const SHA256 = /^[a-f0-9]{64}$/u;
|
|
24
22
|
|
|
25
23
|
interface ControlBase {
|
|
26
|
-
version: typeof CONTROL_PROTOCOL_VERSION;
|
|
27
24
|
command: ControlCommandName;
|
|
28
25
|
stage: ControlStage;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
interface NamedControlBase extends ControlBase {
|
|
32
26
|
change: string;
|
|
33
27
|
}
|
|
34
28
|
|
|
35
|
-
export interface
|
|
36
|
-
command: "start";
|
|
37
|
-
operationId: string;
|
|
38
|
-
provisionalKey?: string;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
export interface ProvisionalDesignStartControlCommand extends ControlBase {
|
|
29
|
+
export interface StartControlCommand extends ControlBase {
|
|
42
30
|
command: "start";
|
|
43
|
-
stage: "abel-design";
|
|
44
|
-
provisionalKey: string;
|
|
45
31
|
operationId: string;
|
|
46
32
|
}
|
|
47
33
|
|
|
48
|
-
export
|
|
49
|
-
| NamedStartControlCommand
|
|
50
|
-
| ProvisionalDesignStartControlCommand;
|
|
51
|
-
|
|
52
|
-
export interface StatusControlCommand extends NamedControlBase {
|
|
34
|
+
export interface StatusControlCommand extends ControlBase {
|
|
53
35
|
command: "status";
|
|
54
36
|
}
|
|
55
37
|
|
|
56
|
-
export interface ResumeControlCommand extends
|
|
38
|
+
export interface ResumeControlCommand extends ControlBase {
|
|
57
39
|
command: "resume";
|
|
58
40
|
operationId: string;
|
|
59
41
|
deliveryRevision?: number;
|
|
60
42
|
receiptHash?: string;
|
|
61
43
|
}
|
|
62
44
|
|
|
63
|
-
export interface RebindControlCommand extends
|
|
45
|
+
export interface RebindControlCommand extends ControlBase {
|
|
64
46
|
command: "rebind";
|
|
65
47
|
operationId: string;
|
|
66
48
|
routeId: string;
|
|
67
49
|
}
|
|
68
50
|
|
|
69
|
-
export interface CancelControlCommand extends
|
|
51
|
+
export interface CancelControlCommand extends ControlBase {
|
|
70
52
|
command: "cancel";
|
|
71
53
|
operationId: string;
|
|
72
54
|
}
|
|
73
55
|
|
|
74
|
-
export interface DiscardControlCommand extends
|
|
56
|
+
export interface DiscardControlCommand extends ControlBase {
|
|
75
57
|
command: "discard";
|
|
76
58
|
operationId: string;
|
|
77
59
|
}
|
|
@@ -86,10 +68,7 @@ export type ControlCommand =
|
|
|
86
68
|
|
|
87
69
|
export type ControlCommandValidation =
|
|
88
70
|
| { ok: true; value: ControlCommand }
|
|
89
|
-
| {
|
|
90
|
-
ok: false;
|
|
91
|
-
code: "invalid-control-command" | "unsupported-control-version";
|
|
92
|
-
};
|
|
71
|
+
| { ok: false; code: "invalid-control-command" };
|
|
93
72
|
|
|
94
73
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
95
74
|
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
@@ -110,7 +89,6 @@ function hasExactKeys(
|
|
|
110
89
|
|
|
111
90
|
function validCommon(value: Record<string, unknown>): boolean {
|
|
112
91
|
return (
|
|
113
|
-
value.version === CONTROL_PROTOCOL_VERSION &&
|
|
114
92
|
(CONTROL_COMMANDS as readonly unknown[]).includes(value.command) &&
|
|
115
93
|
(CONTROL_STAGES as readonly unknown[]).includes(value.stage)
|
|
116
94
|
);
|
|
@@ -128,21 +106,14 @@ export function validateControlCommand(
|
|
|
128
106
|
value: unknown,
|
|
129
107
|
): ControlCommandValidation {
|
|
130
108
|
if (!isRecord(value)) return { ok: false, code: "invalid-control-command" };
|
|
131
|
-
if (
|
|
132
|
-
Object.hasOwn(value, "version") &&
|
|
133
|
-
value.version !== CONTROL_PROTOCOL_VERSION
|
|
134
|
-
) {
|
|
135
|
-
return { ok: false, code: "unsupported-control-version" };
|
|
136
|
-
}
|
|
137
109
|
if (!validCommon(value)) {
|
|
138
110
|
return { ok: false, code: "invalid-control-command" };
|
|
139
111
|
}
|
|
140
112
|
|
|
141
|
-
const common = ["
|
|
142
|
-
const named = [...common, "change"] as const;
|
|
113
|
+
const common = ["command", "stage", "change"] as const;
|
|
143
114
|
switch (value.command) {
|
|
144
115
|
case "status":
|
|
145
|
-
if (!hasExactKeys(value,
|
|
116
|
+
if (!hasExactKeys(value, common) || !validChange(value.change)) break;
|
|
146
117
|
return {
|
|
147
118
|
ok: true,
|
|
148
119
|
value: structuredClone(value) as unknown as StatusControlCommand,
|
|
@@ -150,7 +121,7 @@ export function validateControlCommand(
|
|
|
150
121
|
case "cancel":
|
|
151
122
|
case "discard":
|
|
152
123
|
if (
|
|
153
|
-
hasExactKeys(value, [...
|
|
124
|
+
hasExactKeys(value, [...common, "operationId"]) &&
|
|
154
125
|
validChange(value.change) &&
|
|
155
126
|
validOperationId(value.operationId)
|
|
156
127
|
) {
|
|
@@ -162,37 +133,19 @@ export function validateControlCommand(
|
|
|
162
133
|
break;
|
|
163
134
|
case "start":
|
|
164
135
|
if (
|
|
165
|
-
hasExactKeys(value, [...
|
|
136
|
+
hasExactKeys(value, [...common, "operationId"]) &&
|
|
166
137
|
validChange(value.change) &&
|
|
167
|
-
validOperationId(value.operationId) &&
|
|
168
|
-
(value.provisionalKey === undefined ||
|
|
169
|
-
(value.stage === "abel-design" &&
|
|
170
|
-
typeof value.provisionalKey === "string" &&
|
|
171
|
-
SHA256.test(value.provisionalKey)))
|
|
172
|
-
) {
|
|
173
|
-
return {
|
|
174
|
-
ok: true,
|
|
175
|
-
value: structuredClone(value) as unknown as NamedStartControlCommand,
|
|
176
|
-
};
|
|
177
|
-
}
|
|
178
|
-
if (
|
|
179
|
-
hasExactKeys(value, [...common, "provisionalKey", "operationId"]) &&
|
|
180
|
-
value.stage === "abel-design" &&
|
|
181
|
-
typeof value.provisionalKey === "string" &&
|
|
182
|
-
SHA256.test(value.provisionalKey) &&
|
|
183
138
|
validOperationId(value.operationId)
|
|
184
139
|
) {
|
|
185
140
|
return {
|
|
186
141
|
ok: true,
|
|
187
|
-
value: structuredClone(
|
|
188
|
-
value,
|
|
189
|
-
) as unknown as ProvisionalDesignStartControlCommand,
|
|
142
|
+
value: structuredClone(value) as unknown as StartControlCommand,
|
|
190
143
|
};
|
|
191
144
|
}
|
|
192
145
|
break;
|
|
193
146
|
case "rebind":
|
|
194
147
|
if (
|
|
195
|
-
hasExactKeys(value, [...
|
|
148
|
+
hasExactKeys(value, [...common, "operationId", "routeId"]) &&
|
|
196
149
|
validChange(value.change) &&
|
|
197
150
|
validOperationId(value.operationId) &&
|
|
198
151
|
typeof value.routeId === "string" &&
|
|
@@ -208,7 +161,7 @@ export function validateControlCommand(
|
|
|
208
161
|
if (
|
|
209
162
|
!hasExactKeys(
|
|
210
163
|
value,
|
|
211
|
-
[...
|
|
164
|
+
[...common, "operationId"],
|
|
212
165
|
["deliveryRevision", "receiptHash"],
|
|
213
166
|
) ||
|
|
214
167
|
!validChange(value.change) ||
|