@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.
@@ -0,0 +1,6 @@
1
+ export type DiffSummary = {
2
+ filesChanged: number;
3
+ added: number;
4
+ removed: number;
5
+ files: Array<{ path: string; added: number; removed: number }>;
6
+ };
@@ -0,0 +1,23 @@
1
+ import type { DiffBundle } from "@smithers-orchestrator/engine/effect/DiffBundle";
2
+ import type { DiffSummary } from "./DiffSummary";
3
+
4
+ export type GetNodeDiffStatPayload = {
5
+ seq: number;
6
+ baseRef: string;
7
+ summary: DiffSummary;
8
+ };
9
+
10
+ export type GetNodeDiffRoutePayload = DiffBundle | GetNodeDiffStatPayload;
11
+
12
+ export type GetNodeDiffRouteResult =
13
+ | {
14
+ ok: true;
15
+ payload: GetNodeDiffRoutePayload;
16
+ }
17
+ | {
18
+ ok: false;
19
+ error: {
20
+ code: string;
21
+ message: string;
22
+ };
23
+ };
@@ -0,0 +1 @@
1
+ export const NODE_OUTPUT_MAX_BYTES = 100 * 1024 * 1024;
@@ -0,0 +1 @@
1
+ export const NODE_OUTPUT_WARN_BYTES = 1_048_576;
@@ -0,0 +1,22 @@
1
+ export type NodeOutputResponse = {
2
+ status: "produced" | "pending" | "failed";
3
+ row: Record<string, unknown> | null;
4
+ schema: {
5
+ fields: Array<{
6
+ name: string;
7
+ type:
8
+ | "string"
9
+ | "number"
10
+ | "boolean"
11
+ | "object"
12
+ | "array"
13
+ | "null"
14
+ | "unknown";
15
+ optional: boolean;
16
+ nullable: boolean;
17
+ description?: string;
18
+ enum?: readonly unknown[];
19
+ }>;
20
+ } | null;
21
+ partial?: Record<string, unknown> | null;
22
+ };
@@ -0,0 +1,14 @@
1
+ /** @typedef {import("@smithers-orchestrator/protocol/errors").NodeOutputErrorCode} NodeOutputErrorCode */
2
+
3
+ export class NodeOutputRouteError extends Error {
4
+ /**
5
+ * @param {NodeOutputErrorCode} code
6
+ * @param {string} message
7
+ */
8
+ constructor(code, message) {
9
+ super(message);
10
+ this.name = "NodeOutputRouteError";
11
+ /** @type {NodeOutputErrorCode} */
12
+ this.code = code;
13
+ }
14
+ }
@@ -0,0 +1,428 @@
1
+ import { SmithersDevToolsCore, snapshotSerialize } from "@smithers-orchestrator/devtools";
2
+ import { computeRunStateFromRow } from "@smithers-orchestrator/db/runState";
3
+
4
+ /** @typedef {import("@smithers-orchestrator/db/adapter").SmithersDb} SmithersDb */
5
+ /** @typedef {import("@smithers-orchestrator/protocol/devtools").DevToolsNode} DevToolsNode */
6
+ /** @typedef {import("@smithers-orchestrator/protocol/devtools").DevToolsSnapshot} DevToolsSnapshot */
7
+ /** @typedef {import("@smithers-orchestrator/protocol/devtools").DevToolsNodeType} DevToolsNodeType */
8
+ /** @typedef {import("@smithers-orchestrator/devtools/snapshotSerializer").SnapshotSerializerWarning} SnapshotSerializerWarning */
9
+
10
+ export const DEVTOOLS_RUN_ID_PATTERN = /^[a-z0-9_-]{1,64}$/;
11
+ export const DEVTOOLS_MAX_FRAME_NO = 2_147_483_647;
12
+ export const DEVTOOLS_TREE_MAX_DEPTH = 256;
13
+
14
+ const DEVTOOLS_TAG_TO_TYPE = {
15
+ "smithers:workflow": "workflow",
16
+ "smithers:task": "task",
17
+ "smithers:sequence": "sequence",
18
+ "smithers:parallel": "parallel",
19
+ "smithers:merge-queue": "merge-queue",
20
+ "smithers:branch": "branch",
21
+ "smithers:ralph": "loop",
22
+ "smithers:worktree": "worktree",
23
+ "smithers:approval": "approval",
24
+ "smithers:timer": "timer",
25
+ "smithers:subflow": "subflow",
26
+ "smithers:wait-for-event": "wait-for-event",
27
+ "smithers:saga": "saga",
28
+ "smithers:try-catch-finally": "try-catch",
29
+ };
30
+
31
+ export class DevToolsRouteError extends Error {
32
+ /**
33
+ * @param {string} code
34
+ * @param {string} message
35
+ * @param {string} [hint]
36
+ */
37
+ constructor(code, message, hint) {
38
+ super(message);
39
+ this.name = "DevToolsRouteError";
40
+ this.code = code;
41
+ this.hint = hint;
42
+ }
43
+ }
44
+
45
+ /**
46
+ * @param {unknown} value
47
+ * @returns {value is Record<string, unknown>}
48
+ */
49
+ function asObject(value) {
50
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
51
+ }
52
+
53
+ export const DEVTOOLS_EMPTY_ROOT_ID = 0;
54
+
55
+ /**
56
+ * @returns {DevToolsNode}
57
+ */
58
+ export function emptyDevToolsRoot() {
59
+ return {
60
+ id: DEVTOOLS_EMPTY_ROOT_ID,
61
+ type: "workflow",
62
+ name: "(empty)",
63
+ props: {},
64
+ children: [],
65
+ depth: 0,
66
+ };
67
+ }
68
+
69
+ /**
70
+ * @param {string} runId
71
+ * @returns {string}
72
+ */
73
+ export function validateRunId(runId) {
74
+ if (!DEVTOOLS_RUN_ID_PATTERN.test(runId)) {
75
+ throw new DevToolsRouteError("InvalidRunId", "runId must match /^[a-z0-9_-]{1,64}$/.");
76
+ }
77
+ return runId;
78
+ }
79
+
80
+ /**
81
+ * @param {unknown} frameNo
82
+ * @param {number} latestFrameNo
83
+ * @returns {number}
84
+ */
85
+ export function validateRequestedFrameNo(frameNo, latestFrameNo) {
86
+ if (!Number.isInteger(frameNo) || frameNo < 0 || frameNo > DEVTOOLS_MAX_FRAME_NO || frameNo > latestFrameNo) {
87
+ throw new DevToolsRouteError("FrameOutOfRange", `frameNo must be between 0 and ${latestFrameNo}.`);
88
+ }
89
+ return frameNo;
90
+ }
91
+
92
+ /**
93
+ * @param {Record<string, unknown>} props
94
+ * @returns {DevToolsNode["task"] | undefined}
95
+ */
96
+ function extractTaskInfo(props) {
97
+ const rawNodeId = typeof props.id === "string"
98
+ ? props.id
99
+ : typeof props.nodeId === "string"
100
+ ? props.nodeId
101
+ : null;
102
+ if (!rawNodeId) {
103
+ return undefined;
104
+ }
105
+ let nodeId = rawNodeId;
106
+ let iteration = typeof props.iteration === "number" ? props.iteration : undefined;
107
+ const match = rawNodeId.match(/^(.*)::(\d+)$/);
108
+ if (match) {
109
+ nodeId = match[1];
110
+ if (iteration === undefined) {
111
+ iteration = Number(match[2]);
112
+ }
113
+ }
114
+ const kind = props.__smithersKind === "agent" || props.kind === "agent"
115
+ ? "agent"
116
+ : props.__smithersKind === "compute" || props.kind === "compute"
117
+ ? "compute"
118
+ : "static";
119
+ return {
120
+ nodeId,
121
+ kind,
122
+ agent: typeof props.agent === "string" ? props.agent : undefined,
123
+ label: typeof props.label === "string" ? props.label : undefined,
124
+ outputTableName: typeof props.outputTableName === "string"
125
+ ? props.outputTableName
126
+ : typeof props.output === "string"
127
+ ? props.output
128
+ : undefined,
129
+ iteration: typeof iteration === "number" && Number.isFinite(iteration)
130
+ ? iteration
131
+ : undefined,
132
+ };
133
+ }
134
+
135
+ /**
136
+ * @param {string} raw
137
+ * @returns {unknown}
138
+ */
139
+ function parsePropValue(raw) {
140
+ if (raw === "true") {
141
+ return true;
142
+ }
143
+ if (raw === "false") {
144
+ return false;
145
+ }
146
+ if (raw === "null") {
147
+ return null;
148
+ }
149
+ if (/^-?\d+(?:\.\d+)?$/.test(raw)) {
150
+ const parsedNumber = Number(raw);
151
+ if (Number.isFinite(parsedNumber)) {
152
+ return parsedNumber;
153
+ }
154
+ }
155
+ if ((raw.startsWith("{") && raw.endsWith("}")) || (raw.startsWith("[") && raw.endsWith("]"))) {
156
+ try {
157
+ return JSON.parse(raw);
158
+ }
159
+ catch {
160
+ return raw;
161
+ }
162
+ }
163
+ return raw;
164
+ }
165
+
166
+ /**
167
+ * Derive a stable 31-bit numeric id from a node identity string.
168
+ *
169
+ * The identity must be deterministic across frames for the same logical node
170
+ * so that diff/apply round-trips do not mistake a reorder for a removal + re-add
171
+ * or reuse an id across unrelated nodes.
172
+ *
173
+ * @param {string} identity
174
+ * @returns {number}
175
+ */
176
+ function stableNodeId(identity) {
177
+ // FNV-1a 32-bit hash, masked to 31-bit positive so JSON numbers are safe.
178
+ let hash = 0x811c9dc5;
179
+ for (let index = 0; index < identity.length; index += 1) {
180
+ hash ^= identity.charCodeAt(index);
181
+ hash = (hash + ((hash << 1) + (hash << 4) + (hash << 7) + (hash << 8) + (hash << 24))) >>> 0;
182
+ }
183
+ return hash & 0x7fffffff;
184
+ }
185
+
186
+ /**
187
+ * @param {Record<string, unknown>} element
188
+ * @returns {string}
189
+ */
190
+ function nodeIdentityFragment(element) {
191
+ const tag = typeof element.tag === "string" ? element.tag : "unknown";
192
+ const rawProps = asObject(element.props) ? element.props : {};
193
+ const taskId = typeof rawProps.id === "string"
194
+ ? rawProps.id
195
+ : typeof rawProps.nodeId === "string"
196
+ ? rawProps.nodeId
197
+ : "";
198
+ if (taskId) {
199
+ return `${tag}#${taskId}`;
200
+ }
201
+ return tag;
202
+ }
203
+
204
+ /**
205
+ * @param {unknown} xml
206
+ * @param {(warning: SnapshotSerializerWarning) => void} [onWarning]
207
+ * @returns {DevToolsNode}
208
+ */
209
+ export function parseXmlToDevToolsRoot(xml, onWarning) {
210
+ if (!asObject(xml) || xml.kind !== "element") {
211
+ return emptyDevToolsRoot();
212
+ }
213
+ /** @type {Set<number>} */
214
+ const usedIds = new Set();
215
+ /**
216
+ * @param {string} identity
217
+ * @returns {number}
218
+ */
219
+ const assignId = (identity) => {
220
+ let candidate = identity;
221
+ let id = stableNodeId(candidate);
222
+ // Collisions across unrelated paths: rehash with a suffix until unique.
223
+ let salt = 0;
224
+ while (usedIds.has(id) && salt < 1024) {
225
+ salt += 1;
226
+ candidate = `${identity}\u0000${salt}`;
227
+ id = stableNodeId(candidate);
228
+ }
229
+ usedIds.add(id);
230
+ return id;
231
+ };
232
+ /**
233
+ * @param {Record<string, unknown>} element
234
+ * @param {number} depth
235
+ * @param {string} path
236
+ * @returns {DevToolsNode}
237
+ */
238
+ const makeNode = (element, depth, path) => {
239
+ const tag = typeof element.tag === "string" ? element.tag : "unknown";
240
+ const nodeType = DEVTOOLS_TAG_TO_TYPE[tag] ?? "unknown";
241
+ const rawProps = asObject(element.props) ? element.props : {};
242
+ /** @type {Record<string, unknown>} */
243
+ const serializedProps = {};
244
+ for (const [key, value] of Object.entries(rawProps)) {
245
+ const parsedValue = typeof value === "string" ? parsePropValue(value) : value;
246
+ serializedProps[key] = snapshotSerialize(parsedValue, {
247
+ onWarning,
248
+ });
249
+ }
250
+ const displayName = nodeType === "workflow" && typeof serializedProps.name === "string"
251
+ ? serializedProps.name
252
+ : tag.startsWith("smithers:")
253
+ ? tag.slice("smithers:".length)
254
+ : tag;
255
+ return {
256
+ id: assignId(path),
257
+ type: /** @type {DevToolsNodeType} */ (nodeType),
258
+ name: displayName || "unknown",
259
+ props: serializedProps,
260
+ task: nodeType === "task" ? extractTaskInfo(serializedProps) : undefined,
261
+ children: [],
262
+ depth,
263
+ };
264
+ };
265
+ const rootIdentity = nodeIdentityFragment(xml);
266
+ const root = makeNode(xml, 0, rootIdentity);
267
+ /** @type {Array<{ xml: Record<string, unknown>; node: DevToolsNode; depth: number; path: string }>} */
268
+ const stack = [{ xml, node: root, depth: 0, path: rootIdentity }];
269
+ while (stack.length > 0) {
270
+ const current = stack.pop();
271
+ if (!current) {
272
+ continue;
273
+ }
274
+ const rawChildren = Array.isArray(current.xml.children)
275
+ ? current.xml.children
276
+ : [];
277
+ /** @type {Array<{ xml: Record<string, unknown>; node: DevToolsNode; depth: number; path: string }>} */
278
+ const childPairs = [];
279
+ /** @type {Map<string, number>} */
280
+ const siblingCounts = new Map();
281
+ for (const child of rawChildren) {
282
+ if (!asObject(child) || child.kind !== "element") {
283
+ continue;
284
+ }
285
+ const childDepth = current.depth + 1;
286
+ if (childDepth > DEVTOOLS_TREE_MAX_DEPTH) {
287
+ const markerPath = `${current.path}/__maxdepth__${current.node.children.length}`;
288
+ current.node.children.push({
289
+ id: assignId(markerPath),
290
+ type: "unknown",
291
+ name: "[MaxDepth]",
292
+ props: { value: "[MaxDepth]" },
293
+ children: [],
294
+ depth: childDepth,
295
+ });
296
+ continue;
297
+ }
298
+ const fragment = nodeIdentityFragment(child);
299
+ const occurrence = siblingCounts.get(fragment) ?? 0;
300
+ siblingCounts.set(fragment, occurrence + 1);
301
+ const childPath = occurrence === 0
302
+ ? `${current.path}/${fragment}`
303
+ : `${current.path}/${fragment}[${occurrence}]`;
304
+ const childNode = makeNode(child, childDepth, childPath);
305
+ current.node.children.push(childNode);
306
+ childPairs.push({ xml: child, node: childNode, depth: childDepth, path: childPath });
307
+ }
308
+ for (let index = childPairs.length - 1; index >= 0; index -= 1) {
309
+ stack.push(childPairs[index]);
310
+ }
311
+ }
312
+ return root;
313
+ }
314
+
315
+ /**
316
+ * @param {{
317
+ * runId: string;
318
+ * frameNo: number;
319
+ * xmlJson: string;
320
+ * onWarning?: (warning: SnapshotSerializerWarning) => void;
321
+ * }} input
322
+ * @returns {DevToolsSnapshot}
323
+ */
324
+ export function snapshotFromFrameRow(input) {
325
+ let xml = null;
326
+ try {
327
+ xml = JSON.parse(input.xmlJson);
328
+ }
329
+ catch {
330
+ xml = null;
331
+ }
332
+ const root = parseXmlToDevToolsRoot(xml, input.onWarning);
333
+ // Keep parity with existing devtools snapshot capture semantics.
334
+ const core = new SmithersDevToolsCore();
335
+ core.captureSnapshot(root);
336
+ return {
337
+ version: 1,
338
+ runId: input.runId,
339
+ frameNo: input.frameNo,
340
+ seq: input.frameNo,
341
+ root,
342
+ };
343
+ }
344
+
345
+ /**
346
+ * Validate a frameNo input before any DB or reconciler call so that oversized
347
+ * or malformed numeric inputs never reach the adapter.
348
+ *
349
+ * @param {unknown} frameNo
350
+ * @returns {void}
351
+ */
352
+ export function validateFrameNoInput(frameNo) {
353
+ if (frameNo === undefined) {
354
+ return;
355
+ }
356
+ if (!Number.isInteger(frameNo) || frameNo < 0 || frameNo > DEVTOOLS_MAX_FRAME_NO) {
357
+ throw new DevToolsRouteError("FrameOutOfRange", `frameNo must be an integer between 0 and ${DEVTOOLS_MAX_FRAME_NO}.`);
358
+ }
359
+ }
360
+
361
+ /**
362
+ * Validate a fromSeq input before any DB or reconciler call.
363
+ *
364
+ * @param {unknown} fromSeq
365
+ * @returns {void}
366
+ */
367
+ export function validateFromSeqInput(fromSeq) {
368
+ if (fromSeq === undefined) {
369
+ return;
370
+ }
371
+ if (!Number.isInteger(fromSeq) || fromSeq < 0 || fromSeq > Number.MAX_SAFE_INTEGER) {
372
+ throw new DevToolsRouteError("SeqOutOfRange", "fromSeq must be a non-negative integer.");
373
+ }
374
+ }
375
+
376
+ /**
377
+ * @param {{
378
+ * adapter: SmithersDb;
379
+ * runId: string;
380
+ * frameNo?: number;
381
+ * onWarning?: (warning: SnapshotSerializerWarning) => void;
382
+ * }} input
383
+ * @returns {Promise<DevToolsSnapshot>}
384
+ */
385
+ export async function getDevToolsSnapshotRoute(input) {
386
+ const runId = validateRunId(input.runId);
387
+ validateFrameNoInput(input.frameNo);
388
+ const run = await input.adapter.getRun(runId);
389
+ if (!run) {
390
+ throw new DevToolsRouteError("RunNotFound", `Run not found: ${runId}`);
391
+ }
392
+ const runState = await computeRunStateFromRow(input.adapter, run).catch(
393
+ () => undefined,
394
+ );
395
+ const latestFrame = await input.adapter.getLastFrame(runId);
396
+ if (!latestFrame) {
397
+ // Zero-frame runs: only frameNo === undefined or 0 is permitted. Any
398
+ // higher value is out of range because there is no frame 1 to return.
399
+ if (input.frameNo !== undefined && input.frameNo !== 0) {
400
+ throw new DevToolsRouteError("FrameOutOfRange", `frameNo must be 0 for runs with no frames (got ${input.frameNo}).`);
401
+ }
402
+ return {
403
+ version: 1,
404
+ runId,
405
+ frameNo: 0,
406
+ seq: 0,
407
+ root: emptyDevToolsRoot(),
408
+ ...(runState ? { runState } : {}),
409
+ };
410
+ }
411
+ let requestedFrameNo = latestFrame.frameNo;
412
+ if (input.frameNo !== undefined) {
413
+ requestedFrameNo = validateRequestedFrameNo(input.frameNo, latestFrame.frameNo);
414
+ }
415
+ const frame = requestedFrameNo === latestFrame.frameNo
416
+ ? latestFrame
417
+ : (await input.adapter.listFrames(runId, Math.max(latestFrame.frameNo - requestedFrameNo + 1, 50))).find((entry) => entry.frameNo === requestedFrameNo);
418
+ if (!frame) {
419
+ throw new DevToolsRouteError("FrameOutOfRange", `Frame ${requestedFrameNo} is not available for run ${runId}.`);
420
+ }
421
+ const snapshot = snapshotFromFrameRow({
422
+ runId,
423
+ frameNo: requestedFrameNo,
424
+ xmlJson: String(frame.xmlJson ?? "null"),
425
+ onWarning: input.onWarning,
426
+ });
427
+ return runState ? { ...snapshot, runState } : snapshot;
428
+ }