@abelxiaoxing/cadence 1.2.3 → 1.2.4

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@abelxiaoxing/cadence",
3
- "version": "1.2.3",
3
+ "version": "1.2.4",
4
4
  "description": "Four explicit Abel workflow prompts with stage-isolated design and diagnosis, durable resumable implementation, and three package-owned professional Agents.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -456,12 +456,21 @@ export class DesignController {
456
456
  private constructor(options: DesignControllerOptions) {
457
457
  this.consumerRoot = path.resolve(options.consumerRoot);
458
458
  this.#runs = RunStore.open(options.stateRoot);
459
- this.#journal = DesignJournal.open(options.stateRoot, {
460
- ...(options.now ? { now: options.now } : {}),
461
- ...(options.finalizationLeaseMs !== undefined
462
- ? { finalizationLeaseMs: options.finalizationLeaseMs }
463
- : {}),
464
- });
459
+ try {
460
+ this.#journal = DesignJournal.open(options.stateRoot, {
461
+ ...(options.now ? { now: options.now } : {}),
462
+ ...(options.finalizationLeaseMs !== undefined
463
+ ? { finalizationLeaseMs: options.finalizationLeaseMs }
464
+ : {}),
465
+ });
466
+ } catch (error) {
467
+ try {
468
+ this.#runs.close();
469
+ } catch {
470
+ // The Design journal initialization failure remains authoritative.
471
+ }
472
+ throw error;
473
+ }
465
474
  this.#inspectOpenSpec = options.inspectOpenSpec;
466
475
  }
467
476
 
@@ -588,18 +597,32 @@ export class DesignController {
588
597
 
589
598
  #start(request: Extract<DesignControlRequest, { operation: "start" }>) {
590
599
  const fromRequirement = "requirement" in request;
591
- const run = this.#runs.startRun({
592
- stage: "abel-design",
593
- ...(fromRequirement
594
- ? {
595
- provisionalKey: transientHash(
596
- "abel-design-requirement",
597
- request.requirement,
598
- ),
599
- }
600
- : { change: request.change }),
601
- operationId: request.operationId,
602
- });
600
+ let run: ReturnType<RunStore["startRun"]>;
601
+ try {
602
+ run = this.#runs.startRun({
603
+ stage: "abel-design",
604
+ ...(fromRequirement
605
+ ? {
606
+ provisionalKey: transientHash(
607
+ "abel-design-requirement",
608
+ request.requirement,
609
+ ),
610
+ provisionalAliases: [
611
+ transientHash(
612
+ "abel-design-requirement-v1",
613
+ request.requirement,
614
+ ),
615
+ ],
616
+ }
617
+ : { change: request.change }),
618
+ operationId: request.operationId,
619
+ });
620
+ } catch (error) {
621
+ if (error instanceof Error && error.message === "operation-id-conflict") {
622
+ throw new Error("design-operation-conflict", { cause: error });
623
+ }
624
+ throw error;
625
+ }
603
626
  if (run.state === "created") {
604
627
  this.#runs.transition({
605
628
  runId: run.runId,
@@ -317,12 +317,21 @@ export class DesignJournal {
317
317
  }
318
318
  const stateRoot = prepareStateRoot(unresolved);
319
319
  const database = new DatabaseSync(stateRoot.databasePath);
320
- database.exec("PRAGMA foreign_keys = ON");
321
- database.exec("PRAGMA journal_mode = WAL");
322
- database.exec("PRAGMA synchronous = FULL");
323
- database.exec("PRAGMA busy_timeout = 5000");
324
- database.exec(SCHEMA);
325
- return new DesignJournal(stateRoot, database, options);
320
+ try {
321
+ database.exec("PRAGMA foreign_keys = ON");
322
+ database.exec("PRAGMA journal_mode = WAL");
323
+ database.exec("PRAGMA synchronous = FULL");
324
+ database.exec("PRAGMA busy_timeout = 5000");
325
+ database.exec(SCHEMA);
326
+ return new DesignJournal(stateRoot, database, options);
327
+ } catch (error) {
328
+ try {
329
+ database.close();
330
+ } catch {
331
+ // The Design journal initialization failure remains authoritative.
332
+ }
333
+ throw error;
334
+ }
326
335
  }
