@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 +1 -1
- package/src/design-control.ts +41 -18
- package/src/design-journal.ts +20 -7
- package/src/index.ts +180 -28
- package/src/run-store.ts +221 -25
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@abelxiaoxing/cadence",
|
|
3
|
-
"version": "1.2.
|
|
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",
|
package/src/design-control.ts
CHANGED
|
@@ -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
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
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
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
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,
|
package/src/design-journal.ts
CHANGED
|
@@ -317,12 +317,21 @@ export class DesignJournal {
|
|
|
317
317
|
}
|
|
318
318
|
const stateRoot = prepareStateRoot(unresolved);
|
|
319
319
|
const database = new DatabaseSync(stateRoot.databasePath);
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
261
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
355
|
-
|
|
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
|
-
|
|
361
|
-
|
|
362
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
674
|
+
const operationRows = this.#database
|
|
523
675
|
.prepare(
|
|
524
|
-
`SELECT run_id,
|
|
525
|
-
|
|
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
|
-
.
|
|
528
|
-
|
|
529
|
-
|
|
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 (
|