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