@smithers-orchestrator/server 0.20.4 → 0.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@smithers-orchestrator/server",
3
- "version": "0.20.4",
3
+ "version": "0.21.0",
4
4
  "description": "HTTP, WebSocket, gateway, cron, webhook, and metrics servers for Smithers",
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -26,24 +26,24 @@
26
26
  "effect": "^3.21.1",
27
27
  "hono": "^4.12.14",
28
28
  "ws": "^8.20.0",
29
- "@smithers-orchestrator/components": "0.20.4",
30
- "@smithers-orchestrator/db": "0.20.4",
31
- "@smithers-orchestrator/devtools": "0.20.4",
32
- "@smithers-orchestrator/engine": "0.20.4",
33
- "@smithers-orchestrator/errors": "0.20.4",
34
- "@smithers-orchestrator/observability": "0.20.4",
35
- "@smithers-orchestrator/scheduler": "0.20.4",
36
- "@smithers-orchestrator/gateway": "0.20.4",
37
- "@smithers-orchestrator/time-travel": "0.20.4",
38
- "@smithers-orchestrator/driver": "0.20.4",
39
- "@smithers-orchestrator/protocol": "0.20.4"
29
+ "@smithers-orchestrator/db": "0.21.0",
30
+ "@smithers-orchestrator/devtools": "0.21.0",
31
+ "@smithers-orchestrator/components": "0.21.0",
32
+ "@smithers-orchestrator/driver": "0.21.0",
33
+ "@smithers-orchestrator/errors": "0.21.0",
34
+ "@smithers-orchestrator/engine": "0.21.0",
35
+ "@smithers-orchestrator/gateway": "0.21.0",
36
+ "@smithers-orchestrator/observability": "0.21.0",
37
+ "@smithers-orchestrator/protocol": "0.21.0",
38
+ "@smithers-orchestrator/scheduler": "0.21.0",
39
+ "@smithers-orchestrator/time-travel": "0.21.0"
40
40
  },
41
41
  "devDependencies": {
42
42
  "@types/bun": "latest",
43
43
  "react": "^19.2.5",
44
44
  "typescript": "~5.9.3",
45
45
  "zod": "^4.3.6",
46
- "@smithers-orchestrator/graph": "0.20.4"
46
+ "@smithers-orchestrator/graph": "0.21.0"
47
47
  },
