@abelxiaoxing/cadence 1.2.1 → 1.2.3

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.
@@ -97,6 +97,16 @@ export type PacketChildRunner = (input: {
97
97
  signal: AbortSignal;
98
98
  }) => Promise<ChildSessionResult>;
99
99
 
100
+ type PacketRouteResult =
101
+ | {
102
+ kind: "phase-failure";
103
+ phase: Extract<
104
+ Awaited<ReturnType<typeof runtimeForWorkerRoute>>,
105
+ { ok: false }
106
+ >;
107
+ }
108
+ | { kind: "child"; child: ChildSessionResult };
109
+
100
110
  interface WaitingPacket {
101
111
  controller: AbortController;
102
112
  resolve(acquired: boolean): void;
@@ -251,7 +261,7 @@ export class PacketRuntime {
251
261
  this.#notify(packet, observer, sequence, "running");
252
262
  }
253
263
  const usage = new UsageAggregator();
254
- const child = await this.#dispatchChild(
264
+ let child = await this.#dispatchChild(
255
265
  packet,
256
266
  context,
257
267
  controller.signal,
@@ -259,6 +269,28 @@ export class PacketRuntime {
259
269
  this.#notifyBrokerActivity(packet, observer, sequence, activity),
260
270
  );
261
271
  usage.add("launch:0", child.usage);
272
+ if (
273
+ this.#childRunner &&
274
+ !child.ok &&
275
+ child.failure.kind === "artifact" &&
276
+ !controller.signal.aborted
277
+ ) {
278
+ this.#notifyBrokerActivity(packet, observer, sequence, {
279
+ state: "retrying",
280
+ attempt: 1,
281
+ maxAttempts: 2,
282
+ code: child.failure.code,
283
+ wait: "bounded-policy",
284
+ });
285
+ child = await this.#dispatchChild(
286
+ packet,
287
+ context,
288
+ controller.signal,
289
+ (activity) =>
290
+ this.#notifyBrokerActivity(packet, observer, sequence, activity),
291
+ );
292
+ usage.add("launch:1", child.usage);
293
+ }
262
294
  if (!child.ok) {
263
295
  const state = failureState(child.failureKind);
264
296
  this.#notify(packet, observer, sequence, state);
@@ -319,12 +351,26 @@ export class PacketRuntime {
319
351
  throw new Error("packet agent definition is unavailable");
320
352
  }
321
353
  const broker = this.#brokerFor(context);
322
- const routed = await broker.run({
354
+ const brokerUsage = new UsageAggregator();
355
+ let childAttempt = 0;
356
+ const withBrokerUsage = (
357
+ child: ChildSessionResult,
358
+ ): ChildSessionResult => ({
359
+ ...child,
360
+ usage: brokerUsage.total(),
361
+ });
362
+ const routed = await broker.run<PacketRouteResult>({
323
363
  runId: `${packet.stage}:${packet.id}`,
324
364
  operationId: packet.id,
325
365
  role: packet.role as WorkerRole,
326
366
  signal,
327
367
  ...(onActivity ? { onActivity } : {}),
368
+ classifyResult: (value) =>
369
+ value.kind === "child" &&
370
+ !value.child.ok &&
371
+ value.child.failure.kind === "artifact"
372
+ ? value.child.failure.code
373
+ : undefined,
328
374
  execute: async (attempt) => {
329
375
  const phase = await runtimeForWorkerRoute(
330
376
  attempt.route,
@@ -332,6 +378,7 @@ export class PacketRuntime {
332
378
  this.#parentPayloadBridge,
333
379
  attempt.signal,
334
380
  this.#environment,
381
+ { onResponse: attempt.onHeaders },
335
382
  );
336
383
  if (!phase.ok) {
337
384
  if (phase.failure.kind === "cancelled") {
@@ -360,6 +407,15 @@ export class PacketRuntime {
360
407
  role: packet.role,
361
408
  phase: packet.phase,
362
409
  output: packet.output as "evidence" | "diff",
410
+ ...(packet.role === "diagnosis-worker" && packet.output === "diff"
411
+ ? {
412
+ structuredPatch: {
413
+ workspaceRoot: context.cwd,
414
+ writePaths: packet.declared.write,
415
+ deletePaths: packet.declared.write,
416
+ },
417
+ }
418
+ : {}),
363
419
  roots: packet.roots.map((root) => path.resolve(context.cwd, root)),
364
420
  allowedPaths: [
365
421
  ...new Set([...packet.declared.read, ...packet.declared.write]),
@@ -367,9 +423,9 @@ export class PacketRuntime {
367
423
  timeoutMs: LIMITS.phaseTimeoutMs,
368
424
  signal: attempt.signal,
369
425
  failureOverride: phase.failureOverride,
370
- onStreamStart: attempt.onHeaders,
371
426
  onStreamProgress: attempt.onProgress,
372
427
  });
428
+ brokerUsage.add(`attempt:${childAttempt++}`, child.usage);
373
429
  if (!child.ok && child.failure.kind === "transport") {
374
430
  throw new Error(child.failure.code);
375
431
  }
@@ -379,29 +435,31 @@ export class PacketRuntime {
379
435
  if (!routed.ok) {
380
436
  const cancelled = routed.state === "cancelled" || signal.aborted;
381
437
  const timedOut = !cancelled && routed.code === "phase-timeout";
382
- return syntheticChildFailure(
383
- cancelled ? "packet cancelled" : routed.code,
384
- cancelled
385
- ? { kind: "cancelled", code: "cancelled" }
386
- : timedOut
387
- ? {
388
- kind: "transport",
389
- code: "timeout",
390
- stage: "child-timeout",
391
- }
392
- : {
393
- kind: "transport",
394
- code: "transport-failure",
395
- stage: "child-provider-stream",
396
- },
397
- cancelled ? "cancelled" : timedOut ? "timed-out" : "failed",
438
+ return withBrokerUsage(
439
+ syntheticChildFailure(
440
+ cancelled ? "packet cancelled" : routed.code,
441
+ cancelled
442
+ ? { kind: "cancelled", code: "cancelled" }
443
+ : timedOut
444
+ ? {
445
+ kind: "transport",
446
+ code: "timeout",
447
+ stage: "child-timeout",
448
+ }
449
+ : {
450
+ kind: "transport",
451
+ code: "transport-failure",
452
+ stage: "child-provider-stream",
453
+ },
454
+ cancelled ? "cancelled" : timedOut ? "timed-out" : "failed",
455
+ ),
398
456
  );
399
457
  }
400
458
  if (routed.value.kind === "phase-failure") {
401
459
  const phase = routed.value.phase;
402
- return syntheticChildFailure(phase.error, phase.failure);
460
+ return withBrokerUsage(syntheticChildFailure(phase.error, phase.failure));
403
461
  }
404
- return routed.value.child;
462
+ return withBrokerUsage(routed.value.child);
405
463
  }
406
464
 
407
465
  #brokerFor(context: PacketContext): RunWorkerBroker {
@@ -29,6 +29,10 @@ import {
29
29
 
30
30
  const SUBAGENT_PROVIDER_ID = "abel-subagent";
31
31
 
32
+ export interface PhaseTransportObserver {
33
+ onResponse?(): void;
34
+ }
35
+
32
36
  function customStreamOptions<T extends StreamOptions>(
33
37
  model: Model<string>,
34
38
  options?: T,
@@ -49,32 +53,55 @@ function customStreamOptions<T extends StreamOptions>(
49
53
  } as T;
50
54
  }
51
55
 
56
+ function observedStreamOptions<T extends StreamOptions>(
57
+ options?: T,
58
+ observer?: PhaseTransportObserver,
59
+ ): T | undefined {
60
+ if (!observer?.onResponse) return options;
61
+ const childOnResponse = options?.onResponse;
62
+ return {
63
+ ...options,
64
+ onResponse: async (response, requestModel) => {
65
+ observer.onResponse?.();
66
+ await childOnResponse?.(response, requestModel);
67
+ },
68
+ } as T;
69
+ }
70
+
52
71
  // The compat dispatchers resolve the concrete API implementation from
53
72
  // model.api at call time, so one pair serves all supported dialects.
54
- const SUBAGENT_STREAMS = {
55
- stream(
56
- model: Parameters<typeof subagentStream>[0],
57
- context: Parameters<typeof subagentStream>[1],
58
- options?: Parameters<typeof subagentStream>[2],
59
- ) {
60
- return subagentStream(
61
- model,
62
- context,
63
- customStreamOptions(model as Model<string>, options),
64
- );
65
- },
66
- streamSimple(
67
- model: Parameters<typeof subagentStreamSimple>[0],
68
- context: Parameters<typeof subagentStreamSimple>[1],
69
- options?: Parameters<typeof subagentStreamSimple>[2],
70
- ) {
71
- return subagentStreamSimple(
72
- model,
73
- context,
74
- customStreamOptions(model as Model<string>, options),
75
- );
76
- },
77
- };
73
+ function subagentStreams(observer?: PhaseTransportObserver) {
74
+ return {
75
+ stream(
76
+ model: Parameters<typeof subagentStream>[0],
77
+ context: Parameters<typeof subagentStream>[1],
78
+ options?: Parameters<typeof subagentStream>[2],
79
+ ) {
80
+ return subagentStream(
81
+ model,
82
+ context,
83
+ observedStreamOptions(
84
+ customStreamOptions(model as Model<string>, options),
85
+ observer,
86
+ ),
87
+ );
88
+ },
89
+ streamSimple(
90
+ model: Parameters<typeof subagentStreamSimple>[0],
91
+ context: Parameters<typeof subagentStreamSimple>[1],
92
+ options?: Parameters<typeof subagentStreamSimple>[2],
93
+ ) {
94
+ return subagentStreamSimple(
95
+ model,
96
+ context,
97
+ observedStreamOptions(
98
+ customStreamOptions(model as Model<string>, options),
99
+ observer,
100
+ ),
101
+ );
102
+ },
103
+ };
104
+ }
78
105
 
79
106
  function customSubagentModel(endpoint: ResolvedCustomRoute): Model<string> {
80
107
  return {
@@ -97,6 +124,7 @@ function customSubagentModel(endpoint: ResolvedCustomRoute): Model<string> {
97
124
  function customSubagentProvider(
98
125
  endpoint: ResolvedCustomRoute,
99
126
  model: Model<string>,
127
+ observer?: PhaseTransportObserver,
100
128
  ): Provider {
101
129
  return createProvider({
102
130
  id: SUBAGENT_PROVIDER_ID,
@@ -118,7 +146,7 @@ function customSubagentProvider(
118
146
  },
119
147
  },
120
148
  models: [model],
121
- api: SUBAGENT_STREAMS,
149
+ api: subagentStreams(observer),
122
150
  }) as Provider;
123
151
  }
124
152
 
@@ -126,11 +154,12 @@ export async function customPhaseRuntime(
126
154
  route: CustomRoutePolicy,
127
155
  signal?: AbortSignal,
128
156
  environment: Readonly<Record<string, string | undefined>> = process.env,
157
+ observer?: PhaseTransportObserver,
129
158
  ): Promise<PhaseRuntimeResult> {
130
159
  if (signal?.aborted) return cancelledPhaseRuntime();
131
160
  const endpoint = resolveCustomRoute(route, environment);
132
161
  const model = customSubagentModel(endpoint);
133
- const provider = customSubagentProvider(endpoint, model);
162
+ const provider = customSubagentProvider(endpoint, model, observer);
134
163
  let modelRuntime: ModelRuntime;
135
164
  try {
136
165
  modelRuntime = await runtimeForProvider(provider, signal);
@@ -236,6 +265,7 @@ export function phaseProvider(
236
265
  },
237
266
  payloadBridge: PhasePayloadBridge,
238
267
  diagnostic?: { failure?: PhaseRuntimeFailure },
268
+ observer?: PhaseTransportObserver,
239
269
  ): Provider {
240
270
  return {
241
271
  ...parent,
@@ -262,7 +292,7 @@ export function phaseProvider(
262
292
  diagnostic,
263
293
  );
264
294
  return parent.stream(model, context, {
265
- ...options,
295
+ ...observedStreamOptions(options, observer),
266
296
  apiKey: auth.apiKey,
267
297
  headers: { ...options?.headers, ...auth.headers },
268
298
  env: { ...options?.env, ...auth.env },
@@ -278,7 +308,7 @@ export function phaseProvider(
278
308
  diagnostic,
279
309
  );
280
310
  return parent.streamSimple(model, context, {
281
- ...options,
311
+ ...observedStreamOptions(options, observer),
282
312
  apiKey: auth.apiKey,
283
313
  headers: { ...options?.headers, ...auth.headers },
284
314
  env: { ...options?.env, ...auth.env },
@@ -314,6 +344,7 @@ export async function runtimeFromContext(
314
344
  ctx: Pick<ExtensionContext, "model" | "modelRegistry">,
315
345
  payloadBridge: ParentPayloadBridge,
316
346
  signal?: AbortSignal,
347
+ observer?: PhaseTransportObserver,
317
348
  ): Promise<PhaseRuntimeResult> {
318
349
  if (signal?.aborted) return cancelledPhaseRuntime();
319
350
  if (!ctx.model) {
@@ -398,6 +429,7 @@ export async function runtimeFromContext(
398
429
  capture,
399
430
  },
400
431
  diagnostic,
432
+ observer,
401
433
  );
402
434
  let modelRuntime: ModelRuntime;
403
435
  try {
@@ -432,10 +464,11 @@ export async function runtimeForWorkerRoute(
432
464
  payloadBridge: ParentPayloadBridge,
433
465
  signal?: AbortSignal,
434
466
  environment: Readonly<Record<string, string | undefined>> = process.env,
467
+ observer?: PhaseTransportObserver,
435
468
  ): Promise<PhaseRuntimeResult> {
436
469
  return route.kind === "custom"
437
- ? customPhaseRuntime(route, signal, environment)
438
- : runtimeFromContext(ctx, payloadBridge, signal);
470
+ ? customPhaseRuntime(route, signal, environment, observer)
471
+ : runtimeFromContext(ctx, payloadBridge, signal, observer);
439
472
  }
440
473
 
441
474
  function abortable<T>(promise: Promise<T>, signal?: AbortSignal): Promise<T> {
@@ -4,7 +4,6 @@ import { homedir } from "node:os";
4
4
  import path from "node:path";
5
5
  import { ROLES } from "./contracts.ts";
6
6
 
7
- export const ROUTE_POLICY_VERSION = 2 as const;
8
7
  export const ROUTE_DIALECTS = [
9
8
  "openai-completions",
10
9
  "openai-responses",
@@ -48,7 +47,6 @@ export interface CustomRoutePolicy extends RouteBase {
48
47
  export type WorkerRoutePolicy = InheritedRoutePolicy | CustomRoutePolicy;
49
48
 
50
49
  export interface RoutePolicy {
51
- version: typeof ROUTE_POLICY_VERSION;
52
50
  routes: Readonly<Record<string, WorkerRoutePolicy>>;
53
51
  roles: Readonly<Record<WorkerRole, readonly string[]>>;
54
52
  }
@@ -61,11 +59,11 @@ export type RoutePolicyDiagnosticCode =
61
59
  | "policy-unreadable"
62
60
  | "policy-invalid-json"
63
61
  | "policy-invalid"
62
+ | "policy-version-unsupported"
64
63
  | "route-invalid"
65
64
  | "route-duplicate"
66
65
  | "role-invalid"
67
- | "route-reference-invalid"
68
- | "unsupported-policy-version";
66
+ | "route-reference-invalid";
69
67
 
70
68
  export interface RoutePolicyDiagnostic {
71
69
  code: RoutePolicyDiagnosticCode;
@@ -102,7 +100,6 @@ export type RoutePolicyResolution =
102
100
  */
103
101
  export function unavailableRoutePolicy(): RoutePolicy {
104
102
  return {
105
- version: ROUTE_POLICY_VERSION,
106
103
  routes: Object.freeze({}),
107
104
  roles: Object.freeze(
108
105
  Object.fromEntries(
@@ -273,18 +270,21 @@ function parseRoute(
273
270
  }
274
271
 
275
272
  export function parseRoutePolicy(value: unknown): ParsedRoutePolicy {
276
- if (
277
- !isRecord(value) ||
278
- !hasExactKeys(value, ["version", "routes", "roles"])
279
- ) {
273
+ if (!isRecord(value)) {
280
274
  return { ok: false, diagnostics: [{ code: "policy-invalid" }] };
281
275
  }
282
- if (value.version !== ROUTE_POLICY_VERSION) {
276
+ const legacyVersion = Object.hasOwn(value, "version")
277
+ ? value.version
278
+ : undefined;
279
+ if (legacyVersion !== undefined && legacyVersion !== 2) {
283
280
  return {
284
281
  ok: false,
285
- diagnostics: [{ code: "unsupported-policy-version", field: "version" }],
282
+ diagnostics: [{ code: "policy-version-unsupported", field: "version" }],
286
283
  };
287
284
  }
285
+ if (!hasExactKeys(value, ["routes", "roles"], ["version"])) {
286
+ return { ok: false, diagnostics: [{ code: "policy-invalid" }] };
287
+ }
288
288
  if (!isRecord(value.routes) || !isRecord(value.roles)) {
289
289
  return { ok: false, diagnostics: [{ code: "policy-invalid" }] };
290
290
  }
@@ -341,7 +341,6 @@ export function parseRoutePolicy(value: unknown): ParsedRoutePolicy {
341
341
  return {
342
342
  ok: true,
343
343
  policy: {
344
- version: ROUTE_POLICY_VERSION,
345
344
  routes: Object.freeze(structuredClone(routes)),
346
345
  roles: Object.freeze(
347
346
  Object.fromEntries(
@@ -355,6 +354,48 @@ export function parseRoutePolicy(value: unknown): ParsedRoutePolicy {
355
354
  };
356
355
  }
357
356
 
357
+ function constrainInheritedRoutes(
358
+ policy: RoutePolicy,
359
+ parentModel?: ParentModelCapabilities,
360
+ ): RoutePolicy {
361
+ if (!parentModel) return policy;
362
+ const routes = Object.fromEntries(
363
+ Object.entries(policy.routes).map(([routeId, route]) => {
364
+ if (route.kind !== "inherited") return [routeId, structuredClone(route)];
365
+ const capabilities = {
366
+ ...structuredClone(route.capabilities),
367
+ contextWindow: Math.min(
368
+ route.capabilities.contextWindow,
369
+ parentModel.contextWindow,
370
+ ),
371
+ maxTokens: Math.min(
372
+ route.capabilities.maxTokens,
373
+ parentModel.maxTokens,
374
+ route.capabilities.contextWindow,
375
+ parentModel.contextWindow,
376
+ ),
377
+ };
378
+ const raw = { kind: "inherited" as const, capabilities };
379
+ return [
380
+ routeId,
381
+ {
382
+ id: route.id,
383
+ ...raw,
384
+ fingerprint: routeFingerprint(raw),
385
+ },
386
+ ];
387
+ }),
388
+ );
389
+ return {
390
+ routes: Object.freeze(routes),
391
+ roles: Object.freeze(
392
+ Object.fromEntries(
393
+ ROLES.map((role) => [role, Object.freeze([...policy.roles[role]])]),
394
+ ),
395
+ ) as unknown as RoutePolicy["roles"],
396
+ };
397
+ }
398
+
358
399
  /** Default parent-model route used when no explicit policy file exists. */
359
400
  export function parentRoutePolicy(
360
401
  parentModel?: ParentModelCapabilities,
@@ -366,7 +407,6 @@ export function parentRoutePolicy(
366
407
  ? Math.min(parentModel.maxTokens, contextWindow)
367
408
  : 1;
368
409
  const parsed = parseRoutePolicy({
369
- version: ROUTE_POLICY_VERSION,
370
410
  routes: {
371
411
  parent: {
372
412
  kind: "inherited",
@@ -451,7 +491,11 @@ export function loadRoutePolicy(
451
491
  }
452
492
  const parsed = parseRoutePolicy(value);
453
493
  return parsed.ok
454
- ? { ok: true, source, policy: parsed.policy }
494
+ ? {
495
+ ok: true,
496
+ source,
497
+ policy: constrainInheritedRoutes(parsed.policy, options.parentModel),
498
+ }
455
499
  : { ok: false, source, diagnostics: parsed.diagnostics };
456
500
  }
457
501
 
package/src/run-store.ts CHANGED
@@ -17,7 +17,21 @@ 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
+ }
21
35
 
22
36
  const CHANGE_NAME = /^[a-z0-9](?:[a-z0-9-]{0,126}[a-z0-9])?$/u;
23
37
  const IDENTIFIER = /^[a-z0-9](?:[a-z0-9._:-]{0,126}[a-z0-9])?$/iu;
@@ -84,10 +98,6 @@ export interface OperationLeaseStatus {
84
98
  }
85
99
 
86
100
  const SCHEMA = `
87
- CREATE TABLE IF NOT EXISTS schema_meta (
88
- version INTEGER PRIMARY KEY CHECK (version = 4)
89
- ) STRICT;
90
-
91
101
  CREATE TABLE IF NOT EXISTS runs (
92
102
  run_id TEXT PRIMARY KEY,
93
103
  root_hash TEXT NOT NULL,
@@ -191,6 +201,74 @@ function requireIdentifier(value: string, label: string): void {
191
201
  if (!IDENTIFIER.test(value)) throw new Error(`invalid-${label}`);
192
202
  }
193
203
 
204
+ function configureDatabase(
205
+ database: DatabaseSync,
206
+ busyTimeoutMs: number | undefined,
207
+ ): void {
208
+ database.exec("PRAGMA foreign_keys = ON");
209
+ database.exec("PRAGMA journal_mode = WAL");
210
+ database.exec("PRAGMA synchronous = FULL");
211
+ database.exec(
212
+ `PRAGMA busy_timeout = ${Math.max(1, Math.min(30_000, busyTimeoutMs ?? 5_000))}`,
213
+ );
214
+ }
215
+
216
+ const REQUIRED_TABLES = [
217
+ "runs",
218
+ "delivery_bindings",
219
+ "events",
220
+ "tasks",
221
+ "operations",
222
+ "route_health",
223
+ "artifacts",
224
+ "workspace_revisions",
225
+ "apply_transactions",
226
+ "bootstrap_handoffs",
227
+ ] as const;
228
+
229
+ const REQUIRED_DELIVERY_BINDING_COLUMNS = [
230
+ "run_id",
231
+ "gate",
232
+ "revision",
233
+ "receipt_hash",
234
+ "approval_revision",
235
+ "contract_hash",
236
+ "record_hash",
237
+ "operation_id",
238
+ ] as const;
239
+
240
+ function applicationTables(database: DatabaseSync): string[] {
241
+ return (
242
+ database
243
+ .prepare(
244
+ `SELECT name FROM sqlite_master
245
+ WHERE type = 'table' AND name NOT LIKE 'sqlite_%'
246
+ ORDER BY name`,
247
+ )
248
+ .all() as unknown as Array<{ name: string }>
249
+ ).map(({ name }) => name);
250
+ }
251
+
252
+ function tableColumns(database: DatabaseSync, table: string): string[] {
253
+ return (
254
+ database.prepare(`PRAGMA table_info(${table})`).all() as unknown as Array<{
255
+ name: string;
256
+ }>
257
+ ).map(({ name }) => name);
258
+ }
259
+
260
+ function hasCurrentSchema(database: DatabaseSync): boolean {
261
+ const tables = applicationTables(database);
262
+ const deliveryBindingColumns = tableColumns(database, "delivery_bindings");
263
+ return (
264
+ !tables.includes("schema_meta") &&
265
+ REQUIRED_TABLES.every((table) => tables.includes(table)) &&
266
+ REQUIRED_DELIVERY_BINDING_COLUMNS.every((column) =>
267
+ deliveryBindingColumns.includes(column),
268
+ )
269
+ );
270
+ }
271
+
194
272
  function parseProjection(value: string): RunProjection {
195
273
  const parsed = JSON.parse(value) as RunProjection;
196
274
  if (
@@ -262,25 +340,22 @@ export class RunStore {
262
340
  }
263
341
  chmodSync(stateRoot.databasePath, 0o600);
264
342
 
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");
343
+ let database: DatabaseSync | undefined;
344
+ try {
345
+ database = new DatabaseSync(stateRoot.databasePath);
346
+ 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
+ }
353
+ } catch (error) {
354
+ database?.close();
355
+ if (error instanceof RunStoreFormatError) throw error;
356
+ throw new RunStoreFormatError(stateRoot.databasePath);
283
357
  }
358
+ if (!database) throw new RunStoreFormatError(stateRoot.databasePath);
284
359
  const store = new RunStore(stateRoot, database, options);
285
360
  store.#secureDatabaseFiles();
286
361
  store.recoverExpiredLeases();
@@ -917,7 +992,23 @@ export class RunStore {
917
992
  code?: string;
918
993
  lease?: OperationLease;
919
994
  }): RunProjection {
995
+ return this.transitionAtomically(input, () => {});
996
+ }
997
+
998
+ transitionAtomically(
999
+ input: {
1000
+ runId: string;
1001
+ to: RunState;
1002
+ operationId: string;
1003
+ code?: string;
1004
+ lease?: OperationLease;
1005
+ },
1006
+ write: (database: DatabaseSync) => void,
1007
+ ): RunProjection {
920
1008
  requireIdentifier(input.operationId, "operation-id");
1009
+ if (typeof write !== "function") {
1010
+ throw new Error("state-transition-write-invalid");
1011
+ }
921
1012
  return this.#transaction(() => {
922
1013
  if (input.lease) {
923
1014
  if (input.lease.runId !== input.runId) throw new Error("lease-fenced");
@@ -935,6 +1026,7 @@ export class RunStore {
935
1026
  to: input.to,
936
1027
  ...(input.code ? { code: input.code } : {}),
937
1028
  });
1029
+ write(this.#database);
938
1030
  this.#recordOperation(
939
1031
  input.runId,
940
1032
  input.operationId,