@abelxiaoxing/cadence 1.1.1 → 1.2.1

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.
@@ -11,7 +11,7 @@ export const CONTROL_COMMANDS = [
11
11
  "discard",
12
12
  ] as const;
13
13
 
14
- export const CONTROL_STAGES = ["abel-design", "abel-implement"] as const;
14
+ export const CONTROL_STAGES = ["abel-implement"] as const;
15
15
 
16
16
  export type ControlCommandName = (typeof CONTROL_COMMANDS)[number];
17
17
  export type ControlStage = (typeof CONTROL_STAGES)[number];
@@ -23,52 +23,37 @@ const SHA256 = /^[a-f0-9]{64}$/u;
23
23
  interface ControlBase {
24
24
  command: ControlCommandName;
25
25
  stage: ControlStage;
26
- }
27
-
28
- interface NamedControlBase extends ControlBase {
29
26
  change: string;
30
27
  }
31
28
 
32
- export interface NamedStartControlCommand extends NamedControlBase {
33
- command: "start";
34
- operationId: string;
35
- provisionalKey?: string;
36
- }
37
-
38
- export interface ProvisionalDesignStartControlCommand extends ControlBase {
29
+ export interface StartControlCommand extends ControlBase {
39
30
  command: "start";
40
- stage: "abel-design";
41
- provisionalKey: string;
42
31
  operationId: string;
43
32
  }
44
33
 
45
- export type StartControlCommand =
46
- | NamedStartControlCommand
47
- | ProvisionalDesignStartControlCommand;
48
-
49
- export interface StatusControlCommand extends NamedControlBase {
34
+ export interface StatusControlCommand extends ControlBase {
50
35
  command: "status";
51
36
  }
52
37
 
53
- export interface ResumeControlCommand extends NamedControlBase {
38
+ export interface ResumeControlCommand extends ControlBase {
54
39
  command: "resume";
55
40
  operationId: string;
56
41
  deliveryRevision?: number;
57
42
  receiptHash?: string;
58
43
  }
59
44
 
60
- export interface RebindControlCommand extends NamedControlBase {
45
+ export interface RebindControlCommand extends ControlBase {
61
46
  command: "rebind";
62
47
  operationId: string;
63
48
  routeId: string;
64
49
  }
65
50
 
66
- export interface CancelControlCommand extends NamedControlBase {
51
+ export interface CancelControlCommand extends ControlBase {
67
52
  command: "cancel";
68
53
  operationId: string;
69
54
  }
70
55
 
71
- export interface DiscardControlCommand extends NamedControlBase {
56
+ export interface DiscardControlCommand extends ControlBase {
72
57
  command: "discard";
73
58
  operationId: string;
74
59
  }
@@ -89,6 +74,64 @@ function isRecord(value: unknown): value is Record<string, unknown> {
89
74
  return value !== null && typeof value === "object" && !Array.isArray(value);
90
75
  }
91
76
 
77
+ const CONTROL_TOOL_FIELDS = new Set([
78
+ "command",
79
+ "stage",
80
+ "change",
81
+ "operationId",
82
+ "deliveryRevision",
83
+ "receiptHash",
84
+ "routeId",
85
+ ]);
86
+
87
+ const COMMAND_FIELDS: Readonly<
88
+ Record<ControlCommandName, ReadonlySet<string>>
89
+ > = Object.freeze({
90
+ start: new Set(["command", "stage", "change", "operationId"]),
91
+ status: new Set(["command", "stage", "change"]),
92
+ resume: new Set([
93
+ "command",
94
+ "stage",
95
+ "change",
96
+ "operationId",
97
+ "deliveryRevision",
98
+ "receiptHash",
99
+ ]),
100
+ rebind: new Set(["command", "stage", "change", "operationId", "routeId"]),
101
+ cancel: new Set(["command", "stage", "change", "operationId"]),
102
+ discard: new Set(["command", "stage", "change", "operationId"]),
103
+ });
104
+
105
+ /**
106
+ * Adapts Pi/provider strict-schema padding to the closed command union.
107
+ * Known fields belonging to a different command are projected away, while
108
+ * unknown fields and fields owned by the selected command remain available to
109
+ * the strict validator and therefore cannot bypass its exact-key/type checks.
110
+ */
111
+ export function canonicalizeControlCommandToolInput(value: unknown): unknown {
112
+ if (
113
+ !isRecord(value) ||
114
+ !(CONTROL_COMMANDS as readonly unknown[]).includes(value.command)
115
+ ) {
116
+ return value;
117
+ }
118
+ const command = value.command as ControlCommandName;
119
+ const allowed = COMMAND_FIELDS[command];
120
+ const canonical = Object.create(null) as Record<string, unknown>;
121
+ for (const [key, entry] of Object.entries(value)) {
122
+ if (CONTROL_TOOL_FIELDS.has(key) && !allowed.has(key)) continue;
123
+ if (
124
+ command === "resume" &&
125
+ (key === "deliveryRevision" || key === "receiptHash") &&
126
+ (entry === null || entry === undefined)
127
+ ) {
128
+ continue;
129
+ }
130
+ canonical[key] = entry;
131
+ }
132
+ return canonical;
133
+ }
134
+
92
135
  function hasExactKeys(
93
136
  value: Record<string, unknown>,
94
137
  required: readonly string[],
@@ -125,11 +168,10 @@ export function validateControlCommand(
125
168
  return { ok: false, code: "invalid-control-command" };
126
169
  }
127
170
 
128
- const common = ["command", "stage"] as const;
129
- const named = [...common, "change"] as const;
171
+ const common = ["command", "stage", "change"] as const;
130
172
  switch (value.command) {
131
173
  case "status":
132
- if (!hasExactKeys(value, named) || !validChange(value.change)) break;
174
+ if (!hasExactKeys(value, common) || !validChange(value.change)) break;
133
175
  return {
134
176
  ok: true,
135
177
  value: structuredClone(value) as unknown as StatusControlCommand,
@@ -137,7 +179,7 @@ export function validateControlCommand(
137
179
  case "cancel":
138
180
  case "discard":
139
181
  if (
140
- hasExactKeys(value, [...named, "operationId"]) &&
182
+ hasExactKeys(value, [...common, "operationId"]) &&
141
183
  validChange(value.change) &&
142
184
  validOperationId(value.operationId)
143
185
  ) {
@@ -149,37 +191,19 @@ export function validateControlCommand(
149
191
  break;
150
192
  case "start":
151
193
  if (
152
- hasExactKeys(value, [...named, "operationId"], ["provisionalKey"]) &&
194
+ hasExactKeys(value, [...common, "operationId"]) &&
153
195
  validChange(value.change) &&
154
- validOperationId(value.operationId) &&
155
- (value.provisionalKey === undefined ||
156
- (value.stage === "abel-design" &&
157
- typeof value.provisionalKey === "string" &&
158
- SHA256.test(value.provisionalKey)))
159
- ) {
160
- return {
161
- ok: true,
162
- value: structuredClone(value) as unknown as NamedStartControlCommand,
163
- };
164
- }
165
- if (
166
- hasExactKeys(value, [...common, "provisionalKey", "operationId"]) &&
167
- value.stage === "abel-design" &&
168
- typeof value.provisionalKey === "string" &&
169
- SHA256.test(value.provisionalKey) &&
170
196
  validOperationId(value.operationId)
171
197
  ) {
172
198
  return {
173
199
  ok: true,
174
- value: structuredClone(
175
- value,
176
- ) as unknown as ProvisionalDesignStartControlCommand,
200
+ value: structuredClone(value) as unknown as StartControlCommand,
177
201
  };
178
202
  }
179
203
  break;
180
204
  case "rebind":
181
205
  if (
182
- hasExactKeys(value, [...named, "operationId", "routeId"]) &&
206
+ hasExactKeys(value, [...common, "operationId", "routeId"]) &&
183
207
  validChange(value.change) &&
184
208
  validOperationId(value.operationId) &&
185
209
  typeof value.routeId === "string" &&
@@ -195,7 +219,7 @@ export function validateControlCommand(
195
219
  if (
196
220
  !hasExactKeys(
197
221
  value,
198
- [...named, "operationId"],
222
+ [...common, "operationId"],
199
223
  ["deliveryRevision", "receiptHash"],
200
224
  ) ||
201
225
  !validChange(value.change) ||
@@ -32,9 +32,10 @@ import type { ResolvedStateRoot } from "./state-root.ts";
32
32
 
33
33
  const CHANGE_NAME = /^[a-z0-9](?:[a-z0-9-]{0,126}[a-z0-9])?$/u;
34
34
  const IDENTIFIER = /^[a-z0-9](?:[a-z0-9._:-]{0,126}[a-z0-9])?$/iu;
35
- const SHA256 = /^[a-f0-9]{64}$/u;
36
35
  const ARTIFACT_SEGMENT = /^[a-z0-9](?:[a-z0-9._-]{0,126}[a-z0-9])?$/iu;
37
36
  const MAX_DESIGN_FILE_BYTES = 16 * 1024 * 1024;
37
+ const MAX_DESIGN_REQUIREMENT_BYTES = 64 * 1024;
38
+ const MAX_DESIGN_CONTRACT_BYTES = 256 * 1024;
38
39
 
39
40
  export interface DesignOpenSpecInspection {
40
41
  change: string;
@@ -45,21 +46,47 @@ export interface DesignOpenSpecInspection {
45
46
  }
46
47
 
47
48
  export type DesignControlRequest =
49
+ | {
50
+ operation: "start";
51
+ operationId: string;
52
+ requirement: string;
53
+ }
54
+ | {
55
+ operation: "start";
56
+ operationId: string;
57
+ change: string;
58
+ }
59
+ | {
60
+ operation: "status";
61
+ runId: string;
62
+ }
63
+ | {
64
+ operation: "bind-change";
65
+ runId: string;
66
+ operationId: string;
67
+ change: string;
68
+ }
48
69
  | {
49
70
  operation: "record-decision";
50
71
  runId: string;
51
72
  operationId: string;
52
73
  decisionId: string;
53
74
  category: "behavior" | "technical";
54
- contractHash: string;
75
+ contract: string;
55
76
  refs: string[];
56
77
  }
57
78
  | {
58
79
  operation: "approve-gate";
59
80
  runId: string;
60
81
  operationId: string;
61
- gate: "gate-a" | "gate-b";
62
- contractHash: string;
82
+ gate: "gate-a";
83
+ contract: string;
84
+ }
85
+ | {
86
+ operation: "approve-gate";
87
+ runId: string;
88
+ operationId: string;
89
+ gate: "gate-b";
63
90
  }
64
91
  | {
65
92
  operation: "compile-plan";
@@ -121,6 +148,25 @@ function exactKeys(
121
148
  );
122
149
  }
123
150
 
151
+ function normalizedTransientText(value: string): string {
152
+ return value.replace(/\r\n?/gu, "\n").trim();
153
+ }
154
+
155
+ function transientHash(domain: string, value: string): string {
156
+ return createHash("sha256")
157
+ .update(domain)
158
+ .update("\0")
159
+ .update(normalizedTransientText(value))
160
+ .digest("hex");
161
+ }
162
+
163
+ function lifecycleOperationId(kind: string, runId: string): string {
164
+ return `design-${kind}-${createHash("sha256")
165
+ .update(runId)
166
+ .digest("hex")
167
+ .slice(0, 32)}`;
168
+ }
169
+
124
170
  export function validateDesignControlRequest(
125
171
  value: unknown,
126
172
  ):
@@ -128,6 +174,37 @@ export function validateDesignControlRequest(
128
174
  | { ok: false; code: "invalid-design-control-request" } {
129
175
  if (!isRecord(value))
130
176
  return { ok: false, code: "invalid-design-control-request" };
177
+ if (value.operation === "start") {
178
+ const operationValid =
179
+ typeof value.operationId === "string" &&
180
+ IDENTIFIER.test(value.operationId);
181
+ if (!operationValid)
182
+ return { ok: false, code: "invalid-design-control-request" };
183
+ if (
184
+ exactKeys(value, ["operation", "operationId", "requirement"]) &&
185
+ typeof value.requirement === "string" &&
186
+ value.requirement.trim().length > 0 &&
187
+ Buffer.byteLength(value.requirement, "utf8") <=
188
+ MAX_DESIGN_REQUIREMENT_BYTES
189
+ ) {
190
+ return {
191
+ ok: true,
192
+ value: structuredClone(value) as DesignControlRequest,
193
+ };
194
+ }
195
+ return exactKeys(value, ["operation", "operationId", "change"]) &&
196
+ typeof value.change === "string" &&
197
+ CHANGE_NAME.test(value.change)
198
+ ? { ok: true, value: structuredClone(value) as DesignControlRequest }
199
+ : { ok: false, code: "invalid-design-control-request" };
200
+ }
201
+ if (value.operation === "status") {
202
+ return exactKeys(value, ["operation", "runId"]) &&
203
+ typeof value.runId === "string" &&
204
+ IDENTIFIER.test(value.runId)
205
+ ? { ok: true, value: structuredClone(value) as DesignControlRequest }
206
+ : { ok: false, code: "invalid-design-control-request" };
207
+ }
131
208
  const commonValid =
132
209
  typeof value.runId === "string" &&
133
210
  IDENTIFIER.test(value.runId) &&
@@ -135,6 +212,13 @@ export function validateDesignControlRequest(
135
212
  IDENTIFIER.test(value.operationId);
136
213
  if (!commonValid)
137
214
  return { ok: false, code: "invalid-design-control-request" };
215
+ if (value.operation === "bind-change") {
216
+ return exactKeys(value, ["operation", "runId", "operationId", "change"]) &&
217
+ typeof value.change === "string" &&
218
+ CHANGE_NAME.test(value.change)
219
+ ? { ok: true, value: structuredClone(value) as DesignControlRequest }
220
+ : { ok: false, code: "invalid-design-control-request" };
221
+ }
138
222
  if (value.operation === "write-artifact") {
139
223
  return exactKeys(value, [
140
224
  "operation",
@@ -163,16 +247,22 @@ export function validateDesignControlRequest(
163
247
  : { ok: false, code: "invalid-design-control-request" };
164
248
  }
165
249
  if (value.operation === "approve-gate") {
166
- return exactKeys(value, [
167
- "operation",
168
- "runId",
169
- "operationId",
170
- "gate",
171
- "contractHash",
172
- ]) &&
173
- (value.gate === "gate-a" || value.gate === "gate-b") &&
174
- typeof value.contractHash === "string" &&
175
- SHA256.test(value.contractHash)
250
+ if (value.gate === "gate-b") {
251
+ return exactKeys(value, ["operation", "runId", "operationId", "gate"])
252
+ ? { ok: true, value: structuredClone(value) as DesignControlRequest }
253
+ : { ok: false, code: "invalid-design-control-request" };
254
+ }
255
+ return value.gate === "gate-a" &&
256
+ exactKeys(value, [
257
+ "operation",
258
+ "runId",
259
+ "operationId",
260
+ "gate",
261
+ "contract",
262
+ ]) &&
263
+ typeof value.contract === "string" &&
264
+ value.contract.trim().length > 0 &&
265
+ Buffer.byteLength(value.contract, "utf8") <= MAX_DESIGN_CONTRACT_BYTES
176
266
  ? { ok: true, value: structuredClone(value) as DesignControlRequest }
177
267
  : { ok: false, code: "invalid-design-control-request" };
178
268
  }
@@ -185,14 +275,15 @@ export function validateDesignControlRequest(
185
275
  "operationId",
186
276
  "decisionId",
187
277
  "category",
188
- "contractHash",
278
+ "contract",
189
279
  "refs",
190
280
  ]) &&
191
281
  typeof value.decisionId === "string" &&
192
282
  IDENTIFIER.test(value.decisionId) &&
193
283
  (value.category === "behavior" || value.category === "technical") &&
194
- typeof value.contractHash === "string" &&
195
- SHA256.test(value.contractHash) &&
284
+ typeof value.contract === "string" &&
285
+ value.contract.trim().length > 0 &&
286
+ Buffer.byteLength(value.contract, "utf8") <= MAX_DESIGN_CONTRACT_BYTES &&
196
287
  Array.isArray(value.refs) &&
197
288
  value.refs.every((ref) => typeof ref === "string")
198
289
  ? { ok: true, value: structuredClone(value) as DesignControlRequest }
@@ -407,6 +498,12 @@ export class DesignController {
407
498
  if (!validation.ok) throw new Error(validation.code);
408
499
  const request = validation.value;
409
500
  switch (request.operation) {
501
+ case "start":
502
+ return this.#start(request);
503
+ case "status":
504
+ return this.#statusOutcome(request.runId);
505
+ case "bind-change":
506
+ return this.#bindChange(request);
410
507
  case "record-decision":
411
508
  return this.#journal.recordDecision(request);
412
509
  case "approve-gate":
@@ -422,6 +519,116 @@ export class DesignController {
422
519
  }
423
520
  }
424
521
 
522
+ #statusOutcome(runId: string): Record<string, unknown> {
523
+ const run = this.#runs.status(runId);
524
+ if (run.stage !== "abel-design") throw new Error("design-run-invalid");
525
+ const terminal = ["completed", "discarded", "rejected"].includes(run.state);
526
+ const design = this.#journal.status(runId);
527
+ const latestDecisionSequence = design.decisions.reduce(
528
+ (latest, decision) => Math.max(latest, decision.sequence),
529
+ 0,
530
+ );
531
+ const planReadyForGateB =
532
+ design.gates.gateA.current &&
533
+ design.plan !== null &&
534
+ design.plan.sequence >
535
+ Math.max(
536
+ design.gates.gateA.approvedSequence ?? 0,
537
+ latestDecisionSequence,
538
+ );
539
+ const canApproveGate =
540
+ !design.gates.gateA.current ||
541
+ (planReadyForGateB && !design.gates.gateB.current);
542
+ const legalActions = terminal
543
+ ? ["status"]
544
+ : [
545
+ "status",
546
+ "record-decision",
547
+ ...(canApproveGate ? ["approve-gate"] : []),
548
+ ...(design.gates.gateA.current && !design.change
549
+ ? ["bind-change"]
550
+ : []),
551
+ ...(design.gates.gateA.current && design.change
552
+ ? ["write-artifact", "delete-artifact", "compile-plan"]
553
+ : []),
554
+ ...(design.change &&
555
+ design.plan &&
556
+ design.gates.gateA.current &&
557
+ design.gates.gateB.current
558
+ ? ["finalize-delivery"]
559
+ : []),
560
+ "finish",
561
+ ];
562
+ return {
563
+ runId: run.runId,
564
+ stage: run.stage,
565
+ ...(run.change ? { change: run.change } : {}),
566
+ state: run.state,
567
+ completed: run.state === "completed",
568
+ ...(run.pauseCode ? { pause: { code: run.pauseCode } } : {}),
569
+ legalActions,
570
+ design,
571
+ };
572
+ }
573
+
574
+ #start(request: Extract<DesignControlRequest, { operation: "start" }>) {
575
+ const fromRequirement = "requirement" in request;
576
+ const run = this.#runs.startRun({
577
+ stage: "abel-design",
578
+ ...(fromRequirement
579
+ ? {
580
+ provisionalKey: transientHash(
581
+ "abel-design-requirement-v1",
582
+ request.requirement,
583
+ ),
584
+ }
585
+ : { change: request.change }),
586
+ operationId: request.operationId,
587
+ });
588
+ if (run.state === "created") {
589
+ this.#runs.transition({
590
+ runId: run.runId,
591
+ to: "paused",
592
+ operationId: lifecycleOperationId(
593
+ fromRequirement ? "await-gate-a" : "await-evidence",
594
+ run.runId,
595
+ ),
596
+ code: fromRequirement
597
+ ? "design-awaiting-gate-a"
598
+ : "design-awaiting-evidence",
599
+ });
600
+ }
601
+ return this.#statusOutcome(run.runId);
602
+ }
603
+
604
+ #bindChange(
605
+ request: Extract<DesignControlRequest, { operation: "bind-change" }>,
606
+ ) {
607
+ const run = this.#runs.status(request.runId);
608
+ if (run.change && run.change !== request.change) {
609
+ throw new Error("design-operation-conflict");
610
+ }
611
+ if (!run.change) {
612
+ const design = this.#journal.status(request.runId);
613
+ if (!design.gates.gateA.current)
614
+ throw new Error("design-gate-a-required");
615
+ }
616
+ const bound = this.#runs.bindChange({
617
+ runId: request.runId,
618
+ change: request.change,
619
+ operationId: request.operationId,
620
+ });
621
+ if (bound.state === "paused") {
622
+ this.#runs.transition({
623
+ runId: request.runId,
624
+ to: "paused",
625
+ operationId: lifecycleOperationId("bound", request.runId),
626
+ code: "design-awaiting-evidence",
627
+ });
628
+ }
629
+ return this.#statusOutcome(request.runId);
630
+ }
631
+
425
632
  #artifactRun(runId: string): { change: string; gateACurrent: boolean } {
426
633
  const status = this.#journal.status(runId);
427
634
  if (!status.change) throw new Error("design-change-required");