48
48
  "scripts": {
49
49
  "test": "bun test tests",
@@ -0,0 +1,15 @@
1
+ export type GatewayOperatorUiConfig = {
2
+ /**
3
+ * URL path for the built-in operator console.
4
+ * @default "/console"
5
+ */
6
+ path?: string;
7
+ /**
8
+ * Document title for the generated HTML shell.
9
+ */
10
+ title?: string;
11
+ /**
12
+ * JSON-serializable boot data exposed to the browser.
13
+ */
14
+ props?: Record<string, unknown>;
15
+ };
@@ -1,5 +1,6 @@
1
1
  import type { GatewayAuthConfig } from "./GatewayAuthConfig.js";
2
2
  import type { GatewayDefaults } from "./GatewayDefaults.js";
3
+ import type { GatewayOperatorUiConfig } from "./GatewayOperatorUiConfig.js";
3
4
  import type { GatewayUiConfig } from "./GatewayUiConfig.js";
4
5
 
5
6
  export type GatewayOptions = {
@@ -8,6 +9,11 @@ export type GatewayOptions = {
8
9
  heartbeatMs?: number;
9
10
  auth?: GatewayAuthConfig;
10
11
  ui?: GatewayUiConfig;
12
+ /**
13
+ * Built-in browser console for operators. Set to false to disable it.
14
+ * @default { path: "/console" }
15
+ */
16
+ operatorUi?: GatewayOperatorUiConfig | false;
11
17
  defaults?: GatewayDefaults;
12
18
  maxBodyBytes?: number;
13
19
  maxPayload?: number;
@@ -1,7 +1,8 @@
1
- export type GatewayUiConfig = {
1
+ export type GatewayUiConfig = true | {
2
2
  /**
3
3
  * Browser entry module for the React app. Smithers bundles this with Bun and
4
- * serves it from the Gateway origin.
4
+ * serves it from the Gateway origin. Pass `true` to mount the built-in
5
+ * operator console.
5
6
  */
6
7
  entry: string;
7
8
  /**
package/src/gateway.js CHANGED
@@ -40,10 +40,15 @@ import { recoverInProgressRewindAudits } from "@smithers-orchestrator/time-trave
40
40
  import { GATEWAY_EVENT_WINDOW_DEFAULT, SMITHERS_API_VERSION, getRequiredScopeForGatewayMethod, } from "@smithers-orchestrator/gateway/rpc";
41
41
  import { hasGatewayScope } from "@smithers-orchestrator/gateway/auth/scopes";
42
42
  import { createGatewayUiApp } from "./gatewayUi/createGatewayUiApp.js";
43
+ import { renderDefaultConsoleClient } from "./gatewayUi/defaultConsole.js";
44
+ import { authorizeGatewayUiRequest } from "./gatewayUi/auth.js";
45
+ import { bundleGatewayUiEntry } from "./gatewayUi/bundle.js";
46
+ import { DEFAULT_OPERATOR_UI_ENTRY } from "./gatewayUi/defaultOperatorUi.js";
43
47
  /** @typedef {import("./GatewayWebhookRunConfig.js").GatewayWebhookRunConfig} GatewayWebhookRunConfig */
44
48
  /** @typedef {import("./GatewayWebhookSignalConfig.js").GatewayWebhookSignalConfig} GatewayWebhookSignalConfig */
45
49
  /** @typedef {import("./ConnectRequest.js").ConnectRequest} ConnectRequest */
46
50
  /** @typedef {import("./GatewayAuthConfig.js").GatewayAuthConfig} GatewayAuthConfig */
51
+ /** @typedef {import("./GatewayOperatorUiConfig.js").GatewayOperatorUiConfig} GatewayOperatorUiConfig */
47
52
  /** @typedef {import("./GatewayOptions.js").GatewayOptions} GatewayOptions */
48
53
  /** @typedef {import("./GatewayWebhookConfig.js").GatewayWebhookConfig} GatewayWebhookConfig */
49
54
  /** @typedef {import("node:http").IncomingMessage} IncomingMessage */
@@ -111,11 +116,12 @@ import { createGatewayUiApp } from "./gatewayUi/createGatewayUiApp.js";
111
116
  * path: string;
112
117
  * title?: string;
113
118
  * props?: Record<string, unknown>;
119
+ * builtin?: "operator";
114
120
  * }} ResolvedGatewayUiConfig
115
121
  */
116
122
  /**
117
123
  * @typedef {{
118
- * kind: "gateway" | "workflow";
124
+ * kind: "gateway" | "workflow" | "operator";
119
125
  * workflowKey: string | null;
120
126
  * config: ResolvedGatewayUiConfig;
121
127
  * }} GatewayUiMount
@@ -195,6 +201,15 @@ function resolveGatewayUiConfig(ui, fallbackPath) {
195
201
  if (!ui) {
196
202
  return null;
197
203
  }
204
+ if (ui === true) {
205
+ return {
206
+ entry: DEFAULT_OPERATOR_UI_ENTRY,
207
+ path: normalizeUiMountPath(fallbackPath === "/" ? "/console" : fallbackPath, fallbackPath),
208
+ title: "Smithers Operator Console",
209
+ builtin: "operator",
210
+ props: {},
211
+ };
212
+ }
198
213
  if (typeof ui.entry !== "string" || !ui.entry.trim()) {
199
214
  throw new SmithersError("INVALID_INPUT", "Gateway UI config requires a non-empty entry path.");
200
215
  }
@@ -207,6 +222,25 @@ function resolveGatewayUiConfig(ui, fallbackPath) {
207
222
  : {}),
208
223
  };
209
224
  }
225
+ /**
226
+ * @param {GatewayOperatorUiConfig | false | undefined} ui
227
+ * @returns {ResolvedGatewayUiConfig | null}
228
+ */
229
+ function resolveDefaultOperatorUiConfig(ui) {
230
+ if (ui === false) {
231
+ return null;
232
+ }
233
+ const config = ui && typeof ui === "object" && !Array.isArray(ui) ? ui : {};
234
+ return {
235
+ entry: DEFAULT_OPERATOR_UI_ENTRY,
236
+ path: normalizeUiMountPath(config.path, "/console"),
237
+ title: typeof config.title === "string" ? config.title : "Smithers Operator Console",
238
+ props: config.props && typeof config.props === "object" && !Array.isArray(config.props)
239
+ ? config.props
240
+ : {},
241
+ builtin: "operator",
242
+ };
243
+ }
210
244
 
211
245
  /**
212
246
  * @param {import("node:http").IncomingHttpHeaders} headers
@@ -1197,6 +1231,7 @@ export class Gateway {
1197
1231
  requestTimeout;
1198
1232
  auth;
1199
1233
  ui;
1234
+ operatorUi;
1200
1235
  uiApp;
1201
1236
  defaults;
1202
1237
  workflows = new Map();
@@ -1243,6 +1278,7 @@ export class Gateway {
1243
1278
  : Math.floor(assertPositiveFiniteInteger("requestTimeout", Number(options.requestTimeout)));
1244
1279
  this.auth = options.auth;
1245
1280
  this.ui = resolveGatewayUiConfig(options.ui, "/");
1281
+ this.operatorUi = resolveDefaultOperatorUiConfig(options.operatorUi);
1246
1282
  this.uiApp = createGatewayUiApp({
1247
1283
  resolveMatch: (pathname) => this.resolveUiMatch(pathname),
1248
1284
  renderIndex: (match) => this.renderUiIndex(match),
@@ -1256,7 +1292,14 @@ export class Gateway {
1256
1292
  getUiMounts() {
1257
1293
  const mounts = [];
1258
1294
  if (this.ui) {
1259
- mounts.push({ kind: "gateway", workflowKey: null, config: this.ui });
1295
+ mounts.push({
1296
+ kind: this.ui.builtin === "operator" ? "operator" : "gateway",
1297
+ workflowKey: null,
1298
+ config: this.ui,
1299
+ });
1300
+ }
1301
+ if (this.operatorUi && (!this.ui || this.ui.path !== this.operatorUi.path)) {
1302
+ mounts.push({ kind: "operator", workflowKey: null, config: this.operatorUi });
1260
1303
  }
1261
1304
  for (const [workflowKey, entry] of this.workflows.entries()) {
1262
1305
  if (entry.ui) {
@@ -1341,47 +1384,19 @@ export class Gateway {
1341
1384
  if (match.assetPath !== "client.js") {
1342
1385
  return null;
1343
1386
  }
1344
- const body = await this.bundleUiEntry(match.config.config);
1387
+ if (match.config.config.builtin === "operator") {
1388
+ return {
1389
+ body: renderDefaultConsoleClient(),
1390
+ contentType: "text/javascript; charset=utf-8",
1391
+ };
1392
+ }
1393
+ const body = await bundleGatewayUiEntry(match.config.config, this.uiAssetCache);
1345
1394
  return {
1346
1395
  body,
1347
1396
  contentType: "text/javascript; charset=utf-8",
1348
1397
  };
1349
1398
  }
1350
1399
  /**
1351
- * @param {ResolvedGatewayUiConfig} config
1352
- * @returns {Promise<string>}
1353
- */
1354
- async bundleUiEntry(config) {
1355
- const cached = this.uiAssetCache.get(config.entry);
1356
- if (cached) {
1357
- return cached;
1358
- }
1359
- if (typeof Bun === "undefined" || typeof Bun.build !== "function") {
1360
- throw new SmithersError("INVALID_INPUT", "Gateway UI bundling requires Bun.build.");
1361
- }
1362
- const result = await Bun.build({
1363
- entrypoints: [config.entry],
1364
- root: process.cwd(),
1365
- target: "browser",
1366
- format: "esm",
1367
- sourcemap: "inline",
1368
- minify: false,
1369
- jsx: {
1370
- runtime: "automatic",
1371
- importSource: "react",
1372
- },
1373
- });
1374
- if (!result.success) {
1375
- const message = result.logs?.map((entry) => entry.message).filter(Boolean).join("\n")
1376
- || `Failed to build Gateway UI entry ${config.entry}`;
1377
- throw new SmithersError("INVALID_INPUT", message);
1378
- }
1379
- const output = result.outputs.find((entry) => entry.path.endsWith(".js")) ?? result.outputs[0];
1380
- const body = await output.text();
1381
- this.uiAssetCache.set(config.entry, body);
1382
- return body;
1383
- }
1384
- /**
1385
1400
  * @param {IncomingMessage} req
1386
1401
  * @param {ServerResponse} res
1387
1402
  */
@@ -1390,6 +1405,21 @@ export class Gateway {
1390
1405
  return false;
1391
1406
  }
1392
1407
  const host = headerValue(req, "host") ?? "127.0.0.1";
1408
+ const url = new URL(`http://${host}${req.url ?? "/"}`);
1409
+ const uiMatch = this.resolveUiMatch(url.pathname);
1410
+ if (!uiMatch) {
1411
+ return false;
1412
+ }
1413
+ const uiAuthFailure = await authorizeGatewayUiRequest({
1414
+ match: uiMatch,
1415
+ authMode: gatewayAuthMode(this.auth),
1416
+ token: bearerTokenFromHeaders(req),
1417
+ authenticate: (token) => this.authenticateRequest(req, token),
1418
+ });
1419
+ if (uiAuthFailure) {
1420
+ sendJson(res, statusForRpcError(uiAuthFailure.code), responseError(randomUUID(), uiAuthFailure.code, uiAuthFailure.message, uiAuthFailure.details));
1421
+ return true;
1422
+ }
1393
1423
  const request = new Request(`http://${host}${req.url ?? "/"}`, {
1394
1424
  method: "GET",
1395
1425
  headers: nodeHeadersToFetchHeaders(req.headers),
@@ -0,0 +1,16 @@
1
+ /**
2
+ * @param {{
3
+ * match: { config: { kind: string; config: Record<string, unknown> } };
4
+ * authMode: string;
5
+ * token: string | null;
6
+ * authenticate: (token: string | null) => Promise<{ ok: true } | { ok: false; code: string; message: string; details?: Record<string, unknown> }>;
7
+ * }} options
8
+ */
9
+ export async function authorizeGatewayUiRequest(options) {
10
+ const isBuiltinOperator = options.match.config.config.builtin === "operator";
11
+ if (!isBuiltinOperator || options.authMode === "none") {
12
+ return null;
13
+ }
14
+ const authResult = await options.authenticate(options.token);
15
+ return authResult.ok === false ? authResult : null;
16
+ }
@@ -0,0 +1,36 @@
1
+ import { SmithersError } from "@smithers-orchestrator/errors/SmithersError";
2
+
3
+ /**
4
+ * @param {Record<string, unknown>} config
5
+ * @param {Map<string, string>} cache
6
+ */
7
+ export async function bundleGatewayUiEntry(config, cache) {
8
+ const cached = cache.get(String(config.entry));
9
+ if (cached) {
10
+ return cached;
11
+ }
12
+ if (typeof Bun === "undefined" || typeof Bun.build !== "function") {
13
+ throw new SmithersError("INVALID_INPUT", "Gateway UI bundling requires Bun.build.");
14
+ }
15
+ const result = await Bun.build({
16
+ entrypoints: [String(config.entry)],
17
+ root: process.cwd(),
18
+ target: "browser",
19
+ format: "esm",
20
+ sourcemap: "inline",
21
+ minify: false,
22
+ jsx: {
23
+ runtime: "automatic",
24
+ importSource: "react",
25
+ },
26
+ });
27
+ if (!result.success) {
28
+ const message = result.logs?.map((entry) => entry.message).filter(Boolean).join("\n")
29
+ || `Failed to build Gateway UI entry ${config.entry}`;
30
+ throw new SmithersError("INVALID_INPUT", message);
31
+ }
32
+ const output = result.outputs.find((entry) => entry.path.endsWith(".js")) ?? result.outputs[0];
33
+ const body = await output.text();
34
+ cache.set(String(config.entry), body);
35
+ return body;
36
+ }
@@ -0,0 +1,5 @@
1
+ import { DEFAULT_OPERATOR_UI_CLIENT_JS } from "./defaultOperatorUi.js";
2
+
3
+ export function renderDefaultConsoleClient() {
4
+ return DEFAULT_OPERATOR_UI_CLIENT_JS;
5
+ }
@@ -0,0 +1,469 @@
1
+ export const DEFAULT_OPERATOR_UI_ENTRY = "smithers:default-operator-ui";
2
+
3
+ function defaultOperatorUiClient() {
4
+ const boot = globalThis.__SMITHERS_GATEWAY_UI__ ?? {};
5
+ const root = document.getElementById("root");
6
+ const storageKey = "smithers.gateway.console.token";
7
+ function readStoredToken() {
8
+ try {
9
+ return sessionStorage.getItem(storageKey) ?? "";
10
+ } catch {
11
+ return "";
12
+ }
13
+ }
14
+ function writeStoredToken(value) {
15
+ try {
16
+ if (value) {
17
+ sessionStorage.setItem(storageKey, value);
18
+ } else {
19
+ sessionStorage.removeItem(storageKey);
20
+ }
21
+ } catch {
22
+ // Some embedded browsers disable Web Storage; the in-memory token remains usable.
23
+ }
24
+ }
25
+ const state = {
26
+ token: readStoredToken(),
27
+ health: null,
28
+ workflows: [],
29
+ runs: [],
30
+ approvals: [],
31
+ selectedWorkflow: "",
32
+ runInput: "{}",
33
+ status: "Loading",
34
+ error: "",
35
+ busy: false,
36
+ };
37
+
38
+ const css = `
39
+ :root {
40
+ color-scheme: light;
41
+ --ink: #161616;
42
+ --muted: #6f6a61;
43
+ --line: #ded8ce;
44
+ --surface: #f7f3ec;
45
+ --panel: #fffaf2;
46
+ --accent: #235c58;
47
+ --accent-strong: #17413e;
48
+ --danger: #9f2e24;
49
+ --ok: #2f6b3f;
50
+ font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
51
+ }
52
+ * { box-sizing: border-box; }
53
+ body {
54
+ margin: 0;
55
+ background: var(--surface);
56
+ color: var(--ink);
57
+ }
58
+ button, input, textarea, select { font: inherit; }
59
+ button {
60
+ border: 1px solid var(--accent);
61
+ background: var(--accent);
62
+ color: white;
63
+ min-height: 34px;
64
+ padding: 0 12px;
65
+ border-radius: 6px;
66
+ cursor: pointer;
67
+ }
68
+ button.secondary {
69
+ background: transparent;
70
+ color: var(--accent);
71
+ }
72
+ button.danger {
73
+ background: transparent;
74
+ color: var(--danger);
75
+ border-color: var(--danger);
76
+ }
77
+ button:disabled { opacity: 0.55; cursor: not-allowed; }
78
+ .shell {
79
+ min-height: 100vh;
80
+ display: grid;
81
+ grid-template-columns: 240px minmax(0, 1fr) 360px;
82
+ }
83
+ .nav {
84
+ border-right: 1px solid var(--line);
85
+ padding: 22px 18px;
86
+ }
87
+ .brand {
88
+ font-size: 24px;
89
+ font-weight: 760;
90
+ letter-spacing: 0;
91
+ margin-bottom: 24px;
92
+ }
93
+ .nav-section {
94
+ display: grid;
95
+ gap: 10px;
96
+ margin-top: 24px;
97
+ }
98
+ .label {
99
+ color: var(--muted);
100
+ font-size: 12px;
101
+ text-transform: uppercase;
102
+ letter-spacing: 0.08em;
103
+ }
104
+ .token {
105
+ width: 100%;
106
+ border: 1px solid var(--line);
107
+ background: white;
108
+ border-radius: 6px;
109
+ min-height: 34px;
110
+ padding: 0 10px;
111
+ }
112
+ .main {
113
+ padding: 24px;
114
+ display: grid;
115
+ grid-template-rows: auto auto minmax(0, 1fr);
116
+ gap: 18px;
117
+ }
118
+ .topbar {
119
+ display: flex;
120
+ justify-content: space-between;
121
+ align-items: center;
122
+ gap: 16px;
123
+ }
124
+ .title {
125
+ font-size: 22px;
126
+ font-weight: 720;
127
+ }
128
+ .meta {
129
+ display: flex;
130
+ gap: 10px;
131
+ flex-wrap: wrap;
132
+ }
133
+ .pill {
134
+ min-height: 28px;
135
+ display: inline-flex;
136
+ align-items: center;
137
+ border: 1px solid var(--line);
138
+ border-radius: 999px;
139
+ padding: 0 10px;
140
+ color: var(--muted);
141
+ background: rgba(255,255,255,0.5);
142
+ font-size: 13px;
143
+ }
144
+ .pill.ok { color: var(--ok); border-color: rgba(47,107,63,0.35); }
145
+ .pill.warn { color: var(--danger); border-color: rgba(159,46,36,0.35); }
146
+ .launch {
147
+ border-top: 1px solid var(--line);
148
+ border-bottom: 1px solid var(--line);
149
+ padding: 16px 0;
150
+ display: grid;
151
+ grid-template-columns: minmax(180px, 260px) minmax(260px, 1fr) auto;
152
+ gap: 12px;
153
+ align-items: end;
154
+ }
155
+ .field {
156
+ display: grid;
157
+ gap: 6px;
158
+ }
159
+ .field select, .field textarea {
160
+ border: 1px solid var(--line);
161
+ background: white;
162
+ border-radius: 6px;
163
+ padding: 9px 10px;
164
+ }
165
+ .field textarea {
166
+ min-height: 72px;
167
+ resize: vertical;
168
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
169
+ font-size: 13px;
170
+ }
171
+ .runs {
172
+ overflow: auto;
173
+ display: grid;
174
+ align-content: start;
175
+ }
176
+ .run-row {
177
+ display: grid;
178
+ grid-template-columns: minmax(160px, 1.2fr) 120px 140px 1fr;
179
+ gap: 12px;
180
+ padding: 14px 0;
181
+ border-bottom: 1px solid var(--line);
182
+ }
183
+ .run-id {
184
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
185
+ font-size: 13px;
186
+ }
187
+ .muted { color: var(--muted); }
188
+ .side {
189
+ border-left: 1px solid var(--line);
190
+ background: var(--panel);
191
+ padding: 22px 18px;
192
+ display: grid;
193
+ align-content: start;
194
+ gap: 18px;
195
+ }
196
+ .side h2 {
197
+ font-size: 15px;
198
+ margin: 0;
199
+ }
200
+ .approval {
201
+ border-top: 1px solid var(--line);
202
+ padding-top: 14px;
203
+ display: grid;
204
+ gap: 10px;
205
+ }
206
+ .approval-title {
207
+ font-weight: 680;
208
+ }
209
+ .approval-actions {
210
+ display: flex;
211
+ gap: 8px;
212
+ }
213
+ .empty {
214
+ color: var(--muted);
215
+ padding: 18px 0;
216
+ }
217
+ .error {
218
+ color: var(--danger);
219
+ min-height: 20px;
220
+ }
221
+ @media (max-width: 960px) {
222
+ .shell { grid-template-columns: 1fr; }
223
+ .nav, .side { border: 0; border-bottom: 1px solid var(--line); }
224
+ .main { padding: 18px; }
225
+ .launch { grid-template-columns: 1fr; }
226
+ .run-row { grid-template-columns: 1fr; }
227
+ }
228
+ `;
229
+
230
+ function installStyles() {
231
+ const style = document.createElement("style");
232
+ style.textContent = css;
233
+ document.head.appendChild(style);
234
+ }
235
+
236
+ function setToken(value) {
237
+ state.token = value;
238
+ writeStoredToken(value);
239
+ }
240
+
241
+ async function rpc(method, params = {}) {
242
+ const headers = new Headers({ "content-type": "application/json" });
243
+ if (state.token) {
244
+ headers.set("authorization", "Bearer " + state.token);
245
+ }
246
+ const response = await fetch((boot.rpcPath ?? "/v1/rpc") + "/" + method, {
247
+ method: "POST",
248
+ headers,
249
+ body: JSON.stringify(params),
250
+ });
251
+ const frame = await response.json().catch(() => null);
252
+ if (!response.ok || !frame?.ok) {
253
+ throw new Error(frame?.error?.message ?? "Gateway request failed");
254
+ }
255
+ return frame.payload;
256
+ }
257
+
258
+ function statusClass(status) {
259
+ if (status === "finished") return "ok";
260
+ if (status === "failed" || status === "cancelled") return "warn";
261
+ return "";
262
+ }
263
+
264
+ function formatAge(ms) {
265
+ if (!ms) return "unknown";
266
+ const seconds = Math.max(0, Math.floor((Date.now() - ms) / 1000));
267
+ if (seconds < 60) return seconds + "s ago";
268
+ const minutes = Math.floor(seconds / 60);
269
+ if (minutes < 60) return minutes + "m ago";
270
+ return Math.floor(minutes / 60) + "h ago";
271
+ }
272
+
273
+ function escapeText(value) {
274
+ return String(value ?? "").replace(/[&<>"']/g, (char) => ({
275
+ "&": "&amp;",
276
+ "<": "&lt;",
277
+ ">": "&gt;",
278
+ '"': "&quot;",
279
+ "'": "&#39;",
280
+ })[char]);
281
+ }
282
+
283
+ function renderRuns() {
284
+ if (state.runs.length === 0) {
285
+ return '<div class="empty">No runs found.</div>';
286
+ }
287
+ return state.runs.map((run) => `
288
+ <div class="run-row">
289
+ <div>
290
+ <div class="run-id">${escapeText(run.runId)}</div>
291
+ <div class="muted">${escapeText(run.workflowKey ?? run.workflowName ?? "workflow")}</div>
292
+ </div>
293
+ <div><span class="pill ${statusClass(run.status)}">${escapeText(run.status)}</span></div>
294
+ <div class="muted">${escapeText(formatAge(run.createdAtMs))}</div>
295
+ <div class="muted">${escapeText(run.triggeredBy ?? run.auth?.triggeredBy ?? "")}</div>
296
+ </div>
297
+ `).join("");
298
+ }
299
+
300
+ function renderApprovals() {
301
+ if (state.approvals.length === 0) {
302
+ return '<div class="empty">No pending approvals.</div>';
303
+ }
304
+ return state.approvals.map((approval, index) => `
305
+ <section class="approval">
306
+ <div>
307
+ <div class="approval-title">${escapeText(approval.requestTitle ?? approval.nodeId)}</div>
308
+ <div class="muted">${escapeText(approval.workflowKey)} / ${escapeText(approval.runId)}</div>
309
+ </div>
310
+ <div class="approval-actions">
311
+ <button data-approve="${index}">Approve</button>
312
+ <button class="danger" data-deny="${index}">Deny</button>
313
+ </div>
314
+ </section>
315
+ `).join("");
316
+ }
317
+
318
+ function renderWorkflows() {
319
+ const options = state.workflows.map((workflow) => {
320
+ const selected = workflow.key === state.selectedWorkflow ? " selected" : "";
321
+ return `<option value="${escapeText(workflow.key)}"${selected}>${escapeText(workflow.readableName ?? workflow.key)}</option>`;
322
+ }).join("");
323
+ return `<select id="workflow">${options || '<option value="">No workflows</option>'}</select>`;
324
+ }
325
+
326
+ function render() {
327
+ const activeRuns = state.runs.filter((run) => ["running", "waiting-approval", "waiting-event", "waiting-timer"].includes(run.status)).length;
328
+ root.innerHTML = `
329
+ <div class="shell">
330
+ <aside class="nav">
331
+ <div class="brand">Smithers Console</div>
332
+ <div class="nav-section">
333
+ <div class="label">Gateway</div>
334
+ <span class="pill ${state.health ? "ok" : "warn"}">${state.health ? "online" : "checking"}</span>
335
+ <span class="pill">${escapeText(state.health?.features?.join(", ") ?? "features pending")}</span>
336
+ </div>
337
+ <div class="nav-section">
338
+ <label class="label" for="token">Bearer token</label>
339
+ <input class="token" id="token" value="${escapeText(state.token)}" type="password" autocomplete="off">
340
+ </div>
341
+ </aside>
342
+ <main class="main">
343
+ <div class="topbar">
344
+ <div>
345
+ <div class="title">Operations</div>
346
+ <div class="muted">${escapeText(state.status)}</div>
347
+ </div>
348
+ <div class="meta">
349
+ <span class="pill">${state.workflows.length} workflows</span>
350
+ <span class="pill">${activeRuns} active</span>
351
+ <span class="pill">${state.approvals.length} approvals</span>
352
+ <button class="secondary" id="refresh">Refresh</button>
353
+ </div>
354
+ </div>
355
+ <form class="launch" id="launch">
356
+ <label class="field">
357
+ <span class="label">Workflow</span>
358
+ ${renderWorkflows()}
359
+ </label>
360
+ <label class="field">
361
+ <span class="label">Input JSON</span>
362
+ <textarea id="run-input" spellcheck="false">${escapeText(state.runInput)}</textarea>
363
+ </label>
364
+ <button type="submit" ${state.busy ? "disabled" : ""}>Launch</button>
365
+ </form>
366
+ <section class="runs">${renderRuns()}</section>
367
+ </main>
368
+ <aside class="side">
369
+ <div>
370
+ <h2>Pending Approvals</h2>
371
+ <div class="error">${escapeText(state.error)}</div>
372
+ </div>
373
+ ${renderApprovals()}
374
+ </aside>
375
+ </div>
376
+ `;
377
+ bind();
378
+ }
379
+
380
+ function bind() {
381
+ document.getElementById("token")?.addEventListener("input", (event) => {
382
+ setToken(event.target.value);
383
+ });
384
+ document.getElementById("workflow")?.addEventListener("change", (event) => {
385
+ state.selectedWorkflow = event.target.value;
386
+ });
387
+ document.getElementById("run-input")?.addEventListener("input", (event) => {
388
+ state.runInput = event.target.value;
389
+ });
390
+ document.getElementById("refresh")?.addEventListener("click", () => refresh());
391
+ document.getElementById("launch")?.addEventListener("submit", async (event) => {
392
+ event.preventDefault();
393
+ await launchRun();
394
+ });
395
+ for (const button of document.querySelectorAll("[data-approve]")) {
396
+ button.addEventListener("click", () => decideApproval(Number(button.dataset.approve), true));
397
+ }
398
+ for (const button of document.querySelectorAll("[data-deny]")) {
399
+ button.addEventListener("click", () => decideApproval(Number(button.dataset.deny), false));
400
+ }
401
+ }
402
+
403
+ async function refresh() {
404
+ state.error = "";
405
+ try {
406
+ const [health, workflows, runs, approvals] = await Promise.all([
407
+ rpc("health"),
408
+ rpc("listWorkflows", { limit: 100 }),
409
+ rpc("listRuns", { limit: 100 }),
410
+ rpc("listApprovals", { limit: 50 }),
411
+ ]);
412
+ state.health = health;
413
+ state.workflows = workflows;
414
+ state.runs = runs;
415
+ state.approvals = approvals;
416
+ if (!state.selectedWorkflow && workflows[0]) {
417
+ state.selectedWorkflow = workflows[0].key;
418
+ }
419
+ state.status = "Updated " + new Date().toLocaleTimeString();
420
+ } catch (error) {
421
+ state.error = error instanceof Error ? error.message : String(error);
422
+ state.status = "Refresh failed";
423
+ }
424
+ render();
425
+ }
426
+
427
+ async function launchRun() {
428
+ state.busy = true;
429
+ state.error = "";
430
+ render();
431
+ try {
432
+ const input = JSON.parse(state.runInput || "{}");
433
+ await rpc("launchRun", { workflow: state.selectedWorkflow, input });
434
+ state.status = "Run launched";
435
+ await refresh();
436
+ } catch (error) {
437
+ state.error = error instanceof Error ? error.message : String(error);
438
+ state.busy = false;
439
+ render();
440
+ }
441
+ state.busy = false;
442
+ }
443
+
444
+ async function decideApproval(index, approved) {
445
+ const approval = state.approvals[index];
446
+ if (!approval) return;
447
+ state.error = "";
448
+ try {
449
+ await rpc("submitApproval", {
450
+ runId: approval.runId,
451
+ nodeId: approval.nodeId,
452
+ iteration: approval.iteration ?? 0,
453
+ approved,
454
+ decision: { approved },
455
+ });
456
+ await refresh();
457
+ } catch (error) {
458
+ state.error = error instanceof Error ? error.message : String(error);
459
+ render();
460
+ }
461
+ }
462
+
463
+ installStyles();
464
+ render();
465
+ refresh();
466
+ setInterval(refresh, 5000);
467
+ }
468
+
469
+ export const DEFAULT_OPERATOR_UI_CLIENT_JS = `(${defaultOperatorUiClient.toString()})();\n`;
package/src/index.d.ts CHANGED
@@ -119,10 +119,27 @@ type GatewayDefaults$1 = {
119
119
  cliAgentTools?: "all" | "explicit-only";
120
120
  };
121
121
 
122
- type GatewayUiConfig$1 = {
122
+ type GatewayOperatorUiConfig$1 = {
123
+ /**
124
+ * URL path for the built-in operator console.
125
+ * @default "/console"
126
+ */
127
+ path?: string;
128
+ /**
129
+ * Document title for the generated HTML shell.
130
+ */
131
+ title?: string;
132
+ /**
133
+ * JSON-serializable boot data exposed to the browser.
134
+ */
135
+ props?: Record<string, unknown>;
136
+ };
137
+
138
+ type GatewayUiConfig$1 = true | {
123
139
  /**
124
140
  * Browser entry module for the React app. Smithers bundles this with Bun and
125
- * serves it from the Gateway origin.
141
+ * serves it from the Gateway origin. Pass `true` to mount the built-in
142
+ * operator console.
126
143
  */
127
144
  entry: string;
128
145
  /**
@@ -146,6 +163,11 @@ type GatewayOptions$1 = {
146
163
  heartbeatMs?: number;
147
164
  auth?: GatewayAuthConfig$1;
148
165
  ui?: GatewayUiConfig$1;
166
+ /**
167
+ * Built-in browser console for operators. Set to false to disable it.
168
+ * @default { path: "/console" }
169
+ */
170
+ operatorUi?: GatewayOperatorUiConfig$1 | false;
149
171
  defaults?: GatewayDefaults$1;
150
172
  maxBodyBytes?: number;
151
173
  maxPayload?: number;
@@ -267,6 +289,7 @@ declare class Gateway {
267
289
  requestTimeout: number;
268
290
  auth: GatewayAuthConfig$1 | undefined;
269
291
  ui: ResolvedGatewayUiConfig | null;
292
+ operatorUi: ResolvedGatewayUiConfig | null;
270
293
  uiApp: hono.Hono<hono_types.BlankEnv, hono_types.BlankSchema, "/">;
271
294
  defaults: GatewayDefaults$1 | undefined;
272
295
  workflows: Map<any, any>;
@@ -309,7 +332,7 @@ declare class Gateway {
309
332
  */
310
333
  uiBootConfig(mount: GatewayUiMount): {
311
334
  apiVersion: "v1";
312
- kind: "workflow" | "gateway";
335
+ kind: "workflow" | "gateway" | "operator";
313
336
  workflowKey: string | null;
314
337
  mountPath: string;
315
338
  rpcPath: string;
@@ -334,11 +357,6 @@ declare class Gateway {
334
357
  contentType: string;
335
358
  } | null>;
336
359
  /**
337
- * @param {ResolvedGatewayUiConfig} config
338
- * @returns {Promise<string>}
339
- */
340
- bundleUiEntry(config: ResolvedGatewayUiConfig): Promise<string>;
341
- /**
342
360
  * @param {IncomingMessage} req
343
361
  * @param {ServerResponse} res
344
362
  */
@@ -727,6 +745,7 @@ type GatewayWebhookRunConfig = GatewayWebhookRunConfig$1;
727
745
  type GatewayWebhookSignalConfig = GatewayWebhookSignalConfig$1;
728
746
  type ConnectRequest = ConnectRequest$1;
729
747
  type GatewayAuthConfig = GatewayAuthConfig$1;
748
+ type GatewayOperatorUiConfig = GatewayOperatorUiConfig$1;
730
749
  type GatewayOptions = GatewayOptions$1;
731
750
  type GatewayWebhookConfig = GatewayWebhookConfig$1;
732
751
  type IncomingMessage = node_http.IncomingMessage;
@@ -783,9 +802,10 @@ type ResolvedGatewayUiConfig = {
783
802
  path: string;
784
803
  title?: string;
785
804
  props?: Record<string, unknown>;
805
+ builtin?: "operator";
786
806
  };
787
807
  type GatewayUiMount = {
788
- kind: "gateway" | "workflow";
808
+ kind: "gateway" | "workflow" | "operator";
789
809
  workflowKey: string | null;
790
810
  config: ResolvedGatewayUiConfig;
791
811
  };
@@ -1175,4 +1195,4 @@ type RunRow = _smithers_orchestrator_db_adapter_RunRow.RunRow;
1175
1195
  type ServerResponse = node_http.ServerResponse;
1176
1196
  type ServerOptions = ServerOptions$1;
1177
1197
 
1178
- export { type AttemptRow, type ConnectRequest, type ConnectionState, DEVTOOLS_BACKPRESSURE_LIMIT, DEVTOOLS_EMPTY_ROOT_ID, DEVTOOLS_MAX_FRAME_NO, DEVTOOLS_POLL_INTERVAL_MS, DEVTOOLS_REBASELINE_INTERVAL, DEVTOOLS_RUN_ID_PATTERN, DEVTOOLS_TREE_MAX_DEPTH, type DevToolsEvent, type DevToolsNode, type DevToolsNodeType, DevToolsRouteError, type DiffSummary, type EventFrame, GATEWAY_FRAME_ID_MAX_LENGTH, GATEWAY_METHOD_NAME_MAX_LENGTH, GATEWAY_RPC_INPUT_MAX_BYTES, GATEWAY_RPC_INPUT_MAX_DEPTH, GATEWAY_RPC_MAX_ARRAY_LENGTH, GATEWAY_RPC_MAX_DEPTH, GATEWAY_RPC_MAX_PAYLOAD_BYTES, GATEWAY_RPC_MAX_STRING_LENGTH, Gateway, type GatewayAuthConfig, type GatewayDefaults, type GatewayMetricLabels, type GatewayOptions, type GatewayRegisterOptions, type GatewayRequestContext, type GatewayTokenGrant, type GatewayTransport, type GatewayUiConfig, type GatewayUiMount, type GatewayWebhookConfig, type GatewayWebhookRunConfig, type GatewayWebhookSignalConfig, type GetNodeDiffRouteResult, type HelloResponse, ITERATION_MAX, type IncomingMessage, type JumpResult, NODE_ID_PATTERN, NODE_OUTPUT_MAX_BYTES, NODE_OUTPUT_WARN_BYTES, type NodeOutputErrorCode, type NodeOutputResponse, NodeOutputRouteError, RUN_ID_PATTERN, type RegisteredWorkflow, type RequestFrame, type ResolvedGatewayUiConfig, type ResolvedRun, type ResponseFrame, type RunRow, type RunStartAuthContext, type ServeOptions, type ServerOptions, type ServerResponse, type SmithersWorkflow, assertGatewayInputDepthWithinBounds, createServeApp, emptyDevToolsRoot, getDevToolsSnapshotRoute, getGatewayInputDepth, getNodeDiffRoute, getNodeOutputRoute, jumpToFrameRoute, parseGatewayRequestFrame, parseXmlToDevToolsRoot, runFork, runPromise, runSync, snapshotFromFrameRow, startServer, startServerEffect, statusForRpcError, streamDevToolsRoute, summarizeBundle, validateFrameNoInput, validateFromSeqInput, validateGatewayMethodName, validateRequestedFrameNo, validateRunId };
1198
+ export { type AttemptRow, type ConnectRequest, type ConnectionState, DEVTOOLS_BACKPRESSURE_LIMIT, DEVTOOLS_EMPTY_ROOT_ID, DEVTOOLS_MAX_FRAME_NO, DEVTOOLS_POLL_INTERVAL_MS, DEVTOOLS_REBASELINE_INTERVAL, DEVTOOLS_RUN_ID_PATTERN, DEVTOOLS_TREE_MAX_DEPTH, type DevToolsEvent, type DevToolsNode, type DevToolsNodeType, DevToolsRouteError, type DiffSummary, type EventFrame, GATEWAY_FRAME_ID_MAX_LENGTH, GATEWAY_METHOD_NAME_MAX_LENGTH, GATEWAY_RPC_INPUT_MAX_BYTES, GATEWAY_RPC_INPUT_MAX_DEPTH, GATEWAY_RPC_MAX_ARRAY_LENGTH, GATEWAY_RPC_MAX_DEPTH, GATEWAY_RPC_MAX_PAYLOAD_BYTES, GATEWAY_RPC_MAX_STRING_LENGTH, Gateway, type GatewayAuthConfig, type GatewayDefaults, type GatewayMetricLabels, type GatewayOperatorUiConfig, type GatewayOptions, type GatewayRegisterOptions, type GatewayRequestContext, type GatewayTokenGrant, type GatewayTransport, type GatewayUiConfig, type GatewayUiMount, type GatewayWebhookConfig, type GatewayWebhookRunConfig, type GatewayWebhookSignalConfig, type GetNodeDiffRouteResult, type HelloResponse, ITERATION_MAX, type IncomingMessage, type JumpResult, NODE_ID_PATTERN, NODE_OUTPUT_MAX_BYTES, NODE_OUTPUT_WARN_BYTES, type NodeOutputErrorCode, type NodeOutputResponse, NodeOutputRouteError, RUN_ID_PATTERN, type RegisteredWorkflow, type RequestFrame, type ResolvedGatewayUiConfig, type ResolvedRun, type ResponseFrame, type RunRow, type RunStartAuthContext, type ServeOptions, type ServerOptions, type ServerResponse, type SmithersWorkflow, assertGatewayInputDepthWithinBounds, createServeApp, emptyDevToolsRoot, getDevToolsSnapshotRoute, getGatewayInputDepth, getNodeDiffRoute, getNodeOutputRoute, jumpToFrameRoute, parseGatewayRequestFrame, parseXmlToDevToolsRoot, runFork, runPromise, runSync, snapshotFromFrameRow, startServer, startServerEffect, statusForRpcError, streamDevToolsRoute, summarizeBundle, validateFrameNoInput, validateFromSeqInput, validateGatewayMethodName, validateRequestedFrameNo, validateRunId };