@smithers-orchestrator/server 0.16.9 → 0.18.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/src/gateway.js CHANGED
@@ -1,12 +1,15 @@
1
1
  // @smithers-type-exports-begin
2
2
  /** @typedef {import("./EventFrame.js").EventFrame} EventFrame */
3
3
  /** @typedef {import("./GatewayDefaults.js").GatewayDefaults} GatewayDefaults */
4
+ /** @typedef {import("./GatewayRegisterOptions.js").GatewayRegisterOptions} GatewayRegisterOptions */
4
5
  /** @typedef {import("./GatewayTokenGrant.js").GatewayTokenGrant} GatewayTokenGrant */
6
+ /** @typedef {import("./GatewayUiConfig.js").GatewayUiConfig} GatewayUiConfig */
5
7
  /** @typedef {import("./HelloResponse.js").HelloResponse} HelloResponse */
6
8
  // @smithers-type-exports-end
7
9
 
8
10
  import { createServer } from "node:http";
9
11
  import { createHash, createHmac, randomUUID, timingSafeEqual } from "node:crypto";
12
+ import { resolve } from "node:path";
10
13
  import { CronExpressionParser } from "cron-parser";
11
14
  import { Effect, Metric } from "effect";
12
15
  import { WebSocketServer } from "ws";
@@ -24,7 +27,7 @@ import { errorToJson } from "@smithers-orchestrator/errors/errorToJson";
24
27
  import { isSmithersError } from "@smithers-orchestrator/errors/isSmithersError";
25
28
  import { SmithersError } from "@smithers-orchestrator/errors/SmithersError";
26
29
  import { assertJsonPayloadWithinBounds, assertOptionalStringMaxLength, assertPositiveFiniteInteger, } from "@smithers-orchestrator/db/input-bounds";
27
- import { loadLatestSnapshot, loadSnapshot } from "@smithers-orchestrator/time-travel/snapshot";
30
+ import { loadLatestSnapshot } from "@smithers-orchestrator/time-travel/snapshot";
28
31
  import { diffRawSnapshots } from "@smithers-orchestrator/time-travel/diff";
29
32
  import { getNodeOutputRoute } from "./gatewayRoutes/getNodeOutput.js";
30
33
  import { NodeOutputRouteError } from "./gatewayRoutes/NodeOutputRouteError.js";
@@ -34,6 +37,9 @@ import { streamDevToolsRoute } from "./gatewayRoutes/streamDevTools.js";
34
37
  import { jumpToFrameRoute, JumpToFrameError } from "./gatewayRoutes/jumpToFrame.js";
35
38
  import { writeRewindAuditRow } from "@smithers-orchestrator/time-travel/writeRewindAuditRow";
36
39
  import { recoverInProgressRewindAudits } from "@smithers-orchestrator/time-travel/recoverInProgressRewindAudits";
40
+ import { GATEWAY_EVENT_WINDOW_DEFAULT, SMITHERS_API_VERSION, getRequiredScopeForGatewayMethod, } from "@smithers-orchestrator/gateway/rpc";
41
+ import { hasGatewayScope } from "@smithers-orchestrator/gateway/auth/scopes";
42
+ import { createGatewayUiApp } from "./gatewayUi/createGatewayUiApp.js";
37
43
  /** @typedef {import("./GatewayWebhookRunConfig.js").GatewayWebhookRunConfig} GatewayWebhookRunConfig */
38
44
  /** @typedef {import("./GatewayWebhookSignalConfig.js").GatewayWebhookSignalConfig} GatewayWebhookSignalConfig */
39
45
  /** @typedef {import("./ConnectRequest.js").ConnectRequest} ConnectRequest */
@@ -54,6 +60,7 @@ import { recoverInProgressRewindAudits } from "@smithers-orchestrator/time-trave
54
60
  * role?: string;
55
61
  * scopes?: string[];
56
62
  * userId?: string | null;
63
+ * tokenId?: string | null;
57
64
  * origin?: string;
58
65
  * transport?: GatewayTransport;
59
66
  * }} GatewayRequestContext
@@ -76,6 +83,7 @@ import { recoverInProgressRewindAudits } from "@smithers-orchestrator/time-trave
76
83
  * role: string;
77
84
  * scopes: string[];
78
85
  * userId?: string | null;
86
+ * tokenId?: string | null;
79
87
  * connectionId?: string;
80
88
  * }} RunStartAuthContext
81
89
  */
@@ -86,6 +94,7 @@ import { recoverInProgressRewindAudits } from "@smithers-orchestrator/time-trave
86
94
  * key: string;
87
95
  * schedule?: string;
88
96
  * webhook?: GatewayWebhookConfig;
97
+ * ui?: ResolvedGatewayUiConfig | null;
89
98
  * }} RegisteredWorkflow
90
99
  */
91
100
  /**
@@ -96,11 +105,29 @@ import { recoverInProgressRewindAudits } from "@smithers-orchestrator/time-trave
96
105
  * adapter: SmithersDb;
97
106
  * }} ResolvedRun
98
107
  */
108
+ /**
109
+ * @typedef {{
110
+ * entry: string;
111
+ * path: string;
112
+ * title?: string;
113
+ * props?: Record<string, unknown>;
114
+ * }} ResolvedGatewayUiConfig
115
+ */
116
+ /**
117
+ * @typedef {{
118
+ * kind: "gateway" | "workflow";
119
+ * workflowKey: string | null;
120
+ * config: ResolvedGatewayUiConfig;
121
+ * }} GatewayUiMount
122
+ */
99
123
 
100
124
  const DEFAULT_PROTOCOL = 1;
101
125
  const DEFAULT_HEARTBEAT_MS = 15_000;
102
126
  const DEFAULT_MAX_BODY_BYTES = 1_048_576;
103
127
  const DEFAULT_MAX_CONNECTIONS = 1_000;
128
+ const DEFAULT_HEADERS_TIMEOUT = 30_000;
129
+ const DEFAULT_REQUEST_TIMEOUT = 60_000;
130
+ const RUN_EVENT_HEARTBEAT_MS = 1_000;
104
131
  export const GATEWAY_RPC_MAX_PAYLOAD_BYTES = DEFAULT_MAX_BODY_BYTES;
105
132
  export const GATEWAY_RPC_MAX_DEPTH = 32;
106
133
  export const GATEWAY_RPC_MAX_ARRAY_LENGTH = 256;
@@ -110,43 +137,115 @@ export const GATEWAY_FRAME_ID_MAX_LENGTH = 128;
110
137
  export const GATEWAY_RPC_INPUT_MAX_BYTES = GATEWAY_RPC_MAX_PAYLOAD_BYTES;
111
138
  export const GATEWAY_RPC_INPUT_MAX_DEPTH = GATEWAY_RPC_MAX_DEPTH;
112
139
  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