327
336
 
328
337
  #assertOpen(): void {
@@ -337,7 +346,11 @@ export class DesignJournal {
337
346
  this.#database.exec("COMMIT");
338
347
  return result;
339
348
  } catch (error) {
340
- this.#database.exec("ROLLBACK");
349
+ try {
350
+ this.#database.exec("ROLLBACK");
351
+ } catch {
352
+ // The original operation or commit failure remains authoritative.
353
+ }
341
354
  throw error;
342
355
  }
343
356
  }
package/src/index.ts CHANGED
@@ -69,9 +69,9 @@ import {
69
69
  unavailableRoutePolicy,
70
70
  type WorkerRoutePolicy,
71
71
  } from "./route-policy.ts";
72
- import { RunStoreFormatError } from "./run-store.ts";
72
+ import { RunStoreFormatError, RunStoreMigrationError } from "./run-store.ts";
73
73
  import { observeSafePath } from "./safe-path.ts";
74
- import { resolveStateRoot } from "./state-root.ts";
74
+ import { resolveStateRoot, StateRootError } from "./state-root.ts";
75
75
  import {
76
76
  ACTIVITY_DETAILS_KEY,
77
77
  ActivityController,
@@ -431,20 +431,160 @@ type SafeDesignFailure = {
431
431
  kind: "design-control-failure";
432
432
  operation: string;
433
433
  code: string;
434
- diagnostics: ReadonlyArray<Record<string, string>>;
434
+ diagnostics: ReadonlyArray<Record<string, string | boolean>>;
435
435
  };
436
436
 
437
+ type DesignFailureBoundary =
438
+ | "dispatch"
439
+ | "initialization"
440
+ | "execution"
441
+ | "serialization";
442
+
443
+ function sqliteLocked(error: unknown): boolean {
444
+ return (
445
+ typeof error === "object" &&
446
+ error !== null &&
447
+ "errcode" in error &&
448
+ ((error as { errcode?: unknown }).errcode === 5 ||
449
+ (error as { errcode?: unknown }).errcode === 6)
450
+ );
451
+ }
452
+
453
+ function sqliteUnavailable(error: unknown): boolean {
454
+ if (typeof error !== "object" || error === null) return false;
455
+ const errcode = Number((error as { errcode?: unknown }).errcode);
456
+ const code = (error as { code?: unknown }).code;
457
+ return (
458
+ [8, 10, 13, 14, 23, 24].includes(errcode) ||
459
+ (typeof code === "string" &&
460
+ [
461
+ "EACCES",
462
+ "EBUSY",
463
+ "EMFILE",
464
+ "ENFILE",
465
+ "ENOSPC",
466
+ "EPERM",
467
+ "EROFS",
468
+ ].includes(code))
469
+ );
470
+ }
471
+
472
+ function classifyDesignFailure(
473
+ error: unknown,
474
+ operation: string,
475
+ boundary: DesignFailureBoundary,
476
+ ): {
477
+ code: string;
478
+ category: string;
479
+ retryable: boolean;
480
+ field?: string;
481
+ } {
482
+ const message = error instanceof Error ? error.message : "";
483
+ if (error instanceof DesignPlanValidationError) {
484
+ return {
485
+ code: "design-plan-validation-invalid",
486
+ category: "validation",
487
+ retryable: false,
488
+ };
489
+ }
490
+ if (
491
+ error instanceof RunStoreMigrationError ||
492
+ error instanceof RunStoreFormatError
493
+ ) {
494
+ return {
495
+ code: "design-store-migration-failed",
496
+ category: "storage",
497
+ retryable: false,
498
+ field: "schemaVersion",
499
+ };
500
+ }
501
+ if (sqliteLocked(error)) {
502
+ return {
503
+ code: "design-store-locked",
504
+ category: "storage",
505
+ retryable: true,
506
+ };
507
+ }
508
+ if (sqliteUnavailable(error)) {
509
+ return {
510
+ code: "design-store-unavailable",
511
+ category: "storage",
512
+ retryable: false,
513
+ };
514
+ }
515
+ if (error instanceof StateRootError) {
516
+ return {
517
+ code: "design-store-unavailable",
518
+ category: "storage",
519
+ retryable: false,
520
+ };
521
+ }
522
+ if (
523
+ message === "design-operation-conflict" ||
524
+ message === "operation-id-conflict"
525
+ ) {
526
+ return {
527
+ code: "design-operation-conflict",
528
+ category: "control",
529
+ retryable: false,
530
+ field: "operationId",
531
+ };
532
+ }
533
+ if (message === "stage-control-mismatch") {
534
+ return {
535
+ code: "design-stage-inactive",
536
+ category: "stage",
537
+ retryable: false,
538
+ };
539
+ }
540
+ if (message === "invalid-design-control-request") {
541
+ return {
542
+ code: "invalid-design-control-request",
543
+ category: "validation",
544
+ retryable: false,
545
+ };
546
+ }
547
+ if (boundary === "serialization") {
548
+ return {
549
+ code: "design-receipt-serialization-failed",
550
+ category: "control",
551
+ retryable: false,
552
+ };
553
+ }
554
+ if (boundary === "initialization") {
555
+ return {
556
+ code: "design-store-unavailable",
557
+ category: "storage",
558
+ retryable: false,
559
+ };
560
+ }
561
+ if (
562
+ error instanceof DesignFinalizationError ||
563
+ error instanceof DeliveryValidationError ||
564
+ (message.startsWith("design-") && SAFE_DESIGN_ERROR_CODE.test(message))
565
+ ) {
566
+ return { code: message, category: "control", retryable: false };
567
+ }
568
+ if (operation === "start") {
569
+ return {
570
+ code: "design-run-create-failed",
571
+ category: "storage",
572
+ retryable: false,
573
+ };
574
+ }
575
+ return {
576
+ code: "design-control-internal",
577
+ category: "control",
578
+ retryable: false,
579
+ };
580
+ }
581
+
437
582
  function safeDesignFailure(
438
583
  error: unknown,
439
584
  operation: string,
585
+ boundary: DesignFailureBoundary = "dispatch",
440
586
  ): SafeDesignFailure {
441
- const message = error instanceof Error ? error.message : "";
442
- const code =
443
- error instanceof DesignPlanValidationError
444
- ? "design-plan-validation-invalid"
445
- : SAFE_DESIGN_ERROR_CODE.test(message)
446
- ? message
447
- : "design-control-failed";
587
+ const classified = classifyDesignFailure(error, operation, boundary);
448
588
  const rawDiagnostics: readonly unknown[] =
449
589
  error instanceof DesignFinalizationError ||
450
590
  error instanceof DesignPlanValidationError ||
@@ -468,8 +608,11 @@ function safeDesignFailure(
468
608
  }
469
609
  const admitted = Object.fromEntries(
470
610
  Object.entries(candidate as Record<string, unknown>)
471
- .filter(
472
- (entry): entry is [string, string] =>
611
+ .filter((entry): entry is [string, string | boolean] => {
612
+ if (entry[0] === "retryable") {
613
+ return typeof entry[1] === "boolean";
614
+ }
615
+ return (
473
616
  [
474
617
  "code",
475
618
  "taskId",
@@ -484,8 +627,9 @@ function safeDesignFailure(
484
627
  "producerPhase",
485
628
  ].includes(entry[0]) &&
486
629
  typeof entry[1] === "string" &&
487
- SAFE_DESIGN_ERROR_CODE.test(entry[1]),
488
- )
630
+ SAFE_DESIGN_ERROR_CODE.test(entry[1])
631
+ );
632
+ })
489
633
  .sort(([left], [right]) => left.localeCompare(right)),
490
634
  );
491
635
  return typeof admitted.code === "string" ? [admitted] : [];
@@ -499,10 +643,18 @@ function safeDesignFailure(
499
643
  canonicalJson(candidate) !== canonicalJson(values[index - 1]),
500
644
  )
501
645
  .slice(0, MAX_DESIGN_ERROR_DIAGNOSTICS);
646
+ if (diagnostics.length === 0) {
647
+ diagnostics.push({
648
+ code: classified.code,
649
+ category: classified.category,
650
+ retryable: classified.retryable,
651
+ ...(classified.field ? { field: classified.field } : {}),
652
+ });
653
+ }
502
654
  return {
503
655
  kind: "design-control-failure",
504
656
  operation: SAFE_DESIGN_ERROR_CODE.test(operation) ? operation : "unknown",
505
- code,
657
+ code: classified.code,
506
658
  diagnostics,
507
659
  };
508
660
  }
@@ -2082,18 +2234,19 @@ export function registerWorkflowControl(
2082
2234
  }
2083
2235
  const designRequest = validateDesignControlRequest(record.request);
2084
2236
  if (!designRequest.ok) throw new Error(designRequest.code);
2085
- let engine: WorkflowControlEngine;
2086
- try {
2087
- engine = await engineFor(ctx);
2088
- } catch (error) {
2089
- if (error instanceof RunStoreFormatError)
2090
- throw runStoreResetError(error);
2091
- throw error;
2092
- }
2093
- if (!engine.executeDesign)
2094
- throw new Error("design-control-unavailable");
2237
+ let boundary: DesignFailureBoundary = "initialization";
2095
2238
  try {
2239
+ const engine = await engineFor(ctx);
2240
+ if (!engine.executeDesign)
2241
+ throw new Error("design-control-unavailable");
2242
+ boundary = "execution";
2096
2243
  const payload = await engine.executeDesign(designRequest.value);
2244
+ boundary = "serialization";
2245
+ const serialized = JSON.stringify(payload);
2246
+ if (serialized === undefined) {
2247
+ throw new Error("design-receipt-serialization-failed");
2248
+ }
2249
+ boundary = "execution";
2097
2250
  if (
2098
2251
  designRequest.value.operation === "finalize-delivery" &&
2099
2252
  payload.state === "completed"
@@ -2101,15 +2254,14 @@ export function registerWorkflowControl(
2101
2254
  await deactivateStage();
2102
2255
  }
2103
2256
  return {
2104
- content: [
2105
- { type: "text" as const, text: JSON.stringify(payload) },
2106
- ],
2257
+ content: [{ type: "text" as const, text: serialized }],
2107
2258
  details: payload,
2108
2259
  };
2109
2260
  } catch (error) {
2110
2261
  const failure = safeDesignFailure(
2111
2262
  error,
2112
2263
  designRequest.value.operation,
2264
+ boundary,
2113
2265
  );
2114
2266
  designFailures.set(toolCallId, failure);
2115
2267
  const sanitized = new Error(failure.code);
package/src/run-store.ts CHANGED
@@ -33,6 +33,17 @@ export class RunStoreFormatError extends Error {
33
33
  }
34
34
  }
35
35
 
36
+ export class RunStoreMigrationError extends Error {
37
+ readonly code = "run-store-migration-failed" as const;
38
+ readonly schemaVersion: number;
39
+
40
+ constructor(schemaVersion: number, cause: unknown) {
41
+ super("run-store-migration-failed", { cause });
42
+ this.name = "RunStoreMigrationError";
43
+ this.schemaVersion = schemaVersion;
44
+ }
45
+ }
46
+
36
47
  const CHANGE_NAME = /^[a-z0-9](?:[a-z0-9-]{0,126}[a-z0-9])?$/u;
37
48
  const IDENTIFIER = /^[a-z0-9](?:[a-z0-9._:-]{0,126}[a-z0-9])?$/iu;
38
49
  const SHA256 = /^[a-f0-9]{64}$/u;
@@ -67,6 +78,7 @@ export interface StartRunInput {
67
78
  stage: RunStage;
68
79
  change?: string;
69
80
  provisionalKey?: string;
81
+ provisionalAliases?: readonly string[];
70
82
  operationId: string;
71
83
  }
72
84
 
@@ -236,6 +248,7 @@ const REQUIRED_DELIVERY_BINDING_COLUMNS = [
236
248
  "record_hash",
237
249
  "operation_id",
238
250
  ] as const;
251
+ const LEGACY_SCHEMA_VERSION = 4;
239
252
 
240
253
  function applicationTables(database: DatabaseSync): string[] {
241
254
  return (
@@ -257,11 +270,12 @@ function tableColumns(database: DatabaseSync, table: string): string[] {
257
270
  ).map(({ name }) => name);
258
271
  }
259
272
 
260
- function hasCurrentSchema(database: DatabaseSync): boolean {
261
- const tables = applicationTables(database);
273
+ function hasRequiredCoreSchema(
274
+ database: DatabaseSync,
275
+ tables: readonly string[],
276
+ ): boolean {
262
277
  const deliveryBindingColumns = tableColumns(database, "delivery_bindings");
263
278
  return (
264
- !tables.includes("schema_meta") &&
265
279
  REQUIRED_TABLES.every((table) => tables.includes(table)) &&
266
280
  REQUIRED_DELIVERY_BINDING_COLUMNS.every((column) =>
267
281
  deliveryBindingColumns.includes(column),
@@ -269,6 +283,110 @@ function hasCurrentSchema(database: DatabaseSync): boolean {
269
283
  );
270
284
  }
271
285
 
286
+ function hasCurrentSchema(
287
+ database: DatabaseSync,
288
+ tables: readonly string[],
289
+ ): boolean {
290
+ return (
291
+ !tables.includes("schema_meta") && hasRequiredCoreSchema(database, tables)
292
+ );
293
+ }
294
+
295
+ function isSupportedLegacySchema(
296
+ database: DatabaseSync,
297
+ tables: readonly string[],
298
+ ): boolean {
299
+ if (
300
+ !tables.includes("schema_meta") ||
301
+ !hasRequiredCoreSchema(database, tables) ||
302
+ tableColumns(database, "schema_meta").join("\0") !== "version"
303
+ ) {
304
+ return false;
305
+ }
306
+ const rows = database
307
+ .prepare("SELECT version FROM schema_meta")
308
+ .all() as unknown as Array<{
309
+ version: number;
310
+ }>;
311
+ return (
312
+ rows.length === 1 && Number(rows[0]?.version) === LEGACY_SCHEMA_VERSION
313
+ );
314
+ }
315
+
316
+ function isSqliteLocked(error: unknown): boolean {
317
+ return (
318
+ typeof error === "object" &&
319
+ error !== null &&
320
+ "errcode" in error &&
321
+ ((error as { errcode?: unknown }).errcode === 5 ||
322
+ (error as { errcode?: unknown }).errcode === 6)
323
+ );
324
+ }
325
+
326
+ function isStoreUnavailable(error: unknown): boolean {
327
+ if (typeof error !== "object" || error === null) return false;
328
+ const errcode = (error as { errcode?: unknown }).errcode;
329
+ if ([8, 10, 13, 14, 23, 24].includes(Number(errcode))) return true;
330
+ const code = (error as { code?: unknown }).code;
331
+ return (
332
+ typeof code === "string" &&
333
+ [
334
+ "EACCES",
335
+ "EBUSY",
336
+ "EMFILE",
337
+ "ENFILE",
338
+ "ENOSPC",
339
+ "EPERM",
340
+ "EROFS",
341
+ ].includes(code)
342
+ );
343
+ }
344
+
345
+ function ensureCurrentSchema(
346
+ database: DatabaseSync,
347
+ databasePath: string,
348
+ ): void {
349
+ const initialTables = applicationTables(database);
350
+ if (hasCurrentSchema(database, initialTables)) return;
351
+ let transactionStarted = false;
352
+ let migrating = false;
353
+ try {
354
+ database.exec("BEGIN IMMEDIATE");
355
+ transactionStarted = true;
356
+ const tables = applicationTables(database);
357
+ if (tables.length === 0) {
358
+ database.exec(SCHEMA);
359
+ } else if (hasCurrentSchema(database, tables)) {
360
+ // Another opener may already have completed the same migration.
361
+ } else if (isSupportedLegacySchema(database, tables)) {
362
+ migrating = true;
363
+ database.exec("DROP TABLE schema_meta");
364
+ } else {
365
+ throw new RunStoreFormatError(databasePath);
366
+ }
367
+ database.exec("COMMIT");
368
+ } catch (error) {
369
+ if (transactionStarted) {
370
+ try {
371
+ database.exec("ROLLBACK");
372
+ } catch {
373
+ // The original schema or commit failure remains authoritative.
374
+ }
375
+ }
376
+ if (
377
+ error instanceof RunStoreFormatError ||
378
+ error instanceof RunStoreMigrationError ||
379
+ isSqliteLocked(error)
380
+ ) {
381
+ throw error;
382
+ }
383
+ if (migrating) {
384
+ throw new RunStoreMigrationError(LEGACY_SCHEMA_VERSION, error);
385
+ }
386
+ throw error;
387
+ }
388
+ }
389
+
272
390
  function parseProjection(value: string): RunProjection {
273
391
  const parsed = JSON.parse(value) as RunProjection;
274
392
  if (
@@ -281,22 +399,35 @@ function parseProjection(value: string): RunProjection {
281
399
  return parsed;
282
400
  }
283
401
 
284
- function lookupKey(input: StartRunInput): string {
402
+ function lookupKeys(input: StartRunInput): string[] {
285
403
  const hasChange = typeof input.change === "string";
286
404
  const hasProvisional = typeof input.provisionalKey === "string";
287
405
  if (hasChange === hasProvisional) throw new Error("invalid-run-lookup-key");
288
406
  if (hasChange) {
407
+ if (input.provisionalAliases !== undefined) {
408
+ throw new Error("invalid-run-lookup-key");
409
+ }
289
410
  if (!CHANGE_NAME.test(input.change as string))
290
411
  throw new Error("invalid-change");
291
- return `change:${input.change}`;
412
+ return [`change:${input.change}`];
292
413
  }
293
414
  if (!SHA256.test(input.provisionalKey as string)) {
294
415
  throw new Error("invalid-provisional-key");
295
416
  }
417
+ if (
418
+ input.provisionalAliases !== undefined &&
419
+ (!Array.isArray(input.provisionalAliases) ||
420
+ input.provisionalAliases.length > 4 ||
421
+ input.provisionalAliases.some((alias) => !SHA256.test(alias)))
422
+ ) {
423
+ throw new Error("invalid-provisional-key");
424
+ }
296
425
  if (input.stage !== "abel-design") {
297
426
  throw new Error("provisional-run-requires-design");
298
427
  }
299
- return `provisional:${input.provisionalKey}`;
428
+ return [
429
+ ...new Set([input.provisionalKey, ...(input.provisionalAliases ?? [])]),
430
+ ].map((hash) => `provisional:${hash}`);
300
431
  }
301
432
 
302
433
  export class RunStore {
@@ -344,22 +475,37 @@ export class RunStore {
344
475
  try {
345
476
  database = new DatabaseSync(stateRoot.databasePath);
346
477
  configureDatabase(database, options.busyTimeoutMs);
347
- const tables = applicationTables(database);
348
- if (tables.length === 0) {
349
- database.exec(SCHEMA);
350
- } else if (!hasCurrentSchema(database)) {
351
- throw new RunStoreFormatError(stateRoot.databasePath);
352
- }
478
+ ensureCurrentSchema(database, stateRoot.databasePath);
353
479
  } catch (error) {
354
- database?.close();
355
- if (error instanceof RunStoreFormatError) throw error;
480
+ try {
481
+ database?.close();
482
+ } catch {
483
+ // The run-store initialization failure remains authoritative.
484
+ }
485
+ if (
486
+ error instanceof RunStoreFormatError ||
487
+ error instanceof RunStoreMigrationError ||
488
+ isSqliteLocked(error) ||
489
+ isStoreUnavailable(error)
490
+ ) {
491
+ throw error;
492
+ }
356
493
  throw new RunStoreFormatError(stateRoot.databasePath);
357
494
  }
358
495
  if (!database) throw new RunStoreFormatError(stateRoot.databasePath);
359
496
  const store = new RunStore(stateRoot, database, options);
360
- store.#secureDatabaseFiles();
361
- store.recoverExpiredLeases();
362
- return store;
497
+ try {
498
+ store.#secureDatabaseFiles();
499
+ store.recoverExpiredLeases();
500
+ return store;
501
+ } catch (error) {
502
+ try {
503
+ database.close();
504
+ } catch {
505
+ // The permission or lease-recovery failure remains authoritative.
506
+ }
507
+ throw error;
508
+ }
363
509
  }
364
510
 
365
511
  #assertOpen(): void {
@@ -374,7 +520,11 @@ export class RunStore {
374
520
  value = work();
375
521
  this.#database.exec("COMMIT");
376
522
  } catch (error) {
377
- this.#database.exec("ROLLBACK");
523
+ try {
524
+ this.#database.exec("ROLLBACK");
525
+ } catch {
526
+ // The original operation or commit failure remains authoritative.
527
+ }
378
528
  throw error;
379
529
  }
380
530
  this.#secureDatabaseFiles();
@@ -517,16 +667,62 @@ export class RunStore {
517
667
  if (input.stage !== "abel-design" && input.stage !== "abel-implement") {
518
668
  throw new Error("invalid-run-stage");
519
669
  }
520
- const key = lookupKey(input);
670
+ const keys = lookupKeys(input);
671
+ const key = keys[0];
672
+ if (!key) throw new Error("invalid-run-lookup-key");
521
673
  return this.#transaction(() => {
522
- let existing = this.#database
674
+ const operationRows = this.#database
523
675
  .prepare(
524
- `SELECT run_id, projection_json FROM runs
525
- WHERE root_hash = ? AND stage = ? AND lookup_key = ?`,
676
+ `SELECT operations.run_id, operations.kind, operations.state,
677
+ operations.outcome_json, runs.lookup_key
678
+ FROM operations
679
+ JOIN runs ON runs.run_id = operations.run_id
680
+ WHERE runs.root_hash = ? AND runs.stage = ?
681
+ AND operations.operation_id = ?`,
526
682
  )
527
- .get(this.stateRoot.consumerRootHash, input.stage, key) as
528
- | RunRow
529
- | undefined;
683
+ .all(
684
+ this.stateRoot.consumerRootHash,
685
+ input.stage,
686
+ input.operationId,
687
+ ) as unknown as Array<{
688
+ run_id: string;
689
+ kind: string;
690
+ state: string;
691
+ outcome_json: string | null;
692
+ lookup_key: string;
693
+ }>;
694
+ if (operationRows.length > 0) {
695
+ const replay = operationRows.find(
696
+ (row) =>
697
+ row.kind === "start" &&
698
+ row.state === "committed" &&
699
+ row.outcome_json !== null &&
700
+ keys.includes(row.lookup_key),
701
+ );
702
+ if (operationRows.length !== 1 || !replay) {
703
+ throw new Error("operation-id-conflict");
704
+ }
705
+ const outcome = parseProjection(replay.outcome_json as string);
706
+ if (outcome.runId !== replay.run_id) {
707
+ throw new Error("operation-outcome-invalid");
708
+ }
709
+ return outcome;
710
+ }
711
+ let existing: RunRow | undefined;
712
+ for (const compatibleKey of keys) {
713
+ const candidate = this.#database
714
+ .prepare(
715
+ `SELECT run_id, projection_json FROM runs
716
+ WHERE root_hash = ? AND stage = ? AND lookup_key = ?`,
717
+ )
718
+ .get(this.stateRoot.consumerRootHash, input.stage, compatibleKey) as
719
+ | RunRow
720
+ | undefined;
721
+ if (candidate && existing && candidate.run_id !== existing.run_id) {
722
+ throw new Error("run-lookup-conflict");
723
+ }
724
+ existing ??= candidate;
725
+ }
530
726
  if (existing) {
531
727
  const projection = parseProjection(existing.projection_json);
532
728
  if (