@abelxiaoxing/cadence 1.2.4 → 1.4.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 +155 -10
- package/package.json +5 -3
- package/prompts/abel-design.md +71 -17
- package/prompts/abel-diagnose.md +10 -1
- package/prompts/abel-implement.md +118 -20
- package/prompts/abel-init.md +9 -2
- package/src/apply-transaction.ts +16 -14
- package/src/artifact-store.ts +17 -0
- package/src/candidate-patch.ts +2 -1
- package/src/canonical.ts +16 -0
- package/src/change-contract.ts +268 -0
- package/src/child-session.ts +4 -3
- package/src/contracts.ts +141 -33
- package/src/control-contracts.ts +51 -1
- package/src/delivery-compiler.ts +148 -31
- package/src/design-control.ts +83 -15
- package/src/design-journal.ts +216 -72
- package/src/durable-workflow.ts +4385 -0
- package/src/implement-graph.ts +11 -16
- package/src/index.ts +399 -1079
- package/src/isolation-backend.ts +84 -23
- package/src/openspec-cli.ts +494 -0
- package/src/package-delivery.ts +486 -0
- package/src/package-verification.ts +509 -0
- package/src/route-policy.ts +1 -11
- package/src/run-state.ts +4 -16
- package/src/run-store.ts +5 -144
- package/src/scoped-grep-worker.mjs +23 -0
- package/src/scoped-tools.ts +312 -116
- package/src/sqlite-schema.ts +171 -0
- package/src/storage-schema.ts +352 -0
- package/src/subagent-activity.ts +14 -1
- package/src/submit-tool.ts +20 -2
- package/src/task-ledger.ts +14 -55
- package/src/verification-capability.ts +298 -102
- package/src/verification-environment.ts +213 -0
- package/src/verification-identity.ts +77 -0
- package/src/worker-broker.ts +97 -34
- package/src/workflow-engine.ts +28 -8285
- package/src/workflow-policy.ts +1427 -0
- package/src/workflow-state-machine.ts +4478 -0
- package/src/workspace-io-entry.mjs +58 -0
- package/src/workspace-io-worker.mjs +984 -0
- package/src/workspace-io.ts +84 -0
- package/src/workspace-store.ts +125 -19
- package/skills/abel-workflow/SKILL.md +0 -205
package/src/delivery-compiler.ts
CHANGED
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
|
+
import { compareCanonicalStrings } from "./canonical.ts";
|
|
3
|
+
import {
|
|
4
|
+
assertPlanWithinChangeContract,
|
|
5
|
+
type ChangeContract,
|
|
6
|
+
normalizeChangeContract,
|
|
7
|
+
} from "./change-contract.ts";
|
|
2
8
|
import {
|
|
3
9
|
type AgentsImpact,
|
|
4
10
|
type ImplementGraphBoundary,
|
|
@@ -17,7 +23,10 @@ import {
|
|
|
17
23
|
hashCanonicalValue,
|
|
18
24
|
type ImplementGraphReadiness,
|
|
19
25
|
} from "./implement-graph.ts";
|
|
20
|
-
import {
|
|
26
|
+
import {
|
|
27
|
+
bindDraftVerificationInputs,
|
|
28
|
+
validateVerificationAdapterCapability,
|
|
29
|
+
} from "./verification-capability.ts";
|
|
21
30
|
|
|
22
31
|
export const IMPLEMENT_PLAN_PATH = "implement-plan.json" as const;
|
|
23
32
|
export const GATE_A_RECEIPT_PATH = "gate-a.yaml" as const;
|
|
@@ -117,7 +126,7 @@ export class DesignPlanValidationError extends Error {
|
|
|
117
126
|
diagnostics
|
|
118
127
|
.map((diagnostic) => structuredClone(diagnostic))
|
|
119
128
|
.sort((left, right) =>
|
|
120
|
-
canonicalJson(left)
|
|
129
|
+
compareCanonicalStrings(canonicalJson(left), canonicalJson(right)),
|
|
121
130
|
)
|
|
122
131
|
.map((diagnostic) => [canonicalJson(diagnostic), diagnostic]),
|
|
123
132
|
).values(),
|
|
@@ -218,7 +227,9 @@ export function assessDeliveryTraceability(input: {
|
|
|
218
227
|
}
|
|
219
228
|
const verificationIds = input.plan.tasks
|
|
220
229
|
.flatMap((task) => [
|
|
221
|
-
task.
|
|
230
|
+
...(task.verificationMode && task.verificationMode !== "behavior"
|
|
231
|
+
? []
|
|
232
|
+
: [task.phases.red.verification.id]),
|
|
222
233
|
task.phases.green.verification.id,
|
|
223
234
|
...(task.phases.refactor ? [task.phases.refactor.verification.id] : []),
|
|
224
235
|
])
|
|
@@ -304,6 +315,7 @@ export interface PlanTracking {
|
|
|
304
315
|
}
|
|
305
316
|
|
|
306
317
|
export interface PlanDraft {
|
|
318
|
+
changeContract?: ChangeContract;
|
|
307
319
|
changeId: string;
|
|
308
320
|
tasks: PlanTaskDraft[];
|
|
309
321
|
outputs: ImplementGraphOutput[];
|
|
@@ -371,7 +383,9 @@ function normalizeArtifactBindings(
|
|
|
371
383
|
paths.add(binding.path);
|
|
372
384
|
return { path: binding.path, rawSha256: binding.rawSha256 };
|
|
373
385
|
});
|
|
374
|
-
return normalized.sort((left, right) =>
|
|
386
|
+
return normalized.sort((left, right) =>
|
|
387
|
+
compareCanonicalStrings(left.path, right.path),
|
|
388
|
+
);
|
|
375
389
|
}
|
|
376
390
|
|
|
377
391
|
function requireReceiptIdentity(
|
|
@@ -405,7 +419,41 @@ function normalizeGateProof(value: GateApprovalProof): GateApprovalProof {
|
|
|
405
419
|
return structuredClone(value);
|
|
406
420
|
}
|
|
407
421
|
|
|
408
|
-
|
|
422
|
+
export interface DeliveryReadOptions {
|
|
423
|
+
/** Only the proof-bound delivery loader may admit historical collection ordering. */
|
|
424
|
+
allowLegacyOrder?: boolean;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
const legacyPlans = new WeakMap<object, string>();
|
|
428
|
+
|
|
429
|
+
function unorderedIdentity(value: unknown): string {
|
|
430
|
+
if (Array.isArray(value))
|
|
431
|
+
return `[${value.map(unorderedIdentity).sort().join(",")}]`;
|
|
432
|
+
if (isRecord(value))
|
|
433
|
+
return `{${Object.entries(value)
|
|
434
|
+
.sort(([a], [b]) => compareCanonicalStrings(a, b))
|
|
435
|
+
.map(
|
|
436
|
+
([key, entry]) => `${JSON.stringify(key)}:${unorderedIdentity(entry)}`,
|
|
437
|
+
)
|
|
438
|
+
.join(",")}}`;
|
|
439
|
+
return JSON.stringify(value) ?? "null";
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
function sameNormalized(
|
|
443
|
+
left: unknown,
|
|
444
|
+
right: unknown,
|
|
445
|
+
options: DeliveryReadOptions,
|
|
446
|
+
): boolean {
|
|
447
|
+
return options.allowLegacyOrder
|
|
448
|
+
? unorderedIdentity(left) === unorderedIdentity(right)
|
|
449
|
+
: canonicalJson(left) === canonicalJson(right);
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
function parseCanonicalReceipt<T>(
|
|
453
|
+
bytes: Uint8Array,
|
|
454
|
+
code: string,
|
|
455
|
+
options: DeliveryReadOptions = {},
|
|
456
|
+
): T {
|
|
409
457
|
const text = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
|
410
458
|
let parsed: unknown;
|
|
411
459
|
try {
|
|
@@ -413,7 +461,11 @@ function parseCanonicalReceipt<T>(bytes: Uint8Array, code: string): T {
|
|
|
413
461
|
} catch {
|
|
414
462
|
throw new Error(code);
|
|
415
463
|
}
|
|
416
|
-
if (
|
|
464
|
+
if (
|
|
465
|
+
`${canonicalJson(parsed)}\n` !== text &&
|
|
466
|
+
(!options.allowLegacyOrder || `${JSON.stringify(parsed)}\n` !== text)
|
|
467
|
+
)
|
|
468
|
+
throw new Error(code);
|
|
417
469
|
return parsed as T;
|
|
418
470
|
}
|
|
419
471
|
|
|
@@ -438,10 +490,14 @@ export function compileGateAReceipt(input: {
|
|
|
438
490
|
};
|
|
439
491
|
}
|
|
440
492
|
|
|
441
|
-
export function parseGateAReceipt(
|
|
493
|
+
export function parseGateAReceipt(
|
|
494
|
+
bytes: Uint8Array,
|
|
495
|
+
options: DeliveryReadOptions = {},
|
|
496
|
+
): GateAReceipt {
|
|
442
497
|
const receipt = parseCanonicalReceipt<GateAReceipt>(
|
|
443
498
|
bytes,
|
|
444
499
|
"gate-a-receipt-invalid",
|
|
500
|
+
options,
|
|
445
501
|
);
|
|
446
502
|
if (
|
|
447
503
|
!isRecord(receipt) ||
|
|
@@ -455,8 +511,8 @@ export function parseGateAReceipt(bytes: Uint8Array): GateAReceipt {
|
|
|
455
511
|
const approval = normalizeGateProof(receipt.approval);
|
|
456
512
|
const artifacts = normalizeArtifactBindings(receipt.artifacts);
|
|
457
513
|
if (
|
|
458
|
-
|
|
459
|
-
|
|
514
|
+
!sameNormalized(approval, receipt.approval, options) ||
|
|
515
|
+
!sameNormalized(artifacts, receipt.artifacts, options)
|
|
460
516
|
) {
|
|
461
517
|
throw new Error("gate-a-receipt-invalid");
|
|
462
518
|
}
|
|
@@ -537,10 +593,14 @@ export function compileReadyReceipt(input: {
|
|
|
537
593
|
};
|
|
538
594
|
}
|
|
539
595
|
|
|
540
|
-
export function parseReadyReceipt(
|
|
596
|
+
export function parseReadyReceipt(
|
|
597
|
+
bytes: Uint8Array,
|
|
598
|
+
options: DeliveryReadOptions = {},
|
|
599
|
+
): ReadyReceipt {
|
|
541
600
|
const receipt = parseCanonicalReceipt<ReadyReceipt>(
|
|
542
601
|
bytes,
|
|
543
602
|
"delivery-receipt-invalid",
|
|
603
|
+
options,
|
|
544
604
|
);
|
|
545
605
|
if (
|
|
546
606
|
!isRecord(receipt) ||
|
|
@@ -592,9 +652,9 @@ export function parseReadyReceipt(bytes: Uint8Array): ReadyReceipt {
|
|
|
592
652
|
const traceability = normalizeTraceability(receipt.traceability);
|
|
593
653
|
if (
|
|
594
654
|
gateB.contractHash !== receipt.plan.canonicalHash ||
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
655
|
+
!sameNormalized(gateB, receipt.approvals.gateB, options) ||
|
|
656
|
+
!sameNormalized(artifacts, receipt.artifacts, options) ||
|
|
657
|
+
!sameNormalized(traceability, receipt.traceability, options)
|
|
598
658
|
) {
|
|
599
659
|
throw new Error("delivery-receipt-invalid");
|
|
600
660
|
}
|
|
@@ -602,12 +662,14 @@ export function parseReadyReceipt(bytes: Uint8Array): ReadyReceipt {
|
|
|
602
662
|
}
|
|
603
663
|
|
|
604
664
|
function sortStrings(values: readonly string[]): string[] {
|
|
605
|
-
return [...values].sort((left, right) =>
|
|
665
|
+
return [...values].sort((left, right) =>
|
|
666
|
+
compareCanonicalStrings(left, right),
|
|
667
|
+
);
|
|
606
668
|
}
|
|
607
669
|
|
|
608
670
|
function sortCanonical<T>(values: readonly T[]): T[] {
|
|
609
671
|
return [...values].sort((left, right) =>
|
|
610
|
-
canonicalJson(left)
|
|
672
|
+
compareCanonicalStrings(canonicalJson(left), canonicalJson(right)),
|
|
611
673
|
);
|
|
612
674
|
}
|
|
613
675
|
|
|
@@ -628,7 +690,7 @@ function normalizeVerification(
|
|
|
628
690
|
return cloned;
|
|
629
691
|
}
|
|
630
692
|
|
|
631
|
-
function normalizePhase(value: unknown): PlanPhaseDraft {
|
|
693
|
+
function normalizePhase(value: unknown, readOnly = false): PlanPhaseDraft {
|
|
632
694
|
if (
|
|
633
695
|
!isRecord(value) ||
|
|
634
696
|
!exactKeys(value, [
|
|
@@ -651,7 +713,7 @@ function normalizePhase(value: unknown): PlanPhaseDraft {
|
|
|
651
713
|
}
|
|
652
714
|
const write = sortStrings(value.write as string[]);
|
|
653
715
|
const deletions = sortStrings(value.delete as string[]);
|
|
654
|
-
if (write.length + deletions.length === 0) {
|
|
716
|
+
if (!readOnly && write.length + deletions.length === 0) {
|
|
655
717
|
throw new Error("delivery-plan-empty-phase-boundary");
|
|
656
718
|
}
|
|
657
719
|
if (write.some((candidate) => deletions.includes(candidate))) {
|
|
@@ -724,7 +786,12 @@ function normalizeTask(value: unknown): PlanTaskDraft {
|
|
|
724
786
|
candidate: unknown,
|
|
725
787
|
) => {
|
|
726
788
|
try {
|
|
727
|
-
return normalizePhase(
|
|
789
|
+
return normalizePhase(
|
|
790
|
+
candidate,
|
|
791
|
+
phase === "red" &&
|
|
792
|
+
value.verificationMode !== undefined &&
|
|
793
|
+
value.verificationMode !== "behavior",
|
|
794
|
+
);
|
|
728
795
|
} catch (error) {
|
|
729
796
|
throw asPlanValidationError(error, {
|
|
730
797
|
...context,
|
|
@@ -758,8 +825,8 @@ function normalizeTask(value: unknown): PlanTaskDraft {
|
|
|
758
825
|
searchEvidence: sortStrings(cloned.impactClosure.searchEvidence),
|
|
759
826
|
relatedTests: [...cloned.impactClosure.relatedTests].sort(
|
|
760
827
|
(left, right) =>
|
|
761
|
-
left.path
|
|
762
|
-
left.disposition
|
|
828
|
+
compareCanonicalStrings(left.path, right.path) ||
|
|
829
|
+
compareCanonicalStrings(left.disposition, right.disposition),
|
|
763
830
|
),
|
|
764
831
|
affectedSuite: sortStrings(cloned.impactClosure.affectedSuite),
|
|
765
832
|
},
|
|
@@ -936,7 +1003,7 @@ function normalizeAgentsCheckpointOperations(
|
|
|
936
1003
|
: normalizeAgentsManagedBlock(operation.managedBlock),
|
|
937
1004
|
};
|
|
938
1005
|
})
|
|
939
|
-
.sort((left, right) => left.target
|
|
1006
|
+
.sort((left, right) => compareCanonicalStrings(left.target, right.target));
|
|
940
1007
|
}
|
|
941
1008
|
|
|
942
1009
|
function normalizeVerificationPlan(value: unknown): PlanVerification {
|
|
@@ -1059,6 +1126,7 @@ function normalizeTracking(value: unknown): PlanTracking {
|
|
|
1059
1126
|
}
|
|
1060
1127
|
|
|
1061
1128
|
function normalizeDraft(value: unknown): ImplementPlan {
|
|
1129
|
+
value = structuredClone(value);
|
|
1062
1130
|
if (
|
|
1063
1131
|
!isRecord(value) ||
|
|
1064
1132
|
!exactKeys(value, [
|
|
@@ -1067,6 +1135,7 @@ function normalizeDraft(value: unknown): ImplementPlan {
|
|
|
1067
1135
|
"outputs",
|
|
1068
1136
|
"verification",
|
|
1069
1137
|
"tracking",
|
|
1138
|
+
...(value.changeContract === undefined ? [] : ["changeContract"]),
|
|
1070
1139
|
]) ||
|
|
1071
1140
|
typeof value.changeId !== "string" ||
|
|
1072
1141
|
!CHANGE_NAME.test(value.changeId) ||
|
|
@@ -1075,9 +1144,26 @@ function normalizeDraft(value: unknown): ImplementPlan {
|
|
|
1075
1144
|
) {
|
|
1076
1145
|
throw new Error("delivery-plan-invalid");
|
|
1077
1146
|
}
|
|
1147
|
+
const source = value;
|
|
1148
|
+
const taskDrafts = value.tasks.map((task: unknown) => {
|
|
1149
|
+
if (
|
|
1150
|
+
!isRecord(task) ||
|
|
1151
|
+
!["mechanical", "refactor"].includes(String(task.verificationMode))
|
|
1152
|
+
)
|
|
1153
|
+
return task;
|
|
1154
|
+
if (!isRecord(task.phases) || !isRecord(task.phases.green))
|
|
1155
|
+
throw new Error("delivery-plan-invalid-task-phases");
|
|
1156
|
+
if (!source.changeContract)
|
|
1157
|
+
throw new Error("change-contract-verification-mode-required");
|
|
1158
|
+
const green = task.phases.green;
|
|
1159
|
+
return {
|
|
1160
|
+
...task,
|
|
1161
|
+
phases: { ...task.phases, red: { ...green, write: [], delete: [] } },
|
|
1162
|
+
};
|
|
1163
|
+
});
|
|
1078
1164
|
const draftGraph = validateImplementGraphBoundary({
|
|
1079
1165
|
changeId: value.changeId,
|
|
1080
|
-
tasks:
|
|
1166
|
+
tasks: taskDrafts.map((candidate) => {
|
|
1081
1167
|
if (!isRecord(candidate)) return candidate;
|
|
1082
1168
|
const {
|
|
1083
1169
|
affectedVerification: _affectedVerification,
|
|
@@ -1095,12 +1181,17 @@ function normalizeDraft(value: unknown): ImplementPlan {
|
|
|
1095
1181
|
);
|
|
1096
1182
|
}
|
|
1097
1183
|
const plan: ImplementPlan = {
|
|
1184
|
+
...(value.changeContract === undefined
|
|
1185
|
+
? {}
|
|
1186
|
+
: { changeContract: normalizeChangeContract(value.changeContract) }),
|
|
1098
1187
|
changeId: value.changeId,
|
|
1099
|
-
tasks:
|
|
1188
|
+
tasks: taskDrafts
|
|
1100
1189
|
.map(normalizeTask)
|
|
1101
|
-
.sort((left, right) =>
|
|
1190
|
+
.sort((left, right) =>
|
|
1191
|
+
compareCanonicalStrings(left.taskId, right.taskId),
|
|
1192
|
+
),
|
|
1102
1193
|
outputs: (structuredClone(value.outputs) as ImplementGraphOutput[]).sort(
|
|
1103
|
-
(left, right) => left.id
|
|
1194
|
+
(left, right) => compareCanonicalStrings(left.id, right.id),
|
|
1104
1195
|
),
|
|
1105
1196
|
verification: normalizeVerificationPlan(value.verification),
|
|
1106
1197
|
tracking: normalizeTracking(value.tracking),
|
|
@@ -1141,6 +1232,11 @@ function normalizeDraft(value: unknown): ImplementPlan {
|
|
|
1141
1232
|
) {
|
|
1142
1233
|
throw new Error("delivery-agents-checkpoint-mismatch");
|
|
1143
1234
|
}
|
|
1235
|
+
if (plan.changeContract)
|
|
1236
|
+
assertPlanWithinChangeContract(plan.changeContract, plan.tasks, [
|
|
1237
|
+
plan.verification.change.fullSuite,
|
|
1238
|
+
plan.verification.change.postApply,
|
|
1239
|
+
]);
|
|
1144
1240
|
const graphValidation = validateImplementGraphBoundary(graphFromPlan(plan));
|
|
1145
1241
|
if (!graphValidation.ok) {
|
|
1146
1242
|
throw new DesignPlanValidationError(
|
|
@@ -1158,7 +1254,12 @@ function renderTasks(plan: ImplementPlan): string {
|
|
|
1158
1254
|
lines.push(
|
|
1159
1255
|
` - Depends on: ${task.dependsOn.length > 0 ? task.dependsOn.join(", ") : "[]"}`,
|
|
1160
1256
|
);
|
|
1161
|
-
|
|
1257
|
+
if (!task.verificationMode || task.verificationMode === "behavior")
|
|
1258
|
+
lines.push(` - Red verification: ${task.phases.red.verification.id}`);
|
|
1259
|
+
else
|
|
1260
|
+
lines.push(
|
|
1261
|
+
` - Evidence mode: ${task.verificationMode}; baseline and postconditions required`,
|
|
1262
|
+
);
|
|
1162
1263
|
lines.push(` - Green verification: ${task.phases.green.verification.id}`);
|
|
1163
1264
|
}
|
|
1164
1265
|
lines.push("");
|
|
@@ -1167,10 +1268,16 @@ function renderTasks(plan: ImplementPlan): string {
|
|
|
1167
1268
|
|
|
1168
1269
|
export function compileImplementPlan(
|
|
1169
1270
|
draft: unknown,
|
|
1170
|
-
options: { consumerRoot: string },
|
|
1271
|
+
options: { consumerRoot: string; bindExecutionInputs?: boolean },
|
|
1171
1272
|
): CompiledDelivery {
|
|
1172
1273
|
try {
|
|
1173
|
-
|
|
1274
|
+
if (options.bindExecutionInputs)
|
|
1275
|
+
draft = bindDraftVerificationInputs(options.consumerRoot, draft);
|
|
1276
|
+
const normalized = normalizeDraft(draft);
|
|
1277
|
+
const plan =
|
|
1278
|
+
isRecord(draft) && legacyPlans.get(draft) === JSON.stringify(draft)
|
|
1279
|
+
? (draft as unknown as ImplementPlan)
|
|
1280
|
+
: normalized;
|
|
1174
1281
|
const graph = graphFromPlan(plan);
|
|
1175
1282
|
const readiness = assessImplementGraphReadiness(
|
|
1176
1283
|
options.consumerRoot,
|
|
@@ -1217,7 +1324,10 @@ export function compileImplementPlan(
|
|
|
1217
1324
|
}
|
|
1218
1325
|
}
|
|
1219
1326
|
|
|
1220
|
-
export function parseImplementPlan(
|
|
1327
|
+
export function parseImplementPlan(
|
|
1328
|
+
bytes: Uint8Array,
|
|
1329
|
+
options: DeliveryReadOptions = {},
|
|
1330
|
+
): ImplementPlan {
|
|
1221
1331
|
const text = new TextDecoder().decode(bytes);
|
|
1222
1332
|
let parsed: unknown;
|
|
1223
1333
|
try {
|
|
@@ -1226,8 +1336,15 @@ export function parseImplementPlan(bytes: Uint8Array): ImplementPlan {
|
|
|
1226
1336
|
throw new Error("delivery-plan-json-invalid");
|
|
1227
1337
|
}
|
|
1228
1338
|
const normalized = normalizeDraft(parsed);
|
|
1229
|
-
if (`${canonicalJson(normalized)}\n`
|
|
1339
|
+
if (`${canonicalJson(normalized)}\n` === text) return normalized;
|
|
1340
|
+
if (
|
|
1341
|
+
!options.allowLegacyOrder ||
|
|
1342
|
+
`${canonicalJson(parsed)}\n` !== text ||
|
|
1343
|
+
!sameNormalized(normalized, parsed, options)
|
|
1344
|
+
) {
|
|
1230
1345
|
throw new Error("delivery-plan-not-canonical");
|
|
1231
1346
|
}
|
|
1232
|
-
|
|
1347
|
+
// Keep proof-bound historical bytes unchanged; a mutation invalidates this association.
|
|
1348
|
+
legacyPlans.set(parsed as object, JSON.stringify(parsed));
|
|
1349
|
+
return parsed as ImplementPlan;
|
|
1233
1350
|
}
|
package/src/design-control.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
1
2
|
import { createHash, randomUUID } from "node:crypto";
|
|
2
3
|
import {
|
|
3
4
|
lstatSync,
|
|
@@ -8,6 +9,10 @@ import {
|
|
|
8
9
|
writeFileSync,
|
|
9
10
|
} from "node:fs";
|
|
10
11
|
import path from "node:path";
|
|
12
|
+
import {
|
|
13
|
+
type ChangeContract,
|
|
14
|
+
normalizeChangeContract,
|
|
15
|
+
} from "./change-contract.ts";
|
|
11
16
|
import { isValidRelativePath } from "./contracts.ts";
|
|
12
17
|
import {
|
|
13
18
|
assessDeliveryTraceability,
|
|
@@ -27,6 +32,8 @@ import {
|
|
|
27
32
|
DesignJournal,
|
|
28
33
|
type DesignStatusProjection,
|
|
29
34
|
} from "./design-journal.ts";
|
|
35
|
+
import { OpenSpecCliError, type OpenSpecDiagnostic } from "./openspec-cli.ts";
|
|
36
|
+
import { canonicalJson } from "./run-state.ts";
|
|
30
37
|
import { RunStore } from "./run-store.ts";
|
|
31
38
|
import { observeSafePath } from "./safe-path.ts";
|
|
32
39
|
import type { ResolvedStateRoot } from "./state-root.ts";
|
|
@@ -38,6 +45,20 @@ const MAX_DESIGN_FILE_BYTES = 16 * 1024 * 1024;
|
|
|
38
45
|
const MAX_DESIGN_REQUIREMENT_BYTES = 64 * 1024;
|
|
39
46
|
const MAX_DESIGN_CONTRACT_BYTES = 256 * 1024;
|
|
40
47
|
|
|
48
|
+
function validGateContract(value: unknown): boolean {
|
|
49
|
+
if (typeof value === "string")
|
|
50
|
+
return (
|
|
51
|
+
value.trim().length > 0 &&
|
|
52
|
+
Buffer.byteLength(value, "utf8") <= MAX_DESIGN_CONTRACT_BYTES
|
|
53
|
+
);
|
|
54
|
+
try {
|
|
55
|
+
normalizeChangeContract(value);
|
|
56
|
+
return true;
|
|
57
|
+
} catch {
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
41
62
|
export interface DesignOpenSpecInspection {
|
|
42
63
|
change: string;
|
|
43
64
|
schema: string;
|
|
@@ -81,7 +102,7 @@ export type DesignControlRequest =
|
|
|
81
102
|
runId: string;
|
|
82
103
|
operationId: string;
|
|
83
104
|
gate: "gate-a";
|
|
84
|
-
contract: string;
|
|
105
|
+
contract: string | ChangeContract;
|
|
85
106
|
}
|
|
86
107
|
| {
|
|
87
108
|
operation: "approve-gate";
|
|
@@ -131,11 +152,17 @@ export interface DesignControllerOptions {
|
|
|
131
152
|
export class DesignFinalizationError extends Error {
|
|
132
153
|
readonly diagnostics: readonly string[];
|
|
133
154
|
|
|
134
|
-
|
|
155
|
+
readonly openSpecDiagnostic?: Readonly<OpenSpecDiagnostic>;
|
|
156
|
+
|
|
157
|
+
constructor(
|
|
158
|
+
diagnostics: readonly string[],
|
|
159
|
+
openSpecDiagnostic?: Readonly<OpenSpecDiagnostic>,
|
|
160
|
+
) {
|
|
135
161
|
const normalized = [...new Set(diagnostics)].sort();
|
|
136
162
|
super("design-finalization-invalid");
|
|
137
163
|
this.name = "DesignFinalizationError";
|
|
138
164
|
this.diagnostics = Object.freeze(normalized);
|
|
165
|
+
this.openSpecDiagnostic = openSpecDiagnostic;
|
|
139
166
|
}
|
|
140
167
|
}
|
|
141
168
|
|
|
@@ -268,9 +295,7 @@ export function validateDesignControlRequest(
|
|
|
268
295
|
"gate",
|
|
269
296
|
"contract",
|
|
270
297
|
]) &&
|
|
271
|
-
|
|
272
|
-
value.contract.trim().length > 0 &&
|
|
273
|
-
Buffer.byteLength(value.contract, "utf8") <= MAX_DESIGN_CONTRACT_BYTES
|
|
298
|
+
validGateContract(value.contract)
|
|
274
299
|
? { ok: true, value: structuredClone(value) as DesignControlRequest }
|
|
275
300
|
: { ok: false, code: "invalid-design-control-request" };
|
|
276
301
|
}
|
|
@@ -452,6 +477,7 @@ export class DesignController {
|
|
|
452
477
|
readonly #runs: RunStore;
|
|
453
478
|
readonly #inspectOpenSpec: DesignControllerOptions["inspectOpenSpec"];
|
|
454
479
|
#closed = false;
|
|
480
|
+
readonly #authority = new AsyncLocalStorage<() => void>();
|
|
455
481
|
|
|
456
482
|
private constructor(options: DesignControllerOptions) {
|
|
457
483
|
this.consumerRoot = path.resolve(options.consumerRoot);
|
|
@@ -478,7 +504,22 @@ export class DesignController {
|
|
|
478
504
|
return new DesignController(options);
|
|
479
505
|
}
|
|
480
506
|
|
|
507
|
+
executeWithAuthority(
|
|
508
|
+
value: unknown,
|
|
509
|
+
assertAuthority: () => void,
|
|
510
|
+
): Promise<Record<string, unknown>> {
|
|
511
|
+
return this.#authority.run(assertAuthority, () => this.execute(value));
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
#assertFinalizationLease(
|
|
515
|
+
lease: Parameters<DesignJournal["assertFinalizationLease"]>[0],
|
|
516
|
+
): void {
|
|
517
|
+
this.#authority.getStore()?.();
|
|
518
|
+
this.#journal.assertFinalizationLease(lease);
|
|
519
|
+
}
|
|
520
|
+
|
|
481
521
|
#assertOpen(): void {
|
|
522
|
+
this.#authority.getStore()?.();
|
|
482
523
|
if (this.#closed) throw new Error("design-controller-closed");
|
|
483
524
|
}
|
|
484
525
|
|
|
@@ -624,6 +665,7 @@ export class DesignController {
|
|
|
624
665
|
throw error;
|
|
625
666
|
}
|
|
626
667
|
if (run.state === "created") {
|
|
668
|
+
this.#journal.inheritFinalizedAuthority(run.runId);
|
|
627
669
|
this.#runs.transition({
|
|
628
670
|
runId: run.runId,
|
|
629
671
|
to: "paused",
|
|
@@ -824,7 +866,21 @@ export class DesignController {
|
|
|
824
866
|
{ code: "design-plan-draft-invalid", field: "plan-draft.json" },
|
|
825
867
|
]);
|
|
826
868
|
}
|
|
869
|
+
if (status.changeContract) {
|
|
870
|
+
if (!draft || typeof draft !== "object" || Array.isArray(draft))
|
|
871
|
+
throw new Error("change-contract-invalid");
|
|
872
|
+
const record = draft as Record<string, unknown>;
|
|
873
|
+
if (
|
|
874
|
+
record.changeContract !== undefined &&
|
|
875
|
+
canonicalJson(normalizeChangeContract(record.changeContract)) !==
|
|
876
|
+
canonicalJson(status.changeContract)
|
|
877
|
+
)
|
|
878
|
+
throw new Error("change-contract-mismatch");
|
|
879
|
+
record.changeContract = status.changeContract;
|
|
880
|
+
} else if (draft && typeof draft === "object" && "changeContract" in draft)
|
|
881
|
+
throw new Error("change-contract-gate-a-required");
|
|
827
882
|
const compiled = compileImplementPlan(draft, {
|
|
883
|
+
bindExecutionInputs: true,
|
|
828
884
|
consumerRoot: this.consumerRoot,
|
|
829
885
|
});
|
|
830
886
|
if (compiled.plan.changeId !== status.change) {
|
|
@@ -875,9 +931,15 @@ export class DesignController {
|
|
|
875
931
|
if (!change) throw new Error("design-change-required");
|
|
876
932
|
const readyPath = safeRelative(change, "ready.yaml");
|
|
877
933
|
const gateAPath = safeRelative(change, "gate-a.yaml");
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
934
|
+
const unchanged =
|
|
935
|
+
status.gates.gateB.current &&
|
|
936
|
+
status.plan?.rawSha256 === compiled.rawSha256 &&
|
|
937
|
+
status.plan.canonicalHash === compiled.planHash;
|
|
938
|
+
if (!unchanged) {
|
|
939
|
+
removeSafeFile(this.consumerRoot, readyPath);
|
|
940
|
+
removeSafeFile(this.consumerRoot, gateAPath);
|
|
941
|
+
}
|
|
942
|
+
this.#assertFinalizationLease(lease);
|
|
881
943
|
atomicWrite(
|
|
882
944
|
this.consumerRoot,
|
|
883
945
|
safeRelative(change, "implement-plan.json"),
|
|
@@ -890,6 +952,7 @@ export class DesignController {
|
|
|
890
952
|
rawSha256: compiled.rawSha256,
|
|
891
953
|
canonicalHash: compiled.planHash,
|
|
892
954
|
lease,
|
|
955
|
+
certify: true,
|
|
893
956
|
});
|
|
894
957
|
} finally {
|
|
895
958
|
this.#journal.releaseFinalizationLease(lease);
|
|
@@ -934,9 +997,12 @@ export class DesignController {
|
|
|
934
997
|
removeSafeFile(this.consumerRoot, readyPath);
|
|
935
998
|
const diagnostics = new Set<string>();
|
|
936
999
|
let inspection: DesignOpenSpecInspection | undefined;
|
|
1000
|
+
let openSpecDiagnostic: Readonly<OpenSpecDiagnostic> | undefined;
|
|
937
1001
|
try {
|
|
938
1002
|
inspection = await this.#inspectOpenSpec(this.consumerRoot, change);
|
|
939
|
-
} catch {
|
|
1003
|
+
} catch (error) {
|
|
1004
|
+
if (error instanceof OpenSpecCliError)
|
|
1005
|
+
openSpecDiagnostic = error.diagnostic;
|
|
940
1006
|
diagnostics.add("design-openspec-unavailable");
|
|
941
1007
|
}
|
|
942
1008
|
if (inspection) {
|
|
@@ -960,7 +1026,9 @@ export class DesignController {
|
|
|
960
1026
|
}
|
|
961
1027
|
let compiled: ReturnType<typeof compileImplementPlan> | undefined;
|
|
962
1028
|
try {
|
|
963
|
-
const plan = parseImplementPlan(installedPlanBytes
|
|
1029
|
+
const plan = parseImplementPlan(installedPlanBytes, {
|
|
1030
|
+
allowLegacyOrder: true,
|
|
1031
|
+
});
|
|
964
1032
|
compiled = compileImplementPlan(plan, {
|
|
965
1033
|
consumerRoot: this.consumerRoot,
|
|
966
1034
|
});
|
|
@@ -1004,7 +1072,7 @@ export class DesignController {
|
|
|
1004
1072
|
let traceability:
|
|
1005
1073
|
| ReturnType<typeof assessDeliveryTraceability>
|
|
1006
1074
|
| undefined;
|
|
1007
|
-
if (compiled) {
|
|
1075
|
+
if (compiled && inspection) {
|
|
1008
1076
|
const tasks = artifactBytes.get("tasks.md");
|
|
1009
1077
|
const specs = [...artifactBytes]
|
|
1010
1078
|
.filter(
|
|
@@ -1035,7 +1103,7 @@ export class DesignController {
|
|
|
1035
1103
|
!traceability?.ok ||
|
|
1036
1104
|
diagnostics.size > 0
|
|
1037
1105
|
) {
|
|
1038
|
-
throw new DesignFinalizationError([...diagnostics]);
|
|
1106
|
+
throw new DesignFinalizationError([...diagnostics], openSpecDiagnostic);
|
|
1039
1107
|
}
|
|
1040
1108
|
const gateAReceipt = compileGateAReceipt({
|
|
1041
1109
|
change,
|
|
@@ -1057,14 +1125,14 @@ export class DesignController {
|
|
|
1057
1125
|
traceability: traceability.value,
|
|
1058
1126
|
});
|
|
1059
1127
|
readyHash = readyReceipt.rawSha256;
|
|
1060
|
-
this.#
|
|
1128
|
+
this.#assertFinalizationLease(lease);
|
|
1061
1129
|
atomicWrite(this.consumerRoot, gateAPath, gateAReceipt.bytes);
|
|
1062
1130
|
atomicWrite(this.consumerRoot, readyPath, readyReceipt.bytes);
|
|
1063
1131
|
readyInstalled = true;
|
|
1064
1132
|
const installedGateA = readSafeFile(this.consumerRoot, gateAPath);
|
|
1065
1133
|
const installedReady = readSafeFile(this.consumerRoot, readyPath);
|
|
1066
|
-
parseGateAReceipt(installedGateA);
|
|
1067
|
-
parseReadyReceipt(installedReady);
|
|
1134
|
+
parseGateAReceipt(installedGateA, { allowLegacyOrder: true });
|
|
1135
|
+
parseReadyReceipt(installedReady, { allowLegacyOrder: true });
|
|
1068
1136
|
if (
|
|
1069
1137
|
!Buffer.from(installedGateA).equals(Buffer.from(gateAReceipt.bytes)) ||
|
|
1070
1138
|
!Buffer.from(installedReady).equals(Buffer.from(readyReceipt.bytes))
|