@smithers-orchestrator/db 0.16.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.
Files changed (130) hide show
  1. package/LICENSE +21 -0
  2. package/package.json +43 -0
  3. package/src/JsonBounds.ts +6 -0
  4. package/src/SchemaRegistryEntry.ts +6 -0
  5. package/src/SqlMessageStorage.js +818 -0
  6. package/src/SqlMessageStorageEventHistoryQuery.ts +7 -0
  7. package/src/SqliteWriteRetryOptions.ts +7 -0
  8. package/src/adapter/AlertRow.ts +29 -0
  9. package/src/adapter/AlertSeverity.ts +2 -0
  10. package/src/adapter/AlertStatus.ts +2 -0
  11. package/src/adapter/ApprovalRow.ts +13 -0
  12. package/src/adapter/AttemptRow.ts +17 -0
  13. package/src/adapter/CacheRow.ts +12 -0
  14. package/src/adapter/DB_ALERT_ALLOWED_SEVERITIES.js +5 -0
  15. package/src/adapter/DB_ALERT_ALLOWED_STATUSES.js +6 -0
  16. package/src/adapter/DB_ALERT_ID_MAX_LENGTH.js +1 -0
  17. package/src/adapter/DB_ALERT_MESSAGE_MAX_LENGTH.js +1 -0
  18. package/src/adapter/DB_ALERT_POLICY_NAME_MAX_LENGTH.js +1 -0
  19. package/src/adapter/DB_RUN_ALLOWED_STATUSES.js +10 -0
  20. package/src/adapter/DB_RUN_ID_MAX_LENGTH.js +1 -0
  21. package/src/adapter/DB_RUN_WORKFLOW_NAME_MAX_LENGTH.js +1 -0
  22. package/src/adapter/EventHistoryQuery.ts +7 -0
  23. package/src/adapter/HumanRequestRow.ts +19 -0
  24. package/src/adapter/NodeDiffCacheRow.ts +9 -0
  25. package/src/adapter/NodeRow.ts +10 -0
  26. package/src/adapter/PendingHumanRequestRow.ts +7 -0
  27. package/src/adapter/RunAncestryRow.ts +5 -0
  28. package/src/adapter/RunRow.ts +21 -0
  29. package/src/adapter/SignalQuery.ts +6 -0
  30. package/src/adapter/SignalRow.ts +9 -0
  31. package/src/adapter/SmithersDb.js +2236 -0
  32. package/src/adapter/StaleRunRecord.ts +7 -0
  33. package/src/adapter/index.js +27 -0
  34. package/src/adapter.js +2359 -0
  35. package/src/assertJsonPayloadWithinBounds.js +94 -0
  36. package/src/assertMaxBytes.js +23 -0
  37. package/src/assertMaxJsonDepth.js +40 -0
  38. package/src/assertMaxStringLength.js +16 -0
  39. package/src/assertOptionalArrayMaxLength.js +16 -0
  40. package/src/assertOptionalStringMaxLength.js +11 -0
  41. package/src/assertPositiveFiniteInteger.js +14 -0
  42. package/src/assertPositiveFiniteNumber.js +12 -0
  43. package/src/buildHumanRequestId.js +9 -0
  44. package/src/cache/nodeDiffCache.js +124 -0
  45. package/src/ensure.js +18 -0
  46. package/src/ensureSqlMessageStorage.js +11 -0
  47. package/src/ensureSqlMessageStorageEffect.js +12 -0
  48. package/src/frame-codec/FRAME_KEYFRAME_INTERVAL.js +1 -0
  49. package/src/frame-codec/FrameDelta.ts +6 -0
  50. package/src/frame-codec/FrameDeltaOp.ts +20 -0
  51. package/src/frame-codec/FrameEncoding.ts +1 -0
  52. package/src/frame-codec/JsonPath.ts +3 -0
  53. package/src/frame-codec/JsonPathSegment.ts +1 -0
  54. package/src/frame-codec/applyFrameDelta.js +143 -0
  55. package/src/frame-codec/applyFrameDeltaJson.js +10 -0
  56. package/src/frame-codec/encodeFrameDelta.js +247 -0
  57. package/src/frame-codec/index.js +15 -0
  58. package/src/frame-codec/normalizeFrameEncoding.js +13 -0
  59. package/src/frame-codec/parseFrameDelta.js +27 -0
  60. package/src/frame-codec/serializeFrameDelta.js +9 -0
  61. package/src/frame-codec.js +409 -0
  62. package/src/getSqlMessageStorage.js +11 -0
  63. package/src/index.d.ts +5203 -0
  64. package/src/index.js +20 -0
  65. package/src/input-bounds.js +12 -0
  66. package/src/input.js +17 -0
  67. package/src/internal-schema/index.js +19 -0
  68. package/src/internal-schema/smithersAlerts.js +27 -0
  69. package/src/internal-schema/smithersApprovals.js +18 -0
  70. package/src/internal-schema/smithersAttempts.js +20 -0
  71. package/src/internal-schema/smithersCache.js +13 -0
  72. package/src/internal-schema/smithersCron.js +11 -0
  73. package/src/internal-schema/smithersEvents.js +10 -0
  74. package/src/internal-schema/smithersFrames.js +14 -0
  75. package/src/internal-schema/smithersHumanRequests.js +17 -0
  76. package/src/internal-schema/smithersNodeDiffs.js +12 -0
  77. package/src/internal-schema/smithersNodes.js +13 -0
  78. package/src/internal-schema/smithersRalph.js +10 -0
  79. package/src/internal-schema/smithersRuns.js +22 -0
  80. package/src/internal-schema/smithersSandboxes.js +16 -0
  81. package/src/internal-schema/smithersSignals.js +12 -0
  82. package/src/internal-schema/smithersTimeTravelAudit.js +12 -0
  83. package/src/internal-schema/smithersToolCalls.js +19 -0
  84. package/src/internal-schema/smithersVectors.js +12 -0
  85. package/src/internal-schema.js +245 -0
  86. package/src/isRetryableSqliteWriteError.js +53 -0
  87. package/src/loadInputEffect.js +28 -0
  88. package/src/loadOutputsEffect.js +87 -0
  89. package/src/output/OutputKey.ts +1 -0
  90. package/src/output/buildKeyWhere.js +17 -0
  91. package/src/output/buildOutputRow.js +34 -0
  92. package/src/output/describeSchemaShape.js +70 -0
  93. package/src/output/getAgentOutputSchema.js +13 -0
  94. package/src/output/getKeyColumns.js +19 -0
  95. package/src/output/index.js +14 -0
  96. package/src/output/selectOutputRowEffect.js +30 -0
  97. package/src/output/stripAutoColumns.js +10 -0
  98. package/src/output/upsertOutputRowEffect.js +38 -0
  99. package/src/output/validateExistingOutput.js +17 -0
  100. package/src/output/validateOutput.js +17 -0
  101. package/src/output-schema-descriptor.js +163 -0
  102. package/src/output.js +240 -0
  103. package/src/react-output.js +10 -0
  104. package/src/runState/ComputeRunStateOptions.ts +4 -0
  105. package/src/runState/DeriveRunStateInput.ts +10 -0
  106. package/src/runState/RUN_STATE_HEARTBEAT_STALE_MS.js +1 -0
  107. package/src/runState/ReasonBlocked.ts +10 -0
  108. package/src/runState/ReasonUnhealthy.ts +6 -0
  109. package/src/runState/RunState.ts +12 -0
  110. package/src/runState/RunStateView.ts +11 -0
  111. package/src/runState/computeRunState.js +22 -0
  112. package/src/runState/computeRunStateFromRow.js +102 -0
  113. package/src/runState/deriveRunState.js +109 -0
  114. package/src/runState/parseEventMeta.js +18 -0
  115. package/src/runState/parseTimerMeta.js +16 -0
  116. package/src/runState-types.ts +23 -0
  117. package/src/runState.js +7 -0
  118. package/src/schema-signature.js +22 -0
  119. package/src/snapshot.js +125 -0
  120. package/src/sql-message-storage.js +839 -0
  121. package/src/storage/InMemoryStorage.js +484 -0
  122. package/src/storage/StorageService.js +7 -0
  123. package/src/storage/StorageServiceShape.ts +122 -0
  124. package/src/storage/StorageServiceTypes.ts +150 -0
  125. package/src/unwrapZodType.js +17 -0
  126. package/src/utils/camelToSnake.js +6 -0
  127. package/src/withSqliteWriteRetryEffect.js +110 -0
  128. package/src/write-retry.js +49 -0
  129. package/src/zodToCreateTableSQL.js +41 -0
  130. package/src/zodToTable.js +60 -0