- };
140
+ const GATEWAY_UI_ASSET_PREFIX = "__smithers_ui";
141
+
142
+ /**
143
+ * @param {string} value
144
+ * @returns {string}
145
+ */
146
+ function escapeHtml(value) {
147
+ return value
148
+ .replaceAll("&", "&amp;")
149
+ .replaceAll("<", "&lt;")
150
+ .replaceAll(">", "&gt;")
151
+ .replaceAll('"', "&quot;");
152
+ }
153
+
154
+ /**
155
+ * @param {unknown} value
156
+ * @returns {string}
157
+ */
158
+ function safeJsonScript(value) {
159
+ return JSON.stringify(value).replaceAll("<", "\\u003c");
160
+ }
161
+
162
+ /**
163
+ * @param {string | undefined} rawPath
164
+ * @param {string} fallbackPath
165
+ * @returns {string}
166
+ */
167
+ function normalizeUiMountPath(rawPath, fallbackPath) {
168
+ const candidate = (rawPath && rawPath.trim()) || fallbackPath;
169
+ const withSlash = candidate.startsWith("/") ? candidate : `/${candidate}`;
170
+ const withoutTrailing = withSlash.length > 1 ? withSlash.replace(/\/+$/, "") : withSlash;
171
+ if (!/^\/[A-Za-z0-9/_:.-]*$/.test(withoutTrailing)) {
172
+ throw new SmithersError("INVALID_INPUT", `Gateway UI path is invalid: ${candidate}`);
173
+ }
174
+ return withoutTrailing;
175
+ }
176
+
177
+ /**
178
+ * @param {string} mountPath
179
+ * @param {string} suffix
180
+ * @returns {string}
181
+ */
182
+ function joinUiPath(mountPath, suffix) {
183
+ if (mountPath === "/") {
184
+ return `/${suffix.replace(/^\/+/, "")}`;
185
+ }
186
+ return `${mountPath}/${suffix.replace(/^\/+/, "")}`;
187
+ }
188
+
189
+ /**
190
+ * @param {GatewayUiConfig | undefined} ui
191
+ * @param {string} fallbackPath
192
+ * @returns {ResolvedGatewayUiConfig | null}
193
+ */
194
+ function resolveGatewayUiConfig(ui, fallbackPath) {
195
+ if (!ui) {
196
+ return null;
197
+ }
198
+ if (typeof ui.entry !== "string" || !ui.entry.trim()) {
199
+ throw new SmithersError("INVALID_INPUT", "Gateway UI config requires a non-empty entry path.");
200
+ }
201
+ return {
202
+ entry: resolve(process.cwd(), ui.entry),
203
+ path: normalizeUiMountPath(ui.path, fallbackPath),
204
+ ...(typeof ui.title === "string" ? { title: ui.title } : {}),
205
+ ...(ui.props && typeof ui.props === "object" && !Array.isArray(ui.props)
206
+ ? { props: ui.props }
207
+ : {}),
208
+ };
209
+ }
210
+
211
+ /**
212
+ * @param {import("node:http").IncomingHttpHeaders} headers
213
+ * @returns {Headers}
214
+ */
215
+ function nodeHeadersToFetchHeaders(headers) {
216
+ const out = new Headers();
217
+ for (const [key, value] of Object.entries(headers)) {
218
+ if (value === undefined) {
219
+ continue;
220
+ }
221
+ if (Array.isArray(value)) {
222
+ for (const entry of value) {
223
+ out.append(key, entry);
224
+ }
225
+ continue;
226
+ }
227
+ out.set(key, value);
228
+ }
229
+ return out;
230
+ }
231
+
232
+ /**
233
+ * @param {ServerResponse} res
234
+ * @param {Response} response
235
+ * @param {boolean} headOnly
236
+ */
237
+ async function writeFetchResponse(res, response, headOnly = false) {
238
+ res.statusCode = response.status;
239
+ response.headers.forEach((value, key) => {
240
+ res.setHeader(key, value);
241
+ });
242
+ if (headOnly) {
243
+ res.end();
244
+ return;
245
+ }
246
+ const body = Buffer.from(await response.arrayBuffer());
247
+ res.end(body);
248
+ }
150
249
  /**
151
250
  * @template T
152
251
  * @param {string | null | undefined} value
@@ -182,6 +281,7 @@ function sendJson(res, status, payload) {
182
281
  res.setHeader("Content-Type", "application/json");
183
282
  res.setHeader("Cache-Control", "no-store");
184
283
  res.setHeader("X-Content-Type-Options", "nosniff");
284
+ res.setHeader("X-Smithers-API-Version", SMITHERS_API_VERSION);
185
285
  res.end(JSON.stringify(payload));
186
286
  }
187
287
  /**
@@ -194,6 +294,7 @@ function sendText(res, status, payload, contentType = "text/plain; charset=utf-8
194
294
  res.setHeader("Content-Type", contentType);
195
295
  res.setHeader("Cache-Control", "no-store");
196
296
  res.setHeader("X-Content-Type-Options", "nosniff");
297
+ res.setHeader("X-Smithers-API-Version", SMITHERS_API_VERSION);
197
298
  res.end(payload);
198
299
  }
199
300
  /**
@@ -315,6 +416,7 @@ function gatewayContextAnnotations(context) {
315
416
  transport: context.transport,
316
417
  ...(context.userId ? { userId: context.userId } : {}),
317
418
  ...(context.role ? { role: context.role } : {}),
419
+ ...(context.tokenId ? { tokenId: context.tokenId } : {}),
318
420
  };
319
421
  }
320
422
  /**
@@ -450,7 +552,7 @@ function bearerTokenFromHeaders(req) {
450
552
  if (!authHeader) {
451
553
  return null;
452
554
  }
453
- return authHeader.startsWith("Bearer ") ? authHeader.slice(7) : authHeader;
555
+ return authHeader.slice(0, 7).toLowerCase() === "bearer " ? authHeader.slice(7) : authHeader;
454
556
  }
455
557
  /**
456
558
  * @param {unknown} value
@@ -465,16 +567,39 @@ function asStringRecord(value) {
465
567
  * @returns {ResponseFrame}
466
568
  */
467
569
  function responseOk(id, payload) {
468
- return { type: "res", id, ok: true, payload };
570
+ return { type: "res", id, ok: true, apiVersion: SMITHERS_API_VERSION, payload };
469
571
  }
470
572
  /**
471
573
  * @param {string} id
472
574
  * @param {string} code
473
575
  * @param {string} message
576
+ * @param {Record<string, unknown>} [details]
474
577
  * @returns {ResponseFrame}
475
578
  */
