@smithers-orchestrator/server 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.
- package/LICENSE +21 -0
- package/package.json +46 -0
- package/src/ConnectRequest.ts +17 -0
- package/src/EventFrame.ts +7 -0
- package/src/GatewayAuthConfig.ts +26 -0
- package/src/GatewayDefaults.ts +3 -0
- package/src/GatewayOptions.ts +13 -0
- package/src/GatewayTokenGrant.ts +5 -0
- package/src/GatewayWebhookConfig.ts +10 -0
- package/src/GatewayWebhookRunConfig.ts +4 -0
- package/src/GatewayWebhookSignalConfig.ts +6 -0
- package/src/HelloResponse.ts +18 -0
- package/src/RequestFrame.ts +6 -0
- package/src/ResponseFrame.ts +10 -0
- package/src/ServeOptions.ts +11 -0
- package/src/ServerOptions.ts +8 -0
- package/src/gateway.js +3402 -0
- package/src/gatewayRoutes/DiffSummary.ts +6 -0
- package/src/gatewayRoutes/GetNodeDiffRouteResult.ts +23 -0
- package/src/gatewayRoutes/NODE_OUTPUT_MAX_BYTES.js +1 -0
- package/src/gatewayRoutes/NODE_OUTPUT_WARN_BYTES.js +1 -0
- package/src/gatewayRoutes/NodeOutputResponse.ts +22 -0
- package/src/gatewayRoutes/NodeOutputRouteError.js +14 -0
- package/src/gatewayRoutes/getDevToolsSnapshot.js +428 -0
- package/src/gatewayRoutes/getNodeDiff.js +609 -0
- package/src/gatewayRoutes/getNodeOutput.js +504 -0
- package/src/gatewayRoutes/jumpToFrame.js +84 -0
- package/src/gatewayRoutes/streamDevTools.js +525 -0
- package/src/index.d.ts +953 -0
- package/src/index.js +1240 -0
- package/src/serve.js +315 -0
- package/src/smithersRuntime.js +63 -0
|
@@ -0,0 +1,504 @@
|
|
|
1
|
+
import { getTableName } from "drizzle-orm";
|
|
2
|
+
import { Effect, Metric, MetricBoundaries } from "effect";
|
|
3
|
+
import { getAgentOutputSchema, selectOutputRow, stripAutoColumns } from "@smithers-orchestrator/db/output";
|
|
4
|
+
import { buildOutputSchemaDescriptor } from "@smithers-orchestrator/db/output-schema-descriptor";
|
|
5
|
+
import { runPromise } from "../smithersRuntime.js";
|
|
6
|
+
import { NodeOutputRouteError } from "./NodeOutputRouteError.js";
|
|
7
|
+
import { NODE_OUTPUT_WARN_BYTES } from "./NODE_OUTPUT_WARN_BYTES.js";
|
|
8
|
+
import { NODE_OUTPUT_MAX_BYTES } from "./NODE_OUTPUT_MAX_BYTES.js";
|
|
9
|
+
|
|
10
|
+
/** @typedef {import("./NodeOutputResponse.js").NodeOutputResponse} NodeOutputResponse */
|
|
11
|
+
|
|
12
|
+
const RUN_ID_PATTERN = /^[a-z0-9_-]{1,64}$/;
|
|
13
|
+
const NODE_ID_PATTERN = /^[a-zA-Z0-9:_-]{1,128}$/;
|
|
14
|
+
const INT32_MAX = 2_147_483_647;
|
|
15
|
+
|
|
16
|
+
const fastBucketsMs = MetricBoundaries.exponential({ start: 1, factor: 2, count: 12 });
|
|
17
|
+
const sizeBuckets = MetricBoundaries.exponential({ start: 100, factor: 2, count: 16 });
|
|
18
|
+
|
|
19
|
+
const nodeOutputRequestTotal = Metric.counter("smithers_node_output_request_total");
|
|
20
|
+
const nodeOutputBytes = Metric.histogram("smithers_node_output_bytes", sizeBuckets);
|
|
21
|
+
const nodeOutputDurationMs = Metric.histogram("smithers_node_output_duration_ms", fastBucketsMs);
|
|
22
|
+
const nodeOutputSchemaConversionErrorTotal = Metric.counter("smithers_node_output_schema_conversion_error_total");
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Wrap a promise-returning function in a real tracing span with attributes.
|
|
26
|
+
* Mirrors the pattern used by getNodeDiffRoute: one Effect.withSpan, plus a
|
|
27
|
+
* debug log carrying the span name and duration so log-only backends still
|
|
28
|
+
* get a record of the child span.
|
|
29
|
+
*
|
|
30
|
+
* @template T
|
|
31
|
+
* @param {(effect: Effect.Effect<void>) => Promise<unknown>} emitEffect
|
|
32
|
+
* @param {string} spanName
|
|
33
|
+
* @param {Record<string, unknown>} attrs
|
|
34
|
+
* @param {() => Promise<T>} run
|
|
35
|
+
* @returns {Promise<T>}
|
|
36
|
+
*/
|
|
37
|
+
async function emitEffectSpan(emitEffect, spanName, attrs, run) {
|
|
38
|
+
const startedAt = Date.now();
|
|
39
|
+
try {
|
|
40
|
+
const result = await runPromise(Effect.promise(() => run()).pipe(Effect.withSpan(spanName, { attributes: attrs })));
|
|
41
|
+
await swallow(() => emitEffect(Effect.logDebug(spanName).pipe(Effect.annotateLogs({
|
|
42
|
+
...attrs,
|
|
43
|
+
span: spanName,
|
|
44
|
+
durationMs: Date.now() - startedAt,
|
|
45
|
+
}))));
|
|
46
|
+
return result;
|
|
47
|
+
}
|
|
48
|
+
catch (error) {
|
|
49
|
+
await swallow(() => emitEffect(Effect.logError(`${spanName} failed`).pipe(Effect.annotateLogs({
|
|
50
|
+
...attrs,
|
|
51
|
+
span: spanName,
|
|
52
|
+
durationMs: Date.now() - startedAt,
|
|
53
|
+
error: error instanceof Error ? error.message : String(error),
|
|
54
|
+
}))));
|
|
55
|
+
throw error;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* @param {() => Promise<void>} run
|
|
61
|
+
*/
|
|
62
|
+
async function swallow(run) {
|
|
63
|
+
try {
|
|
64
|
+
await run();
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
// Observability must never break RPC responses.
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Resolve per-node output row plus schema hints for DevTools rendering.
|
|
73
|
+
*
|
|
74
|
+
* @param {{
|
|
75
|
+
* runId: unknown;
|
|
76
|
+
* nodeId: unknown;
|
|
77
|
+
* iteration: unknown;
|
|
78
|
+
* resolveRun: (runId: string) => Promise<{ workflow: import("@smithers-orchestrator/components/SmithersWorkflow").SmithersWorkflow<unknown>; adapter: import("@smithers-orchestrator/db/adapter").SmithersDb } | null>;
|
|
79
|
+
* selectOutputRowImpl?: typeof selectOutputRow;
|
|
80
|
+
* emitEffect?: (effect: Effect.Effect<void>) => Promise<unknown>;
|
|
81
|
+
* }} params
|
|
82
|
+
* @returns {Promise<NodeOutputResponse>}
|
|
83
|
+
*/
|
|
84
|
+
export async function getNodeOutputRoute(params) {
|
|
85
|
+
const emitEffect = params.emitEffect ?? ((effect) => runPromise(effect));
|
|
86
|
+
const startedAt = performance.now();
|
|
87
|
+
let statusForMetrics = "error";
|
|
88
|
+
let rowBytes = 0;
|
|
89
|
+
let logRunId = asString(params.runId) ?? null;
|
|
90
|
+
let logNodeId = asString(params.nodeId) ?? null;
|
|
91
|
+
let logIteration = coerceOptionalInteger(params.iteration);
|
|
92
|
+
let logErrorCode = null;
|
|
93
|
+
const rootSpanAttrs = {
|
|
94
|
+
runId: logRunId ?? "",
|
|
95
|
+
nodeId: logNodeId ?? "",
|
|
96
|
+
iteration: logIteration ?? -1,
|
|
97
|
+
status: "unknown",
|
|
98
|
+
bytes: 0,
|
|
99
|
+
};
|
|
100
|
+
try {
|
|
101
|
+
const runId = parseRunId(params.runId);
|
|
102
|
+
const nodeId = parseNodeId(params.nodeId);
|
|
103
|
+
const iteration = parseIteration(params.iteration);
|
|
104
|
+
logRunId = runId;
|
|
105
|
+
logNodeId = nodeId;
|
|
106
|
+
logIteration = iteration;
|
|
107
|
+
rootSpanAttrs.runId = runId;
|
|
108
|
+
rootSpanAttrs.nodeId = nodeId;
|
|
109
|
+
rootSpanAttrs.iteration = iteration;
|
|
110
|
+
|
|
111
|
+
const resolved = await params.resolveRun(runId);
|
|
112
|
+
if (!resolved) {
|
|
113
|
+
throw new NodeOutputRouteError("RunNotFound", `Run not found: ${runId}`);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const nodeIterations = await resolved.adapter.listNodeIterations(runId, nodeId);
|
|
117
|
+
if (!Array.isArray(nodeIterations) || nodeIterations.length === 0) {
|
|
118
|
+
throw new NodeOutputRouteError("NodeNotFound", `Node not found: ${nodeId}`);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const node = nodeIterations.find((entry) => (entry?.iteration ?? 0) === iteration);
|
|
122
|
+
if (!node) {
|
|
123
|
+
throw new NodeOutputRouteError("IterationNotFound", `Iteration not found: ${iteration}`);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const outputTableName = asString(node.outputTable)?.trim() ?? "";
|
|
127
|
+
if (!outputTableName) {
|
|
128
|
+
throw new NodeOutputRouteError("NodeHasNoOutput", `Node ${nodeId} has no output table.`);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const output = resolveOutputDefinition(resolved.workflow, outputTableName);
|
|
132
|
+
if (!output?.table) {
|
|
133
|
+
throw new NodeOutputRouteError("NodeHasNoOutput", `Output table ${outputTableName} is not registered.`);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const { descriptor, warnings } = await emitEffectSpan(
|
|
137
|
+
emitEffect,
|
|
138
|
+
"devtools.buildSchemaDescriptor",
|
|
139
|
+
{ runId, nodeId, iteration },
|
|
140
|
+
async () => {
|
|
141
|
+
const collected = [];
|
|
142
|
+
const schemaForDescriptor = isDescriptorSchema(output.zodSchema)
|
|
143
|
+
? output.zodSchema
|
|
144
|
+
: getAgentOutputSchema(output.table);
|
|
145
|
+
const builtDescriptor = buildOutputSchemaDescriptor(schemaForDescriptor, {
|
|
146
|
+
onWarning: (warning) => collected.push(warning),
|
|
147
|
+
});
|
|
148
|
+
return { descriptor: builtDescriptor, warnings: collected };
|
|
149
|
+
},
|
|
150
|
+
);
|
|
151
|
+
|
|
152
|
+
if (warnings.length > 0) {
|
|
153
|
+
for (const warning of warnings) {
|
|
154
|
+
await swallow(() => emitEffect(Effect.all([
|
|
155
|
+
Metric.increment(nodeOutputSchemaConversionErrorTotal),
|
|
156
|
+
Effect.logWarning("getNodeOutput schema conversion warning").pipe(Effect.annotateLogs({
|
|
157
|
+
runId,
|
|
158
|
+
nodeId,
|
|
159
|
+
iteration,
|
|
160
|
+
errorCode: warning.code,
|
|
161
|
+
field: warning.field,
|
|
162
|
+
construct: warning.construct,
|
|
163
|
+
})),
|
|
164
|
+
], { discard: true })));
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const selectOutputRowImpl = params.selectOutputRowImpl ?? selectOutputRow;
|
|
169
|
+
let selectedRow;
|
|
170
|
+
try {
|
|
171
|
+
selectedRow = await emitEffectSpan(
|
|
172
|
+
emitEffect,
|
|
173
|
+
"db.outputs.select",
|
|
174
|
+
{ runId, nodeId, iteration },
|
|
175
|
+
() => selectOutputRowImpl(resolved.workflow.db, output.table, {
|
|
176
|
+
runId,
|
|
177
|
+
nodeId,
|
|
178
|
+
iteration,
|
|
179
|
+
}),
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
catch (error) {
|
|
183
|
+
if (looksLikeMalformedOutputRow(error)) {
|
|
184
|
+
throw new NodeOutputRouteError("MalformedOutputRow", "Output row is not parseable JSON.");
|
|
185
|
+
}
|
|
186
|
+
throw error;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const hasRow = selectedRow !== undefined;
|
|
190
|
+
let normalizedRow = null;
|
|
191
|
+
if (hasRow) {
|
|
192
|
+
normalizedRow = normalizeOutputRow(selectedRow);
|
|
193
|
+
if (normalizedRow !== null && !isPlainObject(normalizedRow)) {
|
|
194
|
+
throw new NodeOutputRouteError("MalformedOutputRow", "Output row must be a JSON object or null.");
|
|
195
|
+
}
|
|
196
|
+
rowBytes = byteLengthOfJson(normalizedRow);
|
|
197
|
+
rootSpanAttrs.bytes = rowBytes;
|
|
198
|
+
if (rowBytes > NODE_OUTPUT_MAX_BYTES) {
|
|
199
|
+
throw new NodeOutputRouteError("PayloadTooLarge", `Output payload exceeds ${NODE_OUTPUT_MAX_BYTES} bytes.`);
|
|
200
|
+
}
|
|
201
|
+
if (rowBytes > NODE_OUTPUT_WARN_BYTES) {
|
|
202
|
+
await swallow(() => emitEffect(Effect.logWarning("getNodeOutput large payload").pipe(Effect.annotateLogs({
|
|
203
|
+
runId,
|
|
204
|
+
nodeId,
|
|
205
|
+
iteration,
|
|
206
|
+
rowBytes,
|
|
207
|
+
}))));
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if (hasRow) {
|
|
212
|
+
statusForMetrics = "produced";
|
|
213
|
+
rootSpanAttrs.status = "produced";
|
|
214
|
+
return {
|
|
215
|
+
status: "produced",
|
|
216
|
+
row: normalizedRow,
|
|
217
|
+
schema: descriptor,
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const attempts = await resolved.adapter.listAttempts(runId, nodeId, iteration);
|
|
222
|
+
const latestAttempt = Array.isArray(attempts) ? attempts[0] : undefined;
|
|
223
|
+
const failed =
|
|
224
|
+
node.state === "failed" ||
|
|
225
|
+
latestAttempt?.state === "failed" ||
|
|
226
|
+
(typeof latestAttempt?.errorJson === "string" && latestAttempt.errorJson.length > 0);
|
|
227
|
+
|
|
228
|
+
if (failed) {
|
|
229
|
+
statusForMetrics = "failed";
|
|
230
|
+
rootSpanAttrs.status = "failed";
|
|
231
|
+
return {
|
|
232
|
+
status: "failed",
|
|
233
|
+
row: null,
|
|
234
|
+
schema: descriptor,
|
|
235
|
+
partial: parsePartialHeartbeat(latestAttempt?.heartbeatDataJson),
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
statusForMetrics = "pending";
|
|
240
|
+
rootSpanAttrs.status = "pending";
|
|
241
|
+
return {
|
|
242
|
+
status: "pending",
|
|
243
|
+
row: null,
|
|
244
|
+
schema: descriptor,
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
catch (error) {
|
|
248
|
+
if (error instanceof NodeOutputRouteError) {
|
|
249
|
+
logErrorCode = error.code;
|
|
250
|
+
if (error.code === "MalformedOutputRow") {
|
|
251
|
+
await swallow(() => emitEffect(Effect.logError("getNodeOutput malformed row").pipe(Effect.annotateLogs({
|
|
252
|
+
runId: logRunId,
|
|
253
|
+
nodeId: logNodeId,
|
|
254
|
+
iteration: logIteration,
|
|
255
|
+
errorCode: error.code,
|
|
256
|
+
}))));
|
|
257
|
+
}
|
|
258
|
+
throw error;
|
|
259
|
+
}
|
|
260
|
+
logErrorCode = asString(error?.code) ?? "ServerError";
|
|
261
|
+
await swallow(() => emitEffect(Effect.logError("getNodeOutput failed").pipe(Effect.annotateLogs({
|
|
262
|
+
runId: logRunId,
|
|
263
|
+
nodeId: logNodeId,
|
|
264
|
+
iteration: logIteration,
|
|
265
|
+
errorCode: logErrorCode,
|
|
266
|
+
errorMessage: asString(error?.message) ?? String(error),
|
|
267
|
+
}))));
|
|
268
|
+
throw error;
|
|
269
|
+
}
|
|
270
|
+
finally {
|
|
271
|
+
const durationMs = Math.max(0, performance.now() - startedAt);
|
|
272
|
+
rootSpanAttrs.bytes = rowBytes;
|
|
273
|
+
// Root span wrapping the finalisation (one span per RPC).
|
|
274
|
+
await swallow(() => runPromise(Effect.sync(() => {
|
|
275
|
+
// No-op body; the span records start/end times and attributes.
|
|
276
|
+
}).pipe(Effect.withSpan("devtools.getNodeOutput", {
|
|
277
|
+
attributes: {
|
|
278
|
+
...rootSpanAttrs,
|
|
279
|
+
status: statusForMetrics,
|
|
280
|
+
bytes: rowBytes,
|
|
281
|
+
durationMs,
|
|
282
|
+
...(logErrorCode ? { errorCode: logErrorCode } : {}),
|
|
283
|
+
},
|
|
284
|
+
}))));
|
|
285
|
+
await swallow(() => emitEffect(Effect.all([
|
|
286
|
+
Metric.increment(Metric.tagged(nodeOutputRequestTotal, "status", statusForMetrics)),
|
|
287
|
+
Metric.update(nodeOutputBytes, rowBytes),
|
|
288
|
+
Metric.update(nodeOutputDurationMs, durationMs),
|
|
289
|
+
Effect.logInfo("getNodeOutput completed").pipe(Effect.annotateLogs({
|
|
290
|
+
runId: logRunId,
|
|
291
|
+
nodeId: logNodeId,
|
|
292
|
+
iteration: logIteration,
|
|
293
|
+
status: statusForMetrics,
|
|
294
|
+
rowBytes,
|
|
295
|
+
durationMs,
|
|
296
|
+
...(logErrorCode ? { errorCode: logErrorCode } : {}),
|
|
297
|
+
})),
|
|
298
|
+
], { discard: true })));
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* @param {unknown} value
|
|
304
|
+
*/
|
|
305
|
+
function parseRunId(value) {
|
|
306
|
+
const runId = asString(value);
|
|
307
|
+
if (!runId || !RUN_ID_PATTERN.test(runId)) {
|
|
308
|
+
throw new NodeOutputRouteError("InvalidRunId", "runId must match /^[a-z0-9_-]{1,64}$/.");
|
|
309
|
+
}
|
|
310
|
+
return runId;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* @param {unknown} value
|
|
315
|
+
*/
|
|
316
|
+
function parseNodeId(value) {
|
|
317
|
+
const nodeId = asString(value);
|
|
318
|
+
if (!nodeId || !NODE_ID_PATTERN.test(nodeId)) {
|
|
319
|
+
throw new NodeOutputRouteError("InvalidNodeId", "nodeId must match /^[a-zA-Z0-9:_-]{1,128}$/.");
|
|
320
|
+
}
|
|
321
|
+
return nodeId;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* @param {unknown} value
|
|
326
|
+
*/
|
|
327
|
+
function parseIteration(value) {
|
|
328
|
+
const normalized = coerceOptionalInteger(value);
|
|
329
|
+
if (normalized === undefined || normalized < 0 || normalized > INT32_MAX) {
|
|
330
|
+
throw new NodeOutputRouteError("InvalidIteration", "iteration must be a non-negative 32-bit integer.");
|
|
331
|
+
}
|
|
332
|
+
return normalized;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* @param {unknown} value
|
|
337
|
+
* @returns {number | undefined}
|
|
338
|
+
*/
|
|
339
|
+
function coerceOptionalInteger(value) {
|
|
340
|
+
if (value === undefined || value === null || value === "") {
|
|
341
|
+
return undefined;
|
|
342
|
+
}
|
|
343
|
+
const numeric = typeof value === "number" ? value : Number(value);
|
|
344
|
+
if (!Number.isFinite(numeric) || !Number.isInteger(numeric)) {
|
|
345
|
+
return undefined;
|
|
346
|
+
}
|
|
347
|
+
return numeric;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* @param {unknown} workflow
|
|
352
|
+
* @param {string} outputTableName
|
|
353
|
+
* @returns {{ table: unknown; zodSchema?: unknown } | null}
|
|
354
|
+
*/
|
|
355
|
+
function resolveOutputDefinition(workflow, outputTableName) {
|
|
356
|
+
const wf = /** @type {Record<string, unknown> | null | undefined} */ (
|
|
357
|
+
workflow && typeof workflow === "object" ? workflow : null
|
|
358
|
+
);
|
|
359
|
+
const schemaRegistry = /** @type {{ get?: (key: string) => { table?: unknown; zodSchema?: unknown } | undefined; values?: () => Iterable<{ table?: unknown; zodSchema?: unknown }>; } | undefined} */ (
|
|
360
|
+
wf?.schemaRegistry
|
|
361
|
+
);
|
|
362
|
+
if (schemaRegistry && typeof schemaRegistry.get === "function") {
|
|
363
|
+
const hit = schemaRegistry.get(outputTableName);
|
|
364
|
+
if (hit?.table) {
|
|
365
|
+
return {
|
|
366
|
+
table: hit.table,
|
|
367
|
+
zodSchema: hit.zodSchema,
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
if (typeof schemaRegistry.values === "function") {
|
|
371
|
+
for (const entry of schemaRegistry.values()) {
|
|
372
|
+
if (!entry?.table) {
|
|
373
|
+
continue;
|
|
374
|
+
}
|
|
375
|
+
try {
|
|
376
|
+
if (getTableName(entry.table) === outputTableName) {
|
|
377
|
+
return {
|
|
378
|
+
table: entry.table,
|
|
379
|
+
zodSchema: entry.zodSchema,
|
|
380
|
+
};
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
catch { }
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
const db = /** @type {Record<string, unknown> | undefined} */ (
|
|
389
|
+
wf?.db && typeof wf.db === "object" ? wf.db : undefined
|
|
390
|
+
);
|
|
391
|
+
const dbInternal = /** @type {Record<string, unknown> | undefined} */ (
|
|
392
|
+
db?._ && typeof db._ === "object" ? db._ : undefined
|
|
393
|
+
);
|
|
394
|
+
const candidates = [
|
|
395
|
+
dbInternal?.fullSchema,
|
|
396
|
+
dbInternal?.schema,
|
|
397
|
+
db?.schema,
|
|
398
|
+
];
|
|
399
|
+
for (const candidate of candidates) {
|
|
400
|
+
if (!candidate || typeof candidate !== "object") {
|
|
401
|
+
continue;
|
|
402
|
+
}
|
|
403
|
+
const candidateRecord = /** @type {Record<string, unknown>} */ (candidate);
|
|
404
|
+
const direct = candidateRecord[outputTableName];
|
|
405
|
+
if (direct) {
|
|
406
|
+
return { table: direct };
|
|
407
|
+
}
|
|
408
|
+
for (const table of Object.values(candidateRecord)) {
|
|
409
|
+
try {
|
|
410
|
+
if (getTableName(table) === outputTableName) {
|
|
411
|
+
return { table };
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
catch { }
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
return null;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* @param {unknown} value
|
|
423
|
+
*/
|
|
424
|
+
function isDescriptorSchema(value) {
|
|
425
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value) && "shape" in value;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* @param {unknown} row
|
|
430
|
+
* @returns {unknown}
|
|
431
|
+
*/
|
|
432
|
+
function normalizeOutputRow(row) {
|
|
433
|
+
if (!row || typeof row !== "object") {
|
|
434
|
+
return row ?? null;
|
|
435
|
+
}
|
|
436
|
+
const r = /** @type {Record<string, unknown>} */ (row);
|
|
437
|
+
const keys = Object.keys(r);
|
|
438
|
+
const payloadOnly =
|
|
439
|
+
"payload" in r &&
|
|
440
|
+
keys.every((key) => key === "runId" || key === "nodeId" || key === "iteration" || key === "payload");
|
|
441
|
+
if (payloadOnly) {
|
|
442
|
+
return r.payload ?? null;
|
|
443
|
+
}
|
|
444
|
+
return stripAutoColumns(r);
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/**
|
|
448
|
+
* @param {unknown} value
|
|
449
|
+
*/
|
|
450
|
+
function parsePartialHeartbeat(value) {
|
|
451
|
+
if (typeof value !== "string" || value.length === 0) {
|
|
452
|
+
return null;
|
|
453
|
+
}
|
|
454
|
+
try {
|
|
455
|
+
const parsed = JSON.parse(value);
|
|
456
|
+
return isPlainObject(parsed) ? parsed : null;
|
|
457
|
+
}
|
|
458
|
+
catch {
|
|
459
|
+
return null;
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* @param {unknown} value
|
|
465
|
+
*/
|
|
466
|
+
function byteLengthOfJson(value) {
|
|
467
|
+
let json;
|
|
468
|
+
try {
|
|
469
|
+
json = JSON.stringify(value);
|
|
470
|
+
}
|
|
471
|
+
catch {
|
|
472
|
+
throw new NodeOutputRouteError("MalformedOutputRow", "Output row is not valid JSON.");
|
|
473
|
+
}
|
|
474
|
+
if (typeof json !== "string") {
|
|
475
|
+
throw new NodeOutputRouteError("MalformedOutputRow", "Output row is not valid JSON.");
|
|
476
|
+
}
|
|
477
|
+
return Buffer.byteLength(json, "utf8");
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/**
|
|
481
|
+
* @param {unknown} error
|
|
482
|
+
*/
|
|
483
|
+
function looksLikeMalformedOutputRow(error) {
|
|
484
|
+
if (error instanceof SyntaxError) {
|
|
485
|
+
return true;
|
|
486
|
+
}
|
|
487
|
+
const message = asString(error?.message)?.toLowerCase() ?? "";
|
|
488
|
+
return message.includes("json") || message.includes("parse") || message.includes("malformed");
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/**
|
|
492
|
+
* @param {unknown} value
|
|
493
|
+
* @returns {value is Record<string, unknown>}
|
|
494
|
+
*/
|
|
495
|
+
function isPlainObject(value) {
|
|
496
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/**
|
|
500
|
+
* @param {unknown} value
|
|
501
|
+
*/
|
|
502
|
+
function asString(value) {
|
|
503
|
+
return typeof value === "string" ? value : undefined;
|
|
504
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { jumpToFrame, JumpToFrameError } from "@smithers-orchestrator/time-travel/jumpToFrame";
|
|
2
|
+
|
|
3
|
+
/** @typedef {import("@smithers-orchestrator/db/adapter").SmithersDb} SmithersDb */
|
|
4
|
+
/** @typedef {import("@smithers-orchestrator/observability/SmithersEvent").SmithersEvent} SmithersEvent */
|
|
5
|
+
/** @typedef {import("@smithers-orchestrator/time-travel/jumpToFrame").JumpResult} JumpResult */
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Gateway wrapper around time-travel jump orchestration.
|
|
9
|
+
*
|
|
10
|
+
* The gateway has no direct hook into the engine's in-memory reconciler
|
|
11
|
+
* (reconciler state is DB-backed: frames, nodes, attempts). We wire real
|
|
12
|
+
* capture/restore/rebuild functions that operate on the run's DB state so
|
|
13
|
+
* that the transaction rollback path inside jumpToFrame has meaningful
|
|
14
|
+
* inputs, and callers can plug in an in-memory reconciler if they have one.
|
|
15
|
+
*
|
|
16
|
+
* @param {{
|
|
17
|
+
* adapter: SmithersDb;
|
|
18
|
+
* runId: unknown;
|
|
19
|
+
* frameNo: unknown;
|
|
20
|
+
* confirm?: unknown;
|
|
21
|
+
* caller?: string;
|
|
22
|
+
* pauseRunLoop?: () => Promise<void> | void;
|
|
23
|
+
* resumeRunLoop?: () => Promise<void> | void;
|
|
24
|
+
* emitEvent?: (event: SmithersEvent) => Promise<void> | void;
|
|
25
|
+
* captureReconcilerState?: () => Promise<unknown> | unknown;
|
|
26
|
+
* restoreReconcilerState?: (snapshot: unknown) => Promise<void> | void;
|
|
27
|
+
* rebuildReconcilerState?: (xmlJson: string) => Promise<void> | void;
|
|
28
|
+
* onLog?: (level: "info" | "warn" | "error", message: string, fields?: Record<string, unknown>) => Promise<void> | void;
|
|
29
|
+
* }} input
|
|
30
|
+
* @returns {Promise<JumpResult>}
|
|
31
|
+
*/
|
|
32
|
+
export async function jumpToFrameRoute(input) {
|
|
33
|
+
const adapter = input.adapter;
|
|
34
|
+
const runId = typeof input.runId === "string" ? input.runId : null;
|
|
35
|
+
|
|
36
|
+
// Default reconciler hooks: DB-backed snapshot of latest frame + no-op
|
|
37
|
+
// restore (frames are rolled back by the main transaction) + a rebuild
|
|
38
|
+
// that simply annotates the run record with the target frame xml hash,
|
|
39
|
+
// making it observable that a rewind happened.
|
|
40
|
+
const defaultCapture = async () => {
|
|
41
|
+
if (!runId) {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
try {
|
|
45
|
+
const latest = await adapter.getLastFrame(runId);
|
|
46
|
+
if (!latest) {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
return {
|
|
50
|
+
frameNo: Number(latest.frameNo),
|
|
51
|
+
createdAtMs: Number(latest.createdAtMs),
|
|
52
|
+
xmlHash: typeof latest.xmlHash === "string" ? latest.xmlHash : null,
|
|
53
|
+
};
|
|
54
|
+
} catch {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
const defaultRestore = async () => {
|
|
59
|
+
// Frames/attempts/nodes rollback is handled by the main transaction; no
|
|
60
|
+
// separate in-memory restore is required for the DB-driven engine.
|
|
61
|
+
};
|
|
62
|
+
const defaultRebuild = async () => {
|
|
63
|
+
// Rebuild is a no-op for the DB-driven engine; the next resume reads
|
|
64
|
+
// state directly from _smithers_frames. Callers that have an in-memory
|
|
65
|
+
// reconciler can inject their own rebuildReconcilerState hook.
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
return await jumpToFrame({
|
|
69
|
+
adapter: input.adapter,
|
|
70
|
+
runId: input.runId,
|
|
71
|
+
frameNo: input.frameNo,
|
|
72
|
+
confirm: input.confirm,
|
|
73
|
+
caller: input.caller,
|
|
74
|
+
pauseRunLoop: input.pauseRunLoop,
|
|
75
|
+
resumeRunLoop: input.resumeRunLoop,
|
|
76
|
+
emitEvent: input.emitEvent,
|
|
77
|
+
captureReconcilerState: input.captureReconcilerState ?? defaultCapture,
|
|
78
|
+
restoreReconcilerState: input.restoreReconcilerState ?? defaultRestore,
|
|
79
|
+
rebuildReconcilerState: input.rebuildReconcilerState ?? defaultRebuild,
|
|
80
|
+
onLog: input.onLog,
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export { JumpToFrameError };
|