@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/package.json +18 -11
- package/src/EventFrame.ts +1 -0
- package/src/GatewayOptions.ts +19 -0
- package/src/GatewayRegisterOptions.ts +8 -0
- package/src/GatewayTokenGrant.ts +4 -0
- package/src/GatewayUiConfig.ts +20 -0
- package/src/ResponseFrame.ts +5 -0
- package/src/ServerOptions.ts +12 -0
- package/src/gateway.js +801 -103
- package/src/gatewayRoutes/getNodeDiff.js +2 -4
- package/src/gatewayUi/createGatewayUiApp.js +47 -0
- package/src/index.d.ts +271 -47
- package/src/index.js +9 -2
- package/src/serve.js +1 -2
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
|
|
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
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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("&", "&")
|
|
149
|
+
.replaceAll("<", "<")
|
|
150
|
+
.replaceAll(">", ">")
|
|
151
|
+
.replaceAll('"', """);
|
|
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.
|
|
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 {
|
|
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 {
|
|
816
|
+
* @returns {import("@smithers-orchestrator/gateway/auth/scopes").GatewayScope}
|
|
687
817
|
*/
|
|
688
|
-
function
|
|
689
|
-
|
|
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
|
-
|
|
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("
|
|
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("
|
|
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
|
-
|
|
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
|
-
|
|
1379
|
-
const
|
|
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 {
|
|
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
|
|
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
|
|
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
|
-
|
|
2730
|
-
const
|
|
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 "
|
|
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 "
|
|
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
|
-
|
|
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
|
-
|
|
3226
|
-
|
|
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
|
|
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,
|
|
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,
|
|
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
|
|
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
|
-
|
|
3330
|
-
|
|
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
|
}
|