@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/output.js ADDED
@@ -0,0 +1,240 @@
1
+ import { and, eq } from "drizzle-orm";
2
+ import { getTableColumns } from "drizzle-orm/utils";
3
+ import { createInsertSchema, createSelectSchema } from "drizzle-zod";
4
+ import { Effect } from "effect";
5
+ import { z } from "zod";
6
+ import { SmithersError } from "@smithers-orchestrator/errors/SmithersError";
7
+ import { toSmithersError } from "@smithers-orchestrator/errors/toSmithersError";
8
+ import { withSqliteWriteRetryEffect } from "./write-retry.js";
9
+ /** @typedef {import("drizzle-orm").AnyColumn} AnyColumn */
10
+ /** @typedef {import("./output/OutputKey.ts").OutputKey} _OutputKey */
11
+ /** @typedef {import("drizzle-orm").Table} _Table */
12
+ /** @typedef {import("drizzle-orm/bun-sqlite").BunSQLiteDatabase} BunSQLiteDatabase */
13
+
14
+ /**
15
+ * @param {_Table} table
16
+ * @param {string} runId
17
+ * @param {string} nodeId
18
+ * @param {number} iteration
19
+ * @param {unknown} payload
20
+ * @returns {Record<string, unknown>}
21
+ */
22
+ export function buildOutputRow(table, runId, nodeId, iteration, payload) {
23
+ const cols = getTableColumns(table);
24
+ const keys = Object.keys(cols);
25
+ const hasPayload = keys.includes("payload");
26
+ const payloadOnly = hasPayload && keys.every((key) => key === "runId" || key === "nodeId" || key === "iteration" || key === "payload");
27
+ if (payloadOnly) {
28
+ return { runId, nodeId, iteration, payload: (payload ?? null) };
29
+ }
30
+ return {
31
+ ...(/** @type {Record<string, unknown>} */ (payload ?? {})),
32
+ runId, nodeId, iteration,
33
+ };
34
+ }
35
+ /**
36
+ * @param {unknown} payload
37
+ * @returns {unknown}
38
+ */
39
+ export function stripAutoColumns(payload) {
40
+ if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
41
+ return payload;
42
+ }
43
+ const { runId: _runId, nodeId: _nodeId, iteration: _iteration, ...rest } = /** @type {Record<string, unknown>} */ (payload);
44
+ return rest;
45
+ }
46
+ /**
47
+ * @param {_Table} table
48
+ * @returns {{ runId: AnyColumn; nodeId: AnyColumn; iteration?: AnyColumn; }}
49
+ */
50
+ export function getKeyColumns(table) {
51
+ const cols = getTableColumns(table);
52
+ const runId = cols.runId;
53
+ const nodeId = cols.nodeId;
54
+ const iteration = cols.iteration;
55
+ if (!runId || !nodeId) {
56
+ throw new SmithersError("DB_MISSING_COLUMNS", `Output table ${table["_"]?.name ?? ""} must include runId and nodeId columns.`);
57
+ }
58
+ return { runId, nodeId, iteration };
59
+ }
60
+ /**
61
+ * @param {_Table} table
62
+ * @param {_OutputKey} key
63
+ * @returns {ReturnType<typeof and>}
64
+ */
65
+ export function buildKeyWhere(table, key) {
66
+ const cols = getKeyColumns(table);
67
+ const clauses = [eq(cols.runId, key.runId), eq(cols.nodeId, key.nodeId)];
68
+ if (cols.iteration) clauses.push(eq(cols.iteration, key.iteration ?? 0));
69
+ return and(...clauses);
70
+ }
71
+ /**
72
+ * @template T
73
+ * @param {BunSQLiteDatabase<Record<string, unknown>>} db
74
+ * @param {_Table} table
75
+ * @param {_OutputKey} key
76
+ * @returns {Effect.Effect<T | undefined, SmithersError>}
77
+ */
78
+ export function selectOutputRowEffect(db, table, key) {
79
+ const where = buildKeyWhere(table, key);
80
+ return Effect.tryPromise({
81
+ try: () => db.select().from(table).where(where).limit(1),
82
+ catch: (cause) => toSmithersError(cause, `select output ${table["_"]?.name ?? "output"}`, {
83
+ code: "DB_QUERY_FAILED",
84
+ details: { outputTable: table["_"]?.name ?? "output" },
85
+ }),
86
+ }).pipe(Effect.map((rows) => rows[0]), Effect.annotateLogs({
87
+ outputTable: table["_"]?.name ?? "output",
88
+ runId: key.runId,
89
+ nodeId: key.nodeId,
90
+ iteration: key.iteration ?? 0,
91
+ }), Effect.withLogSpan("db:select-output-row"));
92
+ }
93
+ /**
94
+ * @template T
95
+ * @param {BunSQLiteDatabase<Record<string, unknown>>} db
96
+ * @param {_Table} table
97
+ * @param {_OutputKey} key
98
+ * @returns {Promise<T | undefined>}
99
+ */
100
+ export function selectOutputRow(db, table, key) {
101
+ return Effect.runPromise(selectOutputRowEffect(db, table, key));
102
+ }
103
+ /**
104
+ * @param {BunSQLiteDatabase<Record<string, unknown>>} db
105
+ * @param {_Table} table
106
+ * @param {_OutputKey} key
107
+ * @param {Record<string, unknown>} payload
108
+ * @returns {Effect.Effect<void, SmithersError>}
109
+ */
110
+ export function upsertOutputRowEffect(db, table, key, payload) {
111
+ const cols = getKeyColumns(table);
112
+ /** @type {Record<string, unknown>} */
113
+ const values = { ...payload };
114
+ values.runId = key.runId;
115
+ values.nodeId = key.nodeId;
116
+ if (cols.iteration) values.iteration = key.iteration ?? 0;
117
+ const target = cols.iteration ? [cols.runId, cols.nodeId, cols.iteration] : [cols.runId, cols.nodeId];
118
+ return withSqliteWriteRetryEffect(() => Effect.tryPromise({
119
+ try: () => db.insert(table).values(values).onConflictDoUpdate({ target, set: values }),
120
+ catch: (cause) => toSmithersError(cause, `upsert output ${table["_"]?.name ?? "output"}`, {
121
+ code: "DB_WRITE_FAILED",
122
+ details: { outputTable: table["_"]?.name ?? "output" },
123
+ }),
124
+ }), { label: `upsert output ${table["_"]?.name ?? "output"}` }).pipe(Effect.asVoid, Effect.annotateLogs({
125
+ outputTable: table["_"]?.name ?? "output",
126
+ runId: key.runId,
127
+ nodeId: key.nodeId,
128
+ iteration: key.iteration ?? 0,
129
+ }), Effect.withLogSpan("db:upsert-output-row"));
130
+ }
131
+ /**
132
+ * @param {BunSQLiteDatabase<Record<string, unknown>>} db
133
+ * @param {_Table} table
134
+ * @param {_OutputKey} key
135
+ * @param {Record<string, unknown>} payload
136
+ * @returns {Promise<void>}
137
+ */
138
+ export function upsertOutputRow(db, table, key, payload) {
139
+ return Effect.runPromise(upsertOutputRowEffect(db, table, key, payload));
140
+ }
141
+ /**
142
+ * @param {_Table} table
143
+ * @param {unknown} payload
144
+ * @returns {{ ok: boolean; data?: unknown; error?: z.ZodError; }}
145
+ */
146
+ export function validateOutput(table, payload) {
147
+ const schema = createInsertSchema(table);
148
+ const result = schema.safeParse(payload);
149
+ if (result.success) return { ok: true, data: result.data };
150
+ return { ok: false, error: result.error };
151
+ }
152
+ /**
153
+ * @param {_Table} table
154
+ * @param {unknown} payload
155
+ * @returns {{ ok: boolean; data?: unknown; error?: z.ZodError; }}
156
+ */
157
+ export function validateExistingOutput(table, payload) {
158
+ const schema = createSelectSchema(table);
159
+ const result = schema.safeParse(payload);
160
+ if (result.success) return { ok: true, data: result.data };
161
+ return { ok: false, error: result.error };
162
+ }
163
+ /**
164
+ * @param {_Table} table
165
+ * @returns {z.ZodObject}
166
+ */
167
+ export function getAgentOutputSchema(table) {
168
+ const baseSchema = createInsertSchema(table);
169
+ const rest = { ...baseSchema.shape };
170
+ delete rest.runId;
171
+ delete rest.nodeId;
172
+ delete rest.iteration;
173
+ return z.object(rest);
174
+ }
175
+ /**
176
+ * @param {_Table | z.ZodObject} tableOrSchema
177
+ * @param {z.ZodObject} [zodSchema]
178
+ * @returns {string}
179
+ */
180
+ export function describeSchemaShape(tableOrSchema, zodSchema) {
181
+ const schema = zodSchema ?? (isZodSchema(tableOrSchema) ? tableOrSchema : null);
182
+ if (schema && typeof schema.toJSONSchema === "function") {
183
+ const jsonSchema = schema.toJSONSchema();
184
+ return JSON.stringify(jsonSchema, null, 2);
185
+ }
186
+ if (!isZodSchema(tableOrSchema)) {
187
+ const agentSchema = getAgentOutputSchema(tableOrSchema);
188
+ if (typeof agentSchema.toJSONSchema === "function") {
189
+ const jsonSchema = agentSchema.toJSONSchema();
190
+ return JSON.stringify(jsonSchema, null, 2);
191
+ }
192
+ }
193
+ const target = schema ?? (isZodSchema(tableOrSchema) ? tableOrSchema : getAgentOutputSchema(tableOrSchema));
194
+ const shape = target.shape;
195
+ /** @type {Record<string, string>} */
196
+ const fields = {};
197
+ for (const [key, zodType] of Object.entries(shape)) {
198
+ fields[key] = describeZodType(/** @type {z.ZodType} */ (zodType));
199
+ }
200
+ return JSON.stringify(fields, null, 2);
201
+ }
202
+ /**
203
+ * @param {unknown} val
204
+ * @returns {val is z.ZodObject}
205
+ */
206
+ function isZodSchema(val) {
207
+ return (!!val && typeof val === "object" && "shape" in val && typeof (/** @type {{ shape: unknown }} */ (val)).shape === "object");
208
+ }
209
+ /**
210
+ * @param {z.ZodType} schema
211
+ * @returns {string}
212
+ */
213
+ function describeZodType(schema) {
214
+ const internal = /** @type {{ _zod?: { def?: Record<string, unknown> } }} */ (/** @type {unknown} */ (schema));
215
+ if (internal._zod?.def) {
216
+ const def = internal._zod.def;
217
+ const typeName = /** @type {string} */ (def.type);
218
+ if (typeName === "optional" || typeName === "default" || typeName === "nullable") {
219
+ const inner = def.innerType ? describeZodType(/** @type {z.ZodType} */ (def.innerType)) : "unknown";
220
+ if (typeName === "optional") return `${inner} (optional)`;
221
+ if (typeName === "nullable") return `${inner} | null`;
222
+ return inner;
223
+ }
224
+ if (typeName === "string") return "string";
225
+ if (typeName === "number" || typeName === "int" || typeName === "float") return "number";
226
+ if (typeName === "boolean") return "boolean";
227
+ if (typeName === "array") {
228
+ const itemType = def.element ? describeZodType(/** @type {z.ZodType} */ (def.element)) : "unknown";
229
+ return `${itemType}[]`;
230
+ }
231
+ if (typeName === "object") return "object";
232
+ if (typeName === "enum") return `enum(${(/** @type {unknown[]} */ (def.values ?? [])).join(" | ")})`;
233
+ if (typeName === "literal") return `literal(${JSON.stringify(def.value)})`;
234
+ if (typeName === "union") {
235
+ const options = (/** @type {z.ZodType[]} */ (def.options ?? [])).map((o) => describeZodType(o));
236
+ return options.join(" | ");
237
+ }
238
+ }
239
+ return "unknown";
240
+ }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * @param {unknown} payload
3
+ */
4
+ export function stripAutoColumns(payload) {
5
+ if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
6
+ return payload;
7
+ }
8
+ const { runId: _runId, nodeId: _nodeId, iteration: _iteration, ...rest } = payload;
9
+ return rest;
10
+ }
@@ -0,0 +1,4 @@
1
+ export type ComputeRunStateOptions = {
2
+ now?: number;
3
+ staleThresholdMs?: number;
4
+ };
@@ -0,0 +1,10 @@
1
+ import type { RunRow } from "../adapter/RunRow.ts";
2
+
3
+ export type DeriveRunStateInput = {
4
+ run: RunRow;
5
+ pendingApproval?: { nodeId: string; requestedAtMs: number } | null;
6
+ pendingTimer?: { nodeId: string; firesAtMs: number } | null;
7
+ pendingEvent?: { nodeId: string; correlationKey: string } | null;
8
+ now?: number;
9
+ staleThresholdMs?: number;
10
+ };
@@ -0,0 +1 @@
1
+ export const RUN_STATE_HEARTBEAT_STALE_MS = 30_000;
@@ -0,0 +1,10 @@
1
+ export type ReasonBlocked =
2
+ | { kind: "approval"; nodeId: string; requestedAt: string }
3
+ | { kind: "event"; nodeId: string; correlationKey: string }
4
+ | { kind: "timer"; nodeId: string; wakeAt: string }
5
+ | {
6
+ kind: "provider";
7
+ nodeId: string;
8
+ code: "rate-limit" | "auth" | "timeout";
9
+ }
10
+ | { kind: "tool"; nodeId: string; toolName: string; code: string };
@@ -0,0 +1,6 @@
1
+ export type ReasonUnhealthy =
2
+ | { kind: "engine-heartbeat-stale"; lastHeartbeatAt: string }
3
+ | { kind: "ui-heartbeat-stale"; lastSeenAt: string }
4
+ | { kind: "db-lock" }
5
+ | { kind: "sandbox-unreachable" }
6
+ | { kind: "supervisor-backoff"; attempt: number; nextAt: string };
@@ -0,0 +1,12 @@
1
+ export type RunState =
2
+ | "running"
3
+ | "waiting-approval"
4
+ | "waiting-event"
5
+ | "waiting-timer"
6
+ | "recovering"
7
+ | "stale"
8
+ | "orphaned"
9
+ | "failed"
10
+ | "cancelled"
11
+ | "succeeded"
12
+ | "unknown";
@@ -0,0 +1,11 @@
1
+ import type { ReasonBlocked } from "./ReasonBlocked.ts";
2
+ import type { ReasonUnhealthy } from "./ReasonUnhealthy.ts";
3
+ import type { RunState } from "./RunState.ts";
4
+
5
+ export type RunStateView = {
6
+ runId: string;
7
+ state: RunState;
8
+ blocked?: ReasonBlocked;
9
+ unhealthy?: ReasonUnhealthy;
10
+ computedAt: string;
11
+ };
@@ -0,0 +1,22 @@
1
+ import { SmithersError } from "@smithers-orchestrator/errors/SmithersError";
2
+ import { computeRunStateFromRow } from "./computeRunStateFromRow.js";
3
+
4
+ /** @typedef {import("../adapter/SmithersDb.js").SmithersDb} SmithersDb */
5
+ /** @typedef {import("./RunStateView.ts").RunStateView} RunStateView */
6
+ /** @typedef {import("./ComputeRunStateOptions.ts").ComputeRunStateOptions} ComputeRunStateOptions */
7
+
8
+ /**
9
+ * @param {SmithersDb} adapter
10
+ * @param {string} runId
11
+ * @param {ComputeRunStateOptions} [options]
12
+ * @returns {Promise<RunStateView>}
13
+ */
14
+ export async function computeRunState(adapter, runId, options = {}) {
15
+ const run = await adapter.getRun(runId);
16
+ if (!run) {
17
+ throw new SmithersError("RUN_NOT_FOUND", `Run not found: ${runId}`, {
18
+ runId,
19
+ });
20
+ }
21
+ return computeRunStateFromRow(adapter, run, options);
22
+ }
@@ -0,0 +1,102 @@
1
+ import { deriveRunState } from "./deriveRunState.js";
2
+ import { parseEventMeta } from "./parseEventMeta.js";
3
+ import { parseTimerMeta } from "./parseTimerMeta.js";
4
+
5
+ /** @typedef {import("../adapter/RunRow.ts").RunRow} RunRow */
6
+ /** @typedef {import("../adapter/SmithersDb.js").SmithersDb} SmithersDb */
7
+ /** @typedef {import("./RunStateView.ts").RunStateView} RunStateView */
8
+ /** @typedef {import("./ComputeRunStateOptions.ts").ComputeRunStateOptions} ComputeRunStateOptions */
9
+
10
+ /**
11
+ * @param {SmithersDb} adapter
12
+ * @param {RunRow} run
13
+ * @param {ComputeRunStateOptions} [options]
14
+ * @returns {Promise<RunStateView>}
15
+ */
16
+ export async function computeRunStateFromRow(adapter, run, options = {}) {
17
+ let pendingApproval = null;
18
+ let pendingTimer = null;
19
+ let pendingEvent = null;
20
+
21
+ if (run.status === "waiting-approval") {
22
+ pendingApproval = await loadPendingApproval(adapter, run.runId);
23
+ } else if (run.status === "waiting-timer") {
24
+ pendingTimer = await loadPendingTimer(adapter, run.runId);
25
+ } else if (run.status === "waiting-event") {
26
+ pendingEvent = await loadPendingEvent(adapter, run.runId);
27
+ }
28
+
29
+ return deriveRunState({
30
+ run,
31
+ pendingApproval,
32
+ pendingTimer,
33
+ pendingEvent,
34
+ now: options.now,
35
+ staleThresholdMs: options.staleThresholdMs,
36
+ });
37
+ }
38
+
39
+ /**
40
+ * @param {SmithersDb} adapter
41
+ * @param {string} runId
42
+ */
43
+ async function loadPendingApproval(adapter, runId) {
44
+ const approvals = await adapter.listPendingApprovals(runId);
45
+ let earliest = null;
46
+ for (const a of approvals) {
47
+ if (typeof a.requestedAtMs !== "number") continue;
48
+ if (earliest == null || a.requestedAtMs < earliest.requestedAtMs) {
49
+ earliest = { nodeId: a.nodeId, requestedAtMs: a.requestedAtMs };
50
+ }
51
+ }
52
+ return earliest;
53
+ }
54
+
55
+ /**
56
+ * @param {SmithersDb} adapter
57
+ * @param {string} runId
58
+ */
59
+ async function loadPendingTimer(adapter, runId) {
60
+ const nodes = await adapter.listNodes(runId);
61
+ let earliest = null;
62
+ for (const node of nodes) {
63
+ if (node.state !== "waiting-timer") continue;
64
+ const attempts = await adapter.listAttempts(
65
+ runId,
66
+ node.nodeId,
67
+ node.iteration ?? 0,
68
+ );
69
+ const waiting =
70
+ attempts.find((a) => a.state === "waiting-timer") ?? attempts[0];
71
+ const parsed = parseTimerMeta(waiting?.metaJson);
72
+ if (parsed == null) continue;
73
+ if (earliest == null || parsed.firesAtMs < earliest.firesAtMs) {
74
+ earliest = { nodeId: node.nodeId, firesAtMs: parsed.firesAtMs };
75
+ }
76
+ }
77
+ return earliest;
78
+ }
79
+
80
+ /**
81
+ * @param {SmithersDb} adapter
82
+ * @param {string} runId
83
+ */
84
+ async function loadPendingEvent(adapter, runId) {
85
+ const nodes = await adapter.listNodes(runId);
86
+ for (const node of nodes) {
87
+ if (node.state !== "waiting-event") continue;
88
+ const attempts = await adapter.listAttempts(
89
+ runId,
90
+ node.nodeId,
91
+ node.iteration ?? 0,
92
+ );
93
+ const waiting =
94
+ attempts.find((a) => a.state === "waiting-event") ?? attempts[0];
95
+ const parsed = parseEventMeta(waiting?.metaJson);
96
+ return {
97
+ nodeId: node.nodeId,
98
+ correlationKey: parsed?.correlationKey ?? "",
99
+ };
100
+ }
101
+ return null;
102
+ }
@@ -0,0 +1,109 @@
1
+ import { RUN_STATE_HEARTBEAT_STALE_MS } from "./RUN_STATE_HEARTBEAT_STALE_MS.js";
2
+
3
+ /** @typedef {import("./DeriveRunStateInput.ts").DeriveRunStateInput} DeriveRunStateInput */
4
+ /** @typedef {import("./RunStateView.ts").RunStateView} RunStateView */
5
+
6
+ /**
7
+ * @param {DeriveRunStateInput} input
8
+ * @returns {RunStateView}
9
+ */
10
+ export function deriveRunState(input) {
11
+ const {
12
+ run,
13
+ pendingApproval = null,
14
+ pendingTimer = null,
15
+ pendingEvent = null,
16
+ now = Date.now(),
17
+ staleThresholdMs = RUN_STATE_HEARTBEAT_STALE_MS,
18
+ } = input;
19
+
20
+ const computedAt = new Date(now).toISOString();
21
+ const base = { runId: run.runId, computedAt };
22
+
23
+ switch (run.status) {
24
+ case "finished":
25
+ case "continued":
26
+ return { ...base, state: "succeeded" };
27
+ case "failed":
28
+ return { ...base, state: "failed" };
29
+ case "cancelled":
30
+ return { ...base, state: "cancelled" };
31
+ case "waiting-approval":
32
+ return pendingApproval
33
+ ? {
34
+ ...base,
35
+ state: "waiting-approval",
36
+ blocked: {
37
+ kind: "approval",
38
+ nodeId: pendingApproval.nodeId,
39
+ requestedAt: new Date(
40
+ pendingApproval.requestedAtMs,
41
+ ).toISOString(),
42
+ },
43
+ }
44
+ : { ...base, state: "waiting-approval" };
45
+ case "waiting-timer":
46
+ return pendingTimer
47
+ ? {
48
+ ...base,
49
+ state: "waiting-timer",
50
+ blocked: {
51
+ kind: "timer",
52
+ nodeId: pendingTimer.nodeId,
53
+ wakeAt: new Date(pendingTimer.firesAtMs).toISOString(),
54
+ },
55
+ }
56
+ : { ...base, state: "waiting-timer" };
57
+ case "waiting-event":
58
+ return pendingEvent
59
+ ? {
60
+ ...base,
61
+ state: "waiting-event",
62
+ blocked: {
63
+ kind: "event",
64
+ nodeId: pendingEvent.nodeId,
65
+ correlationKey: pendingEvent.correlationKey,
66
+ },
67
+ }
68
+ : { ...base, state: "waiting-event" };
69
+ case "running":
70
+ return classifyRunning(run, now, staleThresholdMs, base);
71
+ default:
72
+ return { ...base, state: "unknown" };
73
+ }
74
+ }
75
+
76
+ /**
77
+ * @param {import("../adapter/RunRow.ts").RunRow} run
78
+ * @param {number} now
79
+ * @param {number} staleThresholdMs
80
+ * @param {{ runId: string; computedAt: string }} base
81
+ * @returns {RunStateView}
82
+ */
83
+ function classifyRunning(run, now, staleThresholdMs, base) {
84
+ const heartbeat =
85
+ typeof run.heartbeatAtMs === "number" ? run.heartbeatAtMs : null;
86
+ const startedAt =
87
+ typeof run.startedAtMs === "number" ? run.startedAtMs : null;
88
+ // Fall back to startedAt so a brand-new run with no heartbeat yet
89
+ // isn't reported as stale.
90
+ const lastAlive = Math.max(heartbeat ?? 0, startedAt ?? 0);
91
+
92
+ if (lastAlive === 0) {
93
+ return { ...base, state: "unknown" };
94
+ }
95
+
96
+ if (now - lastAlive <= staleThresholdMs) {
97
+ return { ...base, state: "running" };
98
+ }
99
+
100
+ const lastHeartbeatAt = new Date(lastAlive).toISOString();
101
+ // Without a registered owner, supervisor has nothing to take over.
102
+ const orphaned =
103
+ run.runtimeOwnerId == null || run.runtimeOwnerId.length === 0;
104
+ return {
105
+ ...base,
106
+ state: orphaned ? "orphaned" : "stale",
107
+ unhealthy: { kind: "engine-heartbeat-stale", lastHeartbeatAt },
108
+ };
109
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * @param {string | null | undefined} metaJson
3
+ * @returns {{ correlationKey: string } | null}
4
+ */
5
+ export function parseEventMeta(metaJson) {
6
+ if (!metaJson) return null;
7
+ try {
8
+ const parsed = JSON.parse(metaJson);
9
+ const key =
10
+ parsed?.event?.correlationKey ??
11
+ parsed?.correlationKey ??
12
+ parsed?.event?.eventName ??
13
+ null;
14
+ return typeof key === "string" ? { correlationKey: key } : null;
15
+ } catch {
16
+ return null;
17
+ }
18
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * @param {string | null | undefined} metaJson
3
+ * @returns {{ firesAtMs: number } | null}
4
+ */
5
+ export function parseTimerMeta(metaJson) {
6
+ if (!metaJson) return null;
7
+ try {
8
+ const parsed = JSON.parse(metaJson);
9
+ const candidate = Number(parsed?.timer?.firesAtMs);
10
+ return Number.isFinite(candidate)
11
+ ? { firesAtMs: Math.floor(candidate) }
12
+ : null;
13
+ } catch {
14
+ return null;
15
+ }
16
+ }
@@ -0,0 +1,23 @@
1
+ export type { RunState } from "./runState/RunState.ts";
2
+ export type { RunStateView } from "./runState/RunStateView.ts";
3
+
4
+ import type { DeriveRunStateInput } from "./runState/DeriveRunStateInput.ts";
5
+ import type { RunStateView } from "./runState/RunStateView.ts";
6
+
7
+ export type ComputeRunStateOptions = {
8
+ now?: number;
9
+ staleThresholdMs?: number;
10
+ };
11
+
12
+ export declare const RUN_STATE_HEARTBEAT_STALE_MS: number;
13
+ export declare function deriveRunState(input: DeriveRunStateInput): RunStateView;
14
+ export declare function computeRunState(
15
+ adapter: unknown,
16
+ runId: string,
17
+ options?: ComputeRunStateOptions,
18
+ ): Promise<RunStateView>;
19
+ export declare function computeRunStateFromRow(
20
+ adapter: unknown,
21
+ run: unknown,
22
+ options?: ComputeRunStateOptions,
23
+ ): Promise<RunStateView>;
@@ -0,0 +1,7 @@
1
+ /** @typedef {import("./runState/RunState.ts").RunState} RunState */
2
+ /** @typedef {import("./runState/RunStateView.ts").RunStateView} RunStateView */
3
+
4
+ export { computeRunState } from "./runState/computeRunState.js";
5
+ export { computeRunStateFromRow } from "./runState/computeRunStateFromRow.js";
6
+ export { deriveRunState } from "./runState/deriveRunState.js";
7
+ export { RUN_STATE_HEARTBEAT_STALE_MS } from "./runState/RUN_STATE_HEARTBEAT_STALE_MS.js";
@@ -0,0 +1,22 @@
1
+ import { getTableName } from "drizzle-orm";
2
+ import { getTableColumns } from "drizzle-orm/utils";
3
+ import { createHash } from "node:crypto";
4
+ /** @typedef {import("drizzle-orm").Table} _Table */
5
+
6
+ /**
7
+ * @param {_Table} table
8
+ * @returns {string}
9
+ */
10
+ export function schemaSignature(table) {
11
+ const cols = getTableColumns(table);
12
+ const keys = Object.keys(cols).sort();
13
+ const parts = [getTableName(table)];
14
+ for (const key of keys) {
15
+ const col = cols[key];
16
+ const type = col?.columnType ?? col?.dataType ?? col?.getSQLType?.() ?? "unknown";
17
+ const notNull = col?.notNull ? "1" : "0";
18
+ const primary = col?.primary ? "1" : "0";
19
+ parts.push(`${key}:${type}:${notNull}:${primary}`);
20
+ }
21
+ return createHash("sha256").update(parts.join("|")).digest("hex");
22
+ }