@smithers-orchestrator/db 0.22.0 → 0.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@smithers-orchestrator/db",
3
- "version": "0.22.0",
3
+ "version": "0.24.0",
4
4
  "description": "SQLite and Drizzle persistence adapter for Smithers workflows",
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -10,6 +10,10 @@
10
10
  "import": "./src/index.js",
11
11
  "default": "./src/index.js"
12
12
  },
13
+ "./runState": {
14
+ "import": "./src/runState.js",
15
+ "default": "./src/runState.js"
16
+ },
13
17
  "./*": {
14
18
  "types": "./src/index.d.ts",
15
19
  "import": "./src/*.js",
@@ -26,13 +30,16 @@
26
30
  "drizzle-zod": "^0.8.3",
27
31
  "effect": "^3.21.1",
28
32
  "zod": "^4.3.6",
29
- "@smithers-orchestrator/errors": "0.22.0",
30
- "@smithers-orchestrator/observability": "0.22.0",
31
- "@smithers-orchestrator/scheduler": "0.22.0",
32
- "@smithers-orchestrator/graph": "0.22.0"
33
+ "@smithers-orchestrator/errors": "0.24.0",
34
+ "@smithers-orchestrator/graph": "0.24.0",
35
+ "@smithers-orchestrator/scheduler": "0.24.0",
36
+ "@smithers-orchestrator/observability": "0.24.0"
33
37
  },
34
38
  "devDependencies": {
39
+ "@electric-sql/pglite": "0.5.1",
40
+ "@electric-sql/pglite-socket": "0.2.1",
35
41
  "@types/bun": "latest",
42
+ "pg": "^8.13.1",
36
43
  "typescript": "~5.9.3"
37
44
  },
38
45
  "scripts": {
package/src/adapter.js CHANGED
@@ -11,9 +11,11 @@
11
11
  // @smithers-type-exports-end
12
12
 
13
13
  import { getTableName } from "drizzle-orm";
14
+ import { getTableColumns } from "drizzle-orm/utils";
14
15
  import { Effect, Exit, FiberId, Metric } from "effect";
15
16
  import { toSmithersError } from "@smithers-orchestrator/errors/toSmithersError";
16
17
  import { getSqlMessageStorage } from "./sql-message-storage.js";
18
+ import { POSTGRES, beginTransactionSql } from "./dialect.js";
17
19
  import { alertsAcknowledgedTotal, alertsActive, alertsFiredTotal, dbQueryDuration, dbTransactionDuration, dbTransactionRollbacks, } from "@smithers-orchestrator/observability/metrics";
18
20
  import { assertOptionalStringMaxLength, assertPositiveFiniteNumber, } from "./input-bounds.js";
19
21
  import { FRAME_KEYFRAME_INTERVAL, applyFrameDeltaJson, encodeFrameDelta, normalizeFrameEncoding, serializeFrameDelta, } from "./frame-codec.js";
@@ -24,6 +26,7 @@ import { camelToSnake } from "./utils/camelToSnake.js";
24
26
  /** @typedef {import("./adapter/AlertStatus.ts").AlertStatus} AlertStatus */
25
27
  /** @typedef {import("./adapter/AttemptRow.ts").AttemptRow} AttemptRow */
26
28
  /** @typedef {import("drizzle-orm/bun-sqlite").BunSQLiteDatabase} BunSQLiteDatabase */
29
+ /** @typedef {import("drizzle-orm").Table} Table */
27
30
  /** @typedef {import("./adapter/EventHistoryQuery.ts").EventHistoryQuery} EventHistoryQuery */
28
31
  /** @typedef {import("./adapter/HumanRequestRow.ts").HumanRequestRow} HumanRequestRow */
29
32
  /** @typedef {import("./output/OutputKey.ts").OutputKey} OutputKey */
@@ -409,6 +412,102 @@ function getSqliteTransactionState(client) {
409
412
  }
410
413
  return state;
411
414
  }
