@smithers-orchestrator/server 0.16.8 → 0.17.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@smithers-orchestrator/server",
3
- "version": "0.16.8",
3
+ "version": "0.17.0",
4
4
  "description": "HTTP, WebSocket, gateway, cron, webhook, and metrics servers for Smithers",
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -22,21 +22,28 @@
22
22
  "dependencies": {
23
23
  "@effect/workflow": "^0.18.0",
24
24
  "cron-parser": "^5.5.0",
25
+ "drizzle-orm": "^0.45.2",
25
26
  "effect": "^3.21.1",
26
27
  "hono": "^4.12.14",
27
28
  "ws": "^8.20.0",
28
- "@smithers-orchestrator/components": "0.16.8",
29
- "@smithers-orchestrator/driver": "0.16.8",
30
- "@smithers-orchestrator/db": "0.16.8",
31
- "@smithers-orchestrator/engine": "0.16.8",
32
- "@smithers-orchestrator/errors": "0.16.8",
33
- "@smithers-orchestrator/time-travel": "0.16.8",
34
- "@smithers-orchestrator/scheduler": "0.16.8",
35
- "@smithers-orchestrator/observability": "0.16.8"
29
+ "@smithers-orchestrator/db": "0.17.0",
30
+ "@smithers-orchestrator/driver": "0.17.0",
31
+ "@smithers-orchestrator/devtools": "0.17.0",
32
+ "@smithers-orchestrator/errors": "0.17.0",
33
+ "@smithers-orchestrator/components": "0.17.0",
34
+ "@smithers-orchestrator/engine": "0.17.0",
35
+ "@smithers-orchestrator/gateway": "0.17.0",
36
+ "@smithers-orchestrator/observability": "0.17.0",
37
+ "@smithers-orchestrator/protocol": "0.17.0",
38
+ "@smithers-orchestrator/scheduler": "0.17.0",
39
+ "@smithers-orchestrator/time-travel": "0.17.0"
36
40
  },
37
41
  "devDependencies": {
38
42
  "@types/bun": "latest",
39
- "typescript": "~5.9.3"
43
+ "react": "^19.2.5",
44
+ "typescript": "~5.9.3",
45
+ "zod": "^4.3.6",
46
+ "@smithers-orchestrator/graph": "0.17.0"
40
47
  },
41
48
  "scripts": {
42
49
  "test": "bun test tests",
package/src/EventFrame.ts CHANGED
@@ -4,4 +4,5 @@ export type EventFrame = {
4
4
  payload?: unknown;
5
5
  seq: number;
6
6
  stateVersion: number;
7
+ apiVersion?: "v1";
7
8
  };
@@ -10,4 +10,21 @@ export type GatewayOptions = {
10
10
  maxBodyBytes?: number;
11
11
  maxPayload?: number;
12
12
  maxConnections?: number;
13
+ /**
14
+ * Per-run replay window for Gateway run event streams.
15
+ * @default 10000
16
+ */
17
+ eventWindowSize?: number;
18
+ /**
19
+ * Maximum time (in milliseconds) allowed for the HTTP parser to receive the
20
+ * complete headers of a single request. Helps mitigate slowloris attacks.
21
+ * @default 30000
22
+ */
23
+ headersTimeout?: number;
24
+ /**
25
+ * Maximum time (in milliseconds) allowed for a single request to be received
26
+ * and parsed, including the body. Helps mitigate slowloris attacks.
27
+ * @default 60000
28
+ */
29
+ requestTimeout?: number;
13
30
  };
@@ -2,4 +2,8 @@ export type GatewayTokenGrant = {
2
2
  role: string;
3
3
  scopes: string[];
4
4
  userId?: string;
5
+ tokenId?: string;
6
+ issuedAtMs?: number;
7
+ expiresAtMs?: number;
8
+ revokedAtMs?: number;
5
9
  };
@@ -2,9 +2,14 @@ export type ResponseFrame = {
2
2
  type: "res";
3
3
  id: string;
4
4
  ok: boolean;
5
+ apiVersion?: "v1";
5
6
  payload?: unknown;
6
7
  error?: {
8
+ version?: "v1";
7
9
  code: string;
8
10
  message: string;
11
+ requiredScope?: string;
12
+ refresh?: string;
13
+ details?: unknown;
9
14
  };
10
15
  };
@@ -5,4 +5,16 @@ export type ServerOptions = {
5
5
  maxBodyBytes?: number;
6
6
  rootDir?: string;
7
7
  allowNetwork?: boolean;
8
+ /**
9
+ * Maximum time (in milliseconds) allowed for the HTTP parser to receive the
10
+ * complete headers of a single request. Helps mitigate slowloris attacks.
11
+ * @default 30000
12
+ */
13
+ headersTimeout?: number;
14
+ /**
15
+ * Maximum time (in milliseconds) allowed for a single request to be received
16
+ * and parsed, including the body. Helps mitigate slowloris attacks.
17
+ * @default 60000
18
+ */
19
+ requestTimeout?: number;
8
20
  };
package/src/gateway.js CHANGED
@@ -24,7 +24,7 @@ import { errorToJson } from "@smithers-orchestrator/errors/errorToJson";
24
24
  import { isSmithersError } from "@smithers-orchestrator/errors/isSmithersError";
25
25
  import { SmithersError } from "@smithers-orchestrator/errors/SmithersError";
26
26
  import { assertJsonPayloadWithinBounds, assertOptionalStringMaxLength, assertPositiveFiniteInteger, } from "@smithers-orchestrator/db/input-bounds";
27
- import { loadLatestSnapshot, loadSnapshot } from "@smithers-orchestrator/time-travel/snapshot";
27
+ import { loadLatestSnapshot } from "@smithers-orchestrator/time-travel/snapshot";
28
28
  import { diffRawSnapshots } from "@smithers-orchestrator/time-travel/diff";
29
29
  import { getNodeOutputRoute } from "./gatewayRoutes/getNodeOutput.js";
30
30
  import { NodeOutputRouteError } from "./gatewayRoutes/NodeOutputRouteError.js";
@@ -34,6 +34,8 @@ import { streamDevToolsRoute } from "./gatewayRoutes/streamDevTools.js";
34
34
  import { jumpToFrameRoute, JumpToFrameError } from "./gatewayRoutes/jumpToFrame.js";
35
35
  import { writeRewindAuditRow } from "@smithers-orchestrator/time-travel/writeRewindAuditRow";
36
36
  import { recoverInProgressRewindAudits } from "@smithers-orchestrator/time-travel/recoverInProgressRewindAudits";
37
+ import { GATEWAY_EVENT_WINDOW_DEFAULT, SMITHERS_API_VERSION, getRequiredScopeForGatewayMethod, } from "@smithers-orchestrator/gateway/rpc";
38
+ import { hasGatewayScope } from "@smithers-orchestrator/gateway/auth/scopes";
37
39
  /** @typedef {import("./GatewayWebhookRunConfig.js").GatewayWebhookRunConfig} GatewayWebhookRunConfig */
38
40
  /** @typedef {import("./GatewayWebhookSignalConfig.js").GatewayWebhookSignalConfig} GatewayWebhookSignalConfig */
39
41
  /** @typedef {import("./ConnectRequest.js").ConnectRequest} ConnectRequest */
@@ -54,6 +56,7 @@ import { recoverInProgressRewindAudits } from "@smithers-orchestrator/time-trave
54
56
  * role?: string;
55
57
  * scopes?: string[];
56
58
  * userId?: string | null;
59
+ * tokenId?: string | null;
57
60
  * origin?: string;
58
61
  * transport?: GatewayTransport;
59
62
  * }} GatewayRequestContext
@@ -76,6 +79,7 @@ import { recoverInProgressRewindAudits } from "@smithers-orchestrator/time-trave
76
79
  * role: string;
77
80
  * scopes: string[];
78
81
  * userId?: string | null;
82
+ * tokenId?: string | null;
79
83
  * connectionId?: string;
80
84
  * }} RunStartAuthContext
81
85
  */
@@ -101,6 +105,9 @@ const DEFAULT_PROTOCOL = 1;
101
105
  const DEFAULT_HEARTBEAT_MS = 15_000;
