@abelxiaoxing/cadence 1.2.2 → 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/src/run-store.ts CHANGED
@@ -17,7 +17,32 @@ import {
17
17
  StateRootError,
18
18
  } from "./state-root.ts";
19
19
 
20
- export const RUN_STORE_SCHEMA_VERSION = 4 as const;
20
+ export class RunStoreFormatError extends Error {
21
+ readonly code = "run-store-reset-required" as const;
22
+ readonly databasePath: string;
23
+ readonly recovery = Object.freeze({
24
+ action: "reset-private-run-store" as const,
25
+ requiresBackup: true as const,
26
+ retryCommand: "status-or-start" as const,
27
+ });
28
+
29
+ constructor(databasePath: string) {
30
+ super("run-store-reset-required");
31
+ this.name = "RunStoreFormatError";
32
+ this.databasePath = databasePath;
33
+ }
34
+ }
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
+ }
21
46
 
22
47
  const CHANGE_NAME = /^[a-z0-9](?:[a-z0-9-]{0,126}[a-z0-9])?$/u;
23
48
  const IDENTIFIER = /^[a-z0-9](?:[a-z0-9._:-]{0,126}[a-z0-9])?$/iu;
@@ -53,6 +78,7 @@ export interface StartRunInput {
53
78
  stage: RunStage;
54
79
  change?: string;
55
80
  provisionalKey?: string;
81
+ provisionalAliases?: readonly string[];
56
82
  operationId: string;
57
83
  }
58
84
 
@@ -84,10 +110,6 @@ export interface OperationLeaseStatus {
84
110
  }
85
111
 