415
+ /**
416
+ * Resolve a Drizzle output table's on-disk name. The name lives on the
417
+ * `Symbol(drizzle:Name)` slot, which `getTableName` reads; `table["_"].name` is
418
+ * `undefined` for these tables, so the raw-SQL Postgres path must not depend on
419
+ * it. Mirrors how the Drizzle `db.insert(table)` path resolves the name on
420
+ * SQLite.
421
+ * @param {unknown} table
422
+ * @returns {string}
423
+ */
424
+ function resolveOutputTableName(table) {
425
+ try {
426
+ return getTableName(/** @type {Table} */ (table));
427
+ }
428
+ catch {
429
+ return "output";
430
+ }
431
+ }
432
+ /**
433
+ * @param {unknown} db
434
+ * @param {string} tableName
435
+ * @returns {unknown | null}
436
+ */
437
+ function findDrizzleTableByName(db, tableName) {
438
+ const schema = /** @type {{ _?: { fullSchema?: Record<string, unknown> } }} */ (db)?._?.fullSchema;
439
+ if (!schema) return null;
440
+ for (const table of Object.values(schema)) {
441
+ try {
442
+ if (getTableName(/** @type {Table} */ (table)) === tableName) return table;
443
+ }
444
+ catch {
445
+ // Ignore non-table schema entries.
446
+ }
447
+ }
448
+ return null;
449
+ }
450
+ /**
451
+ * @param {unknown} table
452
+ * @returns {string[]}
453
+ */
454
+ function getPhysicalBooleanColumnNames(table) {
455
+ try {
456
+ const cols = getTableColumns(/** @type {Table} */ (table));
457
+ const names = [];
458
+ for (const col of Object.values(cols)) {
459
+ const c = /** @type {Record<string, unknown> & { config?: { mode?: string }; mapFromDriverValue?: unknown; name?: unknown }} */ (/** @type {unknown} */ (col));
460
+ const mapFn = /** @type {{ toString?: () => string } | undefined} */ (c?.mapFromDriverValue);
461
+ if (c?.columnType === "SQLiteBoolean" ||
462
+ c?.config?.mode === "boolean" ||
463
+ c?.mode === "boolean" ||
464
+ c?.dataType === "boolean" ||
465
+ mapFn?.toString?.().includes("Boolean")) {
466
+ const name = typeof c.name === "string" ? c.name : null;
467
+ if (name) names.push(name);
468
+ }
469
+ }
470
+ return names;
471
+ }
472
+ catch {
473
+ return [];
474
+ }
475
+ }
476
+ /**
477
+ * @param {Record<string, unknown> | null | undefined} row
478
+ * @param {readonly string[]} columnNames
479
+ * @returns {Record<string, unknown> | null | undefined}
480
+ */
481
+ function coerceRawBooleanColumns(row, columnNames) {
482
+ if (!row || columnNames.length === 0) return row;
483
+ const next = { ...row };
484
+ for (const columnName of columnNames) {
485
+ const value = next[columnName];
486
+ if (columnName in next && typeof value !== "boolean") {
487
+ if (value === 0 || value === 0n || value === "0") next[columnName] = false;
488
+ else if (value === 1 || value === 1n || value === "1") next[columnName] = true;
489
+ }
490
+ }
491
+ return next;
492
+ }
493
+ /**
494
+ * @param {unknown} client
495
+ * @param {string} tableName
496
+ * @returns {string[]}
497
+ */
498
+ function getPersistedBooleanColumnNames(client, tableName) {
499
+ try {
500
+ const rows = /** @type {{ query: (sql: string) => { all: (...args: unknown[]) => Array<Record<string, unknown>> } }} */ (client)
501
+ .query(`SELECT column_name FROM _smithers_output_schema_columns WHERE table_name = ? AND kind = 'boolean'`)
502
+ .all(tableName);
503
+ return rows
504
+ .map((row) => row.column_name)
505
+ .filter((name) => typeof name === "string");
506
+ }
507
+ catch {
508
+ return [];
509
+ }
510
+ }
412
511
  /**
413
512
  * @param {unknown} db
414
513
  * @returns {{ run: (sql: string) => unknown; query: (sql: string) => { run: (...args: unknown[]) => unknown; get: (...args: unknown[]) => Record<string, unknown> | null | undefined; all: () => Array<Record<string, unknown>> }; exec: (sql: string) => unknown; $client?: unknown }}
@@ -505,6 +604,9 @@ export class SmithersDb {
505
604
  }),
506
605
  });
507
606
  return yield* self.read(`raw query ${validatedQuery.slice(0, 20)}`, () => {
607
+ if (self.internalStorage.dialect === POSTGRES) {
608
+ return self.internalStorage.queryAllRaw(validatedQuery);
609
+ }
508
610
  const client = self.db.session.client;
509
611
  const stmt = client.query(validatedQuery);
510
612
  return Promise.resolve(stmt.all());
@@ -648,7 +750,18 @@ export class SmithersDb {
648
750
  const transactionState = getSqliteTransactionState(resolveSqliteClientKey(self.db));
649
751
  const start = performance.now();
650
752
  return yield* Effect.gen(function* () {
651
- const client = yield* self.getSqliteTransactionClient();
753
+ const isPostgres = self.internalStorage.dialect === POSTGRES;
754
+ const client = isPostgres ? null : yield* self.getSqliteTransactionClient();
755
+ /**
756
+ * Run a transaction-control statement on the active connection,
757
+ * dialect-appropriately: synchronous bun:sqlite client.run for
758
+ * SQLite, async @effect/sql execute (same connection) for Postgres.
759
+ * @param {string} sql
760
+ * @returns {Promise<unknown>}
761
+ */
762
+ const runControl = (sql) => isPostgres
763
+ ? self.internalStorage.execute(sql)
764
+ : Promise.resolve(client.run(sql));
652
765
  /**
653
766
  * @param {"operation" | "commit"} phase
654
767
  * @param {unknown} error
@@ -660,18 +773,11 @@ export class SmithersDb {
660
773
  phase,
661
774
  error: String(error),
662
775
  }));
663
- yield* Effect.sync(() => {
664
- try {
665
- client.run("ROLLBACK");
666
- }
667
- catch {
668
- // ignore rollback failures
669
- }
670
- });
776
+ yield* Effect.promise(() => runControl("ROLLBACK").then(() => undefined, () => undefined));
671
777
  });
672
- yield* Effect.try({
673
- try: () => {
674
- client.run("BEGIN IMMEDIATE");
778
+ yield* Effect.tryPromise({
779
+ try: async () => {
780
+ await runControl(beginTransactionSql(self.internalStorage.dialect));
675
781
  transactionState.depth += 1;
676
782
  transactionState.ownerThread = currentFiberThread;
677
783
  self.transactionDepth = transactionState.depth;
@@ -688,10 +794,8 @@ export class SmithersDb {
688
794
  yield* rollback("operation", operationExit.cause);
689
795
  return yield* Effect.failCause(operationExit.cause);
690
796
  }
691
- const commitExit = yield* Effect.exit(Effect.try({
692
- try: () => {
693
- client.run("COMMIT");
694
- },
797
+ const commitExit = yield* Effect.exit(Effect.tryPromise({
798
+ try: () => runControl("COMMIT"),
695
799
  catch: (cause) => toSmithersError(cause, "commit sqlite transaction", {
696
800
  code: "DB_WRITE_FAILED",
697
801
  details: { writeGroup, phase: "commit" },
@@ -884,9 +988,34 @@ export class SmithersDb {
884
988
  */