102
106
  const DEFAULT_MAX_BODY_BYTES = 1_048_576;
103
107
  const DEFAULT_MAX_CONNECTIONS = 1_000;
108
+ const DEFAULT_HEADERS_TIMEOUT = 30_000;
109
+ const DEFAULT_REQUEST_TIMEOUT = 60_000;
110
+ const RUN_EVENT_HEARTBEAT_MS = 1_000;
104
111
  export const GATEWAY_RPC_MAX_PAYLOAD_BYTES = DEFAULT_MAX_BODY_BYTES;
105
112
  export const GATEWAY_RPC_MAX_DEPTH = 32;
106
113
  export const GATEWAY_RPC_MAX_ARRAY_LENGTH = 256;
@@ -110,43 +117,6 @@ export const GATEWAY_FRAME_ID_MAX_LENGTH = 128;
110
117
  export const GATEWAY_RPC_INPUT_MAX_BYTES = GATEWAY_RPC_MAX_PAYLOAD_BYTES;
111
118
  export const GATEWAY_RPC_INPUT_MAX_DEPTH = GATEWAY_RPC_MAX_DEPTH;
112
119
  const GATEWAY_METHOD_NAME_PATTERN = /^[a-z][a-zA-Z0-9]*(?:\.[a-z][a-zA-Z0-9]*)*$/;
113
- const ACCESS_RANK = {
114
- read: 1,
115
- execute: 2,
116
- approve: 3,
117
- admin: 4,
118
- };
119
- const METHOD_ACCESS = {
120
- health: "read",
121
- "runs.list": "read",
122
- "runs.get": "read",
123
- "runs.diff": "read",
124
- getNodeDiff: "read",
125
- "devtools.getNodeDiff": "read",
126
- getNodeOutput: "read",
127
- "devtools.getNodeOutput": "read",
128
- getDevToolsSnapshot: "read",
129
- streamDevTools: "read",
130
- // jumpToFrame authorizes per-request: owner OR admin role may rewind.
131
- // Require only `execute` scope so non-admin owners are not pre-blocked
132
- // at the scope gate; the run handler performs the final auth check.
133
- jumpToFrame: "execute",
134
- "devtools.jumpToFrame": "execute",
135
- "frames.list": "read",
136
- "frames.get": "read",
137
- "attempts.list": "read",
138
- "attempts.get": "read",
139
- "approvals.list": "read",
140
- "runs.create": "execute",
141
- "runs.cancel": "execute",
142
- "runs.rerun": "execute",
143
- "signals.send": "execute",
144
- "approvals.decide": "approve",
145
- "cron.list": "read",
146
- "cron.add": "admin",
147
- "cron.remove": "admin",
148
- "cron.trigger": "execute",
149
- };
150
120
  /**
151
121
  * @template T
152
122
  * @param {string | null | undefined} value
@@ -182,6 +152,7 @@ function sendJson(res, status, payload) {
182
152
  res.setHeader("Content-Type", "application/json");
183
153
  res.setHeader("Cache-Control", "no-store");
184
154
  res.setHeader("X-Content-Type-Options", "nosniff");
155
+ res.setHeader("X-Smithers-API-Version", SMITHERS_API_VERSION);
185
156
  res.end(JSON.stringify(payload));
186
157
  }
187
158
  /**
@@ -194,6 +165,7 @@ function sendText(res, status, payload, contentType = "text/plain; charset=utf-8
194
165
  res.setHeader("Content-Type", contentType);
195
166
  res.setHeader("Cache-Control", "no-store");
196
167
  res.setHeader("X-Content-Type-Options", "nosniff");
168
+ res.setHeader("X-Smithers-API-Version", SMITHERS_API_VERSION);
197
169
  res.end(payload);
198
170
  }
199
171
  /**
@@ -315,6 +287,7 @@ function gatewayContextAnnotations(context) {
315
287
  transport: context.transport,
316
288
  ...(context.userId ? { userId: context.userId } : {}),
317
289
  ...(context.role ? { role: context.role } : {}),
290
+ ...(context.tokenId ? { tokenId: context.tokenId } : {}),
318
291
  };
319
292
  }
320
293
  /**
@@ -450,7 +423,7 @@ function bearerTokenFromHeaders(req) {
450
423
  if (!authHeader) {
451
424
  return null;
452
425
  }
453
- return authHeader.startsWith("Bearer ") ? authHeader.slice(7) : authHeader;
426
+ return authHeader.slice(0, 7).toLowerCase() === "bearer " ? authHeader.slice(7) : authHeader;
454
427
  }
455
428
  /**
456
429
  * @param {unknown} value
@@ -465,16 +438,39 @@ function asStringRecord(value) {
465
438
  * @returns {ResponseFrame}
466
439
  */
467
440
  function responseOk(id, payload) {
468
- return { type: "res", id, ok: true, payload };
441
+ return { type: "res", id, ok: true, apiVersion: SMITHERS_API_VERSION, payload };
469
442
  }
470
443
  /**
471
444
  * @param {string} id
472
445
  * @param {string} code
473
446
  * @param {string} message
447
+ * @param {Record<string, unknown>} [details]
474
448
  * @returns {ResponseFrame}
475
449
  */