86
112
  const SCHEMA = `
87
- CREATE TABLE IF NOT EXISTS schema_meta (
88
- version INTEGER PRIMARY KEY CHECK (version = 4)
89
- ) STRICT;
90
-
91
113
  CREATE TABLE IF NOT EXISTS runs (
92
114
  run_id TEXT PRIMARY KEY,
93
115
  root_hash TEXT NOT NULL,
@@ -191,6 +213,180 @@ function requireIdentifier(value: string, label: string): void {
191
213
  if (!IDENTIFIER.test(value)) throw new Error(`invalid-${label}`);
192
214
  }
193
215
 
216
+ function configureDatabase(
217
+ database: DatabaseSync,
218
+ busyTimeoutMs: number | undefined,
219
+ ): void {
220
+ database.exec("PRAGMA foreign_keys = ON");
221
+ database.exec("PRAGMA journal_mode = WAL");
222
+ database.exec("PRAGMA synchronous = FULL");
223
+ database.exec(
224
+ `PRAGMA busy_timeout = ${Math.max(1, Math.min(30_000, busyTimeoutMs ?? 5_000))}`,
225
+ );
226
+ }
227
+
228
+ const REQUIRED_TABLES = [
229
+ "runs",
230
+ "delivery_bindings",
231
+ "events",
232
+ "tasks",
233
+ "operations",
234
+ "route_health",
235
+ "artifacts",
236
+ "workspace_revisions",
237
+ "apply_transactions",
238
+ "bootstrap_handoffs",
239
+ ] as const;
240
+
241
+ const REQUIRED_DELIVERY_BINDING_COLUMNS = [
242
+ "run_id",
243
+ "gate",
244
+ "revision",
245
+ "receipt_hash",
246
+ "approval_revision",
247
+ "contract_hash",
248
+ "record_hash",
249
+ "operation_id",
250
+ ] as const;
251
+ const LEGACY_SCHEMA_VERSION = 4;
252
+
253
+ function applicationTables(database: DatabaseSync): string[] {
254
+ return (
255
+ database
256
+ .prepare(
257
+ `SELECT name FROM sqlite_master
258
+ WHERE type = 'table' AND name NOT LIKE 'sqlite_%'
259
+ ORDER BY name`,
260
+ )
261
+ .all() as unknown as Array<{ name: string }>
262
+ ).map(({ name }) => name);
263
+ }
264
+
265
+ function tableColumns(database: DatabaseSync, table: string): string[] {
266
+ return (
267
+ database.prepare(`PRAGMA table_info(${table})`).all() as unknown as Array<{
268
+ name: string;
269
+ }>
270
+ ).map(({ name }) => name);
271
+ }
272
+
273
+ function hasRequiredCoreSchema(
274
+ database: DatabaseSync,
275
+ tables: readonly string[],
276
+ ): boolean {
277
+ const deliveryBindingColumns = tableColumns(database, "delivery_bindings");
278
+ return (
279
+ REQUIRED_TABLES.every((table) => tables.includes(table)) &&
280
+ REQUIRED_DELIVERY_BINDING_COLUMNS.every((column) =>
281
+ deliveryBindingColumns.includes(column),
282
+ )
283
+ );
284
+ }
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
+
194
390
  function parseProjection(value: string): RunProjection {
195
391
  const parsed = JSON.parse(value) as RunProjection;
196
392
  if (
@@ -203,22 +399,35 @@ function parseProjection(value: string): RunProjection {
203
399
  return parsed;
204
400
  }
205
401
 
206
- function lookupKey(input: StartRunInput): string {
402
+ function lookupKeys(input: StartRunInput): string[] {
207
403
  const hasChange = typeof input.change === "string";
208
404
  const hasProvisional = typeof input.provisionalKey === "string";
209
405
  if (hasChange === hasProvisional) throw new Error("invalid-run-lookup-key");
210
406
  if (hasChange) {
407
+ if (input.provisionalAliases !== undefined) {
408
+ throw new Error("invalid-run-lookup-key");
409
+ }
211
410
  if (!CHANGE_NAME.test(input.change as string))
212
411
  throw new Error("invalid-change");
213
- return `change:${input.change}`;
412
+ return [`change:${input.change}`];
214
413
  }
215
414
  if (!SHA256.test(input.provisionalKey as string)) {
216
415
  throw new Error("invalid-provisional-key");
217
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
+ }
218
425
  if (input.stage !== "abel-design") {
219
426
  throw new Error("provisional-run-requires-design");
220
427
  }
221
- return `provisional:${input.provisionalKey}`;
428
+ return [
429
+ ...new Set([input.provisionalKey, ...(input.provisionalAliases ?? [])]),
430
+ ].map((hash) => `provisional:${hash}`);
222
431
  }
223
432
 
224
433
  export class RunStore {
@@ -262,29 +471,41 @@ export class RunStore {
262
471
  }
263
472
  chmodSync(stateRoot.databasePath, 0o600);
264
473
 
265
- const database = new DatabaseSync(stateRoot.databasePath);
266
- database.exec("PRAGMA foreign_keys = ON");
267
- database.exec("PRAGMA journal_mode = WAL");
268
- database.exec("PRAGMA synchronous = FULL");
269
- database.exec(
270
- `PRAGMA busy_timeout = ${Math.max(1, Math.min(30_000, options.busyTimeoutMs ?? 5_000))}`,
271
- );
272
- database.exec(SCHEMA);
273
- const schemaRow = database
274
- .prepare("SELECT version FROM schema_meta LIMIT 1")
275
- .get() as { version: number } | undefined;
276
- if (!schemaRow) {
277
- database
278
- .prepare("INSERT INTO schema_meta(version) VALUES (?)")
279
- .run(RUN_STORE_SCHEMA_VERSION);
280
- } else if (schemaRow.version !== RUN_STORE_SCHEMA_VERSION) {
281
- database.close();
282
- throw new Error("run-store-schema-version-rejected");
474
+ let database: DatabaseSync | undefined;
475
+ try {
476
+ database = new DatabaseSync(stateRoot.databasePath);
477
+ configureDatabase(database, options.busyTimeoutMs);
478
+ ensureCurrentSchema(database, stateRoot.databasePath);
479
+ } catch (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
+ }
493
+ throw new RunStoreFormatError(stateRoot.databasePath);
283
494
  }
495
+ if (!database) throw new RunStoreFormatError(stateRoot.databasePath);
284
496
  const store = new RunStore(stateRoot, database, options);
285
- store.#secureDatabaseFiles();
286
- store.recoverExpiredLeases();
287
- 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
+ }
288
509
  }
289
510
 
290
511
  #assertOpen(): void {
@@ -299,7 +520,11 @@ export class RunStore {
299
520
  value = work();
300
521
  this.#database.exec("COMMIT");
301
522
  } catch (error) {
302
- this.#database.exec("ROLLBACK");
523
+ try {
524
+ this.#database.exec("ROLLBACK");
525
+ } catch {
526
+ // The original operation or commit failure remains authoritative.
527
+ }
303
528
  throw error;
304
529
  }
305
530
  this.#secureDatabaseFiles();
@@ -442,16 +667,62 @@ export class RunStore {
442
667
  if (input.stage !== "abel-design" && input.stage !== "abel-implement") {
443
668
  throw new Error("invalid-run-stage");
444
669
  }
445
- const key = lookupKey(input);
670
+ const keys = lookupKeys(input);
671
+ const key = keys[0];
672
+ if (!key) throw new Error("invalid-run-lookup-key");
446
673
  return this.#transaction(() => {
447
- let existing = this.#database
674
+ const operationRows = this.#database
448
675
  .prepare(
449
- `SELECT run_id, projection_json FROM runs
450
- 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 = ?`,
451
682
  )
452
- .get(this.stateRoot.consumerRootHash, input.stage, key) as
453
- | RunRow
454
- | 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
+ }
455
726
  if (existing) {
456
727
  const projection = parseProjection(existing.projection_json);
457
728
  if (
@@ -87,7 +87,6 @@ export type ActivitySummary = EvidenceActivitySummary | DiffActivitySummary;
87
87
  export type ActivityTone = "accent" | "success" | "warning" | "muted" | "error";
88
88
 
89
89
  export interface ActivityDisplay {
90
- version: 1;
91
90
  kind: "activityDisplay";
92
91
  requestId: string;
93
92
  role: string;
@@ -188,6 +187,7 @@ export function sanitizeDisplayText(value: unknown, maxLength = 240): string {
188
187
  }
189
188
 
190
189
  const SAFE_WORKFLOW_CODE = /^[a-z0-9](?:[a-z0-9._:-]{0,126}[a-z0-9])?$/iu;
190
+ const SAFE_DESIGN_DETAIL_VALUE = /^[a-z0-9](?:[a-z0-9._:-]{0,126}[a-z0-9])?$/iu;
191
191
  const SAFE_CONTROL_ACTIONS = new Set([
192
192
  "start",
193
193
  "status",
@@ -221,6 +221,82 @@ export function sanitizeFailureReason(value: unknown, maxLength = 240): string {
221
221
  return safe.slice(0, Math.max(0, maxLength));
222
222
  }
223
223
 
224
+ function safeDesignFailure(value: unknown): {
225
+ operation: string;
226
+ code: string;
227
+ diagnostics: Array<Record<string, string>>;
228
+ } | null {
229
+ const failure = asRecord(value);
230
+ if (
231
+ failure?.kind !== "design-control-failure" ||
232
+ typeof failure.operation !== "string" ||
233
+ !SAFE_DESIGN_DETAIL_VALUE.test(failure.operation) ||
234
+ typeof failure.code !== "string" ||
235
+ !SAFE_DESIGN_DETAIL_VALUE.test(failure.code) ||
236
+ !Array.isArray(failure.diagnostics)
237
+ ) {
238
+ return null;
239
+ }
240
+ const diagnostics = failure.diagnostics.flatMap((candidate) => {
241
+ const record = asRecord(candidate);
242
+ if (
243
+ !record ||
244
+ typeof record.code !== "string" ||
245
+ !SAFE_DESIGN_DETAIL_VALUE.test(record.code)
246
+ ) {
247
+ return [];
248
+ }
249
+ return [
250
+ Object.fromEntries(
251
+ Object.entries(record).filter(
252
+ ([, entry]) =>
253
+ typeof entry === "string" && SAFE_DESIGN_DETAIL_VALUE.test(entry),
254
+ ),
255
+ ) as Record<string, string>,
256
+ ];
257
+ });
258
+ return {
259
+ operation: failure.operation,
260
+ code: failure.code,
261
+ diagnostics,
262
+ };
263
+ }
264
+
265
+ function renderDesignFailure(
266
+ failure: ReturnType<typeof safeDesignFailure> & object,
267
+ expanded: boolean,
268
+ theme?: ActivityTheme,
269
+ ): Component {
270
+ const title = styled(
271
+ theme,
272
+ "error",
273
+ `${dispatchLabel("design")} ${failure.operation} failed: ${failure.code}`,
274
+ );
275
+ if (!expanded || failure.diagnostics.length === 0) {
276
+ const suffix =
277
+ failure.diagnostics.length > 0
278
+ ? styled(
279
+ theme,
280
+ "muted",
281
+ ` (${failure.diagnostics.length} diagnostic${failure.diagnostics.length === 1 ? "" : "s"})`,
282
+ )
283
+ : "";
284
+ return new Text(`${title}${suffix}`, 0, 0);
285
+ }
286
+ const lines = failure.diagnostics.map((diagnostic) => {
287
+ const details = Object.entries(diagnostic)
288
+ .filter(([key]) => key !== "code")
289
+ .map(([key, value]) => `${key}=${value}`)
290
+ .join(" · ");
291
+ return ` - ${diagnostic.code}${details ? ` · ${details}` : ""}`;
292
+ });
293
+ return new Text(
294
+ [title, ...lines.map((entry) => styled(theme, "muted", entry))].join("\n"),
295
+ 0,
296
+ 0,
297
+ );
298
+ }
299
+
224
300
  export function formatElapsed(milliseconds: number): string {
225
301
  const value = Math.max(
226
302
  0,
@@ -470,6 +546,7 @@ function workflowStateOf(
470
546
  }
471
547
  const state = result.state;
472
548
  switch (state) {
549
+ case "not-started":
473
550
  case "created":
474
551
  case "ready":
475
552
  case "queued":
@@ -659,7 +736,6 @@ export function projectWorkflowActivity(
659
736
  ? "completion-state-inconsistent"
660
737
  : workflowCode(payload);
661
738
  return {
662
- version: 1,
663
739
  kind: "activityDisplay",
664
740
  requestId: sanitizeDisplayText(
665
741
  runId ?? taskId ?? change ?? command.operationId ?? "pending",
@@ -792,7 +868,6 @@ function displayFromEvent(
792
868
  ): ActivityDisplay {
793
869
  const reason = event.failureReason;
794
870
  return {
795
- version: 1,
796
871
  kind: "activityDisplay",
797
872
  requestId: entry.requestId,
798
873
  role: entry.role,
@@ -989,7 +1064,6 @@ export class ActivityController {
989
1064
  const entry = this.entries.get(toolCallId);
990
1065
  if (!entry) return;
991
1066
  const display: ActivityDisplay = {
992
- version: 1,
993
1067
  kind: "activityDisplay",
994
1068
  requestId: entry.requestId,
995
1069
  role: entry.role,
@@ -1230,7 +1304,6 @@ export function renderActivityResult(
1230
1304
  if (failedRun) {
1231
1305
  const snapshot = identityFromDispatchArgs(asRecord(context?.args)?.request);
1232
1306
  const display: ActivityDisplay = {
1233
- version: 1,
1234
1307
  kind: "activityDisplay",
1235
1308
  requestId: isActivityDisplay(activity)
1236
1309
  ? activity.requestId
@@ -1250,6 +1323,10 @@ export function renderActivityResult(
1250
1323
  return new ActivityInlineComponent(display, theme);
1251
1324
  }
1252
1325
  if (failed) {
1326
+ const designFailure = safeDesignFailure(details?.designFailure);
1327
+ if (args?.action === "design" && designFailure) {
1328
+ return renderDesignFailure(designFailure, options.expanded, theme);
1329
+ }
1253
1330
  return new Text(
1254
1331
  `${dispatchLabel(args?.action ?? args?.command)} failed`,
1255
1332
  0,
@@ -1275,7 +1352,6 @@ function isActivityDisplay(value: unknown): value is ActivityDisplay {
1275
1352
  const record = asRecord(value);
1276
1353
  return (
1277
1354
  record?.kind === "activityDisplay" &&
1278
- record.version === 1 &&
1279
1355
  typeof record.requestId === "string" &&
1280
1356
  typeof record.role === "string" &&
1281
1357
  typeof record.phase === "string" &&