476
- function responseError(id, code, message) {
477
- return { type: "res", id, ok: false, error: { code, message } };
579
+ function responseError(id, code, message, details = {}) {
580
+ return {
581
+ type: "res",
582
+ id,
583
+ ok: false,
584
+ apiVersion: SMITHERS_API_VERSION,
585
+ error: {
586
+ version: SMITHERS_API_VERSION,
587
+ code,
588
+ message,
589
+ ...details,
590
+ },
591
+ };
592
+ }
593
+ /**
594
+ * @param {string} id
595
+ * @param {string} method
596
+ * @returns {ResponseFrame}
597
+ */
598
+ function responseForbidden(id, method) {
599
+ const requiredScope = requiredScopeForMethod(method);
600
+ return responseError(id, "FORBIDDEN", `Missing required scope ${requiredScope} for ${method}`, {
601
+ requiredScope,
602
+ });
478
603
  }
479
604
  /**
480
605
  * @param {unknown} raw
@@ -624,13 +749,16 @@ export function statusForRpcError(code) {
624
749
  case "Unauthorized":
625
750
  return 401;
626
751
  case "FORBIDDEN":
752
+ case "Forbidden":
627
753
  return 403;
628
754
  case "NOT_FOUND":
629
755
  case "METHOD_NOT_FOUND":
630
756
  return 404;
631
757
  case "INVALID_REQUEST":
758
+ case "InvalidRequest":
632
759
  case "INVALID_FRAME":
633
760
  case "INVALID_INPUT":
761
+ case "InvalidInput":
634
762
  case "PROTOCOL_UNSUPPORTED":
635
763
  case "InvalidRunId":
636
764
  case "InvalidNodeId":
@@ -649,9 +777,11 @@ export function statusForRpcError(code) {
649
777
  return 404;
650
778
  case "AttemptNotFinished":
651
779
  case "Busy":
780
+ case "AlreadyDecided":
652
781
  return 409;
653
782
  case "DiffTooLarge":
654
783
  case "PayloadTooLarge":
784
+ case "PAYLOAD_TOO_LARGE":
655
785
  return 413;
656
786
  case "RateLimited":
657
787
  case "BackpressureDisconnect":
@@ -683,10 +813,23 @@ function normalizeGrantedScope(scope) {
683
813
  }
684
814
  /**
685
815
  * @param {string} method
686
- * @returns {MethodAccess}
816
+ * @returns {import("@smithers-orchestrator/gateway/auth/scopes").GatewayScope}
687
817
  */
688
- function accessForMethod(method) {
689
- return METHOD_ACCESS[method] ?? (method.startsWith("config.") ? "admin" : "read");
818
+ function requiredScopeForMethod(method) {
819
+ if (method === "run:read" ||
820
+ method === "run:write" ||
821
+ method === "run:admin" ||
822
+ method === "approval:submit" ||
823
+ method === "signal:submit" ||
824
+ method === "cron:read" ||
825
+ method === "cron:write" ||
826
+ method === "observability:read") {
827
+ return method;
828
+ }
829
+ if (method.startsWith("config.")) {
830
+ return "run:admin";
831
+ }
832
+ return getRequiredScopeForGatewayMethod(method) ?? "run:read";
690
833
  }
691
834
  /**
692
835
  * @param {string[]} scopes
@@ -694,26 +837,7 @@ function accessForMethod(method) {
694
837
  * @returns {boolean}
695
838
  */
696
839
  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;
840
+ return hasGatewayScope(scopes.map(normalizeGrantedScope), requiredScopeForMethod(method), method);
717
841
  }
718
842
  /**
719
843
  * @param {unknown} value
@@ -962,13 +1086,13 @@ async function readRawBody(req, maxBytes) {
962
1086
  const lengthHeader = headerValue(req, "content-length");
963
1087
  const declaredLength = lengthHeader ? Number(lengthHeader) : NaN;
964
1088
  if (Number.isFinite(declaredLength) && declaredLength > maxBytes) {
965
- throw new SmithersError("INVALID_INPUT", `Gateway request payload exceeds ${maxBytes} bytes.`, { maxBytes });
1089
+ throw new SmithersError("PayloadTooLarge", `Gateway request payload exceeds ${maxBytes} bytes.`, { maxBytes });
966
1090
  }
967
1091
  for await (const chunk of req) {
968
1092
  const buffer = Buffer.from(chunk);
969
1093
  total += buffer.length;
970
1094
  if (total > maxBytes) {
971
- throw new SmithersError("INVALID_INPUT", `Gateway request payload exceeds ${maxBytes} bytes.`, { maxBytes });
1095
+ throw new SmithersError("PayloadTooLarge", `Gateway request payload exceeds ${maxBytes} bytes.`, { maxBytes });
972
1096
  }
973
1097
  chunks.push(buffer);
974
1098
  }
@@ -1055,7 +1179,12 @@ export class Gateway {
1055
1179
  maxBodyBytes;
1056
1180
  maxPayload;
1057
1181
  maxConnections;
1182
+ eventWindowSize;
1183
+ headersTimeout;
1184
+ requestTimeout;
1058
1185
  auth;
1186
+ ui;
1187
+ uiApp;
1059
1188
  defaults;
1060
1189
  workflows = new Map();
1061
1190
  connections = new Set();
@@ -1063,10 +1192,12 @@ export class Gateway {
1063
1192
  activeRuns = new Map();
1064
1193
  inflightRuns = new Map();
1065
1194
  devtoolsSubscribers = new Map();
1195
+ runEventWindows = new Map();
1066
1196
  /** Absolute active subscriber count per runId (gauge source of truth). */
1067
1197
  devtoolsSubscriberCounts = new Map();
1068
1198
  /** Flagged subscriber IDs that should force a snapshot on their next emit. */
1069
1199
  devtoolsInvalidateFlags = new Set();
1200
+ uiAssetCache = new Map();
1070
1201
  server = null;
1071
1202
  wsServer = null;
1072
1203
  schedulerTimer = null;
@@ -1088,9 +1219,203 @@ export class Gateway {
1088
1219
  this.maxConnections = options.maxConnections === undefined
1089
1220
  ? DEFAULT_MAX_CONNECTIONS
1090
1221
  : Math.floor(assertPositiveFiniteInteger("maxConnections", Number(options.maxConnections)));
1222
+ this.eventWindowSize = options.eventWindowSize === undefined
1223
+ ? GATEWAY_EVENT_WINDOW_DEFAULT
1224
+ : Math.floor(assertPositiveFiniteInteger("eventWindowSize", Number(options.eventWindowSize)));
1225
+ this.headersTimeout = options.headersTimeout === undefined
1226
+ ? DEFAULT_HEADERS_TIMEOUT
1227
+ : Math.floor(assertPositiveFiniteInteger("headersTimeout", Number(options.headersTimeout)));
1228
+ this.requestTimeout = options.requestTimeout === undefined
1229
+ ? DEFAULT_REQUEST_TIMEOUT
1230
+ : Math.floor(assertPositiveFiniteInteger("requestTimeout", Number(options.requestTimeout)));
1091
1231
  this.auth = options.auth;
1232
+ this.ui = resolveGatewayUiConfig(options.ui, "/");
1233
+ this.uiApp = createGatewayUiApp({
1234
+ resolveMatch: (pathname) => this.resolveUiMatch(pathname),
1235
+ renderIndex: (match) => this.renderUiIndex(match),
1236
+ renderAsset: (match) => this.renderUiAsset(match),
1237
+ });
1092
1238
  this.defaults = options.defaults;
1093
1239
  }
1240
+ /**
1241
+ * @returns {GatewayUiMount[]}
1242
+ */
1243
+ getUiMounts() {
1244
+ const mounts = [];
1245
+ if (this.ui) {
1246
+ mounts.push({ kind: "gateway", workflowKey: null, config: this.ui });
1247
+ }
1248
+ for (const [workflowKey, entry] of this.workflows.entries()) {
1249
+ if (entry.ui) {
1250
+ mounts.push({ kind: "workflow", workflowKey, config: entry.ui });
1251
+ }
1252
+ }
1253
+ return mounts.sort((left, right) => right.config.path.length - left.config.path.length);
1254
+ }
1255
+ /**
1256
+ * @param {string} pathname
1257
+ * @returns {GatewayUiMount | null}
1258
+ */
1259
+ findUiMount(pathname) {
1260
+ for (const mount of this.getUiMounts()) {
1261
+ const mountPath = mount.config.path;
1262
+ if (mountPath === "/" || pathname === mountPath || pathname.startsWith(`${mountPath}/`)) {
1263
+ return mount;
1264
+ }
1265
+ }
1266
+ return null;
1267
+ }
1268
+ /**
1269
+ * @param {string} pathname
1270
+ */
1271
+ resolveUiMatch(pathname) {
1272
+ const mount = this.findUiMount(pathname);
1273
+ if (!mount) {
1274
+ return null;
1275
+ }
1276
+ const assetBase = joinUiPath(mount.config.path, `${GATEWAY_UI_ASSET_PREFIX}/`);
1277
+ const assetPath = pathname.startsWith(assetBase)
1278
+ ? pathname.slice(assetBase.length)
1279
+ : null;
1280
+ return {
1281
+ pathname,
1282
+ mountPath: mount.config.path,
1283
+ assetPath,
1284
+ config: mount,
1285
+ };
1286
+ }
1287
+ /**
1288
+ * @param {GatewayUiMount} mount
1289
+ */
1290
+ uiBootConfig(mount) {
1291
+ return {
1292
+ apiVersion: SMITHERS_API_VERSION,
1293
+ kind: mount.kind,
1294
+ workflowKey: mount.workflowKey,
1295
+ mountPath: mount.config.path,
1296
+ rpcPath: "/v1/rpc",
1297
+ wsPath: "/",
1298
+ assetBasePath: joinUiPath(mount.config.path, `${GATEWAY_UI_ASSET_PREFIX}/`),
1299
+ props: mount.config.props ?? {},
1300
+ };
1301
+ }
1302
+ /**
1303
+ * @param {{ config: GatewayUiMount }} match
1304
+ */
1305
+ renderUiIndex(match) {
1306
+ const mount = match.config;
1307
+ const title = mount.config.title ?? (mount.workflowKey ? `${mount.workflowKey} | Smithers` : "Smithers");
1308
+ const boot = this.uiBootConfig(mount);
1309
+ const assetSrc = joinUiPath(mount.config.path, `${GATEWAY_UI_ASSET_PREFIX}/client.js`);
1310
+ return `<!doctype html>
1311
+ <html lang="en">
1312
+ <head>
1313
+ <meta charset="utf-8">
1314
+ <meta name="viewport" content="width=device-width, initial-scale=1">
1315
+ <title>${escapeHtml(title)}</title>
1316
+ </head>
1317
+ <body>
1318
+ <div id="root"></div>
1319
+ <script>globalThis.__SMITHERS_GATEWAY_UI__=${safeJsonScript(boot)};</script>
1320
+ <script type="module" src="${escapeHtml(assetSrc)}"></script>
1321
+ </body>
1322
+ </html>`;
1323
+ }
1324
+ /**
1325
+ * @param {{ config: GatewayUiMount; assetPath: string | null }} match
1326
+ */
1327
+ async renderUiAsset(match) {
1328
+ if (match.assetPath !== "client.js") {
1329
+ return null;
1330
+ }
1331
+ const body = await this.bundleUiEntry(match.config.config);
1332
+ return {
1333
+ body,
1334
+ contentType: "text/javascript; charset=utf-8",
1335
+ };
1336
+ }
1337
+ /**
1338
+ * @param {ResolvedGatewayUiConfig} config
1339
+ * @returns {Promise<string>}
1340
+ */
1341
+ async bundleUiEntry(config) {
1342
+ const cached = this.uiAssetCache.get(config.entry);
1343
+ if (cached) {
1344
+ return cached;
1345
+ }
1346
+ if (typeof Bun === "undefined" || typeof Bun.build !== "function") {
1347
+ throw new SmithersError("INVALID_INPUT", "Gateway UI bundling requires Bun.build.");
1348
+ }
1349
+ const result = await Bun.build({
1350
+ entrypoints: [config.entry],
1351
+ root: process.cwd(),
1352
+ target: "browser",
1353
+ format: "esm",
1354
+ sourcemap: "inline",
1355
+ minify: false,
1356
+ jsx: {
1357
+ runtime: "automatic",
1358
+ importSource: "react",
1359
+ },
1360
+ });
1361
+ if (!result.success) {
1362
+ const message = result.logs?.map((entry) => entry.message).filter(Boolean).join("\n")
1363
+ || `Failed to build Gateway UI entry ${config.entry}`;
1364
+ throw new SmithersError("INVALID_INPUT", message);
1365
+ }
1366
+ const output = result.outputs.find((entry) => entry.path.endsWith(".js")) ?? result.outputs[0];
1367
+ const body = await output.text();
1368
+ this.uiAssetCache.set(config.entry, body);
1369
+ return body;
1370
+ }
1371
+ /**
1372
+ * @param {IncomingMessage} req
1373
+ * @param {ServerResponse} res
1374
+ */
1375
+ async handleUiHttp(req, res) {
1376
+ if ((req.method ?? "GET") !== "GET" && (req.method ?? "GET") !== "HEAD") {
1377
+ return false;
1378
+ }
1379
+ const host = headerValue(req, "host") ?? "127.0.0.1";
1380
+ const request = new Request(`http://${host}${req.url ?? "/"}`, {
1381
+ method: "GET",
1382
+ headers: nodeHeadersToFetchHeaders(req.headers),
1383
+ });
1384
+ const response = await this.uiApp.fetch(request);
1385
+ if (response.status === 404 && response.headers.get("x-smithers-ui-miss") === "1") {
1386
+ return false;
1387
+ }
1388
+ await writeFetchResponse(res, response, (req.method ?? "GET") === "HEAD");
1389
+ return true;
1390
+ }
1391
+ /**
1392
+ * @param {string} key
1393
+ * @param {RegisteredWorkflow} entry
1394
+ */
1395
+ workflowSummary(key, entry) {
1396
+ return {
1397
+ key,
1398
+ ...(entry.workflow.readableName ? { readableName: entry.workflow.readableName } : {}),
1399
+ ...(entry.workflow.description ? { description: entry.workflow.description } : {}),
1400
+ hasUi: Boolean(entry.ui),
1401
+ uiPath: entry.ui?.path ?? null,
1402
+ };
1403
+ }
1404
+ /**
1405
+ * @param {boolean | undefined} hasUi
1406
+ */
1407
+ listWorkflowSummaries(hasUi) {
1408
+ const rows = [];
1409
+ for (const [key, entry] of this.workflows.entries()) {
1410
+ const summary = this.workflowSummary(key, entry);
1411
+ if (hasUi !== undefined && summary.hasUi !== hasUi) {
1412
+ continue;
1413
+ }
1414
+ rows.push(summary);
1415
+ }
1416
+ rows.sort((left, right) => left.key.localeCompare(right.key));
1417
+ return rows;
1418
+ }
1094
1419
  authModeLabel() {
1095
1420
  return gatewayAuthMode(this.auth);
1096
1421
  }
@@ -1238,6 +1563,153 @@ export class Gateway {
1238
1563
  }
1239
1564
  }
