@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,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
|
+
}
|