package/src/adapter.js ADDED
@@ -0,0 +1,2359 @@
1
+ // @smithers-type-exports-begin
2
+ /** @typedef {import("./adapter/AlertSeverity.ts").AlertSeverity} AlertSeverity */
3
+ /** @typedef {import("./adapter/ApprovalRow.ts").ApprovalRow} ApprovalRow */
4
+ /** @typedef {import("./adapter/CacheRow.ts").CacheRow} CacheRow */
5
+ /** @typedef {import("./adapter/NodeRow.ts").NodeRow} NodeRow */
6
+ /** @typedef {import("./adapter/PendingHumanRequestRow.ts").PendingHumanRequestRow} PendingHumanRequestRow */
7
+ /** @typedef {import("./adapter/RunAncestryRow.ts").RunAncestryRow} RunAncestryRow */
8
+ /** @typedef {import("./adapter/RunRow.ts").RunRow} RunRow */
9
+ /** @typedef {import("./adapter/SignalRow.ts").SignalRow} SignalRow */
10
+ /** @typedef {import("./adapter/StaleRunRecord.ts").StaleRunRecord} StaleRunRecord */
11
+ // @smithers-type-exports-end
12
+
13
+ import { getTableName, sql } from "drizzle-orm";
14
+ import { Effect, Exit, FiberId, Metric } from "effect";
15
+ import { toSmithersError } from "@smithers-orchestrator/errors/toSmithersError";
16
+ import { getSqlMessageStorage } from "./sql-message-storage.js";
17
+ import { alertsAcknowledgedTotal, alertsActive, alertsFiredTotal, dbQueryDuration, dbTransactionDuration, dbTransactionRetries, dbTransactionRollbacks, } from "@smithers-orchestrator/observability/metrics";
18
+ import { assertOptionalStringMaxLength, assertPositiveFiniteNumber, } from "./input-bounds.js";
19
+ import { FRAME_KEYFRAME_INTERVAL, applyFrameDeltaJson, encodeFrameDelta, normalizeFrameEncoding, serializeFrameDelta, } from "./frame-codec.js";
20
+ import { getKeyColumns } from "./output.js";
21
+ import { withSqliteWriteRetryEffect } from "./write-retry.js";
22
+ import { camelToSnake } from "./utils/camelToSnake.js";
23
+ /** @typedef {import("./adapter/AlertRow.ts").AlertRow} AlertRow */
24
+ /** @typedef {import("./adapter/AlertStatus.ts").AlertStatus} AlertStatus */
25
+ /** @typedef {import("./adapter/AttemptRow.ts").AttemptRow} AttemptRow */
26
+ /** @typedef {import("drizzle-orm/bun-sqlite").BunSQLiteDatabase} BunSQLiteDatabase */
27
+ /** @typedef {import("./adapter/EventHistoryQuery.ts").EventHistoryQuery} EventHistoryQuery */
28
+ /** @typedef {import("./adapter/HumanRequestRow.ts").HumanRequestRow} HumanRequestRow */
29
+ /** @typedef {import("./output/OutputKey.ts").OutputKey} OutputKey */
30
+ /**
31
+ * @template A, E
32
+ * @typedef {Effect.Effect<A, E> & PromiseLike<A>} RunnableEffect
33
+ */
34
+ /** @typedef {import("./adapter/SignalQuery.ts").SignalQuery} SignalQuery */
35
+ /** @typedef {import("@smithers-orchestrator/errors/SmithersError").SmithersError} SmithersError */
36
+ /**
37
+ * @typedef {{ runId: string; frameNo: number; createdAtMs: number; xmlJson: string; xmlHash: string; encoding: string; mountedTaskIdsJson: string | null; taskIndexJson: string | null; note: string | null; }} FrameRow
38
+ */
39
+ /**
40
+ * @typedef {{ runId: string; nodeId: string; iteration: number; baseRef: string; diffJson: string; computedAtMs: number; sizeBytes: number; }} NodeDiffCacheRow
41
+ */
42
+ /**
43
+ * @typedef {{ count: number }} CountRow
44
+ */
45
+ /**
46
+ * @typedef {{ ralphId: string; runId: string; done?: boolean }} RalphRow
47
+ */
48
+ /**
49
+ * @typedef {{ cacheKey: string; createdAtMs?: number; nodeId: string; outputTable: string }} CacheRowLike
50
+ */
51
+
52
+
53
+ export const DB_ALERT_ID_MAX_LENGTH = 256;
54
+ export const DB_ALERT_POLICY_NAME_MAX_LENGTH = 256;
55
+ export const DB_ALERT_MESSAGE_MAX_LENGTH = 4096;
56
+ export const DB_ALERT_ALLOWED_SEVERITIES = [
57
+ "info",
58
+ "warning",
59
+ "critical",
60
+ ];
61
+ export const DB_ALERT_ALLOWED_STATUSES = [
62
+ "firing",
63
+ "acknowledged",
64
+ "resolved",
65
+ "silenced",
66
+ ];
67
+ const FRAME_XML_CACHE_MAX = 512;
68
+ export const DB_RUN_ID_MAX_LENGTH = 256;
69
+ export const DB_RUN_WORKFLOW_NAME_MAX_LENGTH = 256;
70
+ export const DB_RUN_ALLOWED_STATUSES = [
71
+ "running",
72
+ "waiting-approval",
73
+ "waiting-event",
74
+ "waiting-timer",
75
+ "finished",
76
+ "failed",
77
+ "cancelled",
78
+ "continued",
79
+ ];
80
+ const RUN_HEARTBEAT_STALE_MS = 30_000;
81
+ const RAW_QUERY_ALLOWED_PREFIX = /^(?:select|with|explain|values)\b/i;
82
+ const RAW_QUERY_FORBIDDEN_KEYWORDS = /\b(?:drop|delete|insert|update|alter|create|attach|detach|pragma)\b/i;
83
+ const ACTIVE_ALERT_STATUSES = new Set([
84
+ "firing",
85
+ "acknowledged",
86
+ "silenced",
87
+ ]);
88
+ /**
89
+ * @param {string} queryString
90
+ * @returns {string}
91
+ */
92
+ function stripSqlCommentsAndLiterals(queryString) {
93
+ let sanitized = "";
94
+ let index = 0;
95
+ while (index < queryString.length) {
96
+ const char = queryString[index];
97
+ const nextChar = queryString[index + 1];
98
+ if (char === "-" && nextChar === "-") {
99
+ sanitized += " ";
100
+ index += 2;
101
+ while (index < queryString.length && queryString[index] !== "\n") {
102
+ index += 1;
103
+ }
104
+ continue;
105
+ }
106
+ if (char === "/" && nextChar === "*") {
107
+ sanitized += " ";
108
+ index += 2;
109
+ while (index < queryString.length) {
110
+ if (queryString[index] === "*" && queryString[index + 1] === "/") {
111
+ index += 2;
112
+ break;
113
+ }
114
+ index += 1;
115
+ }
116
+ continue;
117
+ }
118
+ if (char === "'" || char === "\"" || char === "`") {
119
+ const quote = char;
120
+ sanitized += " ";
121
+ index += 1;
122
+ while (index < queryString.length) {
123
+ if (queryString[index] === quote) {
124
+ if (queryString[index + 1] === quote) {
125
+ index += 2;
126
+ continue;
127
+ }
128
+ index += 1;
129
+ break;
130
+ }
131
+ index += 1;
132
+ }
133
+ continue;
134
+ }
135
+ if (char === "[") {
136
+ sanitized += " ";
137
+ index += 1;
138
+ while (index < queryString.length) {
139
+ if (queryString[index] === "]") {
140
+ index += 1;
141
+ break;
142
+ }
143
+ index += 1;
144
+ }
145
+ continue;
146
+ }
147
+ sanitized += char;
148
+ index += 1;
149
+ }
150
+ return sanitized;
151
+ }
152
+ /**
153
+ * @param {string} queryString
154
+ * @returns {string}
155
+ */
156
+ function validateReadOnlyRawQuery(queryString) {
157
+ const trimmedQuery = queryString.trim();
158
+ if (!trimmedQuery) {
159
+ throw toSmithersError(new Error("Raw query must not be empty"), undefined, {
160
+ code: "INVALID_INPUT",
161
+ details: { operation: "raw query validation" },
162
+ });
163
+ }
164
+ const sanitizedQuery = stripSqlCommentsAndLiterals(trimmedQuery).trim();
165
+ if (!sanitizedQuery) {
166
+ throw toSmithersError(new Error("Raw query must not be empty"), undefined, {
167
+ code: "INVALID_INPUT",
168
+ details: { operation: "raw query validation" },
169
+ });
170
+ }
171
+ const singleStatementQuery = sanitizedQuery.replace(/;+\s*$/, "").trim();
172
+ if (singleStatementQuery.includes(";")) {
173
+ throw toSmithersError(new Error("Raw query must contain a single read-only SQL statement"), undefined, {
174
+ code: "INVALID_INPUT",
175
+ details: { operation: "raw query validation" },
176
+ });
177
+ }
178
+ const forbiddenKeyword = singleStatementQuery.match(RAW_QUERY_FORBIDDEN_KEYWORDS)?.[0];
179
+ if (forbiddenKeyword) {
180
+ throw toSmithersError(new Error(`Raw query cannot use ${forbiddenKeyword.toUpperCase()} statements`), undefined, {
181
+ code: "INVALID_INPUT",
182
+ details: {
183
+ operation: "raw query validation",
184
+ keyword: forbiddenKeyword.toUpperCase(),
185
+ },
186
+ });
187
+ }
188
+ if (!RAW_QUERY_ALLOWED_PREFIX.test(singleStatementQuery)) {
189
+ throw toSmithersError(new Error("Raw query only supports read-only SELECT, WITH, EXPLAIN, or VALUES statements"), undefined, {
190
+ code: "INVALID_INPUT",
191
+ details: { operation: "raw query validation" },
192
+ });
193
+ }
194
+ return trimmedQuery;
195
+ }
196
+ /**
197
+ * @param {unknown} status
198
+ */
199
+ function validateRunStatus(status) {
200
+ if (typeof status !== "string" ||
201
+ !DB_RUN_ALLOWED_STATUSES.includes(status)) {
202
+ throw toSmithersError(new Error("Invalid run status"), `Run status must be one of: ${DB_RUN_ALLOWED_STATUSES.join(", ")}`, {
203
+ code: "INVALID_INPUT",
204
+ details: { status },
205
+ });
206
+ }
207
+ }
208
+ /**
209
+ * @param {unknown} severity
210
+ */
211
+ function validateAlertSeverity(severity) {
212
+ if (typeof severity !== "string" ||
213
+ !DB_ALERT_ALLOWED_SEVERITIES.includes(severity)) {
214
+ throw toSmithersError(new Error("Invalid alert severity"), `Alert severity must be one of: ${DB_ALERT_ALLOWED_SEVERITIES.join(", ")}`, {
215
+ code: "INVALID_INPUT",
216
+ details: { severity },
217
+ });
218
+ }
219
+ }
220
+ /**
221
+ * @param {unknown} status
222
+ */
223
+ function validateAlertStatus(status) {
224
+ if (typeof status !== "string" ||
225
+ !DB_ALERT_ALLOWED_STATUSES.includes(status)) {
226
+ throw toSmithersError(new Error("Invalid alert status"), `Alert status must be one of: ${DB_ALERT_ALLOWED_STATUSES.join(", ")}`, {
227
+ code: "INVALID_INPUT",
228
+ details: { status },
229
+ });
230
+ }
231
+ }
232
+ /**
233
+ * @param {Record<string, unknown>} row
234
+ * @param {string} field
235
+ */
236
+ function validateOptionalPositiveTimestamp(row, field) {
237
+ const value = row[field];
238
+ if (value === undefined || value === null)
239
+ return;
240
+ assertPositiveFiniteNumber(field, Number(value));
241
+ }
242
+ /**
243
+ * @param {unknown} row
244
+ * @returns {void}
245
+ */
246
+ function validateRunRow(row) {
247
+ if (!row || typeof row !== "object") {
248
+ throw toSmithersError(new Error("Invalid run row"), "Run row must be an object", {
249
+ code: "INVALID_INPUT",
250
+ });
251
+ }
252
+ const r = /** @type {Record<string, unknown>} */ (row);
253
+ assertOptionalStringMaxLength("runId", r.runId, DB_RUN_ID_MAX_LENGTH);
254
+ assertOptionalStringMaxLength("parentRunId", r.parentRunId, DB_RUN_ID_MAX_LENGTH);
255
+ assertOptionalStringMaxLength("workflowName", r.workflowName, DB_RUN_WORKFLOW_NAME_MAX_LENGTH);
256
+ validateRunStatus(r.status);
257
+ validateOptionalPositiveTimestamp(r, "createdAtMs");
258
+ validateOptionalPositiveTimestamp(r, "startedAtMs");
259
+ validateOptionalPositiveTimestamp(r, "finishedAtMs");
260
+ validateOptionalPositiveTimestamp(r, "heartbeatAtMs");
261
+ validateOptionalPositiveTimestamp(r, "cancelRequestedAtMs");
262
+ validateOptionalPositiveTimestamp(r, "hijackRequestedAtMs");
263
+ }
264
+ /**
265
+ * @param {unknown} patch
266
+ * @returns {void}
267
+ */
268
+ function validateRunPatch(patch) {
269
+ if (!patch || typeof patch !== "object")
270
+ return;
271
+ const p = /** @type {Record<string, unknown>} */ (patch);
272
+ if ("workflowName" in p) {
273
+ assertOptionalStringMaxLength("workflowName", p.workflowName, DB_RUN_WORKFLOW_NAME_MAX_LENGTH);
274
+ }
275
+ if ("status" in p) {
276
+ validateRunStatus(p.status);
277
+ }
278
+ validateOptionalPositiveTimestamp(p, "startedAtMs");
279
+ validateOptionalPositiveTimestamp(p, "finishedAtMs");
280
+ validateOptionalPositiveTimestamp(p, "heartbeatAtMs");
281
+ validateOptionalPositiveTimestamp(p, "cancelRequestedAtMs");
282
+ validateOptionalPositiveTimestamp(p, "hijackRequestedAtMs");
283
+ }
284
+ /**
285
+ * @param {AlertRow} row
286
+ */
287
+ function validateAlertRow(row) {
288
+ if (!row || typeof row !== "object") {
289
+ throw toSmithersError(new Error("Invalid alert row"), "Alert row must be an object", { code: "INVALID_INPUT" });
290
+ }
291
+ assertOptionalStringMaxLength("alertId", row.alertId, DB_ALERT_ID_MAX_LENGTH);
292
+ assertOptionalStringMaxLength("runId", row.runId, DB_RUN_ID_MAX_LENGTH);
293
+ assertOptionalStringMaxLength("policyName", row.policyName, DB_ALERT_POLICY_NAME_MAX_LENGTH);
294
+ assertOptionalStringMaxLength("message", row.message, DB_ALERT_MESSAGE_MAX_LENGTH);
295
+ if (typeof row.alertId !== "string" || row.alertId.length === 0) {
296
+ throw toSmithersError(new Error("Invalid alert ID"), "Alert ID must be a non-empty string", { code: "INVALID_INPUT", details: { alertId: row.alertId } });
297
+ }
298
+ if (row.runId !== null && row.runId !== undefined && typeof row.runId !== "string") {
299
+ throw toSmithersError(new Error("Invalid alert run ID"), "Alert run ID must be a string or null", { code: "INVALID_INPUT", details: { runId: row.runId } });
300
+ }
301
+ if (typeof row.policyName !== "string" || row.policyName.length === 0) {
302
+ throw toSmithersError(new Error("Invalid alert policy name"), "Alert policy name must be a non-empty string", { code: "INVALID_INPUT", details: { policyName: row.policyName } });
303
+ }
304
+ if (typeof row.message !== "string" || row.message.length === 0) {
305
+ throw toSmithersError(new Error("Invalid alert message"), "Alert message must be a non-empty string", { code: "INVALID_INPUT", details: { message: row.message } });
306
+ }
307
+ if (row.detailsJson !== null &&
308
+ row.detailsJson !== undefined &&
309
+ typeof row.detailsJson !== "string") {
310
+ throw toSmithersError(new Error("Invalid alert details JSON"), "Alert details JSON must be a string or null", { code: "INVALID_INPUT", details: { detailsJson: row.detailsJson } });
311
+ }
312
+ validateAlertSeverity(row.severity);
313
+ validateAlertStatus(row.status);
314
+ validateOptionalPositiveTimestamp(row, "firedAtMs");
315
+ validateOptionalPositiveTimestamp(row, "resolvedAtMs");
316
+ validateOptionalPositiveTimestamp(row, "acknowledgedAtMs");
317
+ }
318
+ /**
319
+ * @param {string | null | undefined} status
320
+ * @returns {status is AlertStatus}
321
+ */
322
+ function isAlertActiveStatus(status) {
323
+ return status !== undefined && status !== null && ACTIVE_ALERT_STATUSES.has(status);
324
+ }
325
+ /**
326
+ * Returns the row unchanged. Historically this helper relabeled
327
+ * stale-heartbeat "running" rows as "continued", which is wrong: "continued"
328
+ * is reserved for runs that successfully forked into a new run, and
329
+ * `deriveRunState` then maps "continued" → "succeeded" — so a dead workflow
330
+ * was being reported as a success. Heartbeat-based classification now lives
331
+ * exclusively in `deriveRunState`, which correctly returns "stale" or
332
+ * "orphaned". This shim is kept so the `listRuns` / `getRun` call sites
333
+ * continue to compile; remove it once they call `deriveRunState` directly.
334
+ *
335
+ * @template T
336
+ * @param {T} row
337
+ * @returns {T}
338
+ */
339
+ function classifyRunRowStatus(row) {
340
+ return row;
341
+ }
342
+ /**
343
+ * @template A, E
344
+ * @param {Effect.Effect<A, E>} effect
345
+ * @returns {RunnableEffect<A, E>}
346
+ */
347
+ function runnableEffect(effect) {
348
+ const runnable = effect;
349
+ if (typeof runnable.then !== "function") {
350
+ Object.defineProperty(runnable, "then", {
351
+ configurable: true,
352
+ value: (onfulfilled, onrejected) => Effect.runPromise(effect).then(onfulfilled, onrejected),
353
+ });
354
+ }
355
+ return runnable;
356
+ }
357
+ export class SmithersDb {
358
+ /** @type {BunSQLiteDatabase<Record<string, unknown>>} */
359
+ db;
360
+ /** @type {ReturnType<typeof getSqlMessageStorage>} */
361
+ internalStorage;
362
+ /** @type {Map<string, string>} */
363
+ reconstructedFrameXmlCache = new Map();
364
+ transactionDepth = 0;
365
+ /** @type {string | null} */
366
+ transactionOwnerThread = null;
367
+ /** @type {Promise<unknown>} */
368
+ transactionTail = Promise.resolve();
369
+ /**
370
+ * @param {BunSQLiteDatabase<Record<string, unknown>>} db
371
+ */
372
+ constructor(db) {
373
+ this.db = db;
374
+ this.internalStorage = getSqlMessageStorage(db);
375
+ }
376
+ /**
377
+ * @param {string} runId
378
+ * @param {number} frameNo
379
+ * @returns {string}
380
+ */
381
+ frameCacheKey(runId, frameNo) {
382
+ return `${runId}:${frameNo}`;
383
+ }
384
+ /**
385
+ * @param {string} runId
386
+ * @param {number} frameNo
387
+ * @returns {string | undefined}
388
+ */
389
+ getCachedFrameXml(runId, frameNo) {
390
+ const key = this.frameCacheKey(runId, frameNo);
391
+ const value = this.reconstructedFrameXmlCache.get(key);
392
+ if (value === undefined)
393
+ return undefined;
394
+ // Keep recently-used entries hot.
395
+ this.reconstructedFrameXmlCache.delete(key);
396
+ this.reconstructedFrameXmlCache.set(key, value);
397
+ return value;
398
+ }
399
+ /**
400
+ * @param {string} runId
401
+ * @param {number} frameNo
402
+ * @param {string} xmlJson
403
+ */
404
+ rememberFrameXml(runId, frameNo, xmlJson) {
405
+ const key = this.frameCacheKey(runId, frameNo);
406
+ if (this.reconstructedFrameXmlCache.has(key)) {
407
+ this.reconstructedFrameXmlCache.delete(key);
408
+ }
409
+ else if (this.reconstructedFrameXmlCache.size >= FRAME_XML_CACHE_MAX) {
410
+ const oldest = this.reconstructedFrameXmlCache.keys().next().value;
411
+ if (oldest !== undefined) {
412
+ this.reconstructedFrameXmlCache.delete(oldest);
413
+ }
414
+ }
415
+ this.reconstructedFrameXmlCache.set(key, xmlJson);
416
+ }
417
+ /**
418
+ * @param {string} runId
419
+ */
420
+ clearFrameCacheForRun(runId) {
421
+ for (const key of this.reconstructedFrameXmlCache.keys()) {
422
+ if (key.startsWith(`${runId}:`)) {
423
+ this.reconstructedFrameXmlCache.delete(key);
424
+ }
425
+ }
426
+ }
427
+ /**
428
+ * @param {string} queryString
429
+ * @returns {RunnableEffect<unknown[], SmithersError>}
430
+ */
431
+ rawQuery(queryString) {
432
+ const self = this;
433
+ return runnableEffect(Effect.gen(function* () {
434
+ const validatedQuery = yield* Effect.try({
435
+ try: () => validateReadOnlyRawQuery(queryString),
436
+ catch: (cause) => toSmithersError(cause, "validate raw query", {
437
+ code: "INVALID_INPUT",
438
+ details: { operation: "raw query validation" },
439
+ }),
440
+ });
441
+ return yield* self.read(`raw query ${validatedQuery.slice(0, 20)}`, () => {
442
+ const client = self.db.session.client;
443
+ const stmt = client.query(validatedQuery);
444
+ return Promise.resolve(stmt.all());
445
+ });
446
+ }));
447
+ }
448
+ /**
449
+ * @param {string} currentFiberThread
450
+ * @returns {boolean}
451
+ */
452
+ ownsActiveTransaction(currentFiberThread) {
453
+ return (this.transactionDepth > 0 &&
454
+ this.transactionOwnerThread === currentFiberThread);
455
+ }
456
+ /**
457
+ * @template A
458
+ * @param {string} label
459
+ * @param {() => PromiseLike<A>} operation
460
+ * @returns {RunnableEffect<A, SmithersError>}
461
+ */
462
+ read(label, operation) {
463
+ const self = this;
464
+ return runnableEffect(Effect.gen(function* () {
465
+ const start = performance.now();
466
+ const readOperation = Effect.tryPromise({
467
+ try: () => operation(),
468
+ catch: (cause) => toSmithersError(cause, label, {
469
+ code: "DB_QUERY_FAILED",
470
+ details: { operation: label },
471
+ }),
472
+ });
473
+ const currentFiberId = yield* Effect.fiberId;
474
+ const currentFiberThread = FiberId.threadName(currentFiberId);
475
+ let result;
476
+ if (self.ownsActiveTransaction(currentFiberThread)) {
477
+ result = yield* readOperation;
478
+ }
479
+ else {
480
+ const releaseTurn = yield* self.acquireTransactionTurn();
481
+ result = yield* readOperation.pipe(Effect.ensuring(Effect.sync(() => {
482
+ releaseTurn();
483
+ })));
484
+ }
485
+ yield* Metric.update(dbQueryDuration, performance.now() - start);
486
+ return result;
487
+ }).pipe(Effect.annotateLogs({ dbOperation: label }), Effect.withLogSpan(`db:${label}`)));
488
+ }
489
+ /**
490
+ * @template A
491
+ * @param {string} label
492
+ * @param {() => PromiseLike<A>} operation
493
+ * @returns {RunnableEffect<A, SmithersError>}
494
+ */
495
+ write(label, operation) {
496
+ const self = this;
497
+ return runnableEffect(Effect.gen(function* () {
498
+ const start = performance.now();
499
+ const writeOperation = Effect.tryPromise({
500
+ try: () => operation(),
501
+ catch: (cause) => toSmithersError(cause, label, {
502
+ code: "DB_WRITE_FAILED",
503
+ details: { operation: label },
504
+ }),
505
+ });
506
+ const currentFiberId = yield* Effect.fiberId;
507
+ const currentFiberThread = FiberId.threadName(currentFiberId);
508
+ let result;
509
+ if (self.ownsActiveTransaction(currentFiberThread)) {
510
+ result = yield* writeOperation;
511
+ }
512
+ else {
513
+ const releaseTurn = yield* self.acquireTransactionTurn();
514
+ result = yield* withSqliteWriteRetryEffect(() => writeOperation, { label }).pipe(Effect.ensuring(Effect.sync(() => {
515
+ releaseTurn();
516
+ })));
517
+ }
518
+ yield* Metric.update(dbQueryDuration, performance.now() - start);
519
+ return result;
520
+ }).pipe(Effect.annotateLogs({ dbOperation: label }), Effect.withLogSpan(`db:${label}`)));
521
+ }
522
+ /**
523
+ * @returns {Effect.Effect<{ 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 }, SmithersError, never>}
524
+ */
525
+ getSqliteTransactionClient() {
526
+ return Effect.try({
527
+ try: () => {
528
+ const candidate = this.db.session?.client ?? this.db.$client;
529
+ if (!candidate || typeof candidate.run !== "function") {
530
+ throw new Error("SmithersDb.withTransaction requires Bun SQLite client transaction primitives.");
531
+ }
532
+ return candidate;
533
+ },
534
+ catch: (cause) => toSmithersError(cause, "resolve sqlite transaction client", {
535
+ code: "DB_WRITE_FAILED",
536
+ details: { operation: "resolve sqlite transaction client" },
537
+ }),
538
+ });
539
+ }
540
+ /**
541
+ * @returns {Effect.Effect<() => void, SmithersError, never>}
542
+ */
543
+ acquireTransactionTurn() {
544
+ return Effect.tryPromise({
545
+ try: async () => {
546
+ let release;
547
+ const gate = new Promise((resolve) => {
548
+ release = resolve;
549
+ });
550
+ const previous = this.transactionTail.catch(() => undefined);
551
+ this.transactionTail = previous.then(() => gate);
552
+ await previous;
553
+ return release;
554
+ },
555
+ catch: (cause) => toSmithersError(cause, "acquire sqlite transaction turn", {
556
+ code: "DB_WRITE_FAILED",
557
+ details: { operation: "acquire sqlite transaction turn" },
558
+ }),
559
+ });
560
+ }
561
+ /**
562
+ * @template A
563
+ * @param {string} writeGroup
564
+ * @param {Effect.Effect<A, SmithersError>} operation
565
+ * @returns {RunnableEffect<A, SmithersError>}
566
+ */
567
+ withTransactionEffect(writeGroup, operation) {
568
+ const self = this;
569
+ const label = `sqlite transaction ${writeGroup}`;
570
+ return runnableEffect(withSqliteWriteRetryEffect(() => Effect.gen(function* () {
571
+ const currentFiberId = yield* Effect.fiberId;
572
+ const currentFiberThread = FiberId.threadName(currentFiberId);
573
+ if (self.ownsActiveTransaction(currentFiberThread)) {
574
+ return yield* Effect.fail(toSmithersError(new Error(`Nested sqlite transactions are not supported (writeGroup: ${writeGroup}).`), label, {
575
+ code: "DB_WRITE_FAILED",
576
+ details: { writeGroup, nestedTransaction: true },
577
+ }));
578
+ }
579
+ const releaseTurn = yield* self.acquireTransactionTurn();
580
+ const start = performance.now();
581
+ return yield* Effect.gen(function* () {
582
+ const client = yield* self.getSqliteTransactionClient();
583
+ /**
584
+ * @param {"operation" | "commit"} phase
585
+ * @param {unknown} error
586
+ */
587
+ const rollback = (phase, error) => Effect.gen(function* () {
588
+ yield* Metric.increment(dbTransactionRollbacks);
589
+ yield* Effect.logWarning("transaction rollback").pipe(Effect.annotateLogs({
590
+ writeGroup,
591
+ phase,
592
+ error: String(error),
593
+ }));
594
+ yield* Effect.sync(() => {
595
+ try {
596
+ client.run("ROLLBACK");
597
+ }
598
+ catch {
599
+ // ignore rollback failures
600
+ }
601
+ });
602
+ });
603
+ yield* Effect.try({
604
+ try: () => {
605
+ client.run("BEGIN IMMEDIATE");
606
+ self.transactionDepth += 1;
607
+ self.transactionOwnerThread = currentFiberThread;
608
+ },
609
+ catch: (cause) => toSmithersError(cause, "begin sqlite transaction", {
610
+ code: "DB_WRITE_FAILED",
611
+ details: { writeGroup, phase: "begin" },
612
+ }),
613
+ });
614
+ const operationExit = yield* Effect.exit(operation);
615
+ if (Exit.isFailure(operationExit)) {
616
+ yield* rollback("operation", operationExit.cause);
617
+ return yield* Effect.failCause(operationExit.cause);
618
+ }
619
+ const commitExit = yield* Effect.exit(Effect.try({
620
+ try: () => {
621
+ client.run("COMMIT");
622
+ },
623
+ catch: (cause) => toSmithersError(cause, "commit sqlite transaction", {
624
+ code: "DB_WRITE_FAILED",
625
+ details: { writeGroup, phase: "commit" },
626
+ }),
627
+ }));
628
+ if (Exit.isFailure(commitExit)) {
629
+ yield* rollback("commit", commitExit.cause);
630
+ return yield* Effect.failCause(commitExit.cause);
631
+ }
632
+ return operationExit.value;
633
+ }).pipe(Effect.ensuring(Effect.gen(function* () {
634
+ self.transactionDepth = Math.max(0, self.transactionDepth - 1);
635
+ if (self.transactionDepth === 0) {
636
+ self.transactionOwnerThread = null;
637
+ }
638
+ yield* Metric.update(dbTransactionDuration, performance.now() - start);
639
+ }))).pipe(Effect.ensuring(Effect.sync(() => {
640
+ releaseTurn();
641
+ })));
642
+ }), { label }).pipe(Effect.annotateLogs({ writeGroup }), Effect.withLogSpan("db:transaction")));
643
+ }
644
+ /**
645
+ * @template A
646
+ * @param {string} writeGroup
647
+ * @param {Effect.Effect<A, SmithersError>} operation
648
+ * @returns {Promise<A>}
649
+ */
650
+ withTransaction(writeGroup, operation) {
651
+ return Effect.runPromise(this.withTransactionEffect(writeGroup, operation));
652
+ }
653
+ /**
654
+ * @param {Record<string, unknown>} row
655
+ * @returns {RunnableEffect<void, SmithersError>}
656
+ */
657
+ insertRun(row) {
658
+ validateRunRow(row);
659
+ return this.write("insert run", () => this.internalStorage.insertIgnore("_smithers_runs", row));
660
+ }
661
+ /**
662
+ * @param {string} runId
663
+ * @param {Record<string, unknown>} patch
664
+ * @returns {RunnableEffect<void, SmithersError>}
665
+ */
666
+ updateRun(runId, patch) {
667
+ validateRunPatch(patch);
668
+ return this.write(`update run ${runId}`, () => this.internalStorage.updateWhere("_smithers_runs", patch, "run_id = ?", [runId]));
669
+ }
670
+ /**
671
+ * @param {string} runId
672
+ * @param {Record<string, unknown>} patch
673
+ * @returns {RunnableEffect<void, SmithersError>}
674
+ */
675
+ updateRunEffect(runId, patch) {
676
+ return this.updateRun(runId, patch);
677
+ }
678
+ /**
679
+ * @param {string} runId
680
+ * @param {string} runtimeOwnerId
681
+ * @param {number} heartbeatAtMs
682
+ * @returns {RunnableEffect<void, SmithersError>}
683
+ */
684
+ heartbeatRun(runId, runtimeOwnerId, heartbeatAtMs) {
685
+ return this.write(`heartbeat run ${runId}`, () => this.internalStorage.updateWhere("_smithers_runs", { heartbeatAtMs }, "run_id = ? AND runtime_owner_id = ?", [runId, runtimeOwnerId]));
686
+ }
687
+ /**
688
+ * @param {string} runId
689
+ * @param {number} cancelRequestedAtMs
690
+ * @returns {RunnableEffect<void, SmithersError>}
691
+ */
692
+ requestRunCancel(runId, cancelRequestedAtMs) {
693
+ return this.write(`cancel run ${runId}`, () => this.internalStorage.updateWhere("_smithers_runs", { cancelRequestedAtMs }, "run_id = ?", [runId]));
694
+ }
695
+ /**
696
+ * @param {string} runId
697
+ * @param {number} hijackRequestedAtMs
698
+ * @param {string | null} [hijackTarget]
699
+ * @returns {RunnableEffect<void, SmithersError>}
700
+ */
701
+ requestRunHijack(runId, hijackRequestedAtMs, hijackTarget) {
702
+ return this.write(`hijack run ${runId}`, () => this.internalStorage.updateWhere("_smithers_runs", {
703
+ hijackRequestedAtMs,
704
+ hijackTarget: hijackTarget ?? null,
705
+ }, "run_id = ?", [runId]));
706
+ }
707
+ /**
708
+ * @param {string} runId
709
+ * @returns {RunnableEffect<void, SmithersError>}
710
+ */
711
+ clearRunHijack(runId) {
712
+ return this.write(`clear hijack run ${runId}`, () => this.internalStorage.updateWhere("_smithers_runs", {
713
+ hijackRequestedAtMs: null,
714
+ hijackTarget: null,
715
+ }, "run_id = ?", [runId]));
716
+ }
717
+ /**
718
+ * @param {string} runId
719
+ * @returns {RunnableEffect<RunRow | undefined, SmithersError>}
720
+ */
721
+ getRun(runId) {
722
+ return this.read(`get run ${runId}`, async () => {
723
+ const row = await this.internalStorage.queryOne(`SELECT *
724
+ FROM _smithers_runs
725
+ WHERE run_id = ?
726
+ LIMIT 1`, [runId]);
727
+ return row ? classifyRunRowStatus(row) : undefined;
728
+ });
729
+ }
730
+ /**
731
+ * @param {string} runId
732
+ * @returns {RunnableEffect<RunAncestryRow[], SmithersError>}
733
+ */
734
+ listRunAncestry(runId, limit = 1000) {
735
+ return this.read(`list run ancestry ${runId}`, () => this.internalStorage.queryAll(`WITH RECURSIVE ancestry(run_id, parent_run_id, depth) AS (
736
+ SELECT run_id, parent_run_id, 0
737
+ FROM _smithers_runs
738
+ WHERE run_id = ?
739
+ UNION ALL
740
+ SELECT child.run_id, child.parent_run_id, ancestry.depth + 1
741
+ FROM _smithers_runs child
742
+ JOIN ancestry ON child.run_id = ancestry.parent_run_id
743
+ WHERE ancestry.parent_run_id IS NOT NULL
744
+ )
745
+ SELECT
746
+ run_id,
747
+ parent_run_id,
748
+ depth
749
+ FROM ancestry
750
+ ORDER BY depth ASC
751
+ LIMIT ?`, [runId, limit]));
752
+ }
753
+ /**
754
+ * @param {string} parentRunId
755
+ * @returns {RunnableEffect<RunRow | undefined, SmithersError>}
756
+ */
757
+ getLatestChildRun(parentRunId) {
758
+ return this.read(`get latest child run ${parentRunId}`, () => this.internalStorage.queryOne(`SELECT *
759
+ FROM _smithers_runs
760
+ WHERE parent_run_id = ?
761
+ ORDER BY created_at_ms DESC
762
+ LIMIT 1`, [parentRunId]));
763
+ }
764
+ /**
765
+ * @param {string} [status]
766
+ * @returns {RunnableEffect<RunRow[], SmithersError>}
767
+ */
768
+ listRuns(limit = 50, status) {
769
+ return this.read(`list runs ${status ?? "all"}`, async () => {
770
+ const clauses = [];
771
+ const params = [];
772
+ if (status === "running") {
773
+ clauses.push("(status = ? OR status = ?)");
774
+ params.push("running", "continued");
775
+ }
776
+ else if (status) {
777
+ clauses.push("status = ?");
778
+ params.push(status);
779
+ }
780
+ const whereSql = clauses.length > 0 ? `WHERE ${clauses.join(" AND ")}` : "";
781
+ const rows = await this.internalStorage.queryAll(`SELECT *
782
+ FROM _smithers_runs
783
+ ${whereSql}
784
+ ORDER BY created_at_ms DESC
785
+ LIMIT ?`, [...params, limit]);
786
+ return rows.map((row) => classifyRunRowStatus(row));
787
+ });
788
+ }
789
+ /**
790
+ * @param {number} staleBeforeMs
791
+ * @returns {RunnableEffect<StaleRunRecord[], SmithersError>}
792
+ */
793
+ listStaleRunningRuns(staleBeforeMs, limit = 1000) {
794
+ return this.read(`list stale running runs before ${staleBeforeMs}`, () => this.internalStorage.queryAll(`SELECT
795
+ run_id,
796
+ workflow_path,
797
+ heartbeat_at_ms,
798
+ runtime_owner_id,
799
+ status
800
+ FROM _smithers_runs
801
+ WHERE status = 'running'
802
+ AND (heartbeat_at_ms IS NULL OR heartbeat_at_ms < ?)
803
+ ORDER BY COALESCE(heartbeat_at_ms, 0) ASC
804
+ LIMIT ?`, [staleBeforeMs, limit]));
805
+ }
806
+ /**
807
+ * @param {{ runId: string; expectedStatus?: string; expectedRuntimeOwnerId: string | null; expectedHeartbeatAtMs: number | null; staleBeforeMs: number; claimOwnerId: string; claimHeartbeatAtMs: number; requireStale?: boolean; }} params
808
+ * @returns {RunnableEffect<boolean, SmithersError>}
809
+ */
810
+ claimRunForResume(params) {
811
+ return this.write(`claim stale run ${params.runId}`, () => {
812
+ const client = this.db.session.client;
813
+ const expectedStatus = params.expectedStatus ?? "running";
814
+ const requireStale = params.requireStale ?? expectedStatus === "running";
815
+ client
816
+ .query(`UPDATE _smithers_runs
817
+ SET runtime_owner_id = ?, heartbeat_at_ms = ?
818
+ WHERE run_id = ?
819
+ AND status = ?
820
+ AND COALESCE(runtime_owner_id, '') = COALESCE(?, '')
821
+ AND COALESCE(heartbeat_at_ms, -1) = COALESCE(?, -1)
822
+ AND (? = 0 OR heartbeat_at_ms IS NULL OR heartbeat_at_ms < ?)`)
823
+ .run(params.claimOwnerId, params.claimHeartbeatAtMs, params.runId, expectedStatus, params.expectedRuntimeOwnerId, params.expectedHeartbeatAtMs, requireStale ? 1 : 0, params.staleBeforeMs);
824
+ return this.internalStorage
825
+ .queryOne("SELECT changes() AS count")
826
+ .then((row) => Number(row?.count ?? 0) > 0);
827
+ });
828
+ }
829
+ /**
830
+ * @param {{ runId: string; claimOwnerId: string; restoreRuntimeOwnerId: string | null; restoreHeartbeatAtMs: number | null; }} params
831
+ * @returns {RunnableEffect<void, SmithersError>}
832
+ */
833
+ releaseRunResumeClaim(params) {
834
+ return this.write(`release stale run claim ${params.runId}`, () => {
835
+ return this.internalStorage.execute(`UPDATE _smithers_runs
836
+ SET runtime_owner_id = ?, heartbeat_at_ms = ?
837
+ WHERE run_id = ? AND runtime_owner_id = ?`, [
838
+ params.restoreRuntimeOwnerId,
839
+ params.restoreHeartbeatAtMs,
840
+ params.runId,
841
+ params.claimOwnerId,
842
+ ]);
843
+ });
844
+ }
845
+ /**
846
+ * @param {{ runId: string; expectedRuntimeOwnerId: string; expectedHeartbeatAtMs: number | null; patch: Record<string, unknown>; }} params
847
+ * @returns {RunnableEffect<boolean, SmithersError>}
848
+ */
849
+ updateClaimedRun(params) {
850
+ validateRunPatch(params.patch);
851
+ return this.write(`update claimed run ${params.runId}`, () => {
852
+ const client = this.db.session.client;
853
+ const patchEntries = Object.entries(params.patch);
854
+ if (patchEntries.length === 0) {
855
+ return Promise.resolve(true);
856
+ }
857
+ const assignments = patchEntries.map(([key]) => `${camelToSnake(key)} = ?`);
858
+ client
859
+ .query(`UPDATE _smithers_runs
860
+ SET ${assignments.join(", ")}
861
+ WHERE run_id = ?
862
+ AND runtime_owner_id = ?
863
+ AND COALESCE(heartbeat_at_ms, -1) = COALESCE(?, -1)`)
864
+ .run(...patchEntries.map(([, value]) => value), params.runId, params.expectedRuntimeOwnerId, params.expectedHeartbeatAtMs);
865
+ return this.internalStorage
866
+ .queryOne("SELECT changes() AS count")
867
+ .then((row) => Number(row?.count ?? 0) > 0);
868
+ });
869
+ }
870
+ /**
871
+ * @param {Record<string, unknown>} row
872
+ * @returns {RunnableEffect<void, SmithersError>}
873
+ */
874
+ insertNode(row) {
875
+ return this.insertNodeEffect(row);
876
+ }
877
+ /**
878
+ * @param {Record<string, unknown>} row
879
+ * @returns {RunnableEffect<void, SmithersError>}
880
+ */
881
+ insertNodeEffect(row) {
882
+ return this.write(`insert node ${row.nodeId}`, () => this.internalStorage.upsert("_smithers_nodes", row, ["runId", "nodeId", "iteration"]));
883
+ }
884
+ /**
885
+ * @param {string} runId
886
+ * @param {string} nodeId
887
+ * @param {number} iteration
888
+ * @returns {RunnableEffect<NodeRow | undefined, SmithersError>}
889
+ */
890
+ getNode(runId, nodeId, iteration) {
891
+ return this.read(`get node ${nodeId}`, () => this.internalStorage.queryOne(`SELECT *
892
+ FROM _smithers_nodes
893
+ WHERE run_id = ? AND node_id = ? AND iteration = ?
894
+ LIMIT 1`, [runId, nodeId, iteration]));
895
+ }
896
+ /**
897
+ * @param {string} runId
898
+ * @param {string} nodeId
899
+ * @returns {RunnableEffect<NodeRow[], SmithersError>}
900
+ */
901
+ listNodeIterations(runId, nodeId) {
902
+ return this.read(`list node iterations ${nodeId}`, () => this.internalStorage.queryAll(`SELECT *
903
+ FROM _smithers_nodes
904
+ WHERE run_id = ? AND node_id = ?
905
+ ORDER BY iteration DESC`, [runId, nodeId]));
906
+ }
907
+ /**
908
+ * @param {string} runId
909
+ * @returns {RunnableEffect<NodeRow[], SmithersError>}
910
+ */
911
+ listNodes(runId) {
912
+ return this.read(`list nodes ${runId}`, () => this.internalStorage.queryAll(`SELECT *
913
+ FROM _smithers_nodes
914
+ WHERE run_id = ?`, [runId]));
915
+ }
916
+ /**
917
+ * @param {Table} table
918
+ * @param {OutputKey} key
919
+ * @param {Record<string, unknown>} payload
920
+ * @returns {RunnableEffect<unknown, SmithersError>}
921
+ */
922
+ upsertOutputRow(table, key, payload) {
923
+ const cols = getKeyColumns(table);
924
+ const values = { ...payload };
925
+ values.runId = key.runId;
926
+ values.nodeId = key.nodeId;
927
+ if (cols.iteration) {
928
+ values.iteration = key.iteration ?? 0;
929
+ }
930
+ const target = cols.iteration
931
+ ? [cols.runId, cols.nodeId, cols.iteration]
932
+ : [cols.runId, cols.nodeId];
933
+ const tableName = table?.["_"]?.name ?? "output";
934
+ return this.write(`upsert output ${tableName}`, () => this.db
935
+ .insert(table)
936
+ .values(values)
937
+ .onConflictDoUpdate({
938
+ target: target,
939
+ set: values,
940
+ }));
941
+ }
942
+ /**
943
+ * @param {Table} table
944
+ * @param {OutputKey} key
945
+ * @param {Record<string, unknown>} payload
946
+ * @returns {RunnableEffect<unknown, SmithersError>}
947
+ */
948
+ upsertOutputRowEffect(table, key, payload) {
949
+ return this.upsertOutputRow(table, key, payload);
950
+ }
951
+ /**
952
+ * @param {string} tableName
953
+ * @param {OutputKey} key
954
+ * @returns {RunnableEffect<void, SmithersError>}
955
+ */
956
+ deleteOutputRow(tableName, key) {
957
+ return this.write(`delete output ${tableName}`, () => {
958
+ const client = this.db.session.client;
959
+ let resolvedTableName = tableName;
960
+ let escapedTableName = resolvedTableName.replaceAll(`"`, `""`);
961
+ let tableInfo = client
962
+ .query(`PRAGMA table_info("${escapedTableName}")`)
963
+ .all();
964
+ if (tableInfo.length === 0) {
965
+ const schemaCandidates = [
966
+ this.db?._?.fullSchema,
967
+ this.db?._?.schema,
968
+ this.db?.schema,
969
+ ];
970
+ for (const candidate of schemaCandidates) {
971
+ if (!candidate || typeof candidate !== "object")
972
+ continue;
973
+ const table = candidate[tableName];
974
+ if (!table)
975
+ continue;
976
+ try {
977
+ resolvedTableName = getTableName(table);
978
+ escapedTableName = resolvedTableName.replaceAll(`"`, `""`);
979
+ tableInfo = client
980
+ .query(`PRAGMA table_info("${escapedTableName}")`)
981
+ .all();
982
+ if (tableInfo.length > 0) {
983
+ break;
984
+ }
985
+ }
986
+ catch { }
987
+ }
988
+ }
989
+ const columnNames = new Set(tableInfo
990
+ .map((column) => column.name)
991
+ .filter((name) => typeof name === "string"));
992
+ const runIdColumn = columnNames.has("run_id")
993
+ ? "run_id"
994
+ : columnNames.has("runId")
995
+ ? "runId"
996
+ : null;
997
+ const nodeIdColumn = columnNames.has("node_id")
998
+ ? "node_id"
999
+ : columnNames.has("nodeId")
1000
+ ? "nodeId"
1001
+ : null;
1002
+ const iterationColumn = columnNames.has("iteration")
1003
+ ? "iteration"
1004
+ : null;
1005
+ if (!runIdColumn || !nodeIdColumn) {
1006
+ throw new Error(`Output table ${tableName} is missing runId/nodeId columns`);
1007
+ }
1008
+ if (iterationColumn) {
1009
+ client
1010
+ .query(`DELETE FROM "${escapedTableName}"
1011
+ WHERE "${runIdColumn}" = ? AND "${nodeIdColumn}" = ? AND "${iterationColumn}" = ?`)
1012
+ .run(key.runId, key.nodeId, key.iteration ?? 0);
1013
+ }
1014
+ else {
1015
+ client
1016
+ .query(`DELETE FROM "${escapedTableName}"
1017
+ WHERE "${runIdColumn}" = ? AND "${nodeIdColumn}" = ?`)
1018
+ .run(key.runId, key.nodeId);
1019
+ }
1020
+ return Promise.resolve(undefined);
1021
+ });
1022
+ }
1023
+ /**
1024
+ * @param {string} tableName
1025
+ * @param {OutputKey} key
1026
+ * @returns {RunnableEffect<void, SmithersError>}
1027
+ */
1028
+ deleteOutputRowEffect(tableName, key) {
1029
+ return this.deleteOutputRow(tableName, key);
1030
+ }
1031
+ /**
1032
+ * @param {string} tableName
1033
+ * @param {string} runId
1034
+ * @param {string} nodeId
1035
+ * @returns {RunnableEffect<Record<string, unknown> | null, SmithersError>}
1036
+ */
1037
+ getRawNodeOutput(tableName, runId, nodeId) {
1038
+ return runnableEffect(this.read(`get raw node output ${tableName}`, () => {
1039
+ const query = sql.raw(`SELECT * FROM "${tableName}" WHERE run_id = '${runId}' AND node_id = '${nodeId}' ORDER BY iteration DESC LIMIT 1`);
1040
+ const res = this.db.get(query);
1041
+ return Promise.resolve(res ?? null);
1042
+ }).pipe(Effect.catchAll(() => Effect.succeed(null))));
1043
+ }
1044
+ /**
1045
+ * @param {string} tableName
1046
+ * @param {string} runId
1047
+ * @param {string} nodeId
1048
+ * @param {number} iteration
1049
+ * @returns {RunnableEffect<Record<string, unknown> | null, SmithersError>}
1050
+ */
1051
+ getRawNodeOutputForIteration(tableName, runId, nodeId, iteration) {
1052
+ return runnableEffect(this.read(`get raw node output ${tableName} iteration ${iteration}`, () => {
1053
+ const escaped = tableName.replaceAll(`"`, `""`);
1054
+ const client = this.db.session.client;
1055
+ const stmt = client.query(`SELECT * FROM "${escaped}" WHERE run_id = ? AND node_id = ? AND iteration = ? LIMIT 1`);
1056
+ const row = stmt.get(runId, nodeId, iteration);
1057
+ return Promise.resolve(row ?? null);
1058
+ }).pipe(Effect.catchAll(() => Effect.succeed(null))));
1059
+ }
1060
+ /**
1061
+ * @param {Record<string, unknown>} row
1062
+ * @returns {RunnableEffect<void, SmithersError>}
1063
+ */
1064
+ insertAttempt(row) {
1065
+ return this.write(`insert attempt ${row.nodeId}#${row.attempt}`, () => this.internalStorage.upsert("_smithers_attempts", row, ["runId", "nodeId", "iteration", "attempt"]));
1066
+ }
1067
+ /**
1068
+ * @param {Record<string, unknown>} row
1069
+ * @returns {RunnableEffect<void, SmithersError>}
1070
+ */
1071
+ insertAttemptEffect(row) {
1072
+ return this.insertAttempt(row);
1073
+ }
1074
+ /**
1075
+ * @param {string} runId
1076
+ * @param {string} nodeId
1077
+ * @param {number} iteration
1078
+ * @param {number} attempt
1079
+ * @param {Record<string, unknown>} patch
1080
+ * @returns {RunnableEffect<void, SmithersError>}
1081
+ */
1082
+ updateAttempt(runId, nodeId, iteration, attempt, patch) {
1083
+ return this.write(`update attempt ${nodeId}#${attempt}`, () => this.internalStorage.updateWhere("_smithers_attempts", patch, "run_id = ? AND node_id = ? AND iteration = ? AND attempt = ?", [runId, nodeId, iteration, attempt]));
1084
+ }
1085
+ /**
1086
+ * @param {string} runId
1087
+ * @param {string} nodeId
1088
+ * @param {number} iteration
1089
+ * @param {number} attempt
1090
+ * @param {Record<string, unknown>} patch
1091
+ * @returns {RunnableEffect<void, SmithersError>}
1092
+ */
1093
+ updateAttemptEffect(runId, nodeId, iteration, attempt, patch) {
1094
+ return this.updateAttempt(runId, nodeId, iteration, attempt, patch);
1095
+ }
1096
+ /**
1097
+ * @param {string} runId
1098
+ * @param {string} nodeId
1099
+ * @param {number} iteration
1100
+ * @param {number} attempt
1101
+ * @param {number} heartbeatAtMs
1102
+ * @param {string | null} heartbeatDataJson
1103
+ * @returns {RunnableEffect<void, SmithersError>}
1104
+ */
1105
+ heartbeatAttempt(runId, nodeId, iteration, attempt, heartbeatAtMs, heartbeatDataJson) {
1106
+ return this.write(`heartbeat attempt ${nodeId}#${attempt}`, () => this.internalStorage.updateWhere("_smithers_attempts", {
1107
+ heartbeatAtMs,
1108
+ heartbeatDataJson,
1109
+ }, "run_id = ? AND node_id = ? AND iteration = ? AND attempt = ? AND state = ?", [runId, nodeId, iteration, attempt, "in-progress"]));
1110
+ }
1111
+ /**
1112
+ * @param {string} runId
1113
+ * @param {string} nodeId
1114
+ * @param {number} iteration
1115
+ * @returns {RunnableEffect<AttemptRow[], SmithersError>}
1116
+ */
1117
+ listAttempts(runId, nodeId, iteration) {
1118
+ return this.read(`list attempts ${nodeId}`, () => this.internalStorage.queryAll(`SELECT *
1119
+ FROM _smithers_attempts
1120
+ WHERE run_id = ? AND node_id = ? AND iteration = ?
1121
+ ORDER BY attempt DESC`, [runId, nodeId, iteration], { booleanColumns: ["cached"] }));
1122
+ }
1123
+ /**
1124
+ * @param {string} runId
1125
+ * @returns {RunnableEffect<AttemptRow[], SmithersError>}
1126
+ */
1127
+ listAttemptsForRun(runId) {
1128
+ return this.read(`list attempts for run ${runId}`, () => this.internalStorage.queryAll(`SELECT *
1129
+ FROM _smithers_attempts
1130
+ WHERE run_id = ?
1131
+ ORDER BY started_at_ms ASC, node_id ASC, iteration ASC, attempt ASC`, [runId], { booleanColumns: ["cached"] }));
1132
+ }
1133
+ /**
1134
+ * @param {string} runId
1135
+ * @param {string} nodeId
1136
+ * @param {number} iteration
1137
+ * @param {number} attempt
1138
+ * @returns {RunnableEffect<AttemptRow | undefined, SmithersError>}
1139
+ */
1140
+ getAttempt(runId, nodeId, iteration, attempt) {
1141
+ return this.read(`get attempt ${nodeId}#${attempt}`, () => this.internalStorage.queryOne(`SELECT *
1142
+ FROM _smithers_attempts
1143
+ WHERE run_id = ? AND node_id = ? AND iteration = ? AND attempt = ?
1144
+ LIMIT 1`, [runId, nodeId, iteration, attempt], { booleanColumns: ["cached"] }));
1145
+ }
1146
+ /**
1147
+ * @param {string} runId
1148
+ * @returns {RunnableEffect<AttemptRow[], SmithersError>}
1149
+ */
1150
+ listInProgressAttempts(runId) {
1151
+ return this.read(`list in-progress attempts ${runId}`, () => this.internalStorage.queryAll(`SELECT *
1152
+ FROM _smithers_attempts
1153
+ WHERE run_id = ? AND state = ?`, [runId, "in-progress"], { booleanColumns: ["cached"] }));
1154
+ }
1155
+ /**
1156
+ * @returns {RunnableEffect<AttemptRow[], SmithersError>}
1157
+ */
1158
+ listAllInProgressAttempts() {
1159
+ return this.read("list all in-progress attempts", () => this.internalStorage.queryAll(`SELECT *
1160
+ FROM _smithers_attempts
1161
+ WHERE state = ?`, ["in-progress"], { booleanColumns: ["cached"] }));
1162
+ }
1163
+ /**
1164
+ * @param {string} runId
1165
+ * @param {number} frameNo
1166
+ * @param {number} [limit]
1167
+ * @returns {RunnableEffect<FrameRow[], SmithersError>}
1168
+ */
1169
+ listFrameChainDesc(runId, frameNo, limit) {
1170
+ return this.read(`list frame chain ${runId}:${frameNo}`, () => this.internalStorage.queryAll(`SELECT *
1171
+ FROM _smithers_frames
1172
+ WHERE run_id = ? AND frame_no <= ?
1173
+ ORDER BY frame_no DESC${typeof limit === "number" ? " LIMIT ?" : ""}`, typeof limit === "number" ? [runId, frameNo, limit] : [runId, frameNo]));
1174
+ }
1175
+ /**
1176
+ * @param {string} runId
1177
+ * @param {number} frameNo
1178
+ * @param {Map<number, string>} [localCache]
1179
+ * @returns {Effect.Effect<string | undefined, SmithersError>}
1180
+ */
1181
+ reconstructFrameXml(runId, frameNo, localCache = new Map()) {
1182
+ const self = this;
1183
+ return Effect.gen(function* () {
1184
+ const localHit = localCache.get(frameNo);
1185
+ if (localHit !== undefined)
1186
+ return localHit;
1187
+ const cacheHit = self.getCachedFrameXml(runId, frameNo);
1188
+ if (cacheHit !== undefined) {
1189
+ localCache.set(frameNo, cacheHit);
1190
+ return cacheHit;
1191
+ }
1192
+ let rows = (yield* self.listFrameChainDesc(runId, frameNo, FRAME_KEYFRAME_INTERVAL + 2));
1193
+ if (rows.length === 0)
1194
+ return undefined;
1195
+ let anchorIndex = rows.findIndex((row) => normalizeFrameEncoding(row.encoding) !== "delta");
1196
+ if (anchorIndex < 0) {
1197
+ rows = (yield* self.listFrameChainDesc(runId, frameNo));
1198
+ anchorIndex = rows.findIndex((row) => normalizeFrameEncoding(row.encoding) !== "delta");
1199
+ }
1200
+ if (anchorIndex < 0) {
1201
+ return rows.find((row) => row.frameNo === frameNo)?.xmlJson;
1202
+ }
1203
+ const chain = rows.slice(0, anchorIndex + 1).reverse();
1204
+ let currentXml = "";
1205
+ for (const frameRow of chain) {
1206
+ const rowEncoding = normalizeFrameEncoding(frameRow.encoding);
1207
+ if (rowEncoding === "delta") {
1208
+ if (!currentXml) {
1209
+ currentXml = String(frameRow.xmlJson ?? "null");
1210
+ }
1211
+ else {
1212
+ currentXml = yield* Effect.try({
1213
+ try: () => applyFrameDeltaJson(currentXml, String(frameRow.xmlJson ?? "")),
1214
+ catch: (cause) => toSmithersError(cause, `apply frame delta ${runId}:${frameRow.frameNo}`, {
1215
+ code: "DB_QUERY_FAILED",
1216
+ details: { runId, frameNo: frameRow.frameNo },
1217
+ }),
1218
+ });
1219
+ }
1220
+ }
1221
+ else {
1222
+ currentXml = String(frameRow.xmlJson ?? "null");
1223
+ }
1224
+ localCache.set(frameRow.frameNo, currentXml);
1225
+ self.rememberFrameXml(runId, frameRow.frameNo, currentXml);
1226
+ }
1227
+ return localCache.get(frameNo);
1228
+ });
1229
+ }
1230
+ /**
1231
+ * @param {FrameRow} row
1232
+ * @param {Map<number, string>} [localCache]
1233
+ * @returns {Effect.Effect<FrameRow, SmithersError>}
1234
+ */
1235
+ inflateFrameRow(row, localCache = new Map()) {
1236
+ const self = this;
1237
+ return Effect.gen(function* () {
1238
+ const encoding = normalizeFrameEncoding(row?.encoding);
1239
+ if (encoding !== "delta") {
1240
+ const xmlJson = String(row?.xmlJson ?? "null");
1241
+ localCache.set(row.frameNo, xmlJson);
1242
+ self.rememberFrameXml(row.runId, row.frameNo, xmlJson);
1243
+ return { ...row, encoding, xmlJson };
1244
+ }
1245
+ const xmlJson = yield* self.reconstructFrameXml(row.runId, row.frameNo, localCache);
1246
+ return {
1247
+ ...row,
1248
+ encoding,
1249
+ xmlJson: xmlJson ?? String(row?.xmlJson ?? "null"),
1250
+ };
1251
+ });
1252
+ }
1253
+ /**
1254
+ * @param {Record<string, unknown>} row
1255
+ * @returns {RunnableEffect<void, SmithersError>}
1256
+ */
1257
+ insertFrame(row) {
1258
+ const self = this;
1259
+ return runnableEffect(Effect.gen(function* () {
1260
+ const runId = String(row.runId);
1261
+ const frameNo = Number(row.frameNo);
1262
+ const fullXmlJson = String(row.xmlJson ?? "null");
1263
+ let encoding = "keyframe";
1264
+ let persistedXmlJson = fullXmlJson;
1265
+ if (frameNo > 0 && frameNo % FRAME_KEYFRAME_INTERVAL !== 0) {
1266
+ const previousXmlJson = yield* self.reconstructFrameXml(runId, frameNo - 1);
1267
+ if (typeof previousXmlJson === "string") {
1268
+ const delta = yield* Effect.try({
1269
+ try: () => encodeFrameDelta(previousXmlJson, fullXmlJson),
1270
+ catch: (cause) => toSmithersError(cause, `encode frame delta ${runId}:${frameNo}`, {
1271
+ code: "DB_WRITE_FAILED",
1272
+ details: { runId, frameNo },
1273
+ }),
1274
+ });
1275
+ const deltaJson = serializeFrameDelta(delta);
1276
+ if (deltaJson.length < fullXmlJson.length) {
1277
+ encoding = "delta";
1278
+ persistedXmlJson = deltaJson;
1279
+ }
1280
+ }
1281
+ }
1282
+ const persistedRow = {
1283
+ ...row,
1284
+ xmlJson: persistedXmlJson,
1285
+ encoding,
1286
+ };
1287
+ yield* self.write(`insert frame ${frameNo}`, () => self.internalStorage.upsert("_smithers_frames", persistedRow, ["runId", "frameNo"]));
1288
+ self.clearFrameCacheForRun(runId);
1289
+ self.rememberFrameXml(runId, frameNo, fullXmlJson);
1290
+ }));
1291
+ }
1292
+ /**
1293
+ * @param {Record<string, unknown>} row
1294
+ * @returns {RunnableEffect<void, SmithersError>}
1295
+ */
1296
+ insertFrameEffect(row) {
1297
+ return this.insertFrame(row);
1298
+ }
1299
+ /**
1300
+ * @param {string} runId
1301
+ * @returns {RunnableEffect<FrameRow | undefined, SmithersError>}
1302
+ */
1303
+ getLastFrame(runId) {
1304
+ const self = this;
1305
+ return runnableEffect(Effect.gen(function* () {
1306
+ const row = yield* self.read(`get last frame ${runId}`, () => self.internalStorage.queryOne(`SELECT *
1307
+ FROM _smithers_frames
1308
+ WHERE run_id = ?
1309
+ ORDER BY frame_no DESC
1310
+ LIMIT 1`, [runId]));
1311
+ if (!row)
1312
+ return undefined;
1313
+ return yield* self.inflateFrameRow(row);
1314
+ }));
1315
+ }
1316
+ /**
1317
+ * @param {Record<string, unknown>} row
1318
+ * @returns {RunnableEffect<void, SmithersError>}
1319
+ */
1320
+ insertOrUpdateApproval(row) {
1321
+ return this.write(`upsert approval ${row.nodeId}`, () => this.internalStorage.upsert("_smithers_approvals", row, ["runId", "nodeId", "iteration"]));
1322
+ }
1323
+ /**
1324
+ * @param {string} runId
1325
+ * @param {string} nodeId
1326
+ * @param {number} iteration
1327
+ * @returns {RunnableEffect<ApprovalRow | undefined, SmithersError>}
1328
+ */
1329
+ getApproval(runId, nodeId, iteration) {
1330
+ return this.read(`get approval ${nodeId}`, () => this.internalStorage.queryOne(`SELECT *
1331
+ FROM _smithers_approvals
1332
+ WHERE run_id = ? AND node_id = ? AND iteration = ?
1333
+ LIMIT 1`, [runId, nodeId, iteration], { booleanColumns: ["autoApproved"] }));
1334
+ }
1335
+ /**
1336
+ * @param {HumanRequestRow} row
1337
+ * @returns {RunnableEffect<void, SmithersError>}
1338
+ */
1339
+ insertHumanRequest(row) {
1340
+ return this.write(`insert human request ${row.requestId}`, () => this.internalStorage.insertIgnore("_smithers_human_requests", row));
1341
+ }
1342
+ /**
1343
+ * @param {string} requestId
1344
+ * @returns {RunnableEffect<HumanRequestRow | undefined, SmithersError>}
1345
+ */
1346
+ getHumanRequest(requestId) {
1347
+ return this.read(`get human request ${requestId}`, () => this.internalStorage.queryOne(`SELECT *
1348
+ FROM _smithers_human_requests
1349
+ WHERE request_id = ?
1350
+ LIMIT 1`, [requestId]));
1351
+ }
1352
+ /**
1353
+ * @param {string} requestId
1354
+ * @returns {RunnableEffect<void, SmithersError>}
1355
+ */
1356
+ reopenHumanRequest(requestId) {
1357
+ return this.write(`reopen human request ${requestId}`, () => this.internalStorage.updateWhere("_smithers_human_requests", {
1358
+ status: "pending",
1359
+ responseJson: null,
1360
+ answeredAtMs: null,
1361
+ answeredBy: null,
1362
+ }, "request_id = ? AND status = ?", [requestId, "answered"]));
1363
+ }
1364
+ /**
1365
+ * @param {number} [nowMs]
1366
+ * @returns {RunnableEffect<void, SmithersError>}
1367
+ */
1368
+ expireStaleHumanRequests(nowMs = Date.now()) {
1369
+ return this.write(`expire stale human requests before ${nowMs}`, () => this.internalStorage.updateWhere("_smithers_human_requests", {
1370
+ status: "expired",
1371
+ responseJson: null,
1372
+ answeredAtMs: null,
1373
+ answeredBy: null,
1374
+ }, "status = ? AND timeout_at_ms IS NOT NULL AND timeout_at_ms <= ?", ["pending", nowMs]));
1375
+ }
1376
+ /**
1377
+ * @param {number} [nowMs]
1378
+ * @returns {RunnableEffect<PendingHumanRequestRow[], SmithersError>}
1379
+ */
1380
+ listPendingHumanRequests(nowMs = Date.now()) {
1381
+ const self = this;
1382
+ return runnableEffect(Effect.gen(function* () {
1383
+ yield* self.expireStaleHumanRequests(nowMs);
1384
+ return yield* self.read("list pending human requests", () => self.internalStorage.queryAll(`SELECT
1385
+ h.request_id,
1386
+ h.run_id,
1387
+ h.node_id,
1388
+ h.iteration,
1389
+ h.kind,
1390
+ h.status,
1391
+ h.prompt,
1392
+ h.schema_json,
1393
+ h.options_json,
1394
+ h.response_json,
1395
+ h.requested_at_ms,
1396
+ h.answered_at_ms,
1397
+ h.answered_by,
1398
+ h.timeout_at_ms,
1399
+ r.workflow_name,
1400
+ r.status AS run_status,
1401
+ n.label AS node_label
1402
+ FROM _smithers_human_requests h
1403
+ LEFT JOIN _smithers_runs r ON h.run_id = r.run_id
1404
+ LEFT JOIN _smithers_nodes n
1405
+ ON h.run_id = n.run_id
1406
+ AND h.node_id = n.node_id
1407
+ AND h.iteration = n.iteration
1408
+ WHERE h.status = ?
1409
+ ORDER BY h.requested_at_ms ASC, h.run_id, h.node_id, h.iteration`, ["pending"]));
1410
+ }));
1411
+ }
1412
+ /**
1413
+ * @param {string} requestId
1414
+ * @param {string} responseJson
1415
+ * @param {number} answeredAtMs
1416
+ * @param {string | null} [answeredBy]
1417
+ * @returns {RunnableEffect<void, SmithersError>}
1418
+ */
1419
+ answerHumanRequest(requestId, responseJson, answeredAtMs, answeredBy) {
1420
+ return this.write(`answer human request ${requestId}`, () => this.internalStorage.updateWhere("_smithers_human_requests", {
1421
+ status: "answered",
1422
+ responseJson,
1423
+ answeredAtMs,
1424
+ answeredBy: answeredBy ?? null,
1425
+ }, "request_id = ? AND status = ?", [requestId, "pending"]));
1426
+ }
1427
+ /**
1428
+ * @param {string} requestId
1429
+ * @returns {RunnableEffect<void, SmithersError>}
1430
+ */
1431
+ cancelHumanRequest(requestId) {
1432
+ return this.write(`cancel human request ${requestId}`, () => this.internalStorage.updateWhere("_smithers_human_requests", {
1433
+ status: "cancelled",
1434
+ }, "request_id = ? AND status = ?", [requestId, "pending"]));
1435
+ }
1436
+ /**
1437
+ * @param {AlertRow} row
1438
+ * @returns {Promise<AlertRow | undefined>}
1439
+ */
1440
+ insertAlert(row) {
1441
+ validateAlertRow(row);
1442
+ const self = this;
1443
+ return this.withTransaction(`insert alert ${row.alertId}`, Effect.gen(function* () {
1444
+ const existing = yield* self.getAlert(row.alertId);
1445
+ if (existing) {
1446
+ return existing;
1447
+ }
1448
+ yield* self.write(`insert alert ${row.alertId}`, () => self.internalStorage.insertIgnore("_smithers_alerts", row));
1449
+ yield* Metric.increment(Metric.tagged(Metric.tagged(alertsFiredTotal, "policy", row.policyName), "severity", row.severity));
1450
+ if (isAlertActiveStatus(row.status)) {
1451
+ yield* Metric.update(alertsActive, 1);
1452
+ }
1453
+ return yield* self.getAlert(row.alertId);
1454
+ }));
1455
+ }
1456
+ /**
1457
+ * @param {string} alertId
1458
+ * @returns {RunnableEffect<AlertRow | undefined, SmithersError>}
1459
+ */
1460
+ getAlert(alertId) {
1461
+ return this.read(`get alert ${alertId}`, () => this.internalStorage.queryOne(`SELECT *
1462
+ FROM _smithers_alerts
1463
+ WHERE alert_id = ?
1464
+ LIMIT 1`, [alertId]));
1465
+ }
1466
+ /**
1467
+ * @param {readonly AlertStatus[]} [statuses]
1468
+ * @returns {RunnableEffect<AlertRow[], SmithersError>}
1469
+ */
1470
+ listAlerts(limit = 100, statuses) {
1471
+ if (statuses) {
1472
+ for (const status of statuses) {
1473
+ validateAlertStatus(status);
1474
+ }
1475
+ }
1476
+ const normalizedLimit = Math.max(1, Math.floor(limit));
1477
+ return this.read("list alerts", () => {
1478
+ const clauses = [];
1479
+ const params = [];
1480
+ if (statuses && statuses.length > 0) {
1481
+ clauses.push(`status IN (${statuses.map(() => "?").join(", ")})`);
1482
+ params.push(...statuses);
1483
+ }
1484
+ const whereSql = clauses.length > 0 ? `WHERE ${clauses.join(" AND ")}` : "";
1485
+ return this.internalStorage.queryAll(`SELECT *
1486
+ FROM _smithers_alerts
1487
+ ${whereSql}
1488
+ ORDER BY
1489
+ CASE status
1490
+ WHEN 'firing' THEN 0
1491
+ WHEN 'acknowledged' THEN 1
1492
+ WHEN 'silenced' THEN 2
1493
+ WHEN 'resolved' THEN 3
1494
+ ELSE 4
1495
+ END,
1496
+ fired_at_ms DESC,
1497
+ alert_id ASC
1498
+ LIMIT ?`, [...params, normalizedLimit]);
1499
+ });
1500
+ }
1501
+ /**
1502
+ * @param {string} alertId
1503
+ * @returns {Promise<AlertRow | undefined>}
1504
+ */
1505
+ acknowledgeAlert(alertId, acknowledgedAtMs = Date.now()) {
1506
+ validateOptionalPositiveTimestamp({ acknowledgedAtMs }, "acknowledgedAtMs");
1507
+ const self = this;
1508
+ return this.withTransaction(`acknowledge alert ${alertId}`, Effect.gen(function* () {
1509
+ const alert = yield* self.getAlert(alertId);
1510
+ if (!alert) {
1511
+ return undefined;
1512
+ }
1513
+ if (alert.status !== "firing") {
1514
+ return alert;
1515
+ }
1516
+ yield* self.write(`acknowledge alert ${alertId}`, () => self.internalStorage.updateWhere("_smithers_alerts", {
1517
+ status: "acknowledged",
1518
+ acknowledgedAtMs,
1519
+ }, "alert_id = ? AND status = ?", [alertId, "firing"]));
1520
+ yield* Metric.increment(Metric.tagged(alertsAcknowledgedTotal, "policy", alert.policyName));
1521
+ return yield* self.getAlert(alertId);
1522
+ }));
1523
+ }
1524
+ /**
1525
+ * @param {string} alertId
1526
+ * @returns {Promise<AlertRow | undefined>}
1527
+ */
1528
+ resolveAlert(alertId, resolvedAtMs = Date.now()) {
1529
+ validateOptionalPositiveTimestamp({ resolvedAtMs }, "resolvedAtMs");
1530
+ const self = this;
1531
+ return this.withTransaction(`resolve alert ${alertId}`, Effect.gen(function* () {
1532
+ const alert = yield* self.getAlert(alertId);
1533
+ if (!alert) {
1534
+ return undefined;
1535
+ }
1536
+ if (alert.status === "resolved") {
1537
+ return alert;
1538
+ }
1539
+ yield* self.write(`resolve alert ${alertId}`, () => self.internalStorage.updateWhere("_smithers_alerts", {
1540
+ status: "resolved",
1541
+ resolvedAtMs,
1542
+ }, "alert_id = ? AND status != ?", [alertId, "resolved"]));
1543
+ if (isAlertActiveStatus(alert.status)) {
1544
+ yield* Metric.update(alertsActive, -1);
1545
+ }
1546
+ return yield* self.getAlert(alertId);
1547
+ }));
1548
+ }
1549
+ /**
1550
+ * @param {string} alertId
1551
+ * @returns {Promise<AlertRow | undefined>}
1552
+ */
1553
+ silenceAlert(alertId) {
1554
+ const self = this;
1555
+ return this.withTransaction(`silence alert ${alertId}`, Effect.gen(function* () {
1556
+ const alert = yield* self.getAlert(alertId);
1557
+ if (!alert) {
1558
+ return undefined;
1559
+ }
1560
+ if (alert.status === "resolved" || alert.status === "silenced") {
1561
+ return alert;
1562
+ }
1563
+ yield* self.write(`silence alert ${alertId}`, () => self.internalStorage.updateWhere("_smithers_alerts", {
1564
+ status: "silenced",
1565
+ }, "alert_id = ? AND status != ? AND status != ?", [alertId, "resolved", "silenced"]));
1566
+ return yield* self.getAlert(alertId);
1567
+ }));
1568
+ }
1569
+ /**
1570
+ * @param {{ runId: string; signalName: string; correlationId: string | null; payloadJson: string; receivedAtMs: number; receivedBy?: string | null; }} row
1571
+ * @returns {RunnableEffect<number, SmithersError>}
1572
+ */
1573
+ insertSignalWithNextSeq(row) {
1574
+ const label = `insert signal ${row.signalName}`;
1575
+ const self = this;
1576
+ return runnableEffect(withSqliteWriteRetryEffect(() => Effect.gen(function* () {
1577
+ const existing = yield* self.read(label, () => self.internalStorage.queryOne(`SELECT seq
1578
+ FROM _smithers_signals
1579
+ WHERE run_id = ?
1580
+ AND signal_name = ?
1581
+ AND ${row.correlationId === null ? "correlation_id IS NULL" : "correlation_id = ?"}
1582
+ AND payload_json = ?
1583
+ AND received_at_ms = ?
1584
+ AND ${row.receivedBy == null ? "received_by IS NULL" : "received_by = ?"}
1585
+ ORDER BY seq DESC
1586
+ LIMIT 1`, [
1587
+ row.runId,
1588
+ row.signalName,
1589
+ ...(row.correlationId === null ? [] : [row.correlationId]),
1590
+ row.payloadJson,
1591
+ row.receivedAtMs,
1592
+ ...(row.receivedBy == null ? [] : [row.receivedBy]),
1593
+ ]));
1594
+ if (existing?.seq !== undefined) {
1595
+ return existing.seq;
1596
+ }
1597
+ const client = self.db.$client;
1598
+ if (!client ||
1599
+ typeof client.exec !== "function" ||
1600
+ typeof client.query !== "function") {
1601
+ const lastSeq = (yield* self.getLastSignalSeq(row.runId)) ?? -1;
1602
+ const seq = lastSeq + 1;
1603
+ yield* Effect.tryPromise({
1604
+ try: () => self.internalStorage.insertIgnore("_smithers_signals", {
1605
+ ...row,
1606
+ receivedBy: row.receivedBy ?? null,
1607
+ seq,
1608
+ }),
1609
+ catch: (cause) => toSmithersError(cause, "insert fallback signal row"),
1610
+ });
1611
+ return seq;
1612
+ }
1613
+ return yield* Effect.try({
1614
+ try: () => {
1615
+ client.run("BEGIN IMMEDIATE");
1616
+ try {
1617
+ const res = client
1618
+ .query("SELECT COALESCE(MAX(seq), -1) + 1 AS seq FROM _smithers_signals WHERE run_id = ?")
1619
+ .get(row.runId);
1620
+ const seq = Number(res?.seq ?? 0);
1621
+ client
1622
+ .query("INSERT INTO _smithers_signals (run_id, seq, signal_name, correlation_id, payload_json, received_at_ms, received_by) VALUES (?, ?, ?, ?, ?, ?, ?)")
1623
+ .run(row.runId, seq, row.signalName, row.correlationId, row.payloadJson, row.receivedAtMs, row.receivedBy ?? null);
1624
+ client.run("COMMIT");
1625
+ return seq;
1626
+ }
1627
+ catch (error) {
1628
+ try {
1629
+ client.run("ROLLBACK");
1630
+ }
1631
+ catch {
1632
+ // ignore rollback failures
1633
+ }
1634
+ throw error;
1635
+ }
1636
+ },
1637
+ catch: (cause) => toSmithersError(cause, "insert signal transaction"),
1638
+ });
1639
+ }), { label }).pipe(Effect.annotateLogs({
1640
+ runId: row.runId,
1641
+ signalName: row.signalName,
1642
+ correlationId: row.correlationId ?? null,
1643
+ }), Effect.withLogSpan(`db:${label}`)));
1644
+ }
1645
+ /**
1646
+ * @param {string} runId
1647
+ * @returns {RunnableEffect<number | undefined, SmithersError>}
1648
+ */
1649
+ getLastSignalSeq(runId) {
1650
+ return this.read(`get last signal seq ${runId}`, () => this.internalStorage.getLastSignalSeq(runId));
1651
+ }
1652
+ /**
1653
+ * @param {string} runId
1654
+ * @param {SignalQuery} [query]
1655
+ * @returns {RunnableEffect<SignalRow[], SmithersError>}
1656
+ */
1657
+ listSignals(runId, query = {}) {
1658
+ const limit = Math.max(1, Math.floor(query.limit ?? 200));
1659
+ return this.read(`list signals ${runId}`, () => {
1660
+ const clauses = ["run_id = ?"];
1661
+ const params = [runId];
1662
+ if (query.signalName) {
1663
+ clauses.push("signal_name = ?");
1664
+ params.push(query.signalName);
1665
+ }
1666
+ if (query.correlationId !== undefined) {
1667
+ if (query.correlationId === null) {
1668
+ clauses.push("correlation_id IS NULL");
1669
+ }
1670
+ else {
1671
+ clauses.push("correlation_id = ?");
1672
+ params.push(query.correlationId);
1673
+ }
1674
+ }
1675
+ if (typeof query.receivedAfterMs === "number") {
1676
+ clauses.push("received_at_ms >= ?");
1677
+ params.push(query.receivedAfterMs);
1678
+ }
1679
+ return this.internalStorage.queryAll(`SELECT *
1680
+ FROM _smithers_signals
1681
+ WHERE ${clauses.join(" AND ")}
1682
+ ORDER BY seq ASC
1683
+ LIMIT ?`, [...params, limit]);
1684
+ });
1685
+ }
1686
+ /**
1687
+ * @param {Record<string, unknown>} row
1688
+ * @returns {RunnableEffect<void, SmithersError>}
1689
+ */
1690
+ insertToolCall(row) {
1691
+ return this.write(`insert tool call ${row.toolName}`, () => this.internalStorage.insertIgnore("_smithers_tool_calls", row));
1692
+ }
1693
+ /**
1694
+ * @param {Record<string, unknown>} row
1695
+ * @returns {RunnableEffect<void, SmithersError>}
1696
+ */
1697
+ upsertSandbox(row) {
1698
+ return this.write(`upsert sandbox ${row.sandboxId}`, () => this.internalStorage.upsert("_smithers_sandboxes", row, ["runId", "sandboxId"]));
1699
+ }
1700
+ /**
1701
+ * @param {string} runId
1702
+ * @param {string} sandboxId
1703
+ * @returns {RunnableEffect<Record<string, unknown> | undefined, SmithersError>}
1704
+ */
1705
+ getSandbox(runId, sandboxId) {
1706
+ return this.read(`get sandbox ${sandboxId}`, () => this.internalStorage.queryOne(`SELECT *
1707
+ FROM _smithers_sandboxes
1708
+ WHERE run_id = ? AND sandbox_id = ?
1709
+ LIMIT 1`, [runId, sandboxId]));
1710
+ }
1711
+ /**
1712
+ * @param {string} runId
1713
+ * @returns {RunnableEffect<Array<Record<string, unknown>>, SmithersError>}
1714
+ */
1715
+ listSandboxes(runId) {
1716
+ return this.read(`list sandboxes ${runId}`, () => this.internalStorage.queryAll(`SELECT *
1717
+ FROM _smithers_sandboxes
1718
+ WHERE run_id = ?`, [runId]));
1719
+ }
1720
+ /**
1721
+ * @param {string} runId
1722
+ * @param {string} nodeId
1723
+ * @param {number} iteration
1724
+ * @returns {RunnableEffect<Array<Record<string, unknown>>, SmithersError>}
1725
+ */
1726
+ listToolCalls(runId, nodeId, iteration) {
1727
+ return this.read(`list tool calls ${nodeId}`, () => this.internalStorage.queryAll(`SELECT *
1728
+ FROM _smithers_tool_calls
1729
+ WHERE run_id = ? AND node_id = ? AND iteration = ?
1730
+ ORDER BY attempt ASC, seq ASC`, [runId, nodeId, iteration]));
1731
+ }
1732
+ /**
1733
+ * @param {Record<string, unknown>} row
1734
+ * @returns {RunnableEffect<void, SmithersError>}
1735
+ */
1736
+ insertEvent(row) {
1737
+ return this.write(`insert event ${row.type}`, () => this.internalStorage.insertIgnore("_smithers_events", row));
1738
+ }
1739
+ /**
1740
+ * @param {{ runId: string; timestampMs: number; type: string; payloadJson: string; }} row
1741
+ * @returns {RunnableEffect<number, SmithersError>}
1742
+ */
1743
+ insertEventWithNextSeq(row) {
1744
+ const label = `insert event ${row.type}`;
1745
+ const self = this;
1746
+ return runnableEffect(withSqliteWriteRetryEffect(() => Effect.gen(function* () {
1747
+ const existing = yield* self.read(label, () => self.internalStorage.queryOne(`SELECT seq
1748
+ FROM _smithers_events
1749
+ WHERE run_id = ?
1750
+ AND timestamp_ms = ?
1751
+ AND type = ?
1752
+ AND payload_json = ?
1753
+ ORDER BY seq DESC
1754
+ LIMIT 1`, [row.runId, row.timestampMs, row.type, row.payloadJson]));
1755
+ if (existing?.seq !== undefined) {
1756
+ return existing.seq;
1757
+ }
1758
+ const client = self.db.$client;
1759
+ if (!client ||
1760
+ typeof client.exec !== "function" ||
1761
+ typeof client.query !== "function") {
1762
+ const lastSeq = (yield* self.getLastEventSeq(row.runId)) ?? -1;
1763
+ const seq = lastSeq + 1;
1764
+ yield* Effect.tryPromise({
1765
+ try: () => self.internalStorage.insertIgnore("_smithers_events", { ...row, seq }),
1766
+ catch: (cause) => toSmithersError(cause, "insert fallback event row"),
1767
+ });
1768
+ return seq;
1769
+ }
1770
+ return yield* Effect.try({
1771
+ try: () => {
1772
+ client.run("BEGIN IMMEDIATE");
1773
+ try {
1774
+ const res = client
1775
+ .query("SELECT COALESCE(MAX(seq), -1) + 1 AS seq FROM _smithers_events WHERE run_id = ?")
1776
+ .get(row.runId);
1777
+ const seq = Number(res?.seq ?? 0);
1778
+ client
1779
+ .query("INSERT INTO _smithers_events (run_id, seq, timestamp_ms, type, payload_json) VALUES (?, ?, ?, ?, ?)")
1780
+ .run(row.runId, seq, row.timestampMs, row.type, row.payloadJson);
1781
+ client.run("COMMIT");
1782
+ return seq;
1783
+ }
1784
+ catch (error) {
1785
+ try {
1786
+ client.run("ROLLBACK");
1787
+ }
1788
+ catch {
1789
+ // ignore rollback failures
1790
+ }
1791
+ throw error;
1792
+ }
1793
+ },
1794
+ catch: (cause) => toSmithersError(cause, "insert event transaction"),
1795
+ });
1796
+ }), { label }).pipe(Effect.annotateLogs({ dbOperation: label }), Effect.withLogSpan(`db:${label}`)));
1797
+ }
1798
+ /**
1799
+ * @param {string} runId
1800
+ * @returns {RunnableEffect<number | undefined, SmithersError>}
1801
+ */
1802
+ getLastEventSeq(runId) {
1803
+ return this.read(`get last event seq ${runId}`, () => this.internalStorage.getLastEventSeq(runId));
1804
+ }
1805
+ /**
1806
+ * @param {string} runId
1807
+ * @param {EventHistoryQuery} [query]
1808
+ * @returns {{ whereSql: string; params: Array<string | number> }}
1809
+ */
1810
+ buildEventHistoryWhere(runId, query = {}) {
1811
+ const clauses = ["run_id = ?", "seq > ?"];
1812
+ const params = [runId, query.afterSeq ?? -1];
1813
+ if (typeof query.sinceTimestampMs === "number") {
1814
+ clauses.push("timestamp_ms >= ?");
1815
+ params.push(query.sinceTimestampMs);
1816
+ }
1817
+ if (query.types && query.types.length > 0) {
1818
+ clauses.push(`type IN (${query.types.map(() => "?").join(", ")})`);
1819
+ params.push(...query.types);
1820
+ }
1821
+ if (query.nodeId) {
1822
+ clauses.push("json_extract(payload_json, '$.nodeId') = ?");
1823
+ params.push(query.nodeId);
1824
+ }
1825
+ return {
1826
+ whereSql: clauses.join(" AND "),
1827
+ params,
1828
+ };
1829
+ }
1830
+ /**
1831
+ * @param {string} runId
1832
+ * @param {EventHistoryQuery} [query]
1833
+ * @returns {RunnableEffect<Array<Record<string, unknown>>, SmithersError>}
1834
+ */
1835
+ listEventHistory(runId, query = {}) {
1836
+ return this.read(`list event history ${runId}`, () => this.internalStorage.listEventHistory(runId, query));
1837
+ }
1838
+ /**
1839
+ * @param {string} runId
1840
+ * @param {EventHistoryQuery} [query]
1841
+ * @returns {RunnableEffect<number, SmithersError>}
1842
+ */
1843
+ countEventHistory(runId, query = {}) {
1844
+ return this.read(`count event history ${runId}`, () => this.internalStorage.countEventHistory(runId, query));
1845
+ }
1846
+ /**
1847
+ * @param {string} runId
1848
+ * @param {number} afterSeq
1849
+ * @returns {RunnableEffect<Array<Record<string, unknown>>, SmithersError>}
1850
+ */
1851
+ listEvents(runId, afterSeq, limit = 200) {
1852
+ return this.listEventHistory(runId, { afterSeq, limit });
1853
+ }
1854
+ /**
1855
+ * @param {string} runId
1856
+ * @param {string} type
1857
+ * @returns {RunnableEffect<Array<Record<string, unknown>>, SmithersError>}
1858
+ */
1859
+ listEventsByType(runId, type) {
1860
+ return this.read(`list events by type ${type}`, () => this.internalStorage.listEventsByType(runId, type));
1861
+ }
1862
+ /**
1863
+ * @param {Record<string, unknown>} row
1864
+ * @returns {RunnableEffect<void, SmithersError>}
1865
+ */
1866
+ insertOrUpdateRalph(row) {
1867
+ return this.write(`upsert ralph ${row.ralphId}`, () => this.internalStorage.upsert("_smithers_ralph", row, ["runId", "ralphId"]));
1868
+ }
1869
+ /**
1870
+ * @param {string} runId
1871
+ * @returns {RunnableEffect<Array<Record<string, unknown>>, SmithersError>}
1872
+ */
1873
+ listRalph(runId) {
1874
+ return this.read(`list ralph ${runId}`, () => this.internalStorage.queryAll(`SELECT *
1875
+ FROM _smithers_ralph
1876
+ WHERE run_id = ?`, [runId], { booleanColumns: ["done"] }));
1877
+ }
1878
+ /**
1879
+ * @param {string} runId
1880
+ * @returns {RunnableEffect<ApprovalRow[], SmithersError>}
1881
+ */
1882
+ listPendingApprovals(runId) {
1883
+ return this.read(`list pending approvals ${runId}`, () => this.internalStorage.queryAll(`SELECT *
1884
+ FROM _smithers_approvals
1885
+ WHERE run_id = ? AND status = ?`, [runId, "requested"], { booleanColumns: ["autoApproved"] }));
1886
+ }
1887
+ /**
1888
+ * @returns {RunnableEffect<Array<Record<string, unknown>>, SmithersError>}
1889
+ */
1890
+ listAllPendingApprovals() {
1891
+ return this.read("list all pending approvals", () => this.internalStorage.queryAll(`SELECT
1892
+ a.run_id,
1893
+ a.node_id,
1894
+ a.iteration,
1895
+ a.status,
1896
+ a.requested_at_ms,
1897
+ a.note,
1898
+ a.decided_by,
1899
+ r.workflow_name,
1900
+ r.status AS run_status,
1901
+ n.label AS node_label
1902
+ FROM _smithers_approvals a
1903
+ LEFT JOIN _smithers_runs r ON a.run_id = r.run_id
1904
+ LEFT JOIN _smithers_nodes n
1905
+ ON a.run_id = n.run_id
1906
+ AND a.node_id = n.node_id
1907
+ AND a.iteration = n.iteration
1908
+ WHERE a.status = ?
1909
+ ORDER BY COALESCE(a.requested_at_ms, 0) ASC, a.run_id, a.node_id, a.iteration`, ["requested"]));
1910
+ }
1911
+ /**
1912
+ * @param {string} workflowName
1913
+ * @param {string} nodeId
1914
+ * @returns {RunnableEffect<Array<Record<string, unknown>>, SmithersError>}
1915
+ */
1916
+ listApprovalHistoryForNode(workflowName, nodeId, limit = 50) {
1917
+ return this.read(`list approval history ${workflowName}:${nodeId}`, () => this.internalStorage.queryAll(`SELECT
1918
+ a.run_id,
1919
+ a.node_id,
1920
+ a.iteration,
1921
+ a.status,
1922
+ a.requested_at_ms,
1923
+ a.decided_at_ms,
1924
+ a.note,
1925
+ a.decided_by,
1926
+ a.request_json,
1927
+ a.decision_json,
1928
+ a.auto_approved,
1929
+ r.workflow_name,
1930
+ r.created_at_ms AS run_created_at_ms
1931
+ FROM _smithers_approvals a
1932
+ INNER JOIN _smithers_runs r ON a.run_id = r.run_id
1933
+ WHERE r.workflow_name = ? AND a.node_id = ?
1934
+ ORDER BY r.created_at_ms DESC, a.decided_at_ms DESC
1935
+ LIMIT ?`, [workflowName, nodeId, limit], { booleanColumns: ["autoApproved"] }));
1936
+ }
1937
+ /**
1938
+ * @param {string} runId
1939
+ * @param {string} ralphId
1940
+ * @returns {RunnableEffect<Record<string, unknown> | undefined, SmithersError>}
1941
+ */
1942
+ getRalph(runId, ralphId) {
1943
+ return this.read(`get ralph ${ralphId}`, () => this.internalStorage.queryOne(`SELECT *
1944
+ FROM _smithers_ralph
1945
+ WHERE run_id = ? AND ralph_id = ?
1946
+ LIMIT 1`, [runId, ralphId], { booleanColumns: ["done"] }));
1947
+ }
1948
+ /**
1949
+ * @param {Record<string, unknown>} row
1950
+ * @returns {RunnableEffect<void, SmithersError>}
1951
+ */
1952
+ insertCache(row) {
1953
+ return this.write(`insert cache ${row.cacheKey}`, () => this.internalStorage.insertIgnore("_smithers_cache", row));
1954
+ }
1955
+ /**
1956
+ * @param {Record<string, unknown>} row
1957
+ * @returns {RunnableEffect<void, SmithersError>}
1958
+ */
1959
+ insertCacheEffect(row) {
1960
+ return this.insertCache(row);
1961
+ }
1962
+ /**
1963
+ * @param {string} cacheKey
1964
+ * @returns {RunnableEffect<CacheRow | undefined, SmithersError>}
1965
+ */
1966
+ getCache(cacheKey) {
1967
+ return this.read(`get cache ${cacheKey}`, () => this.internalStorage.queryOne(`SELECT *
1968
+ FROM _smithers_cache
1969
+ WHERE cache_key = ?
1970
+ LIMIT 1`, [cacheKey]));
1971
+ }
1972
+ /**
1973
+ * @param {{ runId: string; nodeId: string; iteration: number; baseRef: string; diffJson: string; computedAtMs: number; sizeBytes: number; }} row
1974
+ * @returns {RunnableEffect<void, SmithersError>}
1975
+ */
1976
+ upsertNodeDiffCache(row) {
1977
+ return this.write(`upsert node diff ${row.nodeId}@${row.iteration}`, () => this.internalStorage.upsert("_smithers_node_diffs", row, ["runId", "nodeId", "iteration", "baseRef"]));
1978
+ }
1979
+ /**
1980
+ * @param {string} runId
1981
+ * @param {string} nodeId
1982
+ * @param {number} iteration
1983
+ * @param {string} baseRef
1984
+ * @returns {RunnableEffect<NodeDiffCacheRow | undefined, SmithersError>}
1985
+ */
1986
+ getNodeDiffCache(runId, nodeId, iteration, baseRef) {
1987
+ return this.read(`get node diff ${nodeId}@${iteration}`, () => this.internalStorage.queryOne(`SELECT *
1988
+ FROM _smithers_node_diffs
1989
+ WHERE run_id = ? AND node_id = ? AND iteration = ? AND base_ref = ?
1990
+ LIMIT 1`, [runId, nodeId, iteration, baseRef]));
1991
+ }
1992
+ /**
1993
+ * @param {string} [runId]
1994
+ * @returns {RunnableEffect<number, SmithersError>}
1995
+ */
1996
+ countNodeDiffCacheRows(runId) {
1997
+ const self = this;
1998
+ return runnableEffect(Effect.gen(function* () {
1999
+ const row = yield* (runId
2000
+ ? self.read(`count node diff cache rows ${runId}`, () => self.internalStorage.queryOne(`SELECT COUNT(*) AS count
2001
+ FROM _smithers_node_diffs
2002
+ WHERE run_id = ?`, [runId]))
2003
+ : self.read("count node diff cache rows", () => self.internalStorage.queryOne(`SELECT COUNT(*) AS count
2004
+ FROM _smithers_node_diffs`)));
2005
+ return Number(row?.count ?? 0);
2006
+ }));
2007
+ }
2008
+ /**
2009
+ * @param {string} runId
2010
+ * @param {number} targetFrameNo
2011
+ * @returns {RunnableEffect<number, SmithersError>}
2012
+ */
2013
+ invalidateNodeDiffsAfterFrame(runId, targetFrameNo) {
2014
+ const self = this;
2015
+ return runnableEffect(Effect.gen(function* () {
2016
+ // Frame-based invalidation (not timestamp-based):
2017
+ // An attempt "belongs to" frame F where F is the lowest
2018
+ // frame_no whose created_at_ms >= attempt.started_at_ms
2019
+ // (i.e. the first frame that could record the attempt's
2020
+ // effects). If no such frame exists, the attempt is
2021
+ // conceptually beyond all captured frames and MUST be
2022
+ // invalidated whenever we truncate.
2023
+ //
2024
+ // An attempt is invalidated iff its frameNo > targetFrameNo.
2025
+ // Equivalently: there is NO frame F with
2026
+ // F.frame_no <= targetFrameNo AND F.created_at_ms >= a.started_at_ms.
2027
+ const targetFrame = yield* self.read(`get target frame ${runId}:${targetFrameNo}`, () => self.internalStorage.queryOne(`SELECT created_at_ms AS createdAtMs
2028
+ FROM _smithers_frames
2029
+ WHERE run_id = ? AND frame_no = ?
2030
+ LIMIT 1`, [runId, targetFrameNo]));
2031
+ if (!targetFrame || typeof targetFrame.createdAtMs !== "number") {
2032
+ return 0;
2033
+ }
2034
+ // Reference the target frame's created_at_ms to silence unused
2035
+ // reads on SQLite engines that strip selects; the real frame
2036
+ // membership check happens inside the NOT EXISTS below.
2037
+ void targetFrame;
2038
+ const attemptPredicate = `EXISTS (
2039
+ SELECT 1
2040
+ FROM _smithers_attempts a
2041
+ WHERE a.run_id = d.run_id
2042
+ AND a.node_id = d.node_id
2043
+ AND a.iteration = d.iteration
2044
+ AND NOT EXISTS (
2045
+ SELECT 1
2046
+ FROM _smithers_frames f
2047
+ WHERE f.run_id = a.run_id
2048
+ AND f.frame_no <= ?
2049
+ AND f.created_at_ms >= a.started_at_ms
2050
+ )
2051
+ )`;
2052
+ const countRow = yield* self.read(`count node diff invalidation ${runId}:${targetFrameNo}`, () => self.internalStorage.queryOne(`SELECT COUNT(*) AS count
2053
+ FROM _smithers_node_diffs d
2054
+ WHERE d.run_id = ?
2055
+ AND ${attemptPredicate}`, [runId, targetFrameNo]));
2056
+ const deleted = Number(countRow?.count ?? 0);
2057
+ if (deleted === 0) {
2058
+ return 0;
2059
+ }
2060
+ yield* self.write(`invalidate node diffs ${runId}:${targetFrameNo}`, () => self.internalStorage.deleteWhere("_smithers_node_diffs", `run_id = ?
2061
+ AND EXISTS (
2062
+ SELECT 1
2063
+ FROM _smithers_attempts a
2064
+ WHERE a.run_id = _smithers_node_diffs.run_id
2065
+ AND a.node_id = _smithers_node_diffs.node_id
2066
+ AND a.iteration = _smithers_node_diffs.iteration
2067
+ AND NOT EXISTS (
2068
+ SELECT 1
2069
+ FROM _smithers_frames f
2070
+ WHERE f.run_id = a.run_id
2071
+ AND f.frame_no <= ?
2072
+ AND f.created_at_ms >= a.started_at_ms
2073
+ )
2074
+ )`, [runId, targetFrameNo]));
2075
+ return deleted;
2076
+ }));
2077
+ }
2078
+ /**
2079
+ * @param {string} nodeId
2080
+ * @param {string} [outputTable]
2081
+ * @returns {RunnableEffect<CacheRow[], SmithersError>}
2082
+ */
2083
+ listCacheByNode(nodeId, outputTable, limit = 20) {
2084
+ return this.read(`list cache by node ${nodeId}`, () => this.internalStorage.queryAll(`SELECT *
2085
+ FROM _smithers_cache
2086
+ WHERE node_id = ?${outputTable ? " AND output_table = ?" : ""}
2087
+ ORDER BY created_at_ms DESC
2088
+ LIMIT ?`, outputTable ? [nodeId, outputTable, limit] : [nodeId, limit]));
2089
+ }
2090
+ /**
2091
+ * @param {string} runId
2092
+ * @param {number} frameNo
2093
+ * @returns {RunnableEffect<void, SmithersError>}
2094
+ */
2095
+ deleteFramesAfter(runId, frameNo) {
2096
+ const self = this;
2097
+ return runnableEffect(Effect.gen(function* () {
2098
+ yield* self.write(`delete frames after ${frameNo}`, () => self.internalStorage.deleteWhere("_smithers_frames", "run_id = ? AND frame_no > ?", [runId, frameNo]));
2099
+ self.clearFrameCacheForRun(runId);
2100
+ }));
2101
+ }
2102
+ /**
2103
+ * @param {string} runId
2104
+ * @param {number} limit
2105
+ * @param {number} [afterFrameNo]
2106
+ * @returns {RunnableEffect<FrameRow[], SmithersError>}
2107
+ */
2108
+ listFrames(runId, limit, afterFrameNo) {
2109
+ const self = this;
2110
+ return runnableEffect(Effect.gen(function* () {
2111
+ const rows = (yield* self.read(`list frames ${runId}`, () => self.internalStorage.queryAll(`SELECT *
2112
+ FROM _smithers_frames
2113
+ WHERE run_id = ?${afterFrameNo !== undefined ? " AND frame_no > ?" : ""}
2114
+ ORDER BY frame_no DESC
2115
+ LIMIT ?`, afterFrameNo !== undefined
2116
+ ? [runId, afterFrameNo, limit]
2117
+ : [runId, limit])));
2118
+ const localCache = new Map();
2119
+ const expanded = [];
2120
+ for (const row of rows) {
2121
+ expanded.push(yield* self.inflateFrameRow(row, localCache));
2122
+ }
2123
+ return expanded;
2124
+ }));
2125
+ }
2126
+ /**
2127
+ * @param {string} runId
2128
+ * @returns {RunnableEffect<Array<{ state: string; count: number }>, SmithersError>}
2129
+ */
2130
+ countNodesByState(runId) {
2131
+ return this.read(`count nodes by state ${runId}`, () => this.internalStorage.queryAll(`SELECT state, COUNT(*) AS count
2132
+ FROM _smithers_nodes
2133
+ WHERE run_id = ?
2134
+ GROUP BY state`, [runId]));
2135
+ }
2136
+ /**
2137
+ * @param {Record<string, unknown>} row
2138
+ * @returns {RunnableEffect<void, SmithersError>}
2139
+ */
2140
+ upsertCron(row) {
2141
+ return this.write("upsert cron", () => this.internalStorage.upsert("_smithers_cron", row, ["cronId"], ["pattern", "workflowPath", "enabled", "nextRunAtMs"]));
2142
+ }
2143
+ /**
2144
+ * @param {boolean} [enabledOnly]
2145
+ * @returns {RunnableEffect<Array<Record<string, unknown>>, SmithersError>}
2146
+ */
2147
+ listCrons(enabledOnly = true) {
2148
+ return this.read("list crons", () => this.internalStorage.queryAll(`SELECT *
2149
+ FROM _smithers_cron${enabledOnly ? " WHERE enabled = ?" : ""}`, enabledOnly ? [true] : [], { booleanColumns: ["enabled"] }));
2150
+ }
2151
+ /**
2152
+ * @param {string} cronId
2153
+ * @param {number} lastRunAtMs
2154
+ * @param {number} nextRunAtMs
2155
+ * @param {string | null} [errorJson]
2156
+ * @returns {RunnableEffect<void, SmithersError>}
2157
+ */
2158
+ updateCronRunTime(cronId, lastRunAtMs, nextRunAtMs, errorJson) {
2159
+ return this.write(`update cron run time ${cronId}`, () => this.internalStorage.updateWhere("_smithers_cron", { lastRunAtMs, nextRunAtMs, errorJson: errorJson ?? null }, "cron_id = ?", [cronId]));
2160
+ }
2161
+ /**
2162
+ * @param {string} cronId
2163
+ * @returns {RunnableEffect<void, SmithersError>}
2164
+ */
2165
+ deleteCron(cronId) {
2166
+ return this.write(`delete cron ${cronId}`, () => this.internalStorage.deleteWhere("_smithers_cron", "cron_id = ?", [cronId]));
2167
+ }
2168
+ // ---------------------------------------------------------------------------
2169
+ // Scorer results
2170
+ // ---------------------------------------------------------------------------
2171
+ /**
2172
+ * @param {Record<string, unknown>} row
2173
+ * @returns {RunnableEffect<void, SmithersError>}
2174
+ */
2175
+ insertScorerResult(row) {
2176
+ return this.write(`insert scorer result ${row.scorerId}`, () => this.internalStorage.insertIgnore("_smithers_scorers", row));
2177
+ }
2178
+ /**
2179
+ * @param {string} runId
2180
+ * @param {string} [nodeId]
2181
+ * @returns {RunnableEffect<Array<Record<string, unknown>>, SmithersError>}
2182
+ */
2183
+ listScorerResults(runId, nodeId) {
2184
+ return this.read(`list scorer results ${runId}`, () => this.internalStorage.queryAll(`SELECT *
2185
+ FROM _smithers_scorers
2186
+ WHERE run_id = ?${nodeId ? " AND node_id = ?" : ""}
2187
+ ORDER BY scored_at_ms ASC`, nodeId ? [runId, nodeId] : [runId]));
2188
+ }
2189
+ /**
2190
+ * @param {string} runId
2191
+ * @returns {RunnableEffect<RunRow | undefined, SmithersError>}
2192
+ */
2193
+ getRunEffect(runId) {
2194
+ return this.getRun(runId);
2195
+ }
2196
+ /**
2197
+ * @param {string} [status]
2198
+ * @returns {RunnableEffect<RunRow[], SmithersError>}
2199
+ */
2200
+ listRunsEffect(limit = 50, status) {
2201
+ return this.listRuns(limit, status);
2202
+ }
2203
+ /**
2204
+ * @param {number} staleBeforeMs
2205
+ * @returns {RunnableEffect<StaleRunRecord[], SmithersError>}
2206
+ */
2207
+ listStaleRunningRunsEffect(staleBeforeMs, limit = 1000) {
2208
+ return this.listStaleRunningRuns(staleBeforeMs, limit);
2209
+ }
2210
+ /**
2211
+ * @param {Parameters<SmithersDb["claimRunForResume"]>[0]} params
2212
+ * @returns {RunnableEffect<boolean, SmithersError>}
2213
+ */
2214
+ claimRunForResumeEffect(params) {
2215
+ return this.claimRunForResume(params);
2216
+ }
2217
+ /**
2218
+ * @param {Parameters<SmithersDb["releaseRunResumeClaim"]>[0]} params
2219
+ * @returns {RunnableEffect<void, SmithersError>}
2220
+ */
2221
+ releaseRunResumeClaimEffect(params) {
2222
+ return this.releaseRunResumeClaim(params);
2223
+ }
2224
+ /**
2225
+ * @param {string} runId
2226
+ * @param {string} nodeId
2227
+ * @returns {RunnableEffect<NodeRow[], SmithersError>}
2228
+ */
2229
+ listNodeIterationsEffect(runId, nodeId) {
2230
+ return this.listNodeIterations(runId, nodeId);
2231
+ }
2232
+ /**
2233
+ * @param {string} runId
2234
+ * @returns {RunnableEffect<NodeRow[], SmithersError>}
2235
+ */
2236
+ listNodesEffect(runId) {
2237
+ return this.listNodes(runId);
2238
+ }
2239
+ /**
2240
+ * @param {string} runId
2241
+ * @param {string} nodeId
2242
+ * @param {number} iteration
2243
+ * @returns {RunnableEffect<AttemptRow[], SmithersError>}
2244
+ */
2245
+ listAttemptsEffect(runId, nodeId, iteration) {
2246
+ return this.listAttempts(runId, nodeId, iteration);
2247
+ }
2248
+ /**
2249
+ * @param {string} runId
2250
+ * @returns {RunnableEffect<AttemptRow[], SmithersError>}
2251
+ */
2252
+ listAttemptsForRunEffect(runId) {
2253
+ return this.listAttemptsForRun(runId);
2254
+ }
2255
+ /**
2256
+ * @param {string} runId
2257
+ * @param {string} nodeId
2258
+ * @param {number} iteration
2259
+ * @returns {RunnableEffect<Array<Record<string, unknown>>, SmithersError>}
2260
+ */
2261
+ listToolCallsEffect(runId, nodeId, iteration) {
2262
+ return this.listToolCalls(runId, nodeId, iteration);
2263
+ }
2264
+ /**
2265
+ * @param {string} tableName
2266
+ * @param {string} runId
2267
+ * @param {string} nodeId
2268
+ * @param {number} iteration
2269
+ * @returns {RunnableEffect<Record<string, unknown> | null, SmithersError>}
2270
+ */
2271
+ getRawNodeOutputForIterationEffect(tableName, runId, nodeId, iteration) {
2272
+ return this.getRawNodeOutputForIteration(tableName, runId, nodeId, iteration);
2273
+ }
2274
+ /**
2275
+ * @param {Parameters<SmithersDb["insertEventWithNextSeq"]>[0]} row
2276
+ * @returns {RunnableEffect<number, SmithersError>}
2277
+ */
2278
+ insertEventWithNextSeqEffect(row) {
2279
+ return this.insertEventWithNextSeq(row);
2280
+ }
2281
+ /**
2282
+ * @param {string} runId
2283
+ * @returns {RunnableEffect<number | undefined, SmithersError>}
2284
+ */
2285
+ getLastEventSeqEffect(runId) {
2286
+ return this.getLastEventSeq(runId);
2287
+ }
2288
+ /**
2289
+ * @param {string} runId
2290
+ * @param {EventHistoryQuery} [query]
2291
+ * @returns {RunnableEffect<Array<Record<string, unknown>>, SmithersError>}
2292
+ */
2293
+ listEventHistoryEffect(runId, query = {}) {
2294
+ return this.listEventHistory(runId, query);
2295
+ }
2296
+ /**
2297
+ * @param {string} runId
2298
+ * @param {EventHistoryQuery} [query]
2299
+ * @returns {RunnableEffect<number, SmithersError>}
2300
+ */
2301
+ countEventHistoryEffect(runId, query = {}) {
2302
+ return this.countEventHistory(runId, query);
2303
+ }
2304
+ /**
2305
+ * @param {string} runId
2306
+ * @param {string} type
2307
+ * @returns {RunnableEffect<Array<Record<string, unknown>>, SmithersError>}
2308
+ */
2309
+ listEventsByTypeEffect(runId, type) {
2310
+ return this.listEventsByType(runId, type);
2311
+ }
2312
+ /**
2313
+ * @param {string} runId
2314
+ * @returns {RunnableEffect<ApprovalRow[], SmithersError>}
2315
+ */
2316
+ listPendingApprovalsEffect(runId) {
2317
+ return this.listPendingApprovals(runId);
2318
+ }
2319
+ /**
2320
+ * @param {string} runId
2321
+ * @returns {RunnableEffect<FrameRow | undefined, SmithersError>}
2322
+ */
2323
+ getLastFrameEffect(runId) {
2324
+ return this.getLastFrame(runId);
2325
+ }
2326
+ /**
2327
+ * @param {string} nodeId
2328
+ * @param {string} [outputTable]
2329
+ * @returns {RunnableEffect<CacheRow[], SmithersError>}
2330
+ */
2331
+ listCacheByNodeEffect(nodeId, outputTable, limit = 20) {
2332
+ return this.listCacheByNode(nodeId, outputTable, limit);
2333
+ }
2334
+ /**
2335
+ * @param {boolean} [enabledOnly]
2336
+ * @returns {RunnableEffect<Array<Record<string, unknown>>, SmithersError>}
2337
+ */
2338
+ listCronsEffect(enabledOnly = true) {
2339
+ return this.listCrons(enabledOnly);
2340
+ }
2341
+ /**
2342
+ * @param {string} cronId
2343
+ * @param {number} lastRunAtMs
2344
+ * @param {number} nextRunAtMs
2345
+ * @param {string | null} [errorJson]
2346
+ * @returns {RunnableEffect<void, SmithersError>}
2347
+ */
2348
+ updateCronRunTimeEffect(cronId, lastRunAtMs, nextRunAtMs, errorJson) {
2349
+ return this.updateCronRunTime(cronId, lastRunAtMs, nextRunAtMs, errorJson);
2350
+ }
2351
+ /**
2352
+ * @param {string} runId
2353
+ * @param {string} [nodeId]
2354
+ * @returns {RunnableEffect<Array<Record<string, unknown>>, SmithersError>}
2355
+ */
2356
+ listScorerResultsEffect(runId, nodeId) {
2357
+ return this.listScorerResults(runId, nodeId);
2358
+ }
2359
+ }