885
989
  claimRunForResume(params) {
886
990
  return this.write(`claim stale run ${params.runId}`, () => {
887
- const client = this.db.session.client;
888
991
  const expectedStatus = params.expectedStatus ?? "running";
889
992
  const requireStale = params.requireStale ?? expectedStatus === "running";
993
+ if (this.internalStorage.dialect === POSTGRES) {
994
+ // Null-safe heartbeat compare without wrapping the bigint param in a
995
+ // numeric COALESCE(?, -1): the int4 `-1` literal would force the
996
+ // ms-timestamp param to int4 and overflow. Compare against the
997
+ // bigint column directly so Postgres infers bigint.
998
+ return this.internalStorage
999
+ .queryAllRaw(`UPDATE _smithers_runs
1000
+ SET runtime_owner_id = ?, heartbeat_at_ms = ?
1001
+ WHERE run_id = ?
1002
+ AND status = ?
1003
+ AND COALESCE(runtime_owner_id, '') = COALESCE(?, '')
1004
+ AND (heartbeat_at_ms IS NOT DISTINCT FROM ?)
1005
+ AND (? = 0 OR heartbeat_at_ms IS NULL OR heartbeat_at_ms < ?)
1006
+ RETURNING run_id`, [
1007
+ params.claimOwnerId,
1008
+ params.claimHeartbeatAtMs,
1009
+ params.runId,
1010
+ expectedStatus,
1011
+ params.expectedRuntimeOwnerId,
1012
+ params.expectedHeartbeatAtMs,
1013
+ requireStale ? 1 : 0,
1014
+ params.staleBeforeMs,
1015
+ ])
1016
+ .then((rows) => rows.length > 0);
1017
+ }
1018
+ const client = this.db.session.client;
890
1019
  client
891
1020
  .query(`UPDATE _smithers_runs
892
1021
  SET runtime_owner_id = ?, heartbeat_at_ms = ?
@@ -924,19 +1053,33 @@ export class SmithersDb {
924
1053
  updateClaimedRun(params) {
925
1054
  validateRunPatch(params.patch);
926
1055
  return this.write(`update claimed run ${params.runId}`, () => {
927
- const client = this.db.session.client;
928
1056
  const patchEntries = Object.entries(params.patch);
929
1057
  if (patchEntries.length === 0) {
930
1058
  return Promise.resolve(true);
931
1059
  }
932
1060
  const assignments = patchEntries.map(([key]) => `${camelToSnake(key)} = ?`);
1061
+ const setArgs = patchEntries.map(([, value]) => value);
1062
+ if (this.internalStorage.dialect === POSTGRES) {
1063
+ // Null-safe heartbeat compare (see claimRunForResume): IS NOT
1064
+ // DISTINCT FROM keeps the bigint param from being coerced to int4
1065
+ // by an int4 `-1` sentinel.
1066
+ return this.internalStorage
1067
+ .queryAllRaw(`UPDATE _smithers_runs
1068
+ SET ${assignments.join(", ")}
1069
+ WHERE run_id = ?
1070
+ AND runtime_owner_id = ?
1071
+ AND (heartbeat_at_ms IS NOT DISTINCT FROM ?)
1072
+ RETURNING run_id`, [...setArgs, params.runId, params.expectedRuntimeOwnerId, params.expectedHeartbeatAtMs])
1073
+ .then((rows) => rows.length > 0);
1074
+ }
1075
+ const client = this.db.session.client;
933
1076
  client
934
1077
  .query(`UPDATE _smithers_runs
935
1078
  SET ${assignments.join(", ")}
936
1079
  WHERE run_id = ?
937
1080
  AND runtime_owner_id = ?
938
1081
  AND COALESCE(heartbeat_at_ms, -1) = COALESCE(?, -1)`)
939
- .run(...patchEntries.map(([, value]) => value), params.runId, params.expectedRuntimeOwnerId, params.expectedHeartbeatAtMs);
1082
+ .run(...setArgs, params.runId, params.expectedRuntimeOwnerId, params.expectedHeartbeatAtMs);
940
1083
  return this.internalStorage
941
1084
  .queryOne("SELECT changes() AS count")
942
1085
  .then((row) => Number(row?.count ?? 0) > 0);
@@ -1005,14 +1148,22 @@ export class SmithersDb {
1005
1148
  const target = cols.iteration
1006
1149
  ? [cols.runId, cols.nodeId, cols.iteration]
1007
1150
  : [cols.runId, cols.nodeId];
1008
- const tableName = table?.["_"]?.name ?? "output";
1009
- return this.write(`upsert output ${tableName}`, () => this.db
1010
- .insert(table)
1011
- .values(values)
1012
- .onConflictDoUpdate({
1013
- target: target,
1014
- set: values,
1015
- }));
1151
+ const tableName = resolveOutputTableName(table);
1152
+ const conflictColumns = cols.iteration
1153
+ ? ["runId", "nodeId", "iteration"]
1154
+ : ["runId", "nodeId"];
1155
+ return this.write(`upsert output ${tableName}`, () => {
1156
+ if (this.internalStorage.dialect === POSTGRES) {
1157
+ return this.internalStorage.upsert(tableName, values, conflictColumns);
1158
+ }
1159
+ return this.db
1160
+ .insert(table)
1161
+ .values(values)
1162
+ .onConflictDoUpdate({
1163
+ target: target,
1164
+ set: values,
1165
+ });
1166
+ });
1016
1167
  }
1017
1168
  /**
1018
1169
  * @param {Table} table
@@ -1030,6 +1181,14 @@ export class SmithersDb {
1030
1181
  */
1031
1182
  deleteOutputRow(tableName, key) {
1032
1183
  return this.write(`delete output ${tableName}`, () => {
1184
+ if (this.internalStorage.dialect === POSTGRES) {
1185
+ // PostgreSQL output tables are created from the Zod schema with
1186
+ // snake_case run_id/node_id/iteration columns, so no PRAGMA-based
1187
+ // column discovery is needed.
1188
+ const escapedPg = tableName.replaceAll(`"`, `""`);
1189
+ return this.internalStorage.execute(`DELETE FROM "${escapedPg}"
1190
+ WHERE run_id = ? AND node_id = ? AND iteration = ?`, [key.runId, key.nodeId, key.iteration ?? 0]);
1191
+ }
1033
1192
  const client = this.db.session.client;
1034
1193
  let resolvedTableName = tableName;
1035
1194
  let escapedTableName = resolvedTableName.replaceAll(`"`, `""`);
@@ -1112,10 +1271,20 @@ export class SmithersDb {
1112
1271
  getRawNodeOutput(tableName, runId, nodeId) {
1113
1272
  return runnableEffect(this.read(`get raw node output ${tableName}`, () => {
1114
1273
  const escaped = tableName.replaceAll(`"`, `""`);
1274
+ if (this.internalStorage.dialect === POSTGRES) {
1275
+ const boolColumns = getPhysicalBooleanColumnNames(findDrizzleTableByName(this.db, tableName));
1276
+ return this.internalStorage
1277
+ .queryOneRaw(`SELECT * FROM "${escaped}" WHERE run_id = ? AND node_id = ? ORDER BY iteration DESC LIMIT 1`, [runId, nodeId])
1278
+ .then((row) => coerceRawBooleanColumns(row ?? null, boolColumns) ?? null);
1279
+ }
1115
1280
  const client = this.db.session.client;
1281
+ const boolColumns = [
1282
+ ...getPersistedBooleanColumnNames(client, tableName),
1283
+ ...getPhysicalBooleanColumnNames(findDrizzleTableByName(this.db, tableName)),
1284
+ ];
1116
1285
  const stmt = client.query(`SELECT * FROM "${escaped}" WHERE run_id = ? AND node_id = ? ORDER BY iteration DESC LIMIT 1`);
1117
1286
  const row = stmt.get(runId, nodeId);
1118
- return Promise.resolve(row ?? null);
1287
+ return Promise.resolve(coerceRawBooleanColumns(row ?? null, boolColumns) ?? null);
1119
1288
  }).pipe(Effect.catchAll(() => Effect.succeed(null))));
1120
1289
  }
1121
1290
  /**
@@ -1125,13 +1294,40 @@ export class SmithersDb {
1125
1294
  * @param {number} iteration
1126
1295
  * @returns {RunnableEffect<Record<string, unknown> | null, SmithersError>}
1127
1296
  */
1297
+ /**
1298
+ * Whether a physical table with this exact name exists in the database.
1299
+ * @param {string} tableName
1300
+ * @returns {RunnableEffect<boolean, SmithersError>}
1301
+ */
1302
+ hasPhysicalTable(tableName) {
1303
+ return runnableEffect(this.read(`has physical table ${tableName}`, () => {
1304
+ if (this.internalStorage.dialect === POSTGRES) {
1305
+ return this.internalStorage
1306
+ .queryOneRaw(`SELECT 1 AS one FROM information_schema.tables WHERE table_name = ? LIMIT 1`, [tableName])
1307
+ .then((row) => row != null);
1308
+ }
1309
+ const client = this.db.session.client;
1310
+ const stmt = client.query(`SELECT 1 AS one FROM sqlite_master WHERE type = 'table' AND name = ? LIMIT 1`);
1311
+ return Promise.resolve(stmt.get(tableName) != null);
1312
+ }).pipe(Effect.catchAll(() => Effect.succeed(false))));
1313
+ }
1128
1314
  getRawNodeOutputForIteration(tableName, runId, nodeId, iteration) {
1129
1315
  return runnableEffect(this.read(`get raw node output ${tableName} iteration ${iteration}`, () => {
1130
1316
  const escaped = tableName.replaceAll(`"`, `""`);
1317
+ if (this.internalStorage.dialect === POSTGRES) {
1318
+ const boolColumns = getPhysicalBooleanColumnNames(findDrizzleTableByName(this.db, tableName));
1319
+ return this.internalStorage
1320
+ .queryOneRaw(`SELECT * FROM "${escaped}" WHERE run_id = ? AND node_id = ? AND iteration = ? LIMIT 1`, [runId, nodeId, iteration])
1321
+ .then((row) => coerceRawBooleanColumns(row ?? null, boolColumns) ?? null);
1322
+ }
1131
1323
  const client = this.db.session.client;
1324
+ const boolColumns = [
1325
+ ...getPersistedBooleanColumnNames(client, tableName),
1326
+ ...getPhysicalBooleanColumnNames(findDrizzleTableByName(this.db, tableName)),
1327
+ ];
1132
1328
  const stmt = client.query(`SELECT * FROM "${escaped}" WHERE run_id = ? AND node_id = ? AND iteration = ? LIMIT 1`);
1133
1329
  const row = stmt.get(runId, nodeId, iteration);
1134
- return Promise.resolve(row ?? null);
1330
+ return Promise.resolve(coerceRawBooleanColumns(row ?? null, boolColumns) ?? null);
1135
1331
  }).pipe(Effect.catchAll(() => Effect.succeed(null))));
1136
1332
  }
1137
1333
  /**
@@ -1810,6 +2006,75 @@ export class SmithersDb {
1810
2006
  ORDER BY attempt ASC, seq ASC`, [runId, nodeId, iteration]));
1811
2007
  }
1812
2008
  /**
2009
+ * Record a distinct working-copy state (deduped by jj commit id). Upsert so a
2010
+ * re-snapshot of the same tree refreshes the operation handle.
2011
+ * @param {Record<string, unknown>} row
2012
+ * @returns {RunnableEffect<void, SmithersError>}
2013
+ */
2014
+ upsertWorkspaceState(row) {
2015
+ return this.write(`upsert workspace state ${row.jjCommitId}`, () => this.internalStorage.upsert("_smithers_workspace_states", row, ["runId", "jjCwd", "jjCommitId"]));
2016
+ }
2017
+ /**
2018
+ * @param {string} runId
2019
+ * @returns {RunnableEffect<Array<Record<string, unknown>>, SmithersError>}
2020
+ */
2021
+ listWorkspaceStates(runId) {
2022
+ return this.read(`list workspace states ${runId}`, () => this.internalStorage.queryAll(`SELECT *
2023
+ FROM _smithers_workspace_states
2024
+ WHERE run_id = ?
2025
+ ORDER BY created_at_ms ASC`, [runId]));
2026
+ }
2027
+ /**
2028
+ * Record a snapshot checkpoint event. One row per tool/watch boundary; never
2029
+ * deduped, so a Tier 1 boundary always has a seq to bind a resume to.
2030
+ * @param {Record<string, unknown>} row
2031
+ * @returns {RunnableEffect<void, SmithersError>}
2032
+ */
2033
+ insertWorkspaceCheckpoint(row) {
2034
+ return this.write(`insert workspace checkpoint ${row.nodeId}#${row.seq}`, () => this.internalStorage.insertIgnore("_smithers_workspace_checkpoints", row));
2035
+ }
2036
+ /**
2037
+ * @param {string} runId
2038
+ * @returns {RunnableEffect<Array<Record<string, unknown>>, SmithersError>}
2039
+ */
2040
+ listWorkspaceCheckpoints(runId) {
2041
+ return this.read(`list workspace checkpoints ${runId}`, () => this.internalStorage.queryAll(`SELECT *
2042
+ FROM _smithers_workspace_checkpoints
2043
+ WHERE run_id = ?
2044
+ ORDER BY seq ASC`, [runId]));
2045
+ }
2046
+ /**
2047
+ * Prune old workspace states for a run, keeping the most recent maxKeep by
2048
+ * created_at_ms. The latest rows are always kept. Row-value NOT IN avoids the
2049
+ * string-concat collision a naive key would have; portable to SQLite + Postgres.
2050
+ * @param {string} runId
2051
+ * @param {number} [maxKeep]
2052
+ * @returns {RunnableEffect<void, SmithersError>}
2053
+ */
2054
+ pruneWorkspaceStates(runId, maxKeep = 50) {
2055
+ const keep = Math.max(1, maxKeep);
2056
+ return this.write(`prune workspace states ${runId}`, () => this.internalStorage.deleteWhere("_smithers_workspace_states", `run_id = ? AND (jj_cwd, jj_commit_id) NOT IN (
2057
+ SELECT jj_cwd, jj_commit_id FROM _smithers_workspace_states
2058
+ WHERE run_id = ? ORDER BY created_at_ms DESC LIMIT ?)`, [runId, runId, keep]));
2059
+ }
2060
+ /**
2061
+ * Prune old workspace checkpoints, keeping the most recent maxKeepPerScope per
2062
+ * (node_id, iteration, attempt) via a window function. The latest checkpoint per
2063
+ * scope is always kept, so resume-restore targets survive.
2064
+ * @param {string} runId
2065
+ * @param {number} [maxKeepPerScope]
2066
+ * @returns {RunnableEffect<void, SmithersError>}
2067
+ */
2068
+ pruneWorkspaceCheckpoints(runId, maxKeepPerScope = 100) {
2069
+ const keep = Math.max(1, maxKeepPerScope);
2070
+ return this.write(`prune workspace checkpoints ${runId}`, () => this.internalStorage.deleteWhere("_smithers_workspace_checkpoints", `run_id = ? AND (node_id, iteration, attempt, seq) NOT IN (
2071
+ SELECT node_id, iteration, attempt, seq FROM (
2072
+ SELECT node_id, iteration, attempt, seq,
2073
+ ROW_NUMBER() OVER (PARTITION BY node_id, iteration, attempt ORDER BY seq DESC) AS rn
2074
+ FROM _smithers_workspace_checkpoints WHERE run_id = ?
2075
+ ) sub WHERE rn <= ?)`, [runId, runId, keep]));
2076
+ }
2077
+ /**
1813
2078
  * @param {Record<string, unknown>} row
1814
2079
  * @returns {RunnableEffect<void, SmithersError>}
1815
2080
  */
@@ -1968,6 +2233,34 @@ export class SmithersDb {
1968
2233
  WHERE run_id = ? AND status = ?`, [runId, "requested"], { booleanColumns: ["autoApproved"] }));
1969
2234
  }
1970
2235
  /**
2236
+ * @param {string} runId
2237
+ * @returns {RunnableEffect<ApprovalRow[], SmithersError>}
2238
+ */
2239
+ listDecidedApprovals(runId) {
2240
+ return this.read(`list decided approvals ${runId}`, () => this.internalStorage.queryAll(`SELECT a.*
2241
+ FROM _smithers_approvals a
2242
+ JOIN _smithers_nodes n
2243
+ ON a.run_id = n.run_id
2244
+ AND a.node_id = n.node_id
2245
+ AND a.iteration = n.iteration
2246
+ WHERE a.run_id = ?
2247
+ AND a.status IN ('approved', 'denied')
2248
+ AND n.state = 'pending'`, [runId], { booleanColumns: ["autoApproved"] }));
2249
+ }
2250
+ /**
2251
+ * Returns all decided approvals for a run (approved or denied), regardless of
2252
+ * node state. Used by why-diagnosis so denied gates (node state = 'failed')
2253
+ * are included in the diagnosis output.
2254
+ * @param {string} runId
2255
+ * @returns {RunnableEffect<ApprovalRow[], SmithersError>}
2256
+ */
2257
+ listAllDecidedApprovals(runId) {
2258
+ return this.read(`list all decided approvals ${runId}`, () => this.internalStorage.queryAll(`SELECT *
2259
+ FROM _smithers_approvals
2260
+ WHERE run_id = ?
2261
+ AND status IN ('approved', 'denied')`, [runId], { booleanColumns: ["autoApproved"] }));
2262
+ }
2263
+ /**
1971
2264
  * @returns {RunnableEffect<Array<Record<string, unknown>>, SmithersError>}
1972
2265
  */
1973
2266
  listAllPendingApprovals() {
@@ -1977,8 +2270,12 @@ export class SmithersDb {
1977
2270
  a.iteration,
1978
2271
  a.status,
1979
2272
  a.requested_at_ms,
2273
+ a.decided_at_ms,
1980
2274
  a.note,
1981
2275
  a.decided_by,
2276
+ a.request_json,
2277
+ a.decision_json,
2278
+ a.auto_approved,
1982
2279
  r.workflow_name,
1983
2280
  r.status AS run_status,
1984
2281
  n.label AS node_label
@@ -1989,7 +2286,7 @@ export class SmithersDb {
1989
2286
  AND a.node_id = n.node_id
1990
2287
  AND a.iteration = n.iteration
1991
2288
  WHERE a.status = ?
1992
- ORDER BY COALESCE(a.requested_at_ms, 0) ASC, a.run_id, a.node_id, a.iteration`, ["requested"]));
2289
+ ORDER BY COALESCE(a.requested_at_ms, 0) ASC, a.run_id, a.node_id, a.iteration`, ["requested"], { booleanColumns: ["autoApproved"] }));
1993
2290
  }
1994
2291
  /**
1995
2292
  * @param {string} workflowName
@@ -2401,6 +2698,20 @@ export class SmithersDb {
2401
2698
  }
2402
2699
  /**
2403
2700
  * @param {string} runId
2701
+ * @returns {RunnableEffect<ApprovalRow[], SmithersError>}
2702
+ */
2703
+ listDecidedApprovalsEffect(runId) {
2704
+ return this.listDecidedApprovals(runId);
2705
+ }
2706
+ /**
2707
+ * @param {string} runId
2708
+ * @returns {RunnableEffect<ApprovalRow[], SmithersError>}
2709
+ */
2710
+ listAllDecidedApprovalsEffect(runId) {
2711
+ return this.listAllDecidedApprovals(runId);
2712
+ }
2713
+ /**
2714
+ * @param {string} runId
2404
2715
  * @returns {RunnableEffect<FrameRow | undefined, SmithersError>}
2405
2716
  */
2406
2717
  getLastFrameEffect(runId) {