@smithers-orchestrator/server 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.js ADDED
@@ -0,0 +1,1240 @@
1
+ import { createServer, IncomingMessage, ServerResponse } from "node:http";
2
+ import { readFile, writeFile } from "node:fs/promises";
3
+ import { createHash } from "node:crypto";
4
+ import { pathToFileURL } from "node:url";
5
+ import { resolve, dirname, sep, basename } from "node:path";
6
+ import { Effect } from "effect";
7
+ import { isRunHeartbeatFresh, runWorkflow } from "@smithers-orchestrator/engine";
8
+ import { SmithersDb } from "@smithers-orchestrator/db/adapter";
9
+ import { ensureSmithersTables } from "@smithers-orchestrator/db/ensure";
10
+ import { computeRunStateFromRow } from "@smithers-orchestrator/db/runState";
11
+ import { Metric } from "effect";
12
+ import { toSmithersError } from "@smithers-orchestrator/errors/toSmithersError";
13
+ import { logError, logInfo, logWarning } from "@smithers-orchestrator/observability/logging";
14
+ import { runPromise, runSync } from "./smithersRuntime.js";
15
+ import { httpRequests, httpRequestDuration, trackEvent } from "@smithers-orchestrator/observability/metrics";
16
+ import { approveNode, denyNode } from "@smithers-orchestrator/engine/approvals";
17
+ import { signalRun } from "@smithers-orchestrator/engine/signals";
18
+ import { nowMs } from "@smithers-orchestrator/scheduler/nowMs";
19
+ import { errorToJson } from "@smithers-orchestrator/errors/errorToJson";
20
+ import { SmithersError } from "@smithers-orchestrator/errors/SmithersError";
21
+ import { assertMaxBytes, assertMaxJsonDepth } from "@smithers-orchestrator/db/input-bounds";
22
+ import { prometheusContentType, renderPrometheusMetrics, } from "@smithers-orchestrator/observability";
23
+ /** @typedef {import("./ServerOptions.js").ServerOptions} ServerOptions */
24
+
25
+ // Re-export the full public surface so the tsup-bundled `src/index.d.ts`
26
+ // covers every module reachable via the `./*` wildcard export.
27
+ export * from "./gateway.js";
28
+ export * from "./serve.js";
29
+ export * from "./smithersRuntime.js";
30
+ export * from "./gatewayRoutes/NODE_OUTPUT_MAX_BYTES.js";
31
+ export * from "./gatewayRoutes/NODE_OUTPUT_WARN_BYTES.js";
32
+ export * from "./gatewayRoutes/NodeOutputRouteError.js";
33
+ export * from "./gatewayRoutes/getDevToolsSnapshot.js";
34
+ export * from "./gatewayRoutes/getNodeDiff.js";
35
+ export * from "./gatewayRoutes/getNodeOutput.js";
36
+ export * from "./gatewayRoutes/jumpToFrame.js";
37
+ export * from "./gatewayRoutes/streamDevTools.js";
38
+ // Type-only stubs reachable via `./*` that are NOT already transitively
39
+ // re-exported through the JS modules above.
40
+ export * from "./ServerOptions.js";
41
+
42
+ const runs = new Map();
43
+ const DEFAULT_MAX_BODY_BYTES = 1_048_576;
44
+ const DEFAULT_MAX_BODY_JSON_DEPTH = 32;
45
+ const DEFAULT_SSE_HEARTBEAT_MS = 10_000;
46
+ const COMPLETED_RUN_RETENTION_MS = 60_000;
47
+ class HttpError extends Error {
48
+ status;
49
+ code;
50
+ details;
51
+ /**
52
+ * @param {number} status
53
+ * @param {HttpErrorCode} code
54
+ * @param {string} message
55
+ * @param {Record<string, unknown>} [details]
56
+ */
57
+ constructor(status, code, message, details) {
58
+ super(message);
59
+ this.status = status;
60
+ this.code = code;
61
+ this.details = details;
62
+ }
63
+ }
64
+ /**
65
+ * @param {string | null} value
66
+ * @param {number} fallback
67
+ * @returns {number}
68
+ */
69
+ function parsePositiveInt(value, fallback) {
70
+ if (value === null)
71
+ return fallback;
72
+ const num = Number(value);
73
+ if (!Number.isFinite(num) || num <= 0) {
74
+ throw new HttpError(400, "INVALID_REQUEST", `Expected a positive integer, got "${value}"`);
75
+ }
76
+ return Math.floor(num);
77
+ }
78
+ /**
79
+ * @param {string | null} value
80
+ * @param {number} fallback
81
+ * @returns {number}
82
+ */
83
+ function parseOptionalInt(value, fallback) {
84
+ if (value === null)
85
+ return fallback;
86
+ const num = Number(value);
87
+ if (!Number.isFinite(num)) {
88
+ throw new HttpError(400, "INVALID_REQUEST", `Expected a number, got "${value}"`);
89
+ }
90
+ return Math.floor(num);
91
+ }
92
+ /**
93
+ * @param {IncomingMessage} req
94
+ * @param {number} maxBytes
95
+ * @param {number} maxDepth
96
+ * @returns {Promise<unknown>}
97
+ */
98
+ async function readBody(req, maxBytes, maxDepth) {
99
+ const chunks = [];
100
+ let total = 0;
101
+ const lengthHeader = req.headers["content-length"];
102
+ if (lengthHeader) {
103
+ const len = Array.isArray(lengthHeader)
104
+ ? Number(lengthHeader[0])
105
+ : Number(lengthHeader);
106
+ if (Number.isFinite(len) && len > maxBytes) {
107
+ throw new HttpError(413, "PAYLOAD_TOO_LARGE", `Request body exceeds ${maxBytes} bytes`, { maxBytes });
108
+ }
109
+ }
110
+ for await (const chunk of req) {
111
+ const buf = Buffer.from(chunk);
112
+ total += buf.length;
113
+ if (total > maxBytes) {
114
+ throw new HttpError(413, "PAYLOAD_TOO_LARGE", `Request body exceeds ${maxBytes} bytes`, { maxBytes });
115
+ }
116
+ chunks.push(buf);
117
+ }
118
+ const bodyBuffer = Buffer.concat(chunks);
119
+ try {
120
+ assertMaxBytes("request body", bodyBuffer, maxBytes);
121
+ }
122
+ catch (error) {
123
+ if (error instanceof SmithersError) {
124
+ throw new HttpError(413, "PAYLOAD_TOO_LARGE", error.message, {
125
+ maxBytes,
126
+ });
127
+ }
128
+ throw error;
129
+ }
130
+ const body = bodyBuffer.toString("utf8");
131
+ if (!body)
132
+ return {};
133
+ let parsed;
134
+ try {
135
+ parsed = JSON.parse(body);
136
+ }
137
+ catch (err) {
138
+ throw new HttpError(400, "INVALID_JSON", err?.message ?? "Request body must be valid JSON");
139
+ }
140
+ try {
141
+ assertMaxJsonDepth("request body", parsed, maxDepth);
142
+ }
143
+ catch (error) {
144
+ if (error instanceof SmithersError) {
145
+ throw new HttpError(400, "INVALID_REQUEST", error.message, {
146
+ maxDepth,
147
+ });
148
+ }
149
+ throw error;
150
+ }
151
+ return parsed;
152
+ }
153
+ /**
154
+ * @param {string} absPath
155
+ * @returns {Promise<SmithersWorkflow<unknown>>}
156
+ */
157
+ async function loadWorkflow(absPath) {
158
+ const source = await readFile(absPath);
159
+ const version = createHash("sha1").update(source).digest("hex");
160
+ const extIdx = absPath.lastIndexOf(".");
161
+ const ext = extIdx >= 0 ? absPath.slice(extIdx) : "";
162
+ const base = basename(absPath, ext);
163
+ const shadowPath = resolve(dirname(absPath), `.${base}.smithers-${version}${ext}`);
164
+ await writeFile(shadowPath, source);
165
+ const mod = await import(pathToFileURL(shadowPath).href);
166
+ if (!mod.default)
167
+ throw new SmithersError("WORKFLOW_MISSING_DEFAULT", "Workflow must export default");
168
+ return mod.default;
169
+ }
170
+ /**
171
+ * @param {string} absPath
172
+ */
173
+ function loadWorkflowEffect(absPath) {
174
+ return Effect.tryPromise({
175
+ try: () => loadWorkflow(absPath),
176
+ catch: (cause) => toSmithersError(cause, "load workflow module"),
177
+ }).pipe(Effect.annotateLogs({ workflowPath: absPath }), Effect.withLogSpan("server:load-workflow"));
178
+ }
179
+ /**
180
+ * @param {RunRecord | undefined} record
181
+ */
182
+ function clearRunCleanupTimer(record) {
183
+ if (!record?.cleanupTimer)
184
+ return;
185
+ clearTimeout(record.cleanupTimer);
186
+ record.cleanupTimer = null;
187
+ }
188
+ /**
189
+ * @param {string} runId
190
+ */
191
+ function scheduleRunCleanup(runId) {
192
+ const record = runs.get(runId);
193
+ if (!record)
194
+ return;
195
+ clearRunCleanupTimer(record);
196
+ record.cleanupTimer = setTimeout(() => {
197
+ const current = runs.get(runId);
198
+ if (current === record) {
199
+ runs.delete(runId);
200
+ }
201
+ }, COMPLETED_RUN_RETENTION_MS);
202
+ }
203
+ /**
204
+ * @param {string} runId
205
+ * @param {string} status
206
+ * @param {boolean} hasServerDb
207
+ */
208
+ function finalizeRunRecord(runId, status, hasServerDb) {
209
+ if (hasServerDb ||
210
+ (status !== "waiting-approval" && status !== "waiting-timer")) {
211
+ if (hasServerDb) {
212
+ const record = runs.get(runId);
213
+ clearRunCleanupTimer(record);
214
+ runs.delete(runId);
215
+ return;
216
+ }
217
+ scheduleRunCleanup(runId);
218
+ }
219
+ }
220
+ /**
221
+ * @param {ServerResponse} res
222
+ * @param {number} status
223
+ * @param {unknown} payload
224
+ */
225
+ function sendJson(res, status, payload) {
226
+ res.statusCode = status;
227
+ res.setHeader("Content-Type", "application/json");
228
+ res.setHeader("Cache-Control", "no-store");
229
+ res.setHeader("X-Content-Type-Options", "nosniff");
230
+ res.end(JSON.stringify(payload));
231
+ }
232
+ /** @typedef {import("@smithers-orchestrator/db/adapter/RunRow").RunRow} RunRow */
233
+
234
+ /**
235
+ * @param {SmithersDb} adapter
236
+ * @param {RunRow} run
237
+ * @returns {Promise<RunRow | (RunRow & { runState: import("@smithers-orchestrator/db/runState/RunStateView").RunStateView })>}
238
+ */
239
+ async function withRunState(adapter, run) {
240
+ const runState = await computeRunStateFromRow(adapter, run).catch(() => undefined);
241
+ return runState ? { ...run, runState } : run;
242
+ }
243
+ /**
244
+ * @param {ServerResponse} res
245
+ * @param {number} status
246
+ * @param {string} payload
247
+ */
248
+ function sendText(res, status, payload, contentType = "text/plain; charset=utf-8") {
249
+ res.statusCode = status;
250
+ res.setHeader("Content-Type", contentType);
251
+ res.setHeader("Cache-Control", "no-store");
252
+ res.setHeader("X-Content-Type-Options", "nosniff");
253
+ res.end(payload);
254
+ }
255
+ /**
256
+ * @template A
257
+ * @param {A} metric
258
+ * @param {Record<string, string>} tags
259
+ * @returns {A}
260
+ */
261
+ function taggedMetric(metric, tags) {
262
+ let tagged = metric;
263
+ for (const [key, value] of Object.entries(tags)) {
264
+ tagged = Metric.tagged(tagged, key, value);
265
+ }
266
+ return tagged;
267
+ }
268
+ /**
269
+ * @param {string} pathname
270
+ * @returns {string}
271
+ */
272
+ function normalizeHttpMetricRoute(pathname) {
273
+ if (pathname === "/metrics" || pathname === "/health" || pathname === "/v1/runs") {
274
+ return pathname;
275
+ }
276
+ if (pathname === "/v1/approval/list"
277
+ || pathname === "/v1/approvals"
278
+ || pathname === "/approval/list"
279
+ || pathname === "/approvals") {
280
+ return pathname;
281
+ }
282
+ if (/^\/v1\/runs\/[^/]+\/events$/.test(pathname))
283
+ return "/v1/runs/:runId/events";
284
+ if (/^\/v1\/runs\/[^/]+\/frames$/.test(pathname))
285
+ return "/v1/runs/:runId/frames";
286
+ if (/^\/v1\/runs\/[^/]+\/nodes\/[^/]+\/approve$/.test(pathname)) {
287
+ return "/v1/runs/:runId/nodes/:nodeId/approve";
288
+ }
289
+ if (/^\/v1\/runs\/[^/]+\/nodes\/[^/]+\/deny$/.test(pathname)) {
290
+ return "/v1/runs/:runId/nodes/:nodeId/deny";
291
+ }
292
+ if (/^\/v1\/runs\/[^/]+\/signals\/[^/]+$/.test(pathname)) {
293
+ return "/v1/runs/:runId/signals/:signalName";
294
+ }
295
+ if (/^\/signal\/[^/]+\/[^/]+$/.test(pathname)) {
296
+ return "/signal/:runId/:signalName";
297
+ }
298
+ if (/^\/v1\/runs\/[^/]+$/.test(pathname))
299
+ return "/v1/runs/:runId";
300
+ return pathname;
301
+ }
302
+ /**
303
+ * @param {number} statusCode
304
+ * @returns {string}
305
+ */
306
+ function statusClass(statusCode) {
307
+ const normalized = Number.isFinite(statusCode) && statusCode > 0 ? statusCode : 500;
308
+ return `${Math.floor(normalized / 100)}xx`;
309
+ }
310
+ /**
311
+ * @param {string} method
312
+ * @param {string} pathname
313
+ * @param {number} statusCode
314
+ * @param {number} durationMs
315
+ */
316
+ function recordHttpRequestMetrics(method, pathname, statusCode, durationMs) {
317
+ const tags = {
318
+ method: method.toUpperCase(),
319
+ route: normalizeHttpMetricRoute(pathname),
320
+ status_code: String(statusCode),
321
+ status_class: statusClass(statusCode),
322
+ };
323
+ return Effect.all([
324
+ Metric.increment(taggedMetric(httpRequests, tags)),
325
+ Metric.update(taggedMetric(httpRequestDuration, tags), durationMs),
326
+ ], { discard: true });
327
+ }
328
+ /**
329
+ * @param {string} method
330
+ * @param {string} pathname
331
+ * @param {number} statusCode
332
+ * @param {number} durationMs
333
+ */
334
+ async function recordHttpRequestMetricsSafely(method, pathname, statusCode, durationMs) {
335
+ try {
336
+ await runPromise(recordHttpRequestMetrics(method, pathname, statusCode, durationMs));
337
+ }
338
+ catch (error) {
339
+ logWarning("failed to record server http metrics", {
340
+ method: method.toUpperCase(),
341
+ pathname,
342
+ statusCode,
343
+ error: error instanceof Error ? error.message : String(error),
344
+ }, "server:metrics");
345
+ }
346
+ }
347
+ /**
348
+ * @param {IncomingMessage} req
349
+ * @param {string} [authToken]
350
+ */
351
+ function assertAuth(req, authToken) {
352
+ if (!authToken)
353
+ return;
354
+ const header = req.headers["authorization"] ??
355
+ req.headers["Authorization"] ??
356
+ req.headers["x-smithers-key"];
357
+ const value = Array.isArray(header) ? header[0] : header;
358
+ const token = value?.startsWith("Bearer ") ? value.slice(7) : value;
359
+ if (!token || token !== authToken) {
360
+ throw new HttpError(401, "UNAUTHORIZED", "Missing or invalid authorization token");
361
+ }
362
+ }
363
+ /**
364
+ * @param {string} workflowPath
365
+ * @param {string} [rootDir]
366
+ * @returns {string}
367
+ */
368
+ function resolveWorkflowPath(workflowPath, rootDir) {
369
+ const base = rootDir ? resolve(rootDir) : process.cwd();
370
+ const resolved = resolve(base, workflowPath);
371
+ if (rootDir) {
372
+ const root = resolve(rootDir);
373
+ const rootPrefix = root.endsWith(sep) ? root : root + sep;
374
+ if (resolved !== root && !resolved.startsWith(rootPrefix)) {
375
+ throw new HttpError(400, "WORKFLOW_PATH_OUTSIDE_ROOT", "Workflow path must be within server root directory");
376
+ }
377
+ }
378
+ return resolved;
379
+ }
380
+ /**
381
+ * @param {unknown} db
382
+ * @returns {string | undefined}
383
+ */
384
+ function getDbIdentity(db) {
385
+ if (!db || typeof db !== "object") return undefined;
386
+ const client = /** @type {{ $client?: unknown }} */ (db).$client;
387
+ if (!client || typeof client !== "object")
388
+ return undefined;
389
+ const c = /** @type {Record<string, unknown>} */ (client);
390
+ if (typeof c.filename === "string")
391
+ return c.filename;
392
+ if (typeof c.name === "string")
393
+ return c.name;
394
+ if (typeof c.dbname === "string")
395
+ return c.dbname;
396
+ return undefined;
397
+ }
398
+ /**
399
+ * @param {unknown | null} serverDb
400
+ * @param {unknown} workflowDb
401
+ * @returns {boolean}
402
+ */
403
+ function isSameDb(serverDb, workflowDb) {
404
+ if (!serverDb)
405
+ return false;
406
+ if (serverDb === workflowDb)
407
+ return true;
408
+ const serverId = getDbIdentity(serverDb);
409
+ const workflowId = getDbIdentity(workflowDb);
410
+ return Boolean(serverId && workflowId && serverId === workflowId);
411
+ }
412
+ /**
413
+ * @param {SmithersDb | null} adapter
414
+ * @param {string} runId
415
+ * @param {string} workflowName
416
+ * @param {string} workflowPath
417
+ * @param {string} configJson
418
+ */
419
+ function buildMirrorOnProgress(adapter, runId, workflowName, workflowPath, configJson) {
420
+ if (!adapter)
421
+ return undefined;
422
+ let runInserted = false;
423
+ const ensureRun = async () => {
424
+ if (runInserted)
425
+ return;
426
+ await adapter.insertRun({
427
+ runId,
428
+ workflowName,
429
+ workflowPath,
430
+ workflowHash: null,
431
+ status: "running",
432
+ createdAtMs: nowMs(),
433
+ startedAtMs: nowMs(),
434
+ finishedAtMs: null,
435
+ heartbeatAtMs: eventLoopNow(),
436
+ runtimeOwnerId: null,
437
+ cancelRequestedAtMs: null,
438
+ vcsType: null,
439
+ vcsRoot: null,
440
+ vcsRevision: null,
441
+ errorJson: null,
442
+ configJson,
443
+ });
444
+ runInserted = true;
445
+ };
446
+ /**
447
+ * @param {SmithersEvent} event
448
+ */
449
+ const mirrorEventEffect = (event) => Effect.gen(function* () {
450
+ yield* Effect.tryPromise({
451
+ try: () => ensureRun(),
452
+ catch: (cause) => toSmithersError(cause, "ensure mirrored run"),
453
+ });
454
+ yield* adapter.insertEventWithNextSeq({
455
+ runId,
456
+ timestampMs: event.timestampMs,
457
+ type: event.type,
458
+ payloadJson: JSON.stringify(event),
459
+ });
460
+ switch (event.type) {
461
+ case "RunStarted":
462
+ yield* adapter.updateRun(runId, {
463
+ status: "running",
464
+ startedAtMs: event.timestampMs,
465
+ heartbeatAtMs: event.timestampMs,
466
+ cancelRequestedAtMs: null,
467
+ });
468
+ break;
469
+ case "RunStatusChanged":
470
+ yield* adapter.updateRun(runId, { status: event.status });
471
+ break;
472
+ case "RunContinuedAsNew":
473
+ yield* adapter.updateRun(runId, {
474
+ status: "continued",
475
+ finishedAtMs: event.timestampMs,
476
+ heartbeatAtMs: null,
477
+ runtimeOwnerId: null,
478
+ cancelRequestedAtMs: null,
479
+ });
480
+ break;
481
+ case "RunFinished":
482
+ yield* adapter.updateRun(runId, {
483
+ status: "finished",
484
+ finishedAtMs: event.timestampMs,
485
+ heartbeatAtMs: null,
486
+ runtimeOwnerId: null,
487
+ cancelRequestedAtMs: null,
488
+ });
489
+ break;
490
+ case "RunFailed":
491
+ yield* adapter.updateRun(runId, {
492
+ status: "failed",
493
+ finishedAtMs: event.timestampMs,
494
+ heartbeatAtMs: null,
495
+ runtimeOwnerId: null,
496
+ cancelRequestedAtMs: null,
497
+ errorJson: JSON.stringify(errorToJson(event.error)),
498
+ });
499
+ break;
500
+ case "RunCancelled":
501
+ yield* adapter.updateRun(runId, {
502
+ status: "cancelled",
503
+ finishedAtMs: event.timestampMs,
504
+ heartbeatAtMs: null,
505
+ runtimeOwnerId: null,
506
+ cancelRequestedAtMs: null,
507
+ });
508
+ break;
509
+ case "NodePending":
510
+ yield* adapter.insertNode({
511
+ runId: event.runId,
512
+ nodeId: event.nodeId,
513
+ iteration: event.iteration,
514
+ state: "pending",
515
+ lastAttempt: null,
516
+ updatedAtMs: event.timestampMs,
517
+ outputTable: "",
518
+ label: null,
519
+ });
520
+ break;
521
+ case "NodeWaitingApproval":
522
+ yield* adapter.insertNode({
523
+ runId: event.runId,
524
+ nodeId: event.nodeId,
525
+ iteration: event.iteration,
526
+ state: "waiting-approval",
527
+ lastAttempt: null,
528
+ updatedAtMs: event.timestampMs,
529
+ outputTable: "",
530
+ label: null,
531
+ });
532
+ break;
533
+ case "NodeWaitingTimer":
534
+ yield* adapter.insertNode({
535
+ runId: event.runId,
536
+ nodeId: event.nodeId,
537
+ iteration: event.iteration,
538
+ state: "waiting-timer",
539
+ lastAttempt: null,
540
+ updatedAtMs: event.timestampMs,
541
+ outputTable: "",
542
+ label: null,
543
+ });
544
+ break;
545
+ case "NodeStarted":
546
+ yield* adapter.insertNode({
547
+ runId: event.runId,
548
+ nodeId: event.nodeId,
549
+ iteration: event.iteration,
550
+ state: "in-progress",
551
+ lastAttempt: event.attempt,
552
+ updatedAtMs: event.timestampMs,
553
+ outputTable: "",
554
+ label: null,
555
+ });
556
+ break;
557
+ case "NodeFinished":
558
+ yield* adapter.insertNode({
559
+ runId: event.runId,
560
+ nodeId: event.nodeId,
561
+ iteration: event.iteration,
562
+ state: "finished",
563
+ lastAttempt: event.attempt,
564
+ updatedAtMs: event.timestampMs,
565
+ outputTable: "",
566
+ label: null,
567
+ });
568
+ break;
569
+ case "NodeFailed":
570
+ yield* adapter.insertNode({
571
+ runId: event.runId,
572
+ nodeId: event.nodeId,
573
+ iteration: event.iteration,
574
+ state: "failed",
575
+ lastAttempt: event.attempt,
576
+ updatedAtMs: event.timestampMs,
577
+ outputTable: "",
578
+ label: null,
579
+ });
580
+ break;
581
+ case "NodeCancelled":
582
+ yield* adapter.insertNode({
583
+ runId: event.runId,
584
+ nodeId: event.nodeId,
585
+ iteration: event.iteration,
586
+ state: "cancelled",
587
+ lastAttempt: event.attempt ?? null,
588
+ updatedAtMs: event.timestampMs,
589
+ outputTable: "",
590
+ label: null,
591
+ });
592
+ break;
593
+ case "NodeSkipped":
594
+ yield* adapter.insertNode({
595
+ runId: event.runId,
596
+ nodeId: event.nodeId,
597
+ iteration: event.iteration,
598
+ state: "skipped",
599
+ lastAttempt: null,
600
+ updatedAtMs: event.timestampMs,
601
+ outputTable: "",
602
+ label: null,
603
+ });
604
+ break;
605
+ case "NodeRetrying":
606
+ yield* adapter.insertNode({
607
+ runId: event.runId,
608
+ nodeId: event.nodeId,
609
+ iteration: event.iteration,
610
+ state: "in-progress",
611
+ lastAttempt: event.attempt,
612
+ updatedAtMs: event.timestampMs,
613
+ outputTable: "",
614
+ label: null,
615
+ });
616
+ break;
617
+ }
618
+ }).pipe(Effect.annotateLogs({
619
+ runId,
620
+ workflowName,
621
+ workflowPath,
622
+ eventType: event.type,
623
+ }), Effect.withLogSpan("server:mirror-event"));
624
+ return (event) => {
625
+ void runPromise(mirrorEventEffect(event)).catch((err) => {
626
+ logError("mirror event persistence failed", {
627
+ runId,
628
+ workflowPath,
629
+ eventType: event.type,
630
+ error: err instanceof Error ? err.message : String(err),
631
+ }, "server:mirror-event");
632
+ });
633
+ };
634
+ }
635
+ function eventLoopNow() {
636
+ return nowMs();
637
+ }
638
+ /**
639
+ * @param {ServerOptions} [opts]
640
+ */
641
+ function startServerInternal(opts = {}) {
642
+ const port = opts.port ?? 7331;
643
+ const serverDb = opts.db ?? null;
644
+ const authToken = opts.authToken ?? process.env.SMITHERS_API_KEY;
645
+ const maxBodyBytes = opts.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES;
646
+ const rootDir = opts.rootDir ? resolve(opts.rootDir) : undefined;
647
+ const allowNetwork = Boolean(opts.allowNetwork);
648
+ if (serverDb) {
649
+ ensureSmithersTables(serverDb);
650
+ }
651
+ const serverAdapter = serverDb ? new SmithersDb(serverDb) : null;
652
+ logInfo("starting smithers server", {
653
+ port,
654
+ rootDir: rootDir ?? null,
655
+ allowNetwork,
656
+ hasServerDb: Boolean(serverDb),
657
+ }, "server:start");
658
+ const server = createServer(async (req, res) => {
659
+ const requestStart = performance.now();
660
+ const requestMethod = req.method ?? "GET";
661
+ let requestPathname = (req.url ?? "/").split("?")[0] ?? "/";
662
+ try {
663
+ assertAuth(req, authToken);
664
+ const url = new URL(req.url ?? "/", `http://${req.headers.host}`);
665
+ const method = requestMethod;
666
+ requestPathname = url.pathname;
667
+ if (method === "GET" && url.pathname === "/metrics") {
668
+ return sendText(res, 200, renderPrometheusMetrics(), prometheusContentType);
669
+ }
670
+ /**
671
+ * @param {string} runId
672
+ * @returns {SmithersDb | null}
673
+ */
674
+ function adapterForRun(runId) {
675
+ if (serverAdapter) {
676
+ const record = runs.get(runId);
677
+ if (record) {
678
+ return new SmithersDb(record.workflow.db);
679
+ }
680
+ return serverAdapter;
681
+ }
682
+ const record = runs.get(runId);
683
+ if (!record)
684
+ return null;
685
+ return new SmithersDb(record.workflow.db);
686
+ }
687
+ if (method === "POST" && url.pathname === "/v1/runs") {
688
+ const body = await readBody(req, maxBodyBytes, DEFAULT_MAX_BODY_JSON_DEPTH);
689
+ if (!body?.workflowPath || typeof body.workflowPath !== "string") {
690
+ throw new HttpError(400, "INVALID_REQUEST", "workflowPath must be a string");
691
+ }
692
+ if (body.input !== undefined &&
693
+ (body.input === null ||
694
+ typeof body.input !== "object" ||
695
+ Array.isArray(body.input))) {
696
+ throw new HttpError(400, "INVALID_REQUEST", "input must be a JSON object");
697
+ }
698
+ if (body.config?.maxConcurrency !== undefined) {
699
+ const mc = Number(body.config.maxConcurrency);
700
+ if (!Number.isFinite(mc) || mc <= 0) {
701
+ throw new HttpError(400, "INVALID_REQUEST", "config.maxConcurrency must be a positive number");
702
+ }
703
+ }
704
+ const workflowPath = resolveWorkflowPath(body.workflowPath, rootDir);
705
+ const workflow = await runPromise(loadWorkflowEffect(workflowPath));
706
+ ensureSmithersTables(workflow.db);
707
+ const sameDb = isSameDb(serverDb, workflow.db);
708
+ const abort = new AbortController();
709
+ if (body.resume && !body.runId) {
710
+ throw new HttpError(400, "RUN_ID_REQUIRED", "runId is required when resume is true");
711
+ }
712
+ const runId = body.runId ?? crypto.randomUUID();
713
+ const adapter = new SmithersDb(workflow.db);
714
+ const existing = await adapter.getRun(runId);
715
+ if (body.resume && existing && isRunHeartbeatFresh(existing)) {
716
+ return sendJson(res, 200, { runId, status: "running" });
717
+ }
718
+ if (existing && !body.resume) {
719
+ throw new HttpError(409, "RUN_ALREADY_EXISTS", "Run id already exists");
720
+ }
721
+ if (body.resume && !existing) {
722
+ throw new HttpError(404, "RUN_NOT_FOUND", "Run id does not exist");
723
+ }
724
+ const mirrorAdapter = serverAdapter && !sameDb ? serverAdapter : null;
725
+ const effectiveRoot = rootDir ?? dirname(workflowPath);
726
+ const workflowName = basename(workflowPath, ".tsx");
727
+ const mirrorOnProgress = buildMirrorOnProgress(mirrorAdapter, runId, workflowName, workflowPath, JSON.stringify({
728
+ maxConcurrency: body.config?.maxConcurrency ?? null,
729
+ rootDir: effectiveRoot,
730
+ allowNetwork,
731
+ }));
732
+ const record = {
733
+ workflow,
734
+ abort,
735
+ workflowPath,
736
+ cleanupTimer: null,
737
+ };
738
+ runs.set(runId, record);
739
+ logInfo("accepted run request", {
740
+ runId,
741
+ workflowPath,
742
+ resume: Boolean(body.resume),
743
+ sameDb,
744
+ }, "server:run");
745
+ Effect.runPromise(runWorkflow(workflow, {
746
+ runId,
747
+ input: body.input ?? {},
748
+ resume: body.resume ?? false,
749
+ maxConcurrency: body.config?.maxConcurrency,
750
+ signal: abort.signal,
751
+ workflowPath,
752
+ rootDir: effectiveRoot,
753
+ allowNetwork,
754
+ onProgress: mirrorOnProgress,
755
+ }))
756
+ .then((result) => {
757
+ finalizeRunRecord(result.runId, result.status, Boolean(serverDb));
758
+ })
759
+ .catch((err) => {
760
+ logError("server run execution failed", {
761
+ runId,
762
+ workflowPath,
763
+ error: err instanceof Error ? err.message : String(err),
764
+ }, "server:run");
765
+ clearRunCleanupTimer(runs.get(runId));
766
+ runs.delete(runId);
767
+ });
768
+ sendJson(res, 200, { runId });
769
+ return;
770
+ }
771
+ const resumeMatch = url.pathname.match(/^\/v1\/runs\/([^/]+)\/resume$/);
772
+ if (method === "POST" && resumeMatch) {
773
+ const runId = resumeMatch[1];
774
+ const body = await readBody(req, maxBodyBytes, DEFAULT_MAX_BODY_JSON_DEPTH);
775
+ if (!body?.workflowPath || typeof body.workflowPath !== "string") {
776
+ throw new HttpError(400, "INVALID_REQUEST", "workflowPath must be a string");
777
+ }
778
+ if (body.input !== undefined &&
779
+ (body.input === null ||
780
+ typeof body.input !== "object" ||
781
+ Array.isArray(body.input))) {
782
+ throw new HttpError(400, "INVALID_REQUEST", "input must be a JSON object");
783
+ }
784
+ if (body.config?.maxConcurrency !== undefined) {
785
+ const mc = Number(body.config.maxConcurrency);
786
+ if (!Number.isFinite(mc) || mc <= 0) {
787
+ throw new HttpError(400, "INVALID_REQUEST", "config.maxConcurrency must be a positive number");
788
+ }
789
+ }
790
+ const workflowPath = resolveWorkflowPath(body.workflowPath, rootDir);
791
+ const workflow = await runPromise(loadWorkflowEffect(workflowPath));
792
+ ensureSmithersTables(workflow.db);
793
+ const sameDb = isSameDb(serverDb, workflow.db);
794
+ const adapter = new SmithersDb(workflow.db);
795
+ const existing = await adapter.getRun(runId);
796
+ if (!existing) {
797
+ throw new HttpError(404, "RUN_NOT_FOUND", "Run id does not exist");
798
+ }
799
+ if (isRunHeartbeatFresh(existing)) {
800
+ return sendJson(res, 200, { runId, status: "running" });
801
+ }
802
+ const abort = new AbortController();
803
+ const record = {
804
+ workflow,
805
+ abort,
806
+ workflowPath,
807
+ cleanupTimer: null,
808
+ };
809
+ runs.set(runId, record);
810
+ logInfo("accepted run resume request", {
811
+ runId,
812
+ workflowPath,
813
+ sameDb,
814
+ }, "server:resume");
815
+ const mirrorAdapter = serverAdapter && !sameDb ? serverAdapter : null;
816
+ const effectiveRoot = rootDir ?? dirname(workflowPath);
817
+ const workflowName = basename(workflowPath, ".tsx");
818
+ const mirrorOnProgress = buildMirrorOnProgress(mirrorAdapter, runId, workflowName, workflowPath, JSON.stringify({
819
+ maxConcurrency: body.config?.maxConcurrency ?? null,
820
+ rootDir: effectiveRoot,
821
+ allowNetwork,
822
+ }));
823
+ Effect.runPromise(runWorkflow(workflow, {
824
+ runId,
825
+ input: body.input ?? {},
826
+ resume: true,
827
+ maxConcurrency: body.config?.maxConcurrency,
828
+ signal: abort.signal,
829
+ workflowPath,
830
+ rootDir: effectiveRoot,
831
+ allowNetwork,
832
+ onProgress: mirrorOnProgress,
833
+ }))
834
+ .then((result) => {
835
+ finalizeRunRecord(runId, result.status, Boolean(serverDb));
836
+ })
837
+ .catch((err) => {
838
+ logError("server resume execution failed", {
839
+ runId,
840
+ workflowPath,
841
+ error: err instanceof Error ? err.message : String(err),
842
+ }, "server:resume");
843
+ clearRunCleanupTimer(runs.get(runId));
844
+ runs.delete(runId);
845
+ });
846
+ sendJson(res, 200, { runId });
847
+ return;
848
+ }
849
+ const cancelMatch = url.pathname.match(/^\/v1\/runs\/([^/]+)\/cancel$/);
850
+ if (method === "POST" && cancelMatch) {
851
+ const runId = cancelMatch[1];
852
+ const adapter = adapterForRun(runId);
853
+ const record = runs.get(runId);
854
+ if (!adapter) {
855
+ return sendJson(res, 404, {
856
+ error: { code: "NOT_FOUND", message: "Run not found" },
857
+ });
858
+ }
859
+ const run = await adapter.getRun(runId);
860
+ if (!run) {
861
+ return sendJson(res, 404, {
862
+ error: { code: "NOT_FOUND", message: "Run not found" },
863
+ });
864
+ }
865
+ if (run.status === "waiting-approval" || run.status === "waiting-timer") {
866
+ const cancelledAtMs = nowMs();
867
+ const cancelEvent = {
868
+ type: "RunCancelled",
869
+ runId,
870
+ timestampMs: cancelledAtMs,
871
+ };
872
+ if (run.status === "waiting-timer") {
873
+ const nodes = await adapter.listNodes(runId);
874
+ for (const node of nodes.filter((entry) => entry.state === "waiting-timer")) {
875
+ const attempts = await runPromise(adapter.listAttempts(runId, node.nodeId, node.iteration ?? 0));
876
+ const waitingAttempt = attempts.find((attempt) => attempt.state === "waiting-timer");
877
+ if (waitingAttempt) {
878
+ await adapter.updateAttempt(runId, node.nodeId, node.iteration ?? 0, waitingAttempt.attempt, { state: "cancelled", finishedAtMs: cancelledAtMs });
879
+ await adapter.insertNode({
880
+ runId,
881
+ nodeId: node.nodeId,
882
+ iteration: node.iteration ?? 0,
883
+ state: "cancelled",
884
+ lastAttempt: waitingAttempt.attempt,
885
+ updatedAtMs: cancelledAtMs,
886
+ outputTable: node.outputTable ?? "",
887
+ label: node.label ?? null,
888
+ });
889
+ const timerCancelledEvent = {
890
+ type: "TimerCancelled",
891
+ runId,
892
+ timerId: node.nodeId,
893
+ timestampMs: cancelledAtMs,
894
+ };
895
+ await adapter.insertEventWithNextSeq({
896
+ runId,
897
+ timestampMs: cancelledAtMs,
898
+ type: "TimerCancelled",
899
+ payloadJson: JSON.stringify(timerCancelledEvent),
900
+ });
901
+ await runPromise(trackEvent(timerCancelledEvent));
902
+ }
903
+ }
904
+ }
905
+ logInfo("cancelling paused run", {
906
+ runId,
907
+ status: run.status,
908
+ }, "server:cancel");
909
+ await adapter.updateRun(runId, {
910
+ status: "cancelled",
911
+ finishedAtMs: cancelledAtMs,
912
+ heartbeatAtMs: null,
913
+ runtimeOwnerId: null,
914
+ cancelRequestedAtMs: null,
915
+ });
916
+ await adapter.insertEventWithNextSeq({
917
+ runId,
918
+ timestampMs: cancelledAtMs,
919
+ type: "RunCancelled",
920
+ payloadJson: JSON.stringify(cancelEvent),
921
+ });
922
+ await runPromise(trackEvent(cancelEvent));
923
+ return sendJson(res, 200, { runId });
924
+ }
925
+ if (run.status !== "running" || !isRunHeartbeatFresh(run)) {
926
+ logWarning("cancel rejected for inactive run", {
927
+ runId,
928
+ status: run.status,
929
+ heartbeatAtMs: run.heartbeatAtMs ?? null,
930
+ }, "server:cancel");
931
+ return sendJson(res, 409, {
932
+ error: { code: "RUN_NOT_ACTIVE", message: "Run is not currently active" },
933
+ });
934
+ }
935
+ logInfo("cancelling active run", {
936
+ runId,
937
+ status: run.status,
938
+ }, "server:cancel");
939
+ await adapter.requestRunCancel(runId, nowMs());
940
+ record?.abort.abort();
941
+ return sendJson(res, 200, { runId });
942
+ }
943
+ const runEventsMatch = url.pathname.match(/^\/v1\/runs\/([^/]+)\/events$/);
944
+ if (method === "GET" && runEventsMatch) {
945
+ const runId = runEventsMatch[1];
946
+ const adapter = adapterForRun(runId);
947
+ if (!adapter) {
948
+ return sendJson(res, 404, {
949
+ error: { code: "NOT_FOUND", message: "Run not found" },
950
+ });
951
+ }
952
+ const run = await adapter.getRun(runId);
953
+ if (!run) {
954
+ return sendJson(res, 404, {
955
+ error: { code: "NOT_FOUND", message: "Run not found" },
956
+ });
957
+ }
958
+ res.writeHead(200, {
959
+ "Content-Type": "text/event-stream",
960
+ "Cache-Control": "no-cache",
961
+ Connection: "keep-alive",
962
+ "X-Content-Type-Options": "nosniff",
963
+ "X-Accel-Buffering": "no",
964
+ });
965
+ res.write(`retry: 1000\n\n`);
966
+ let closed = false;
967
+ let lastSeq = parseOptionalInt(url.searchParams.get("afterSeq"), -1);
968
+ logInfo("opened run event stream", {
969
+ runId,
970
+ afterSeq: lastSeq,
971
+ }, "server:sse");
972
+ let lastHeartbeat = Date.now();
973
+ const poll = async () => {
974
+ if (closed || res.writableEnded)
975
+ return;
976
+ const events = await adapter.listEvents(runId, lastSeq, 200);
977
+ for (const ev of events) {
978
+ const seq = typeof ev.seq === "number" ? ev.seq : Number(ev.seq);
979
+ if (Number.isFinite(seq)) {
980
+ lastSeq = seq;
981
+ }
982
+ if (res.writableEnded)
983
+ break;
984
+ res.write(`event: smithers\n`);
985
+ res.write(`data: ${ev.payloadJson}\n\n`);
986
+ }
987
+ const now = Date.now();
988
+ if (now - lastHeartbeat >= DEFAULT_SSE_HEARTBEAT_MS &&
989
+ !res.writableEnded) {
990
+ res.write(`: keep-alive\n\n`);
991
+ lastHeartbeat = now;
992
+ }
993
+ const runRow = await adapter.getRun(runId);
994
+ if (runRow &&
995
+ ["finished", "failed", "cancelled", "continued"].includes(runRow.status) &&
996
+ events.length === 0) {
997
+ closed = true;
998
+ res.end();
999
+ }
1000
+ };
1001
+ req.on("close", () => {
1002
+ closed = true;
1003
+ });
1004
+ (async () => {
1005
+ try {
1006
+ while (!closed && !res.writableEnded) {
1007
+ await poll();
1008
+ await runPromise(Effect.sleep(500));
1009
+ }
1010
+ }
1011
+ catch {
1012
+ closed = true;
1013
+ if (!res.writableEnded) {
1014
+ res.end();
1015
+ }
1016
+ }
1017
+ })();
1018
+ return;
1019
+ }
1020
+ const runMatch = url.pathname.match(/^\/v1\/runs\/([^/]+)$/);
1021
+ if (method === "GET" && runMatch) {
1022
+ const runId = runMatch[1];
1023
+ const adapter = adapterForRun(runId);
1024
+ if (!adapter) {
1025
+ return sendJson(res, 404, {
1026
+ error: { code: "NOT_FOUND", message: "Run not found" },
1027
+ });
1028
+ }
1029
+ const run = await adapter.getRun(runId);
1030
+ if (!run) {
1031
+ return sendJson(res, 404, {
1032
+ error: { code: "NOT_FOUND", message: "Run not found" },
1033
+ });
1034
+ }
1035
+ const summary = await adapter.countNodesByState(runId);
1036
+ const runState = await computeRunStateFromRow(adapter, run).catch(() => undefined);
1037
+ return sendJson(res, 200, {
1038
+ runId,
1039
+ workflowName: run?.workflowName ?? "workflow",
1040
+ status: run?.status ?? "unknown",
1041
+ startedAtMs: run?.startedAtMs ?? null,
1042
+ finishedAtMs: run?.finishedAtMs ?? null,
1043
+ ...(runState ? { runState } : {}),
1044
+ summary: summary.reduce((acc, row) => {
1045
+ acc[row.state] = row.count;
1046
+ return acc;
1047
+ }, {}),
1048
+ });
1049
+ }
1050
+ const framesMatch = url.pathname.match(/^\/v1\/runs\/([^/]+)\/frames$/);
1051
+ if (method === "GET" && framesMatch) {
1052
+ const runId = framesMatch[1];
1053
+ const adapter = adapterForRun(runId);
1054
+ if (!adapter) {
1055
+ return sendJson(res, 404, {
1056
+ error: { code: "NOT_FOUND", message: "Run not found" },
1057
+ });
1058
+ }
1059
+ const run = await adapter.getRun(runId);
1060
+ if (!run) {
1061
+ return sendJson(res, 404, {
1062
+ error: { code: "NOT_FOUND", message: "Run not found" },
1063
+ });
1064
+ }
1065
+ const limit = parsePositiveInt(url.searchParams.get("limit"), 50);
1066
+ const after = url.searchParams.get("afterFrameNo");
1067
+ const afterFrameNo = after ? parseOptionalInt(after, -1) : undefined;
1068
+ const frames = await adapter.listFrames(runId, limit, afterFrameNo !== undefined && afterFrameNo >= 0
1069
+ ? afterFrameNo
1070
+ : undefined);
1071
+ return sendJson(res, 200, frames);
1072
+ }
1073
+ const approveMatch = url.pathname.match(/^\/v1\/runs\/([^/]+)\/nodes\/([^/]+)\/approve$/);
1074
+ if (method === "POST" && approveMatch) {
1075
+ const runId = approveMatch[1];
1076
+ const nodeId = approveMatch[2];
1077
+ const body = await readBody(req, maxBodyBytes, DEFAULT_MAX_BODY_JSON_DEPTH);
1078
+ const adapter = adapterForRun(runId);
1079
+ if (!adapter)
1080
+ return sendJson(res, 404, {
1081
+ error: { code: "NOT_FOUND", message: "Run not found" },
1082
+ });
1083
+ const run = await adapter.getRun(runId);
1084
+ if (!run)
1085
+ return sendJson(res, 404, {
1086
+ error: { code: "NOT_FOUND", message: "Run not found" },
1087
+ });
1088
+ await Effect.runPromise(approveNode(adapter, runId, nodeId, body.iteration ?? 0, body.note, body.decidedBy));
1089
+ return sendJson(res, 200, { runId });
1090
+ }
1091
+ const denyMatch = url.pathname.match(/^\/v1\/runs\/([^/]+)\/nodes\/([^/]+)\/deny$/);
1092
+ if (method === "POST" && denyMatch) {
1093
+ const runId = denyMatch[1];
1094
+ const nodeId = denyMatch[2];
1095
+ const body = await readBody(req, maxBodyBytes, DEFAULT_MAX_BODY_JSON_DEPTH);
1096
+ const adapter = adapterForRun(runId);
1097
+ if (!adapter)
1098
+ return sendJson(res, 404, {
1099
+ error: { code: "NOT_FOUND", message: "Run not found" },
1100
+ });
1101
+ const run = await adapter.getRun(runId);
1102
+ if (!run)
1103
+ return sendJson(res, 404, {
1104
+ error: { code: "NOT_FOUND", message: "Run not found" },
1105
+ });
1106
+ await Effect.runPromise(denyNode(adapter, runId, nodeId, body.iteration ?? 0, body.note, body.decidedBy));
1107
+ return sendJson(res, 200, { runId });
1108
+ }
1109
+ const signalMatch = url.pathname.match(/^\/v1\/runs\/([^/]+)\/signals\/([^/]+)$/) ??
1110
+ url.pathname.match(/^\/signal\/([^/]+)\/([^/]+)$/);
1111
+ if (method === "POST" && signalMatch) {
1112
+ const runId = signalMatch[1];
1113
+ const signalName = decodeURIComponent(signalMatch[2]);
1114
+ const body = await readBody(req, maxBodyBytes, DEFAULT_MAX_BODY_JSON_DEPTH);
1115
+ const adapter = adapterForRun(runId);
1116
+ if (!adapter)
1117
+ return sendJson(res, 404, {
1118
+ error: { code: "NOT_FOUND", message: "Run not found" },
1119
+ });
1120
+ const run = await adapter.getRun(runId);
1121
+ if (!run)
1122
+ return sendJson(res, 404, {
1123
+ error: { code: "NOT_FOUND", message: "Run not found" },
1124
+ });
1125
+ const delivered = await Effect.runPromise(signalRun(adapter, runId, signalName, body.data ?? {}, {
1126
+ correlationId: typeof body.correlationId === "string"
1127
+ ? body.correlationId
1128
+ : undefined,
1129
+ receivedBy: typeof body.receivedBy === "string" ? body.receivedBy : undefined,
1130
+ }));
1131
+ return sendJson(res, 200, delivered);
1132
+ }
1133
+ if (method === "GET" &&
1134
+ (url.pathname === "/v1/approval/list" ||
1135
+ url.pathname === "/v1/approvals" ||
1136
+ url.pathname === "/approval/list" ||
1137
+ url.pathname === "/approvals")) {
1138
+ if (!serverAdapter) {
1139
+ return sendJson(res, 400, {
1140
+ error: {
1141
+ code: "DB_NOT_CONFIGURED",
1142
+ message: "Server DB not configured",
1143
+ },
1144
+ });
1145
+ }
1146
+ const approvals = await runPromise(Effect.gen(function* () {
1147
+ const rows = yield* serverAdapter.listAllPendingApprovals();
1148
+ const now = nowMs();
1149
+ const mapped = rows.map((row) => {
1150
+ const requestedAtMs = row.requestedAtMs ?? null;
1151
+ return {
1152
+ runId: row.runId,
1153
+ nodeId: row.nodeId,
1154
+ iteration: row.iteration ?? 0,
1155
+ workflowName: row.workflowName ?? "workflow",
1156
+ runStatus: row.runStatus ?? null,
1157
+ label: row.nodeLabel ?? row.nodeId,
1158
+ requestTitle: row.nodeLabel ?? row.nodeId,
1159
+ requestSummary: row.note ?? null,
1160
+ requestedAtMs,
1161
+ waitingMs: typeof requestedAtMs === "number" && Number.isFinite(requestedAtMs)
1162
+ ? Math.max(0, now - requestedAtMs)
1163
+ : 0,
1164
+ note: row.note ?? null,
1165
+ decidedBy: row.decidedBy ?? null,
1166
+ };
1167
+ });
1168
+ yield* Effect.logDebug("listed pending approvals").pipe(Effect.annotateLogs({ pendingCount: mapped.length }));
1169
+ return mapped;
1170
+ }).pipe(Effect.withLogSpan("api:approvals:list")));
1171
+ return sendJson(res, 200, { approvals });
1172
+ }
1173
+ if (method === "GET" && url.pathname === "/v1/runs") {
1174
+ if (!serverAdapter) {
1175
+ return sendJson(res, 400, {
1176
+ error: {
1177
+ code: "DB_NOT_CONFIGURED",
1178
+ message: "Server DB not configured",
1179
+ },
1180
+ });
1181
+ }
1182
+ const limit = parsePositiveInt(url.searchParams.get("limit"), 50);
1183
+ const status = url.searchParams.get("status") ?? undefined;
1184
+ const runs = await serverAdapter.listRuns(limit, status);
1185
+ return sendJson(res, 200, await Promise.all(runs.map((run) => withRunState(serverAdapter, run))));
1186
+ }
1187
+ sendJson(res, 404, {
1188
+ error: { code: "NOT_FOUND", message: "Route not found" },
1189
+ });
1190
+ }
1191
+ catch (err) {
1192
+ if (err instanceof HttpError) {
1193
+ sendJson(res, err.status, {
1194
+ error: { code: err.code, message: err.message, details: err.details },
1195
+ });
1196
+ return;
1197
+ }
1198
+ sendJson(res, 500, {
1199
+ error: {
1200
+ code: "SERVER_ERROR",
1201
+ message: err?.message ?? "Unknown error",
1202
+ },
1203
+ });
1204
+ }
1205
+ finally {
1206
+ await recordHttpRequestMetricsSafely(requestMethod, requestPathname, res.statusCode || 500, performance.now() - requestStart);
1207
+ }
1208
+ });
1209
+ server.on("close", () => {
1210
+ logInfo("stopping smithers server", {
1211
+ activeRuns: runs.size,
1212
+ }, "server:stop");
1213
+ for (const [runId, record] of runs) {
1214
+ try {
1215
+ record.abort.abort();
1216
+ }
1217
+ catch { }
1218
+ clearRunCleanupTimer(record);
1219
+ runs.delete(runId);
1220
+ }
1221
+ });
1222
+ server.listen(port);
1223
+ return server;
1224
+ }
1225
+ /**
1226
+ * @param {ServerOptions} [opts]
1227
+ */
1228
+ export function startServerEffect(opts = {}) {
1229
+ return Effect.sync(() => startServerInternal(opts)).pipe(Effect.annotateLogs({
1230
+ port: opts.port ?? 7331,
1231
+ rootDir: opts.rootDir ?? "",
1232
+ allowNetwork: Boolean(opts.allowNetwork),
1233
+ }), Effect.withLogSpan("server:start"));
1234
+ }
1235
+ /**
1236
+ * @param {ServerOptions} [opts]
1237
+ */
1238
+ export function startServer(opts = {}) {
1239
+ return runSync(startServerEffect(opts));
1240
+ }