476
- function responseError(id, code, message) {
477
- return { type: "res", id, ok: false, error: { code, message } };
450
+ function responseError(id, code, message, details = {}) {
451
+ return {
452
+ type: "res",
453
+ id,
454
+ ok: false,
455
+ apiVersion: SMITHERS_API_VERSION,
456
+ error: {
457
+ version: SMITHERS_API_VERSION,
458
+ code,
459
+ message,
460
+ ...details,
461
+ },
462
+ };
463
+ }
464
+ /**
465
+ * @param {string} id
466
+ * @param {string} method
467
+ * @returns {ResponseFrame}
468
+ */
469
+ function responseForbidden(id, method) {
470
+ const requiredScope = requiredScopeForMethod(method);
471
+ return responseError(id, "FORBIDDEN", `Missing required scope ${requiredScope} for ${method}`, {
472
+ requiredScope,
473
+ });
478
474
  }
479
475
  /**
480
476
  * @param {unknown} raw
@@ -624,13 +620,16 @@ export function statusForRpcError(code) {
624
620
  case "Unauthorized":
625
621
  return 401;
626
622
  case "FORBIDDEN":
623
+ case "Forbidden":
627
624
  return 403;
628
625
  case "NOT_FOUND":
629
626
  case "METHOD_NOT_FOUND":
630
627
  return 404;
631
628
  case "INVALID_REQUEST":
629
+ case "InvalidRequest":
632
630
  case "INVALID_FRAME":
633
631
  case "INVALID_INPUT":
632
+ case "InvalidInput":
634
633
  case "PROTOCOL_UNSUPPORTED":
635
634
  case "InvalidRunId":
636
635
  case "InvalidNodeId":
@@ -649,9 +648,11 @@ export function statusForRpcError(code) {
649
648
  return 404;
650
649
  case "AttemptNotFinished":
651
650
  case "Busy":
651
+ case "AlreadyDecided":
652
652
  return 409;
653
653
  case "DiffTooLarge":
654
654
  case "PayloadTooLarge":
655
+ case "PAYLOAD_TOO_LARGE":
655
656
  return 413;
656
657
  case "RateLimited":
657
658
  case "BackpressureDisconnect":
@@ -683,10 +684,23 @@ function normalizeGrantedScope(scope) {
683
684
  }
684
685
  /**
685
686
  * @param {string} method
686
- * @returns {MethodAccess}
687
- */
688
- function accessForMethod(method) {
689
- return METHOD_ACCESS[method] ?? (method.startsWith("config.") ? "admin" : "read");
687
+ * @returns {import("@smithers-orchestrator/gateway/auth/scopes").GatewayScope}
688
+ */
689
+ function requiredScopeForMethod(method) {
690
+ if (method === "run:read" ||
691
+ method === "run:write" ||
692
+ method === "run:admin" ||
693
+ method === "approval:submit" ||
694
+ method === "signal:submit" ||
695
+ method === "cron:read" ||
696
+ method === "cron:write" ||
697
+ method === "observability:read") {
698
+ return method;
699
+ }
700
+ if (method.startsWith("config.")) {
701
+ return "run:admin";
702
+ }
703
+ return getRequiredScopeForGatewayMethod(method) ?? "run:read";
690
704
  }
691
705
  /**
692
706
  * @param {string[]} scopes
@@ -694,26 +708,7 @@ function accessForMethod(method) {
694
708
  * @returns {boolean}
695
709
  */
696
710
  function hasScope(scopes, method) {
697
- if (scopes.includes("*")) {
698
- return true;
699
- }
700
- const requiredAccess = accessForMethod(method);
701
- const grantedLevels = scopes
702
- .map((scope) => scope.trim())
703
- .filter((scope) => scope === "read" || scope === "execute" || scope === "approve" || scope === "admin");
704
- if (grantedLevels.some((level) => ACCESS_RANK[level] >= ACCESS_RANK[requiredAccess])) {
705
- return true;
706
- }
707
- for (const scope of scopes.map(normalizeGrantedScope)) {
708
- if (!scope)
709
- continue;
710
- if (scope === method)
711
- return true;
712
- if (scope.endsWith(".*") && method.startsWith(scope.slice(0, -1))) {
713
- return true;
714
- }
715
- }
716
- return false;
711
+ return hasGatewayScope(scopes.map(normalizeGrantedScope), requiredScopeForMethod(method), method);
717
712
  }
718
713
  /**
719
714
  * @param {unknown} value
@@ -962,13 +957,13 @@ async function readRawBody(req, maxBytes) {
962
957
  const lengthHeader = headerValue(req, "content-length");
963
958
  const declaredLength = lengthHeader ? Number(lengthHeader) : NaN;
964
959
  if (Number.isFinite(declaredLength) && declaredLength > maxBytes) {
965
- throw new SmithersError("INVALID_INPUT", `Gateway request payload exceeds ${maxBytes} bytes.`, { maxBytes });
960
+ throw new SmithersError("PayloadTooLarge", `Gateway request payload exceeds ${maxBytes} bytes.`, { maxBytes });
966
961
  }
967
962
  for await (const chunk of req) {
968
963
  const buffer = Buffer.from(chunk);
969
964
  total += buffer.length;
970
965
  if (total > maxBytes) {
971
- throw new SmithersError("INVALID_INPUT", `Gateway request payload exceeds ${maxBytes} bytes.`, { maxBytes });
966
+ throw new SmithersError("PayloadTooLarge", `Gateway request payload exceeds ${maxBytes} bytes.`, { maxBytes });
972
967
  }
973
968
  chunks.push(buffer);
974
969
  }
@@ -1055,6 +1050,9 @@ export class Gateway {
1055
1050
  maxBodyBytes;
1056
1051
  maxPayload;
1057
1052
  maxConnections;
1053
+ eventWindowSize;
1054
+ headersTimeout;
1055
+ requestTimeout;
1058
1056
  auth;
1059
1057
  defaults;
1060
1058
  workflows = new Map();
@@ -1063,6 +1061,7 @@ export class Gateway {
1063
1061
  activeRuns = new Map();
1064
1062
  inflightRuns = new Map();
1065
1063
  devtoolsSubscribers = new Map();
1064
+ runEventWindows = new Map();
1066
1065
  /** Absolute active subscriber count per runId (gauge source of truth). */
1067
1066
  devtoolsSubscriberCounts = new Map();
1068
1067
  /** Flagged subscriber IDs that should force a snapshot on their next emit. */
@@ -1088,6 +1087,15 @@ export class Gateway {
1088
1087
  this.maxConnections = options.maxConnections === undefined
1089
1088
  ? DEFAULT_MAX_CONNECTIONS
1090
1089
  : Math.floor(assertPositiveFiniteInteger("maxConnections", Number(options.maxConnections)));
1090
+ this.eventWindowSize = options.eventWindowSize === undefined
1091
+ ? GATEWAY_EVENT_WINDOW_DEFAULT
1092
+ : Math.floor(assertPositiveFiniteInteger("eventWindowSize", Number(options.eventWindowSize)));
1093
+ this.headersTimeout = options.headersTimeout === undefined
1094
+ ? DEFAULT_HEADERS_TIMEOUT
1095
+ : Math.floor(assertPositiveFiniteInteger("headersTimeout", Number(options.headersTimeout)));
1096
+ this.requestTimeout = options.requestTimeout === undefined
1097
+ ? DEFAULT_REQUEST_TIMEOUT
1098
+ : Math.floor(assertPositiveFiniteInteger("requestTimeout", Number(options.requestTimeout)));
1091
1099
  this.auth = options.auth;
1092
1100
  this.defaults = options.defaults;
1093
1101
  }
@@ -1238,6 +1246,153 @@ export class Gateway {
1238
1246
  }
1239
1247
  }
1240
1248
  /**
1249
+ * @param {string} runId
1250
+ * @returns {{ nextSeq: number; window: Array<Record<string, unknown>> }}
1251
+ */
1252
+ getRunEventWindow(runId) {
1253
+ let state = this.runEventWindows.get(runId);
1254
+ if (!state) {
1255
+ state = { nextSeq: 0, window: [] };
1256
+ this.runEventWindows.set(runId, state);
1257
+ }
1258
+ return state;
1259
+ }
1260
+ /**
1261
+ * @param {string} event
1262
+ * @param {unknown} payload
1263
+ * @param {number} stateVersion
1264
+ * @returns {Record<string, unknown> | null}
1265
+ */
1266
+ appendRunEventWindow(event, payload, stateVersion) {
1267
+ const runId = eventRunId(payload);
1268
+ if (!runId) {
1269
+ return null;
1270
+ }
1271
+ const state = this.getRunEventWindow(runId);
1272
+ state.nextSeq += 1;
1273
+ const frame = {
1274
+ apiVersion: SMITHERS_API_VERSION,
1275
+ type: "RunEvent",
1276
+ runId,
1277
+ event,
1278
+ payload,
1279
+ seq: state.nextSeq,
1280
+ stateVersion,
1281
+ };
1282
+ state.window.push(frame);
1283
+ while (state.window.length > this.eventWindowSize) {
1284
+ state.window.shift();
1285
+ }
1286
+ return frame;
1287
+ }
1288
+ /**
1289
+ * @param {string} runId
1290
+ * @returns {number}
1291
+ */
1292
+ getRunEventCurrentSeq(runId) {
1293
+ return this.runEventWindows.get(runId)?.nextSeq ?? 0;
1294
+ }
1295
+ /**
1296
+ * @param {ConnectionState} connection
1297
+ * @param {string} streamId
1298
+ * @param {string} runId
1299
+ * @returns {() => void}
1300
+ */
1301
+ registerRunEventSubscriber(connection, streamId, runId) {
1302
+ if (!connection.runEventStreams) {
1303
+ connection.runEventStreams = new Map();
1304
+ }
1305
+ const heartbeat = setInterval(() => {
1306
+ this.sendEvent(connection, "run.heartbeat", {
1307
+ apiVersion: SMITHERS_API_VERSION,
1308
+ type: "Heartbeat",
1309
+ streamId,
1310
+ runId,
1311
+ ts: nowMs(),
1312
+ });
1313
+ }, RUN_EVENT_HEARTBEAT_MS);
1314
+ connection.runEventStreams.set(streamId, { runId, heartbeat });
1315
+ return () => this.unregisterRunEventSubscriber(connection, streamId);
1316
+ }
1317
+ /**
1318
+ * @param {ConnectionState} connection
1319
+ * @param {string} streamId
1320
+ */
1321
+ unregisterRunEventSubscriber(connection, streamId) {
1322
+ const stream = connection.runEventStreams?.get(streamId);
1323
+ if (!stream) {
1324
+ return;
1325
+ }
1326
+ clearInterval(stream.heartbeat);
1327
+ connection.runEventStreams?.delete(streamId);
1328
+ }
1329
+ /**
1330
+ * @param {ConnectionState} connection
1331
+ */
1332
+ cleanupRunEventSubscribers(connection) {
1333
+ const streams = connection.runEventStreams;
1334
+ if (!streams || streams.size === 0) {
1335
+ return;
1336
+ }
1337
+ for (const streamId of streams.keys()) {
1338
+ this.unregisterRunEventSubscriber(connection, streamId);
1339
+ }
1340
+ }
1341
+ /**
1342
+ * @param {ConnectionState} connection
1343
+ * @param {string} streamId
1344
+ * @param {Record<string, unknown>} frame
1345
+ */
1346
+ sendRunEventStreamFrame(connection, streamId, frame) {
1347
+ this.sendEvent(connection, "run.event", {
1348
+ streamId,
1349
+ ...frame,
1350
+ });
1351
+ }
1352
+ /**
1353
+ * @param {ConnectionState} connection
1354
+ * @param {string} streamId
1355
+ * @param {string} runId
1356
+ * @param {number} fromSeq
1357
+ * @param {number} toSeq
1358
+ * @param {unknown} snapshot
1359
+ */
1360
+ sendRunGapResync(connection, streamId, runId, fromSeq, toSeq, snapshot) {
1361
+ this.sendEvent(connection, "run.gap_resync", {
1362
+ apiVersion: SMITHERS_API_VERSION,
1363
+ type: "GapResync",
1364
+ streamId,
1365
+ runId,
1366
+ fromSeq,
1367
+ toSeq,
1368
+ snapshot,
1369
+ });
1370
+ }
1371
+ /**
1372
+ * @param {string} runId
1373
+ */
1374
+ async buildRunSnapshot(runId) {
1375
+ const resolved = await this.resolveRun(runId);
1376
+ if (!resolved) {
1377
+ return null;
1378
+ }
1379
+ const run = await resolved.adapter.getRun(runId);
1380
+ if (!run) {
1381
+ return null;
1382
+ }
1383
+ const summary = await resolved.adapter.countNodesByState(runId);
1384
+ const runState = await computeRunStateFromRow(resolved.adapter, run).catch(() => undefined);
1385
+ return {
1386
+ ...run,
1387
+ workflowKey: resolved.workflowKey,
1388
+ summary: summary.reduce((acc, row) => {
1389
+ acc[row.state] = row.count;
1390
+ return acc;
1391
+ }, {}),
1392
+ ...(runState ? { runState } : {}),
1393
+ };
1394
+ }
1395
+ /**
1241
1396
  * @param {GatewayTransport} transport
1242
1397
  * @param {string} frameType
1243
1398
  * @param {GatewayMetricLabels} [labels]
@@ -1359,8 +1514,10 @@ export class Gateway {
1359
1514
  rpcSuccessEffect(context, frame, response) {
1360
1515
  const params = asObject(frame.params) ?? {};
1361
1516
  switch (frame.method) {
1362
- case "approvals.decide": {
1363
- const approved = asBoolean(params.approved) ?? false;
1517
+ case "approvals.decide":
1518
+ case "submitApproval": {
1519
+ const decision = asObject(params.decision);
1520
+ const approved = asBoolean(params.approved) ?? asBoolean(decision?.approved) ?? false;
1364
1521
  const nodeId = asString(params.nodeId);
1365
1522
  return Effect.all([
1366
1523
  incrementMetric(gatewayApprovalDecisionsTotal, {
@@ -1374,9 +1531,10 @@ export class Gateway {
1374
1531
  })),
1375
1532
  ], { discard: true });
1376
1533
  }
1377
- case "signals.send": {
1378
- const signalName = asString(params.signalName);
1379
- const correlationId = asString(params.correlationId);
1534
+ case "signals.send":
1535
+ case "submitSignal": {
1536
+ const signalName = asString(params.signalName) ?? asString(params.correlationKey);
1537
+ const correlationId = asString(params.correlationId) ?? asString(params.correlationKey);
1380
1538
  return Effect.all([
1381
1539
  incrementMetric(gatewaySignalsTotal, { outcome: "sent" }),
1382
1540
  Effect.logInfo("Gateway signal sent").pipe(Effect.annotateLogs({
@@ -1386,7 +1544,8 @@ export class Gateway {
1386
1544
  })),
1387
1545
  ], { discard: true });
1388
1546
  }
1389
- case "cron.trigger": {
1547
+ case "cron.trigger":
1548
+ case "cronRun": {
1390
1549
  const cronId = asString(params.cronId);
1391
1550
  const workflow = asString(params.workflow);
1392
1551
  return Effect.all([
@@ -1646,9 +1805,13 @@ export class Gateway {
1646
1805
  noServer: true,
1647
1806
  maxPayload: this.maxPayload,
1648
1807
  });
1808
+ wsServer.on("headers", (headers) => {
1809
+ headers.push(`X-Smithers-API-Version: ${SMITHERS_API_VERSION}`);
1810
+ });
1649
1811
  const server = createServer(async (req, res) => {
1650
1812
  const url = new URL(req.url ?? "/", "http://127.0.0.1");
1651
1813
  const webhookMatch = url.pathname.match(/^\/webhooks\/([^/]+)$/);
1814
+ const rpcMatch = url.pathname.match(/^\/v1\/rpc\/([^/]+)$/);
1652
1815
  if ((req.method ?? "GET") === "GET" && (req.url ?? "/") === "/health") {
1653
1816
  return sendJson(res, 200, {
1654
1817
  ok: true,
@@ -1663,11 +1826,16 @@ export class Gateway {
1663
1826
  if ((req.method ?? "GET") === "POST" && webhookMatch) {
1664
1827
  return this.handleWebhook(req, res, decodeURIComponent(webhookMatch[1]));
1665
1828
  }
1829
+ if ((req.method ?? "GET") === "POST" && rpcMatch) {
1830
+ return this.handleHttpRpc(req, res, decodeURIComponent(rpcMatch[1]));
1831
+ }
1666
1832
  if ((req.method ?? "GET") === "POST" && (req.url ?? "/") === "/rpc") {
1667
1833
  return this.handleHttpRpc(req, res);
1668
1834
  }
1669
1835
  return sendJson(res, 404, { error: { code: "NOT_FOUND", message: "Route not found" } });
1670
1836
  });
1837
+ server.headersTimeout = this.headersTimeout;
1838
+ server.requestTimeout = this.requestTimeout;
1671
1839
  server.on("upgrade", (req, socket, head) => {
1672
1840
  if (this.connections.size >= this.maxConnections) {
1673
1841
  emitGatewayEffect(incrementMetric(gatewayErrorsTotal, {
@@ -1683,6 +1851,7 @@ export class Gateway {
1683
1851
  socket.write("HTTP/1.1 503 Service Unavailable\r\n"
1684
1852
  + "Connection: close\r\n"
1685
1853
  + "Content-Type: text/plain; charset=utf-8\r\n"
1854
+ + `X-Smithers-API-Version: ${SMITHERS_API_VERSION}\r\n`
1686
1855
  + `Content-Length: ${Buffer.byteLength(body, "utf8")}\r\n`
1687
1856
  + "\r\n"
1688
1857
  + body);
@@ -1873,6 +2042,7 @@ export class Gateway {
1873
2042
  triggeredBy: auth.triggeredBy,
1874
2043
  source: gatewayTriggerSource(auth.triggeredBy),
1875
2044
  resume: options?.resume ?? false,
2045
+ ...(auth.tokenId ? { tokenId: auth.tokenId } : {}),
1876
2046
  ...(auth.subscribeConnection
1877
2047
  ? gatewayContextAnnotations(auth.subscribeConnection)
1878
2048
  : {}),
@@ -1899,6 +2069,7 @@ export class Gateway {
1899
2069
  triggeredBy: auth.triggeredBy,
1900
2070
  scopes: [...auth.scopes],
1901
2071
  role: auth.role,
2072
+ tokenId: auth.tokenId ?? null,
1902
2073
  createdAt: new Date().toISOString(),
1903
2074
  },
1904
2075
  }))
@@ -1994,6 +2165,7 @@ export class Gateway {
1994
2165
  role: null,
1995
2166
  scopes: [],
1996
2167
  userId: null,
2168
+ tokenId: null,
1997
2169
  subscribedRuns: null,
1998
2170
  devtoolsStreams: new Map(),
1999
2171
  heartbeatTimer: null,
@@ -2024,7 +2196,7 @@ export class Gateway {
2024
2196
  return this.handleConnect(connection, req, frame.id, frame.params);
2025
2197
  }
2026
2198
  if (!hasScope(connection.scopes, frame.method)) {
2027
- return responseError(frame.id, "FORBIDDEN", `Missing scope for ${frame.method}`);
2199
+ return responseForbidden(frame.id, frame.method);
2028
2200
  }
2029
2201
  return this.routeRequest(connection, frame);
2030
2202
  });
@@ -2062,6 +2234,7 @@ export class Gateway {
2062
2234
  }
2063
2235
  this.connections.delete(connection);
2064
2236
  this.cleanupDevToolsSubscribers(connection);
2237
+ this.cleanupRunEventSubscribers(connection);
2065
2238
  emitGatewayEffect(Effect.all([
2066
2239
  updateMetric(gatewayConnectionsActive, -1, { transport: "ws" }),
2067
2240
  incrementMetric(gatewayConnectionsClosedTotal, {
@@ -2146,13 +2319,14 @@ export class Gateway {
2146
2319
  authCode: authResult.code,
2147
2320
  authMessage: authResult.message,
2148
2321
  });
2149
- return responseError(id, authResult.code, authResult.message);
2322
+ return responseError(id, authResult.code, authResult.message, authResult.details);
2150
2323
  }
2151
2324
  connection.authenticated = true;
2152
2325
  connection.sessionToken = randomUUID();
2153
2326
  connection.role = authResult.role;
2154
2327
  connection.scopes = [...authResult.scopes];
2155
2328
  connection.userId = authResult.userId ?? null;
2329
+ connection.tokenId = authResult.tokenId ?? null;
2156
2330
  connection.subscribedRuns = Array.isArray(request.subscribe)
2157
2331
  ? new Set(request.subscribe.filter((value) => typeof value === "string"))
2158
2332
  : null;
@@ -2171,6 +2345,7 @@ export class Gateway {
2171
2345
  role: authResult.role,
2172
2346
  scopes: authResult.scopes,
2173
2347
  userId: authResult.userId ?? null,
2348
+ tokenId: authResult.tokenId ?? null,
2174
2349
  },
2175
2350
  snapshot: await this.buildSnapshot(),
2176
2351
  };
@@ -2214,11 +2389,32 @@ export class Gateway {
2214
2389
  message: "Invalid token",
2215
2390
  };
2216
2391
  }
2392
+ if (typeof grant.revokedAtMs === "number" && grant.revokedAtMs <= Date.now()) {
2393
+ return {
2394
+ ok: false,
2395
+ code: "UNAUTHORIZED",
2396
+ message: "Token has been revoked",
2397
+ details: {
2398
+ refresh: "smithers token issue",
2399
+ },
2400
+ };
2401
+ }
2402
+ if (typeof grant.expiresAtMs === "number" && grant.expiresAtMs <= Date.now()) {
2403
+ return {
2404
+ ok: false,
2405
+ code: "UNAUTHORIZED",
2406
+ message: "Token expired; issue a refreshed token.",
2407
+ details: {
2408
+ refresh: "smithers token issue",
2409
+ },
2410
+ };
2411
+ }
2217
2412
  return {
2218
2413
  ok: true,
2219
2414
  role: grant.role,
2220
2415
  scopes: grant.scopes,
2221
2416
  userId: grant.userId,
2417
+ tokenId: grant.tokenId ?? createHash("sha256").update(token).digest("hex").slice(0, 16),
2222
2418
  };
2223
2419
  }
2224
2420
  if (this.auth.mode === "jwt") {
@@ -2235,6 +2431,9 @@ export class Gateway {
2235
2431
  ok: false,
2236
2432
  code: "UNAUTHORIZED",
2237
2433
  message: verified.message,
2434
+ details: verified.message.includes("expired")
2435
+ ? { refresh: "smithers token issue" }
2436
+ : undefined,
2238
2437
  };
2239
2438
  }
2240
2439
  const scopes = parseJwtScopes(verified.payload[this.auth.scopesClaim ?? "scope"]);
@@ -2247,6 +2446,7 @@ export class Gateway {
2247
2446
  role,
2248
2447
  scopes: scopes.length > 0 ? scopes : [...(this.auth.defaultScopes ?? [])],
2249
2448
  userId: userId ?? undefined,
2449
+ tokenId: createHash("sha256").update(token).digest("hex").slice(0, 16),
2250
2450
  };
2251
2451
  }
2252
2452
  if (this.auth.mode === "trusted-proxy") {
@@ -2271,6 +2471,7 @@ export class Gateway {
2271
2471
  role,
2272
2472
  scopes,
2273
2473
  userId: userId ?? undefined,
2474
+ tokenId: asString(req.headers["x-smithers-token-id"]) ?? undefined,
2274
2475
  };
2275
2476
  }
2276
2477
  return {
@@ -2282,8 +2483,9 @@ export class Gateway {
2282
2483
  /**
2283
2484
  * @param {IncomingMessage} req
2284
2485
  * @param {ServerResponse} res
2486
+ * @param {string} [forcedMethod]
2285
2487
  */
2286
- async handleHttpRpc(req, res) {
2488
+ async handleHttpRpc(req, res, forcedMethod) {
2287
2489
  const requestId = headerValue(req, "x-request-id") ?? randomUUID();
2288
2490
  const baseContext = {
2289
2491
  connectionId: `http:${requestId}`,
@@ -2291,6 +2493,7 @@ export class Gateway {
2291
2493
  role: null,
2292
2494
  scopes: [],
2293
2495
  userId: null,
2496
+ tokenId: null,
2294
2497
  subscribedRuns: null,
2295
2498
  devtoolsStreams: null,
2296
2499
  };
@@ -2309,7 +2512,7 @@ export class Gateway {
2309
2512
  authCode: authResult.code,
2310
2513
  authMessage: authResult.message,
2311
2514
  }, "warning");
2312
- const response = responseError(requestId, authResult.code, authResult.message);
2515
+ const response = responseError(requestId, authResult.code, authResult.message, authResult.details);
2313
2516
  return this.sendHttpRpcResponse(res, statusForRpcError(authResult.code), response);
2314
2517
  }
2315
2518
  context = {
@@ -2317,6 +2520,7 @@ export class Gateway {
2317
2520
  role: authResult.role,
2318
2521
  scopes: [...authResult.scopes],
2319
2522
  userId: authResult.userId ?? null,
2523
+ tokenId: authResult.tokenId ?? null,
2320
2524
  };
2321
2525
  this.recordAuthEvent("http", "success", context, {
2322
2526
  requestId,
@@ -2342,18 +2546,18 @@ export class Gateway {
2342
2546
  maxDepth: GATEWAY_RPC_MAX_DEPTH,
2343
2547
  maxStringLength: GATEWAY_RPC_MAX_STRING_LENGTH,
2344
2548
  });
2345
- const method = validateGatewayMethodName(body.method);
2549
+ const method = validateGatewayMethodName(forcedMethod ?? body.method);
2346
2550
  const bodyId = asString(body.id) ?? requestId;
2347
2551
  assertOptionalStringMaxLength("id", bodyId, GATEWAY_FRAME_ID_MAX_LENGTH);
2348
2552
  const frame = {
2349
2553
  type: "req",
2350
2554
  id: bodyId,
2351
2555
  method,
2352
- params: body.params,
2556
+ params: forcedMethod && body.method === undefined ? body : body.params,
2353
2557
  };
2354
2558
  const response = await this.executeRpc(context, frame, async () => {
2355
2559
  if (!hasScope(context.scopes, method)) {
2356
- return responseError(bodyId, "FORBIDDEN", `Missing scope for ${method}`);
2560
+ return responseForbidden(bodyId, method);
2357
2561
  }
2358
2562
  return this.routeRequest(context, frame);
2359
2563
  });
@@ -2407,6 +2611,7 @@ export class Gateway {
2407
2611
  payload,
2408
2612
  seq: connection.seq,
2409
2613
  stateVersion,
2614
+ apiVersion: SMITHERS_API_VERSION,
2410
2615
  };
2411
2616
  connection.ws.send(JSON.stringify(frame));
2412
2617
  this.recordMessageSent("ws", "event", { event });
@@ -2418,6 +2623,7 @@ export class Gateway {
2418
2623
  broadcastEvent(event, payload) {
2419
2624
  const runId = eventRunId(payload);
2420
2625
  this.stateVersion += 1;
2626
+ const runFrame = this.appendRunEventWindow(event, payload, this.stateVersion);
2421
2627
  let recipientCount = 0;
2422
2628
  for (const connection of this.connections) {
2423
2629
  if (!connection.authenticated || !shouldDeliverEvent(connection, runId)) {
@@ -2425,6 +2631,13 @@ export class Gateway {
2425
2631
  }
2426
2632
  recipientCount += 1;
2427
2633
  this.sendEvent(connection, event, payload, this.stateVersion);
2634
+ if (runFrame && connection.runEventStreams) {
2635
+ for (const [streamId, stream] of connection.runEventStreams.entries()) {
2636
+ if (stream.runId === runId) {
2637
+ this.sendRunEventStreamFrame(connection, streamId, runFrame);
2638
+ }
2639
+ }
2640
+ }
2428
2641
  }
2429
2642
  emitGatewayLog("debug", "Gateway event broadcast", {
2430
2643
  event,
@@ -2725,12 +2938,15 @@ export class Gateway {
2725
2938
  stateVersion: this.stateVersion,
2726
2939
  uptimeMs: nowMs() - this.startedAtMs,
2727
2940
  });
2728
- case "runs.list": {
2729
- const limit = asOptionalPositiveInt(params.limit, "limit") ?? 50;
2730
- const status = asString(params.status);
2941
+ case "runs.list":
2942
+ case "listRuns": {
2943
+ const filter = asObject(params.filter) ?? {};
2944
+ const limit = asOptionalPositiveInt(params.limit ?? filter.limit, "limit") ?? 50;
2945
+ const status = asString(params.status) ?? asString(filter.status);
2731
2946
  return responseOk(frame.id, await this.listRunsAcrossWorkflows(limit, status));
2732
2947
  }
2733
- case "runs.create": {
2948
+ case "runs.create":
2949
+ case "launchRun": {
2734
2950
  const workflowKey = asString(params.workflow);
2735
2951
  if (!workflowKey) {
2736
2952
  return responseError(frame.id, "INVALID_REQUEST", "workflow is required");
@@ -2748,14 +2964,42 @@ export class Gateway {
2748
2964
  }
2749
2965
  throw error;
2750
2966
  }
2967
+ const options = asObject(params.options) ?? {};
2751
2968
  return responseOk(frame.id, await this.startRun(workflowKey, input, {
2752
2969
  triggeredBy: connection.userId ?? "gateway",
2753
2970
  scopes: [...connection.scopes],
2754
2971
  role: connection.role ?? "operator",
2972
+ tokenId: connection.tokenId ?? null,
2755
2973
  subscribeConnection: connection,
2756
- }, asString(params.runId) ?? crypto.randomUUID(), { resume: false }));
2974
+ }, asString(params.runId) ?? asString(options.runId) ?? crypto.randomUUID(), { resume: false }));
2975
+ }
2976
+ case "resumeRun": {
2977
+ const runId = asString(params.runId);
2978
+ if (!runId) {
2979
+ return responseError(frame.id, "INVALID_REQUEST", "runId is required");
2980
+ }
2981
+ const resolved = await this.resolveRun(runId);
2982
+ if (!resolved) {
2983
+ return responseError(frame.id, "NOT_FOUND", `Run not found: ${runId}`);
2984
+ }
2985
+ const run = await resolved.adapter.getRun(runId);
2986
+ if (!run) {
2987
+ return responseError(frame.id, "NOT_FOUND", `Run not found: ${runId}`);
2988
+ }
2989
+ if (run.status === "finished" || run.status === "failed" || run.status === "cancelled") {
2990
+ return responseOk(frame.id, { runId, status: "already_terminal" });
2991
+ }
2992
+ await this.resumeRunIfNeeded(runId, resolved.workflowKey, resolved.adapter, {
2993
+ triggeredBy: connection.userId ?? "gateway",
2994
+ scopes: [...connection.scopes],
2995
+ role: connection.role ?? "operator",
2996
+ tokenId: connection.tokenId ?? null,
2997
+ subscribeConnection: connection.transport === "ws" ? connection : undefined,
2998
+ });
2999
+ return responseOk(frame.id, { runId, status: "resume_requested" });
2757
3000
  }
2758
- case "runs.get": {
3001
+ case "runs.get":
3002
+ case "getRun": {
2759
3003
  const runId = asString(params.runId);
2760
3004
  if (!runId) {
2761
3005
  return responseError(frame.id, "INVALID_REQUEST", "runId is required");
@@ -2918,6 +3162,64 @@ export class Gateway {
2918
3162
  throw error;
2919
3163
  }
2920
3164
  }
3165
+ case "streamRunEvents": {
3166
+ if (connection.transport !== "ws" || !connection.ws) {
3167
+ return responseError(frame.id, "INVALID_REQUEST", "streamRunEvents is only supported over websocket connections");
3168
+ }
3169
+ const runId = asString(params.runId);
3170
+ if (!runId) {
3171
+ return responseError(frame.id, "InvalidRunId", "runId is required");
3172
+ }
3173
+ const afterSeq = params.afterSeq;
3174
+ if (afterSeq !== undefined &&
3175
+ (typeof afterSeq !== "number" || !Number.isInteger(afterSeq) || afterSeq < 0)) {
3176
+ return responseError(frame.id, "SeqOutOfRange", "afterSeq must be a non-negative integer");
3177
+ }
3178
+ const resolved = await this.resolveRun(runId);
3179
+ if (!resolved) {
3180
+ return responseError(frame.id, "RunNotFound", `Run not found: ${runId}`);
3181
+ }
3182
+ const currentSeq = this.getRunEventCurrentSeq(runId);
3183
+ if (typeof afterSeq === "number" && afterSeq > currentSeq) {
3184
+ return responseError(frame.id, "SeqOutOfRange", `afterSeq ${afterSeq} is newer than current seq ${currentSeq}`);
3185
+ }
3186
+ const streamId = randomUUID();
3187
+ this.registerRunEventSubscriber(connection, streamId, runId);
3188
+ queueMicrotask(() => {
3189
+ void (async () => {
3190
+ const state = this.getRunEventWindow(runId);
3191
+ const window = [...state.window];
3192
+ if (typeof afterSeq === "number") {
3193
+ const firstSeq = window.length > 0 ? Number(window[0].seq) : state.nextSeq + 1;
3194
+ if (window.length > 0 && afterSeq < firstSeq - 1) {
3195
+ const snapshot = await this.buildRunSnapshot(runId);
3196
+ this.sendRunGapResync(connection, streamId, runId, afterSeq + 1, firstSeq - 1, snapshot);
3197
+ }
3198
+ for (const eventFrame of window) {
3199
+ if (Number(eventFrame.seq) > afterSeq) {
3200
+ this.sendRunEventStreamFrame(connection, streamId, eventFrame);
3201
+ }
3202
+ }
3203
+ }
3204
+ })().catch((error) => {
3205
+ this.sendEvent(connection, "run.error", {
3206
+ streamId,
3207
+ runId,
3208
+ error: {
3209
+ version: SMITHERS_API_VERSION,
3210
+ code: "Internal",
3211
+ message: error?.message ?? "streamRunEvents replay failed",
3212
+ },
3213
+ });
3214
+ });
3215
+ });
3216
+ return responseOk(frame.id, {
3217
+ streamId,
3218
+ runId,
3219
+ afterSeq: typeof afterSeq === "number" ? afterSeq : null,
3220
+ currentSeq,
3221
+ });
3222
+ }
2921
3223
  case "streamDevTools": {
2922
3224
  if (connection.transport !== "ws" || !connection.ws) {
2923
3225
  this.recordDevToolsSubscribeAttempt("error");
@@ -2928,7 +3230,13 @@ export class Gateway {
2928
3230
  this.recordDevToolsSubscribeAttempt("error");
2929
3231
  return responseError(frame.id, "InvalidRunId", "runId is required");
2930
3232
  }
2931
- const fromSeq = params.fromSeq;
3233
+ if (typeof params.fromSeq === "number" &&
3234
+ typeof params.afterSeq === "number" &&
3235
+ params.fromSeq !== params.afterSeq) {
3236
+ this.recordDevToolsSubscribeAttempt("error");
3237
+ return responseError(frame.id, "SeqOutOfRange", "fromSeq and afterSeq must match when both are provided");
3238
+ }
3239
+ const fromSeq = typeof params.fromSeq === "number" ? params.fromSeq : params.afterSeq;
2932
3240
  const streamId = randomUUID();
2933
3241
  try {
2934
3242
  // Full route-level validation at the gateway boundary so
@@ -3094,6 +3402,7 @@ export class Gateway {
3094
3402
  streamId,
3095
3403
  runId,
3096
3404
  fromSeq: typeof fromSeq === "number" ? fromSeq : null,
3405
+ afterSeq: typeof fromSeq === "number" ? fromSeq : null,
3097
3406
  });
3098
3407
  }
3099
3408
  catch (error) {
@@ -3104,6 +3413,22 @@ export class Gateway {
3104
3413
  throw error;
3105
3414
  }
3106
3415
  }
3416
+ case "hijackRun": {
3417
+ const runId = asString(params.runId);
3418
+ if (!runId) {
3419
+ return responseError(frame.id, "InvalidRunId", "runId is required");
3420
+ }
3421
+ const resolved = await this.resolveRun(runId);
3422
+ if (!resolved) {
3423
+ return responseError(frame.id, "RunNotFound", `Run not found: ${runId}`);
3424
+ }
3425
+ return responseOk(frame.id, {
3426
+ runId,
3427
+ status: "hijack-ready",
3428
+ sessionId: randomUUID(),
3429
+ });
3430
+ }
3431
+ case "rewindRun":
3107
3432
  case "jumpToFrame":
3108
3433
  case "devtools.jumpToFrame": {
3109
3434
  const runId = asString(params.runId);
@@ -3179,6 +3504,7 @@ export class Gateway {
3179
3504
  triggeredBy: connection.userId ?? "gateway",
3180
3505
  scopes: [...connection.scopes],
3181
3506
  role: connection.role ?? "operator",
3507
+ tokenId: connection.tokenId ?? null,
3182
3508
  subscribeConnection: connection.transport === "ws" ? connection : undefined,
3183
3509
  });
3184
3510
  },
@@ -3223,10 +3549,12 @@ export class Gateway {
3223
3549
  }
3224
3550
  case "approvals.list":
3225
3551
  return responseOk(frame.id, await this.listPendingApprovals());
3226
- case "approvals.decide": {
3552
+ case "approvals.decide":
3553
+ case "submitApproval": {
3227
3554
  const runId = asString(params.runId);
3228
3555
  const nodeId = asString(params.nodeId);
3229
- const approved = asBoolean(params.approved);
3556
+ const stableDecision = asObject(params.decision);
3557
+ const approved = asBoolean(params.approved) ?? asBoolean(stableDecision?.approved);
3230
3558
  const iteration = asNumber(params.iteration) ?? 0;
3231
3559
  if (!runId || !nodeId || approved === undefined) {
3232
3560
  return responseError(frame.id, "INVALID_REQUEST", "runId, nodeId, and approved are required");
@@ -3236,6 +3564,14 @@ export class Gateway {
3236
3564
  return responseError(frame.id, "NOT_FOUND", `Run not found: ${runId}`);
3237
3565
  }
3238
3566
  const approval = await resolved.adapter.getApproval(runId, nodeId, iteration);
3567
+ if (approval && approval.status !== "requested") {
3568
+ return responseError(frame.id, "AlreadyDecided", `Approval for ${nodeId} has already been decided`, {
3569
+ runId,
3570
+ nodeId,
3571
+ iteration,
3572
+ status: approval.status,
3573
+ });
3574
+ }
3239
3575
  const request = parseApprovalRequest(parseJson(typeof approval?.requestJson === "string" ? approval.requestJson : null), nodeId);
3240
3576
  if (request.allowedUsers.length > 0 &&
3241
3577
  (!connection.userId || !request.allowedUsers.includes(connection.userId))) {
@@ -3245,7 +3581,8 @@ export class Gateway {
3245
3581
  !request.allowedScopes.some((scope) => hasScope(connection.scopes, scope))) {
3246
3582
  return responseError(frame.id, "FORBIDDEN", "Connection is missing required approval scope");
3247
3583
  }
3248
- const decision = params.decision;
3584
+ const decision = stableDecision && "value" in stableDecision ? stableDecision.value : params.decision;
3585
+ const note = asString(params.note) ?? asString(stableDecision?.note);
3249
3586
  if (approved) {
3250
3587
  const validation = validateApprovalDecision(request, decision);
3251
3588
  if (!validation.ok) {
@@ -3253,22 +3590,25 @@ export class Gateway {
3253
3590
  }
3254
3591
  }
3255
3592
  if (approved) {
3256
- await Effect.runPromise(approveNode(resolved.adapter, runId, nodeId, iteration, asString(params.note), connection.userId ?? undefined, decision));
3593
+ await Effect.runPromise(approveNode(resolved.adapter, runId, nodeId, iteration, note, connection.userId ?? undefined, decision));
3257
3594
  }
3258
3595
  else {
3259
- await Effect.runPromise(denyNode(resolved.adapter, runId, nodeId, iteration, asString(params.note), connection.userId ?? undefined, decision));
3596
+ await Effect.runPromise(denyNode(resolved.adapter, runId, nodeId, iteration, note, connection.userId ?? undefined, decision));
3260
3597
  }
3261
3598
  await this.resumeRunIfNeeded(runId, resolved.workflowKey, resolved.adapter, {
3262
3599
  triggeredBy: connection.userId ?? "gateway",
3263
3600
  scopes: [...connection.scopes],
3264
3601
  role: connection.role ?? "operator",
3602
+ tokenId: connection.tokenId ?? null,
3265
3603
  subscribeConnection: connection,
3266
3604
  });
3267
3605
  return responseOk(frame.id, { runId, nodeId, iteration, approved });
3268
3606
  }
3269
- case "signals.send": {
3607
+ case "signals.send":
3608
+ case "submitSignal": {
3270
3609
  const runId = asString(params.runId);
3271
- const signalName = asString(params.signalName);
3610
+ const correlationKey = asString(params.correlationKey);
3611
+ const signalName = asString(params.signalName) ?? correlationKey;
3272
3612
  if (!runId || !signalName) {
3273
3613
  return responseError(frame.id, "INVALID_REQUEST", "runId and signalName are required");
3274
3614
  }
@@ -3276,19 +3616,21 @@ export class Gateway {
3276
3616
  if (!resolved) {
3277
3617
  return responseError(frame.id, "NOT_FOUND", `Run not found: ${runId}`);
3278
3618
  }
3279
- const delivered = await Effect.runPromise(signalRun(resolved.adapter, runId, signalName, params.data ?? {}, {
3280
- correlationId: asString(params.correlationId),
3619
+ const delivered = await Effect.runPromise(signalRun(resolved.adapter, runId, signalName, params.data ?? params.payload ?? {}, {
3620
+ correlationId: asString(params.correlationId) ?? correlationKey,
3281
3621
  receivedBy: connection.userId,
3282
3622
  }));
3283
3623
  await this.resumeRunIfNeeded(runId, resolved.workflowKey, resolved.adapter, {
3284
3624
  triggeredBy: connection.userId ?? "gateway",
3285
3625
  scopes: [...connection.scopes],
3286
3626
  role: connection.role ?? "operator",
3627
+ tokenId: connection.tokenId ?? null,
3287
3628
  subscribeConnection: connection,
3288
3629
  });
3289
3630
  return responseOk(frame.id, delivered);
3290
3631
  }
3291
- case "runs.cancel": {
3632
+ case "runs.cancel":
3633
+ case "cancelRun": {
3292
3634
  const runId = asString(params.runId);
3293
3635
  if (!runId) {
3294
3636
  return responseError(frame.id, "INVALID_REQUEST", "runId is required");
@@ -3326,8 +3668,16 @@ export class Gateway {
3326
3668
  });
3327
3669
  }
3328
3670
  case "cron.list":
3329
- return responseOk(frame.id, await this.listCrons());
3330
- case "cron.add": {
3671
+ case "cronList": {
3672
+ const filter = asObject(params.filter) ?? {};
3673
+ const workflowFilter = asString(filter.workflow);
3674
+ const rows = await this.listCrons();
3675
+ return responseOk(frame.id, workflowFilter
3676
+ ? rows.filter((row) => row.workflow === workflowFilter)
3677
+ : rows);
3678
+ }
3679
+ case "cron.add":
3680
+ case "cronCreate": {
3331
3681
  const workflowKey = asString(params.workflow);
3332
3682
  const pattern = asString(params.pattern);
3333
3683
  if (!workflowKey || !pattern) {
@@ -3355,7 +3705,8 @@ export class Gateway {
3355
3705
  workflow: workflowKey,
3356
3706
  });
3357
3707
  }
3358
- case "cron.remove": {
3708
+ case "cron.remove":
3709
+ case "cronDelete": {
3359
3710
  const cronId = asString(params.cronId);
3360
3711
  if (!cronId) {
3361
3712
  return responseError(frame.id, "INVALID_REQUEST", "cronId is required");
@@ -3367,7 +3718,8 @@ export class Gateway {
3367
3718
  await resolvedCron.adapter.deleteCron(cronId);
3368
3719
  return responseOk(frame.id, { cronId, removed: true });
3369
3720
  }
3370
- case "cron.trigger": {
3721
+ case "cron.trigger":
3722
+ case "cronRun": {
3371
3723
  const cronId = asString(params.cronId);
3372
3724
  const workflowKey = asString(params.workflow);
3373
3725
  const resolvedCron = cronId ? await this.findCron(cronId) : null;
@@ -3392,6 +3744,7 @@ export class Gateway {
3392
3744
  triggeredBy: connection.userId ?? "gateway",
3393
3745
  scopes: [...connection.scopes],
3394
3746
  role: connection.role ?? "operator",
3747
+ tokenId: connection.tokenId ?? null,
3395
3748
  subscribeConnection: connection,
3396
3749
  }, undefined, { resume: false }));
3397
3750
  }
@@ -273,9 +273,9 @@ export async function getNodeDiffRoute({
273
273
  emitEffect = (effect) => runPromise(effect),
274
274
  computeDiffBundleImpl,
275
275
  computeDiffBundleBetweenRefsImpl,
276
- getCurrentPointerImpl,
276
+ getCurrentPointerImpl: _getCurrentPointerImpl,
277
277
  resolveCommitPointerImpl = resolveCommitPointer,
278
- restorePointerImpl,
278
+ restorePointerImpl: _restorePointerImpl,
279
279
  nowMs = () => Date.now(),
280
280
  // stat: true → return summary only ({ files, filesChanged, added,
281
281
  // removed }). Bypasses the cache and the full-bundle JSON size guard
@@ -292,7 +292,6 @@ export async function getNodeDiffRoute({
292
292
  ? async (baseRef, _targetRef, cwd, seq) => computeDiffBundleImpl(baseRef, cwd, seq)
293
293
  : computeDiffBundleBetweenRefs);
294
294
  let resultLabel = "error";
295
- let cacheResultLabel = "miss";
296
295
  let sizeBytes = 0;
297
296
  let computeDurationMs = 0;
298
297
  const rootSpanAttrs = {
@@ -380,7 +379,6 @@ export async function getNodeDiffRoute({
380
379
  const recordCacheResult = async (cacheResult, bytes) => {
381
380
  sizeBytes = bytes;
382
381
  resultLabel = cacheResult;
383
- cacheResultLabel = cacheResult;
384
382
  rootSpanAttrs.cacheResult = cacheResult;
385
383
  await swallow(() => emitEffect(Effect.all([
386
384
  Metric.increment(taggedMetric(nodeDiffCacheTotal, { result: cacheResult })),
package/src/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { createServer, IncomingMessage, ServerResponse } from "node:http";
1
+ import { createServer } from "node:http";
2
2
  import { readFile, writeFile } from "node:fs/promises";
3
3
  import { createHash } from "node:crypto";
4
4
  import { pathToFileURL } from "node:url";
@@ -20,6 +20,7 @@ import { errorToJson } from "@smithers-orchestrator/errors/errorToJson";
20
20
  import { SmithersError } from "@smithers-orchestrator/errors/SmithersError";
21
21
  import { assertMaxBytes, assertMaxJsonDepth } from "@smithers-orchestrator/db/input-bounds";
22
22
  import { prometheusContentType, renderPrometheusMetrics, } from "@smithers-orchestrator/observability";
23
+ /** @typedef {import("node:http").ServerResponse} ServerResponse */
23
24
  /** @typedef {import("./ServerOptions.js").ServerOptions} ServerOptions */
24
25
 
25
26
  // Re-export the full public surface so the tsup-bundled `src/index.d.ts`
@@ -44,6 +45,8 @@ const DEFAULT_MAX_BODY_BYTES = 1_048_576;
44
45
  const DEFAULT_MAX_BODY_JSON_DEPTH = 32;
45
46
  const DEFAULT_SSE_HEARTBEAT_MS = 10_000;
46
47
  const COMPLETED_RUN_RETENTION_MS = 60_000;
48
+ const DEFAULT_HEADERS_TIMEOUT = 30_000;
49
+ const DEFAULT_REQUEST_TIMEOUT = 60_000;
47
50
  class HttpError extends Error {
48
51
  status;
49
52
  code;
@@ -355,7 +358,7 @@ function assertAuth(req, authToken) {
355
358
  req.headers["Authorization"] ??
356
359
  req.headers["x-smithers-key"];
357
360
  const value = Array.isArray(header) ? header[0] : header;
358
- const token = value?.startsWith("Bearer ") ? value.slice(7) : value;
361
+ const token = value?.slice(0, 7).toLowerCase() === "bearer " ? value.slice(7) : value;
359
362
  if (!token || token !== authToken) {
360
363
  throw new HttpError(401, "UNAUTHORIZED", "Missing or invalid authorization token");
361
364
  }
@@ -645,6 +648,8 @@ function startServerInternal(opts = {}) {
645
648
  const maxBodyBytes = opts.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES;
646
649
  const rootDir = opts.rootDir ? resolve(opts.rootDir) : undefined;
647
650
  const allowNetwork = Boolean(opts.allowNetwork);
651
+ const headersTimeout = opts.headersTimeout ?? DEFAULT_HEADERS_TIMEOUT;
652
+ const requestTimeout = opts.requestTimeout ?? DEFAULT_REQUEST_TIMEOUT;
648
653
  if (serverDb) {
649
654
  ensureSmithersTables(serverDb);
650
655
  }
@@ -1206,6 +1211,8 @@ function startServerInternal(opts = {}) {
1206
1211
  await recordHttpRequestMetricsSafely(requestMethod, requestPathname, res.statusCode || 500, performance.now() - requestStart);
1207
1212
  }
1208
1213
  });
1214
+ server.headersTimeout = headersTimeout;
1215
+ server.requestTimeout = requestTimeout;
1209
1216
  server.on("close", () => {
1210
1217
  logInfo("stopping smithers server", {
1211
1218
  activeRuns: runs.size,
package/src/serve.js CHANGED
@@ -1,7 +1,6 @@
1
1
  import { Hono } from "hono";
2
2
  import { streamSSE } from "hono/streaming";
3
3
  import { Effect, Metric } from "effect";
4
- import { SmithersDb } from "@smithers-orchestrator/db/adapter";
5
4
  import { approveNode, denyNode } from "@smithers-orchestrator/engine/approvals";
6
5
  import { isRunHeartbeatFresh } from "@smithers-orchestrator/engine";
7
6
  import { nowMs } from "@smithers-orchestrator/scheduler/nowMs";
@@ -119,7 +118,7 @@ export function createServeApp(opts) {
119
118
  return next();
120
119
  const authHeader = c.req.header("authorization");
121
120
  if (authHeader) {
122
- const token = authHeader.startsWith("Bearer ")
121
+ const token = authHeader.slice(0, 7).toLowerCase() === "bearer "
123
122
  ? authHeader.slice(7)
124
123
  : authHeader;
125
124
  if (token === authToken)