1240
1565
  /**
1566
+ * @param {string} runId
1567
+ * @returns {{ nextSeq: number; window: Array<Record<string, unknown>> }}
1568
+ */
1569
+ getRunEventWindow(runId) {
1570
+ let state = this.runEventWindows.get(runId);
1571
+ if (!state) {
1572
+ state = { nextSeq: 0, window: [] };
1573
+ this.runEventWindows.set(runId, state);
1574
+ }
1575
+ return state;
1576
+ }
1577
+ /**
1578
+ * @param {string} event
1579
+ * @param {unknown} payload
1580
+ * @param {number} stateVersion
1581
+ * @returns {Record<string, unknown> | null}
1582
+ */
1583
+ appendRunEventWindow(event, payload, stateVersion) {
1584
+ const runId = eventRunId(payload);
1585
+ if (!runId) {
1586
+ return null;
1587
+ }
1588
+ const state = this.getRunEventWindow(runId);
1589
+ state.nextSeq += 1;
1590
+ const frame = {
1591
+ apiVersion: SMITHERS_API_VERSION,
1592
+ type: "RunEvent",
1593
+ runId,
1594
+ event,
1595
+ payload,
1596
+ seq: state.nextSeq,
1597
+ stateVersion,
1598
+ };
1599
+ state.window.push(frame);
1600
+ while (state.window.length > this.eventWindowSize) {
1601
+ state.window.shift();
1602
+ }
1603
+ return frame;
1604
+ }
1605
+ /**
1606
+ * @param {string} runId
1607
+ * @returns {number}
1608
+ */
1609
+ getRunEventCurrentSeq(runId) {
1610
+ return this.runEventWindows.get(runId)?.nextSeq ?? 0;
1611
+ }
1612
+ /**
1613
+ * @param {ConnectionState} connection
1614
+ * @param {string} streamId
1615
+ * @param {string} runId
1616
+ * @returns {() => void}
1617
+ */
1618
+ registerRunEventSubscriber(connection, streamId, runId) {
1619
+ if (!connection.runEventStreams) {
1620
+ connection.runEventStreams = new Map();
1621
+ }
1622
+ const heartbeat = setInterval(() => {
1623
+ this.sendEvent(connection, "run.heartbeat", {
1624
+ apiVersion: SMITHERS_API_VERSION,
1625
+ type: "Heartbeat",
1626
+ streamId,
1627
+ runId,
1628
+ ts: nowMs(),
1629
+ });
1630
+ }, RUN_EVENT_HEARTBEAT_MS);
1631
+ connection.runEventStreams.set(streamId, { runId, heartbeat });
1632
+ return () => this.unregisterRunEventSubscriber(connection, streamId);
1633
+ }
1634
+ /**
1635
+ * @param {ConnectionState} connection
1636
+ * @param {string} streamId
1637
+ */
1638
+ unregisterRunEventSubscriber(connection, streamId) {
1639
+ const stream = connection.runEventStreams?.get(streamId);
1640
+ if (!stream) {
1641
+ return;
1642
+ }
1643
+ clearInterval(stream.heartbeat);
1644
+ connection.runEventStreams?.delete(streamId);
1645
+ }
1646
+ /**
1647
+ * @param {ConnectionState} connection
1648
+ */
1649
+ cleanupRunEventSubscribers(connection) {
1650
+ const streams = connection.runEventStreams;
1651
+ if (!streams || streams.size === 0) {
1652
+ return;
1653
+ }
1654
+ for (const streamId of streams.keys()) {
1655
+ this.unregisterRunEventSubscriber(connection, streamId);
1656
+ }
1657
+ }
1658
+ /**
1659
+ * @param {ConnectionState} connection
1660
+ * @param {string} streamId
1661
+ * @param {Record<string, unknown>} frame
1662
+ */
1663
+ sendRunEventStreamFrame(connection, streamId, frame) {
1664
+ this.sendEvent(connection, "run.event", {
1665
+ streamId,
1666
+ ...frame,
1667
+ });
1668
+ }
1669
+ /**
1670
+ * @param {ConnectionState} connection
1671
+ * @param {string} streamId
1672
+ * @param {string} runId
1673
+ * @param {number} fromSeq
1674
+ * @param {number} toSeq
1675
+ * @param {unknown} snapshot
1676
+ */
1677
+ sendRunGapResync(connection, streamId, runId, fromSeq, toSeq, snapshot) {
1678
+ this.sendEvent(connection, "run.gap_resync", {
1679
+ apiVersion: SMITHERS_API_VERSION,
1680
+ type: "GapResync",
1681
+ streamId,
1682
+ runId,
1683
+ fromSeq,
1684
+ toSeq,
1685
+ snapshot,
1686
+ });
1687
+ }
1688
+ /**
1689
+ * @param {string} runId
1690
+ */
1691
+ async buildRunSnapshot(runId) {
1692
+ const resolved = await this.resolveRun(runId);
1693
+ if (!resolved) {
1694
+ return null;
1695
+ }
1696
+ const run = await resolved.adapter.getRun(runId);
1697
+ if (!run) {
1698
+ return null;
1699
+ }
1700
+ const summary = await resolved.adapter.countNodesByState(runId);
1701
+ const runState = await computeRunStateFromRow(resolved.adapter, run).catch(() => undefined);
1702
+ return {
1703
+ ...run,
1704
+ workflowKey: resolved.workflowKey,
1705
+ summary: summary.reduce((acc, row) => {
1706
+ acc[row.state] = row.count;
1707
+ return acc;
1708
+ }, {}),
1709
+ ...(runState ? { runState } : {}),
1710
+ };
1711
+ }
1712
+ /**
1241
1713
  * @param {GatewayTransport} transport
1242
1714
  * @param {string} frameType
1243
1715
  * @param {GatewayMetricLabels} [labels]
@@ -1359,8 +1831,10 @@ export class Gateway {
1359
1831
  rpcSuccessEffect(context, frame, response) {
1360
1832
  const params = asObject(frame.params) ?? {};
1361
1833
  switch (frame.method) {
1362
- case "approvals.decide": {
1363
- const approved = asBoolean(params.approved) ?? false;
1834
+ case "approvals.decide":
1835
+ case "submitApproval": {
1836
+ const decision = asObject(params.decision);
1837
+ const approved = asBoolean(params.approved) ?? asBoolean(decision?.approved) ?? false;
1364
1838
  const nodeId = asString(params.nodeId);
1365
1839
  return Effect.all([
1366
1840
  incrementMetric(gatewayApprovalDecisionsTotal, {
@@ -1374,9 +1848,10 @@ export class Gateway {
1374
1848
  })),
1375
1849
  ], { discard: true });
1376
1850
  }
1377
- case "signals.send": {
1378
- const signalName = asString(params.signalName);
1379
- const correlationId = asString(params.correlationId);
1851
+ case "signals.send":
1852
+ case "submitSignal": {
1853
+ const signalName = asString(params.signalName) ?? asString(params.correlationKey);
1854
+ const correlationId = asString(params.correlationId) ?? asString(params.correlationKey);
1380
1855
  return Effect.all([
1381
1856
  incrementMetric(gatewaySignalsTotal, { outcome: "sent" }),
1382
1857
  Effect.logInfo("Gateway signal sent").pipe(Effect.annotateLogs({
@@ -1386,7 +1861,8 @@ export class Gateway {
1386
1861
  })),
1387
1862
  ], { discard: true });
1388
1863
  }
1389
- case "cron.trigger": {
1864
+ case "cron.trigger":
1865
+ case "cronRun": {
1390
1866
  const cronId = asString(params.cronId);
1391
1867
  const workflow = asString(params.workflow);
1392
1868
  return Effect.all([
@@ -1611,16 +2087,18 @@ export class Gateway {
1611
2087
  /**
1612
2088
  * @param {string} key
1613
2089
  * @param {SmithersWorkflow} workflow
1614
- * @param {{ schedule?: string; webhook?: GatewayWebhookConfig }} [options]
2090
+ * @param {GatewayRegisterOptions} [options]
1615
2091
  * @returns {this}
1616
2092
  */
1617
2093
  register(key, workflow, options) {
1618
2094
  ensureSmithersTables(workflow.db);
2095
+ const ui = resolveGatewayUiConfig(options?.ui, `/workflows/${encodeURIComponent(key)}`);
1619
2096
  this.workflows.set(key, {
1620
2097
  key,
1621
2098
  workflow,
1622
2099
  schedule: options?.schedule,
1623
2100
  webhook: options?.webhook,
2101
+ ui,
1624
2102
  });
1625
2103
  // Startup recovery: any audit row left in `in_progress` from a prior
1626
2104
  // crash is flipped to `partial` and the associated run is flagged as
@@ -1646,9 +2124,13 @@ export class Gateway {
1646
2124
  noServer: true,
1647
2125
  maxPayload: this.maxPayload,
1648
2126
  });
2127
+ wsServer.on("headers", (headers) => {
2128
+ headers.push(`X-Smithers-API-Version: ${SMITHERS_API_VERSION}`);
2129
+ });
1649
2130
  const server = createServer(async (req, res) => {
1650
2131
  const url = new URL(req.url ?? "/", "http://127.0.0.1");
1651
2132
  const webhookMatch = url.pathname.match(/^\/webhooks\/([^/]+)$/);
2133
+ const rpcMatch = url.pathname.match(/^\/v1\/rpc\/([^/]+)$/);
1652
2134
  if ((req.method ?? "GET") === "GET" && (req.url ?? "/") === "/health") {
1653
2135
  return sendJson(res, 200, {
1654
2136
  ok: true,
@@ -1663,11 +2145,19 @@ export class Gateway {
1663
2145
  if ((req.method ?? "GET") === "POST" && webhookMatch) {
1664
2146
  return this.handleWebhook(req, res, decodeURIComponent(webhookMatch[1]));
1665
2147
  }
2148
+ if ((req.method ?? "GET") === "POST" && rpcMatch) {
2149
+ return this.handleHttpRpc(req, res, decodeURIComponent(rpcMatch[1]));
2150
+ }
1666
2151
  if ((req.method ?? "GET") === "POST" && (req.url ?? "/") === "/rpc") {
1667
2152
  return this.handleHttpRpc(req, res);
1668
2153
  }
2154
+ if (await this.handleUiHttp(req, res)) {
2155
+ return;
2156
+ }
1669
2157
  return sendJson(res, 404, { error: { code: "NOT_FOUND", message: "Route not found" } });
1670
2158
  });
2159
+ server.headersTimeout = this.headersTimeout;
2160
+ server.requestTimeout = this.requestTimeout;
1671
2161
  server.on("upgrade", (req, socket, head) => {
1672
2162
  if (this.connections.size >= this.maxConnections) {
1673
2163
  emitGatewayEffect(incrementMetric(gatewayErrorsTotal, {
@@ -1683,6 +2173,7 @@ export class Gateway {
1683
2173
  socket.write("HTTP/1.1 503 Service Unavailable\r\n"
1684
2174
  + "Connection: close\r\n"
1685
2175
  + "Content-Type: text/plain; charset=utf-8\r\n"
2176
+ + `X-Smithers-API-Version: ${SMITHERS_API_VERSION}\r\n`
1686
2177
  + `Content-Length: ${Buffer.byteLength(body, "utf8")}\r\n`
1687
2178
  + "\r\n"
1688
2179
  + body);
@@ -1873,6 +2364,7 @@ export class Gateway {
1873
2364
  triggeredBy: auth.triggeredBy,
1874
2365
  source: gatewayTriggerSource(auth.triggeredBy),
1875
2366
  resume: options?.resume ?? false,
2367
+ ...(auth.tokenId ? { tokenId: auth.tokenId } : {}),
1876
2368
  ...(auth.subscribeConnection
1877
2369
  ? gatewayContextAnnotations(auth.subscribeConnection)
1878
2370
  : {}),
@@ -1899,6 +2391,7 @@ export class Gateway {
1899
2391
  triggeredBy: auth.triggeredBy,
1900
2392
  scopes: [...auth.scopes],
1901
2393
  role: auth.role,
2394
+ tokenId: auth.tokenId ?? null,
1902
2395
  createdAt: new Date().toISOString(),
1903
2396
  },
1904
2397
  }))
@@ -1994,6 +2487,7 @@ export class Gateway {
1994
2487
  role: null,
1995
2488
  scopes: [],
1996
2489
  userId: null,
2490
+ tokenId: null,
1997
2491
  subscribedRuns: null,
1998
2492
  devtoolsStreams: new Map(),
1999
2493
  heartbeatTimer: null,
@@ -2024,7 +2518,7 @@ export class Gateway {
2024
2518
  return this.handleConnect(connection, req, frame.id, frame.params);
2025
2519
  }
2026
2520
  if (!hasScope(connection.scopes, frame.method)) {
2027
- return responseError(frame.id, "FORBIDDEN", `Missing scope for ${frame.method}`);
2521
+ return responseForbidden(frame.id, frame.method);
2028
2522
  }
2029
2523
  return this.routeRequest(connection, frame);
2030
2524
  });
@@ -2062,6 +2556,7 @@ export class Gateway {
2062
2556
  }
2063
2557
  this.connections.delete(connection);
2064
2558
  this.cleanupDevToolsSubscribers(connection);
2559
+ this.cleanupRunEventSubscribers(connection);
2065
2560
  emitGatewayEffect(Effect.all([
2066
2561
  updateMetric(gatewayConnectionsActive, -1, { transport: "ws" }),
2067
2562
  incrementMetric(gatewayConnectionsClosedTotal, {
@@ -2146,13 +2641,14 @@ export class Gateway {
2146
2641
  authCode: authResult.code,
2147
2642
  authMessage: authResult.message,
2148
2643
  });
2149
- return responseError(id, authResult.code, authResult.message);
2644
+ return responseError(id, authResult.code, authResult.message, authResult.details);
2150
2645
  }
2151
2646
  connection.authenticated = true;
2152
2647
  connection.sessionToken = randomUUID();
2153
2648
  connection.role = authResult.role;
2154
2649
  connection.scopes = [...authResult.scopes];
2155
2650
  connection.userId = authResult.userId ?? null;
2651
+ connection.tokenId = authResult.tokenId ?? null;
2156
2652
  connection.subscribedRuns = Array.isArray(request.subscribe)
2157
2653
  ? new Set(request.subscribe.filter((value) => typeof value === "string"))
2158
2654
  : null;
@@ -2171,6 +2667,7 @@ export class Gateway {
2171
2667
  role: authResult.role,
2172
2668
  scopes: authResult.scopes,
2173
2669
  userId: authResult.userId ?? null,
2670
+ tokenId: authResult.tokenId ?? null,
2174
2671
  },
2175
2672
  snapshot: await this.buildSnapshot(),
2176
2673
  };
@@ -2214,11 +2711,32 @@ export class Gateway {
2214
2711
  message: "Invalid token",
2215
2712
  };
2216
2713
  }
2714
+ if (typeof grant.revokedAtMs === "number" && grant.revokedAtMs <= Date.now()) {
2715
+ return {
2716
+ ok: false,
2717
+ code: "UNAUTHORIZED",
2718
+ message: "Token has been revoked",
2719
+ details: {
2720
+ refresh: "smithers token issue",
2721
+ },
2722
+ };
2723
+ }
2724
+ if (typeof grant.expiresAtMs === "number" && grant.expiresAtMs <= Date.now()) {
2725
+ return {
2726
+ ok: false,
2727
+ code: "UNAUTHORIZED",
2728
+ message: "Token expired; issue a refreshed token.",
2729
+ details: {
2730
+ refresh: "smithers token issue",
2731
+ },
2732
+ };
2733
+ }
2217
2734
  return {
2218
2735
  ok: true,
2219
2736
  role: grant.role,
2220
2737
  scopes: grant.scopes,
2221
2738
  userId: grant.userId,
2739
+ tokenId: grant.tokenId ?? createHash("sha256").update(token).digest("hex").slice(0, 16),
2222
2740
  };
2223
2741
  }
2224
2742
  if (this.auth.mode === "jwt") {
@@ -2235,6 +2753,9 @@ export class Gateway {
2235
2753
  ok: false,
2236
2754
  code: "UNAUTHORIZED",
2237
2755
  message: verified.message,
2756
+ details: verified.message.includes("expired")
2757
+ ? { refresh: "smithers token issue" }
2758
+ : undefined,
2238
2759
  };
2239
2760
  }
2240
2761
  const scopes = parseJwtScopes(verified.payload[this.auth.scopesClaim ?? "scope"]);
@@ -2247,6 +2768,7 @@ export class Gateway {
2247
2768
  role,
2248
2769
  scopes: scopes.length > 0 ? scopes : [...(this.auth.defaultScopes ?? [])],
2249
2770
  userId: userId ?? undefined,
2771
+ tokenId: createHash("sha256").update(token).digest("hex").slice(0, 16),
2250
2772
  };
2251
2773
  }
2252
2774
  if (this.auth.mode === "trusted-proxy") {
@@ -2271,6 +2793,7 @@ export class Gateway {
2271
2793
  role,
2272
2794
  scopes,
2273
2795
  userId: userId ?? undefined,
2796
+ tokenId: asString(req.headers["x-smithers-token-id"]) ?? undefined,
2274
2797
  };
2275
2798
  }
2276
2799
  return {
@@ -2282,8 +2805,9 @@ export class Gateway {
2282
2805
  /**
2283
2806
  * @param {IncomingMessage} req
2284
2807
  * @param {ServerResponse} res
2808
+ * @param {string} [forcedMethod]
2285
2809
  */
2286
- async handleHttpRpc(req, res) {
2810
+ async handleHttpRpc(req, res, forcedMethod) {
2287
2811
  const requestId = headerValue(req, "x-request-id") ?? randomUUID();
2288
2812
  const baseContext = {
2289
2813
  connectionId: `http:${requestId}`,
@@ -2291,6 +2815,7 @@ export class Gateway {
2291
2815
  role: null,
2292
2816
  scopes: [],
2293
2817
  userId: null,
2818
+ tokenId: null,
2294
2819
  subscribedRuns: null,
2295
2820
  devtoolsStreams: null,
2296
2821
  };
@@ -2309,7 +2834,7 @@ export class Gateway {
2309
2834
  authCode: authResult.code,
2310
2835
  authMessage: authResult.message,
2311
2836
  }, "warning");
2312
- const response = responseError(requestId, authResult.code, authResult.message);
2837
+ const response = responseError(requestId, authResult.code, authResult.message, authResult.details);
2313
2838
  return this.sendHttpRpcResponse(res, statusForRpcError(authResult.code), response);
2314
2839
  }
2315
2840
  context = {
@@ -2317,6 +2842,7 @@ export class Gateway {
2317
2842
  role: authResult.role,
2318
2843
  scopes: [...authResult.scopes],
2319
2844
  userId: authResult.userId ?? null,
2845
+ tokenId: authResult.tokenId ?? null,
2320
2846
  };
2321
2847
  this.recordAuthEvent("http", "success", context, {
2322
2848
  requestId,
@@ -2342,18 +2868,18 @@ export class Gateway {
2342
2868
  maxDepth: GATEWAY_RPC_MAX_DEPTH,
2343
2869
  maxStringLength: GATEWAY_RPC_MAX_STRING_LENGTH,
2344
2870
  });
2345
- const method = validateGatewayMethodName(body.method);
2871
+ const method = validateGatewayMethodName(forcedMethod ?? body.method);
2346
2872
  const bodyId = asString(body.id) ?? requestId;
2347
2873
  assertOptionalStringMaxLength("id", bodyId, GATEWAY_FRAME_ID_MAX_LENGTH);
2348
2874
  const frame = {
2349
2875
  type: "req",
2350
2876
  id: bodyId,
2351
2877
  method,
2352
- params: body.params,
2878
+ params: forcedMethod && body.method === undefined ? body : body.params,
2353
2879
  };
2354
2880
  const response = await this.executeRpc(context, frame, async () => {
2355
2881
  if (!hasScope(context.scopes, method)) {
2356
- return responseError(bodyId, "FORBIDDEN", `Missing scope for ${method}`);
2882
+ return responseForbidden(bodyId, method);
2357
2883
  }
2358
2884
  return this.routeRequest(context, frame);
2359
2885
  });
@@ -2407,6 +2933,7 @@ export class Gateway {
2407
2933
  payload,
2408
2934
  seq: connection.seq,
2409
2935
  stateVersion,
2936
+ apiVersion: SMITHERS_API_VERSION,
2410
2937
  };
2411
2938
  connection.ws.send(JSON.stringify(frame));
2412
2939
  this.recordMessageSent("ws", "event", { event });
@@ -2418,6 +2945,7 @@ export class Gateway {
2418
2945
  broadcastEvent(event, payload) {
2419
2946
  const runId = eventRunId(payload);
2420
2947
  this.stateVersion += 1;
2948
+ const runFrame = this.appendRunEventWindow(event, payload, this.stateVersion);
2421
2949
  let recipientCount = 0;
2422
2950
  for (const connection of this.connections) {
2423
2951
  if (!connection.authenticated || !shouldDeliverEvent(connection, runId)) {
@@ -2425,6 +2953,13 @@ export class Gateway {
2425
2953
  }
2426
2954
  recipientCount += 1;
2427
2955
  this.sendEvent(connection, event, payload, this.stateVersion);
2956
+ if (runFrame && connection.runEventStreams) {
2957
+ for (const [streamId, stream] of connection.runEventStreams.entries()) {
2958
+ if (stream.runId === runId) {
2959
+ this.sendRunEventStreamFrame(connection, streamId, runFrame);
2960
+ }
2961
+ }
2962
+ }
2428
2963
  }
2429
2964
  emitGatewayLog("debug", "Gateway event broadcast", {
2430
2965
  event,
@@ -2485,6 +3020,7 @@ export class Gateway {
2485
3020
  const request = parseApprovalRequest(parseJson(approval.requestJson), node?.label ?? approval.nodeId);
2486
3021
  approvals.push({
2487
3022
  runId: approval.runId,
3023
+ workflowKey: entry.key,
2488
3024
  nodeId: approval.nodeId,
2489
3025
  iteration: approval.iteration ?? 0,
2490
3026
  requestTitle: request.title ?? node?.label ?? approval.nodeId,
@@ -2725,12 +3261,21 @@ export class Gateway {
2725
3261
  stateVersion: this.stateVersion,
2726
3262
  uptimeMs: nowMs() - this.startedAtMs,
2727
3263
  });
2728
- case "runs.list": {
2729
- const limit = asOptionalPositiveInt(params.limit, "limit") ?? 50;
2730
- const status = asString(params.status);
3264
+ case "runs.list":
3265
+ case "listRuns": {
3266
+ const filter = asObject(params.filter) ?? {};
3267
+ const limit = asOptionalPositiveInt(params.limit ?? filter.limit, "limit") ?? 50;
3268
+ const status = asString(params.status) ?? asString(filter.status);
2731
3269
  return responseOk(frame.id, await this.listRunsAcrossWorkflows(limit, status));
2732
3270
  }
2733
- case "runs.create": {
3271
+ case "workflows.list":
3272
+ case "listWorkflows": {
3273
+ const filter = asObject(params.filter) ?? {};
3274
+ const hasUi = asBoolean(params.hasUi) ?? asBoolean(filter.hasUi);
3275
+ return responseOk(frame.id, this.listWorkflowSummaries(hasUi));
3276
+ }
3277
+ case "runs.create":
3278
+ case "launchRun": {
2734
3279
  const workflowKey = asString(params.workflow);
2735
3280
  if (!workflowKey) {
2736
3281
  return responseError(frame.id, "INVALID_REQUEST", "workflow is required");
@@ -2748,14 +3293,42 @@ export class Gateway {
2748
3293
  }
2749
3294
  throw error;
2750
3295
  }
3296
+ const options = asObject(params.options) ?? {};
2751
3297
  return responseOk(frame.id, await this.startRun(workflowKey, input, {
2752
3298
  triggeredBy: connection.userId ?? "gateway",
2753
3299
  scopes: [...connection.scopes],
2754
3300
  role: connection.role ?? "operator",
3301
+ tokenId: connection.tokenId ?? null,
2755
3302
  subscribeConnection: connection,
2756
- }, asString(params.runId) ?? crypto.randomUUID(), { resume: false }));
3303
+ }, asString(params.runId) ?? asString(options.runId) ?? crypto.randomUUID(), { resume: false }));
2757
3304
  }
2758
- case "runs.get": {
3305
+ case "resumeRun": {
3306
+ const runId = asString(params.runId);
3307
+ if (!runId) {
3308
+ return responseError(frame.id, "INVALID_REQUEST", "runId is required");
3309
+ }
3310
+ const resolved = await this.resolveRun(runId);
3311
+ if (!resolved) {
3312
+ return responseError(frame.id, "NOT_FOUND", `Run not found: ${runId}`);
3313
+ }
3314
+ const run = await resolved.adapter.getRun(runId);
3315
+ if (!run) {
3316
+ return responseError(frame.id, "NOT_FOUND", `Run not found: ${runId}`);
3317
+ }
3318
+ if (run.status === "finished" || run.status === "failed" || run.status === "cancelled") {
3319
+ return responseOk(frame.id, { runId, status: "already_terminal" });
3320
+ }
3321
+ await this.resumeRunIfNeeded(runId, resolved.workflowKey, resolved.adapter, {
3322
+ triggeredBy: connection.userId ?? "gateway",
3323
+ scopes: [...connection.scopes],
3324
+ role: connection.role ?? "operator",
3325
+ tokenId: connection.tokenId ?? null,
3326
+ subscribeConnection: connection.transport === "ws" ? connection : undefined,
3327
+ });
3328
+ return responseOk(frame.id, { runId, status: "resume_requested" });
3329
+ }
3330
+ case "runs.get":
3331
+ case "getRun": {
2759
3332
  const runId = asString(params.runId);
2760
3333
  if (!runId) {
2761
3334
  return responseError(frame.id, "INVALID_REQUEST", "runId is required");
@@ -2918,6 +3491,64 @@ export class Gateway {
2918
3491
  throw error;
2919
3492
  }
2920
3493
  }
3494
+ case "streamRunEvents": {
3495
+ if (connection.transport !== "ws" || !connection.ws) {
3496
+ return responseError(frame.id, "INVALID_REQUEST", "streamRunEvents is only supported over websocket connections");
3497
+ }
3498
+ const runId = asString(params.runId);
3499
+ if (!runId) {
3500
+ return responseError(frame.id, "InvalidRunId", "runId is required");
3501
+ }
3502
+ const afterSeq = params.afterSeq;
3503
+ if (afterSeq !== undefined &&
3504
+ (typeof afterSeq !== "number" || !Number.isInteger(afterSeq) || afterSeq < 0)) {
3505
+ return responseError(frame.id, "SeqOutOfRange", "afterSeq must be a non-negative integer");
3506
+ }
3507
+ const resolved = await this.resolveRun(runId);
3508
+ if (!resolved) {
3509
+ return responseError(frame.id, "RunNotFound", `Run not found: ${runId}`);
3510
+ }
3511
+ const currentSeq = this.getRunEventCurrentSeq(runId);
3512
+ if (typeof afterSeq === "number" && afterSeq > currentSeq) {
3513
+ return responseError(frame.id, "SeqOutOfRange", `afterSeq ${afterSeq} is newer than current seq ${currentSeq}`);
3514
+ }
3515
+ const streamId = randomUUID();
3516
+ this.registerRunEventSubscriber(connection, streamId, runId);
3517
+ queueMicrotask(() => {
3518
+ void (async () => {
3519
+ const state = this.getRunEventWindow(runId);
3520
+ const window = [...state.window];
3521
+ if (typeof afterSeq === "number") {
3522
+ const firstSeq = window.length > 0 ? Number(window[0].seq) : state.nextSeq + 1;
3523
+ if (window.length > 0 && afterSeq < firstSeq - 1) {
3524
+ const snapshot = await this.buildRunSnapshot(runId);
3525
+ this.sendRunGapResync(connection, streamId, runId, afterSeq + 1, firstSeq - 1, snapshot);
3526
+ }
3527
+ for (const eventFrame of window) {
3528
+ if (Number(eventFrame.seq) > afterSeq) {
3529
+ this.sendRunEventStreamFrame(connection, streamId, eventFrame);
3530
+ }
3531
+ }
3532
+ }
3533
+ })().catch((error) => {
3534
+ this.sendEvent(connection, "run.error", {
3535
+ streamId,
3536
+ runId,
3537
+ error: {
3538
+ version: SMITHERS_API_VERSION,
3539
+ code: "Internal",
3540
+ message: error?.message ?? "streamRunEvents replay failed",
3541
+ },
3542
+ });
3543
+ });
3544
+ });
3545
+ return responseOk(frame.id, {
3546
+ streamId,
3547
+ runId,
3548
+ afterSeq: typeof afterSeq === "number" ? afterSeq : null,
3549
+ currentSeq,
3550
+ });
3551
+ }
2921
3552
  case "streamDevTools": {
2922
3553
  if (connection.transport !== "ws" || !connection.ws) {
2923
3554
  this.recordDevToolsSubscribeAttempt("error");
@@ -2928,7 +3559,13 @@ export class Gateway {
2928
3559
  this.recordDevToolsSubscribeAttempt("error");
2929
3560
  return responseError(frame.id, "InvalidRunId", "runId is required");
2930
3561
  }
2931
- const fromSeq = params.fromSeq;
3562
+ if (typeof params.fromSeq === "number" &&
3563
+ typeof params.afterSeq === "number" &&
3564
+ params.fromSeq !== params.afterSeq) {
3565
+ this.recordDevToolsSubscribeAttempt("error");
3566
+ return responseError(frame.id, "SeqOutOfRange", "fromSeq and afterSeq must match when both are provided");
3567
+ }
3568
+ const fromSeq = typeof params.fromSeq === "number" ? params.fromSeq : params.afterSeq;
2932
3569
  const streamId = randomUUID();
2933
3570
  try {
2934
3571
  // Full route-level validation at the gateway boundary so
@@ -3094,6 +3731,7 @@ export class Gateway {
3094
3731
  streamId,
3095
3732
  runId,
3096
3733
  fromSeq: typeof fromSeq === "number" ? fromSeq : null,
3734
+ afterSeq: typeof fromSeq === "number" ? fromSeq : null,
3097
3735
  });
3098
3736
  }
3099
3737
  catch (error) {
@@ -3104,6 +3742,22 @@ export class Gateway {
3104
3742
  throw error;
3105
3743
  }
3106
3744
  }
3745
+ case "hijackRun": {
3746
+ const runId = asString(params.runId);
3747
+ if (!runId) {
3748
+ return responseError(frame.id, "InvalidRunId", "runId is required");
3749
+ }
3750
+ const resolved = await this.resolveRun(runId);
3751
+ if (!resolved) {
3752
+ return responseError(frame.id, "RunNotFound", `Run not found: ${runId}`);
3753
+ }
3754
+ return responseOk(frame.id, {
3755
+ runId,
3756
+ status: "hijack-ready",
3757
+ sessionId: randomUUID(),
3758
+ });
3759
+ }
3760
+ case "rewindRun":
3107
3761
  case "jumpToFrame":
3108
3762
  case "devtools.jumpToFrame": {
3109
3763
  const runId = asString(params.runId);
@@ -3179,6 +3833,7 @@ export class Gateway {
3179
3833
  triggeredBy: connection.userId ?? "gateway",
3180
3834
  scopes: [...connection.scopes],
3181
3835
  role: connection.role ?? "operator",
3836
+ tokenId: connection.tokenId ?? null,
3182
3837
  subscribeConnection: connection.transport === "ws" ? connection : undefined,
3183
3838
  });
3184
3839
  },
@@ -3222,11 +3877,29 @@ export class Gateway {
3222
3877
  return responseOk(frame.id, diffRawSnapshots(leftSnapshot, rightSnapshot));
3223
3878
  }
3224
3879
  case "approvals.list":
3225
- return responseOk(frame.id, await this.listPendingApprovals());
3226
- case "approvals.decide": {
3880
+ case "listApprovals": {
3881
+ const filter = asObject(params.filter) ?? {};
3882
+ const runId = asString(params.runId) ?? asString(filter.runId);
3883
+ const workflow = asString(params.workflow) ?? asString(filter.workflow);
3884
+ const limit = asOptionalPositiveInt(params.limit ?? filter.limit, "limit");
3885
+ let approvals = await this.listPendingApprovals();
3886
+ if (runId) {
3887
+ approvals = approvals.filter((approval) => approval.runId === runId);
3888
+ }
3889
+ if (workflow) {
3890
+ approvals = approvals.filter((approval) => approval.workflowKey === workflow);
3891
+ }
3892
+ if (limit !== undefined) {
3893
+ approvals = approvals.slice(0, limit);
3894
+ }
3895
+ return responseOk(frame.id, approvals);
3896
+ }
3897
+ case "approvals.decide":
3898
+ case "submitApproval": {
3227
3899
  const runId = asString(params.runId);
3228
3900
  const nodeId = asString(params.nodeId);
3229
- const approved = asBoolean(params.approved);
3901
+ const stableDecision = asObject(params.decision);
3902
+ const approved = asBoolean(params.approved) ?? asBoolean(stableDecision?.approved);
3230
3903
  const iteration = asNumber(params.iteration) ?? 0;
3231
3904
  if (!runId || !nodeId || approved === undefined) {
3232
3905
  return responseError(frame.id, "INVALID_REQUEST", "runId, nodeId, and approved are required");
@@ -3236,6 +3909,14 @@ export class Gateway {
3236
3909
  return responseError(frame.id, "NOT_FOUND", `Run not found: ${runId}`);
3237
3910
  }
3238
3911
  const approval = await resolved.adapter.getApproval(runId, nodeId, iteration);
3912
+ if (approval && approval.status !== "requested") {
3913
+ return responseError(frame.id, "AlreadyDecided", `Approval for ${nodeId} has already been decided`, {
3914
+ runId,
3915
+ nodeId,
3916
+ iteration,
3917
+ status: approval.status,
3918
+ });
3919
+ }
3239
3920
  const request = parseApprovalRequest(parseJson(typeof approval?.requestJson === "string" ? approval.requestJson : null), nodeId);
3240
3921
  if (request.allowedUsers.length > 0 &&
3241
3922
  (!connection.userId || !request.allowedUsers.includes(connection.userId))) {
@@ -3245,7 +3926,8 @@ export class Gateway {
3245
3926
  !request.allowedScopes.some((scope) => hasScope(connection.scopes, scope))) {
3246
3927
  return responseError(frame.id, "FORBIDDEN", "Connection is missing required approval scope");
3247
3928
  }
3248
- const decision = params.decision;
3929
+ const decision = stableDecision && "value" in stableDecision ? stableDecision.value : params.decision;
3930
+ const note = asString(params.note) ?? asString(stableDecision?.note);
3249
3931
  if (approved) {
3250
3932
  const validation = validateApprovalDecision(request, decision);
3251
3933
  if (!validation.ok) {
@@ -3253,22 +3935,25 @@ export class Gateway {
3253
3935
  }
3254
3936
  }
3255
3937
  if (approved) {
3256
- await Effect.runPromise(approveNode(resolved.adapter, runId, nodeId, iteration, asString(params.note), connection.userId ?? undefined, decision));
3938
+ await Effect.runPromise(approveNode(resolved.adapter, runId, nodeId, iteration, note, connection.userId ?? undefined, decision));
3257
3939
  }
3258
3940
  else {
3259
- await Effect.runPromise(denyNode(resolved.adapter, runId, nodeId, iteration, asString(params.note), connection.userId ?? undefined, decision));
3941
+ await Effect.runPromise(denyNode(resolved.adapter, runId, nodeId, iteration, note, connection.userId ?? undefined, decision));
3260
3942
  }
3261
3943
  await this.resumeRunIfNeeded(runId, resolved.workflowKey, resolved.adapter, {
3262
3944
  triggeredBy: connection.userId ?? "gateway",
3263
3945
  scopes: [...connection.scopes],
3264
3946
  role: connection.role ?? "operator",
3947
+ tokenId: connection.tokenId ?? null,
3265
3948
  subscribeConnection: connection,
3266
3949
  });
3267
3950
  return responseOk(frame.id, { runId, nodeId, iteration, approved });
3268
3951
  }
3269
- case "signals.send": {
3952
+ case "signals.send":
3953
+ case "submitSignal": {
3270
3954
  const runId = asString(params.runId);
3271
- const signalName = asString(params.signalName);
3955
+ const correlationKey = asString(params.correlationKey);
3956
+ const signalName = asString(params.signalName) ?? correlationKey;
3272
3957
  if (!runId || !signalName) {
3273
3958
  return responseError(frame.id, "INVALID_REQUEST", "runId and signalName are required");
3274
3959
  }
@@ -3276,19 +3961,21 @@ export class Gateway {
3276
3961
  if (!resolved) {
3277
3962
  return responseError(frame.id, "NOT_FOUND", `Run not found: ${runId}`);
3278
3963
  }
3279
- const delivered = await Effect.runPromise(signalRun(resolved.adapter, runId, signalName, params.data ?? {}, {
3280
- correlationId: asString(params.correlationId),
3964
+ const delivered = await Effect.runPromise(signalRun(resolved.adapter, runId, signalName, params.data ?? params.payload ?? {}, {
3965
+ correlationId: asString(params.correlationId) ?? correlationKey,
3281
3966
  receivedBy: connection.userId,
3282
3967
  }));
3283
3968
  await this.resumeRunIfNeeded(runId, resolved.workflowKey, resolved.adapter, {
3284
3969
  triggeredBy: connection.userId ?? "gateway",
3285
3970
  scopes: [...connection.scopes],
3286
3971
  role: connection.role ?? "operator",
3972
+ tokenId: connection.tokenId ?? null,
3287
3973
  subscribeConnection: connection,
3288
3974
  });
3289
3975
  return responseOk(frame.id, delivered);
3290
3976
  }
3291
- case "runs.cancel": {
3977
+ case "runs.cancel":
3978
+ case "cancelRun": {
3292
3979
  const runId = asString(params.runId);
3293
3980
  if (!runId) {
3294
3981
  return responseError(frame.id, "INVALID_REQUEST", "runId is required");
@@ -3326,8 +4013,16 @@ export class Gateway {
3326
4013
  });
3327
4014
  }
3328
4015
  case "cron.list":
3329
- return responseOk(frame.id, await this.listCrons());
3330
- case "cron.add": {
4016
+ case "cronList": {
4017
+ const filter = asObject(params.filter) ?? {};
4018
+ const workflowFilter = asString(filter.workflow);
4019
+ const rows = await this.listCrons();
4020
+ return responseOk(frame.id, workflowFilter
4021
+ ? rows.filter((row) => row.workflow === workflowFilter)
4022
+ : rows);
4023
+ }
4024
+ case "cron.add":
4025
+ case "cronCreate": {
3331
4026
  const workflowKey = asString(params.workflow);
3332
4027
  const pattern = asString(params.pattern);
3333
4028
  if (!workflowKey || !pattern) {
@@ -3355,7 +4050,8 @@ export class Gateway {
3355
4050
  workflow: workflowKey,
3356
4051
  });
3357
4052
  }
3358
- case "cron.remove": {
4053
+ case "cron.remove":
4054
+ case "cronDelete": {
3359
4055
  const cronId = asString(params.cronId);
3360
4056
  if (!cronId) {
3361
4057
  return responseError(frame.id, "INVALID_REQUEST", "cronId is required");
@@ -3367,7 +4063,8 @@ export class Gateway {
3367
4063
  await resolvedCron.adapter.deleteCron(cronId);
3368
4064
  return responseOk(frame.id, { cronId, removed: true });
3369
4065
  }
3370
- case "cron.trigger": {
4066
+ case "cron.trigger":
4067
+ case "cronRun": {
3371
4068
  const cronId = asString(params.cronId);
3372
4069
  const workflowKey = asString(params.workflow);
3373
4070
  const resolvedCron = cronId ? await this.findCron(cronId) : null;
@@ -3392,6 +4089,7 @@ export class Gateway {
3392
4089
  triggeredBy: connection.userId ?? "gateway",
3393
4090
  scopes: [...connection.scopes],
3394
4091
  role: connection.role ?? "operator",
4092
+ tokenId: connection.tokenId ?? null,
3395
4093
  subscribeConnection: connection,
3396
4094
  }, undefined, { resume: false }));
3397
4095
  }