@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/serve.js ADDED
@@ -0,0 +1,315 @@
1
+ import { Hono } from "hono";
2
+ import { streamSSE } from "hono/streaming";
3
+ import { Effect, Metric } from "effect";
4
+ import { SmithersDb } from "@smithers-orchestrator/db/adapter";
5
+ import { approveNode, denyNode } from "@smithers-orchestrator/engine/approvals";
6
+ import { isRunHeartbeatFresh } from "@smithers-orchestrator/engine";
7
+ import { nowMs } from "@smithers-orchestrator/scheduler/nowMs";
8
+ import { prometheusContentType, renderPrometheusMetrics, } from "@smithers-orchestrator/observability";
9
+ import { logWarning } from "@smithers-orchestrator/observability/logging";
10
+ import { runPromise } from "./smithersRuntime.js";
11
+ import { httpRequests, httpRequestDuration, trackEvent } from "@smithers-orchestrator/observability/metrics";
12
+ /** @typedef {import("./ServeOptions.js").ServeOptions} ServeOptions */
13
+
14
+ class HttpError extends Error {
15
+ status;
16
+ code;
17
+ /**
18
+ * @param {number} status
19
+ * @param {HttpErrorCode} code
20
+ * @param {string} message
21
+ */
22
+ constructor(status, code, message) {
23
+ super(message);
24
+ this.status = status;
25
+ this.code = code;
26
+ }
27
+ }
28
+ /**
29
+ * @template A
30
+ * @param {A} metric
31
+ * @param {Record<string, string>} tags
32
+ * @returns {A}
33
+ */
34
+ function taggedMetric(metric, tags) {
35
+ let tagged = metric;
36
+ for (const [key, value] of Object.entries(tags)) {
37
+ tagged = Metric.tagged(tagged, key, value);
38
+ }
39
+ return tagged;
40
+ }
41
+ /**
42
+ * @param {string} pathname
43
+ * @returns {string}
44
+ */
45
+ function normalizeHttpMetricRoute(pathname) {
46
+ if (pathname === "/"
47
+ || pathname === "/health"
48
+ || pathname === "/events"
49
+ || pathname === "/frames"
50
+ || pathname === "/cancel"
51
+ || pathname === "/metrics") {
52
+ return pathname;
53
+ }
54
+ if (/^\/approve\/[^/]+$/.test(pathname))
55
+ return "/approve/:nodeId";
56
+ if (/^\/deny\/[^/]+$/.test(pathname))
57
+ return "/deny/:nodeId";
58
+ return pathname;
59
+ }
60
+ /**
61
+ * @param {number} statusCode
62
+ * @returns {string}
63
+ */
64
+ function statusClass(statusCode) {
65
+ const normalized = Number.isFinite(statusCode) && statusCode > 0 ? statusCode : 500;
66
+ return `${Math.floor(normalized / 100)}xx`;
67
+ }
68
+ /**
69
+ * @param {string} method
70
+ * @param {string} pathname
71
+ * @param {number} statusCode
72
+ * @param {number} durationMs
73
+ */
74
+ function recordHttpRequestMetrics(method, pathname, statusCode, durationMs) {
75
+ const tags = {
76
+ method: method.toUpperCase(),
77
+ route: normalizeHttpMetricRoute(pathname),
78
+ status_code: String(statusCode),
79
+ status_class: statusClass(statusCode),
80
+ };
81
+ return Effect.all([
82
+ Metric.increment(taggedMetric(httpRequests, tags)),
83
+ Metric.update(taggedMetric(httpRequestDuration, tags), durationMs),
84
+ ], { discard: true });
85
+ }
86
+ /**
87
+ * @param {string} method
88
+ * @param {string} pathname
89
+ * @param {number} statusCode
90
+ * @param {number} durationMs
91
+ */
92
+ async function recordHttpRequestMetricsSafely(method, pathname, statusCode, durationMs) {
93
+ try {
94
+ await runPromise(recordHttpRequestMetrics(method, pathname, statusCode, durationMs));
95
+ }
96
+ catch (error) {
97
+ logWarning("failed to record serve http metrics", {
98
+ method: method.toUpperCase(),
99
+ pathname,
100
+ statusCode,
101
+ error: error instanceof Error ? error.message : String(error),
102
+ }, "serve:metrics");
103
+ }
104
+ }
105
+ /**
106
+ * @param {ServeOptions} opts
107
+ */
108
+ export function createServeApp(opts) {
109
+ const { adapter, runId, abort, authToken, metrics: metricsEnabled = true } = opts;
110
+ const app = new Hono();
111
+ // Health — no auth
112
+ app.get("/health", (c) => c.json({ ok: true }));
113
+ // Auth middleware — applied after /health
114
+ if (authToken) {
115
+ app.use("*", async (c, next) => {
116
+ // /health already matched above, so this won't fire for it
117
+ const smithersKey = c.req.header("x-smithers-key");
118
+ if (smithersKey === authToken)
119
+ return next();
120
+ const authHeader = c.req.header("authorization");
121
+ if (authHeader) {
122
+ const token = authHeader.startsWith("Bearer ")
123
+ ? authHeader.slice(7)
124
+ : authHeader;
125
+ if (token === authToken)
126
+ return next();
127
+ }
128
+ return c.json({ error: { code: "UNAUTHORIZED", message: "Missing or invalid authorization token" } }, 401);
129
+ });
130
+ }
131
+ // Timing middleware
132
+ app.use("*", async (c, next) => {
133
+ const start = performance.now();
134
+ let statusCode = 500;
135
+ try {
136
+ await next();
137
+ statusCode = c.res.status;
138
+ }
139
+ catch (error) {
140
+ statusCode = error instanceof HttpError ? error.status : 500;
141
+ throw error;
142
+ }
143
+ finally {
144
+ await recordHttpRequestMetricsSafely(c.req.method, c.req.path, statusCode, performance.now() - start);
145
+ }
146
+ });
147
+ // GET / — run status
148
+ app.get("/", async (c) => {
149
+ const run = await adapter.getRun(runId);
150
+ if (!run) {
151
+ throw new HttpError(404, "RUN_NOT_FOUND", "Run not found");
152
+ }
153
+ const summary = await adapter.countNodesByState(runId);
154
+ return c.json({
155
+ runId,
156
+ workflowName: run.workflowName ?? "workflow",
157
+ status: run.status ?? "unknown",
158
+ startedAtMs: run.startedAtMs ?? null,
159
+ finishedAtMs: run.finishedAtMs ?? null,
160
+ summary: summary.reduce((acc, row) => {
161
+ acc[row.state] = row.count;
162
+ return acc;
163
+ }, {}),
164
+ });
165
+ });
166
+ // GET /events — SSE stream
167
+ app.get("/events", (c) => {
168
+ const afterSeqParam = c.req.query("afterSeq");
169
+ let lastSeq = afterSeqParam ? parseInt(afterSeqParam, 10) : -1;
170
+ if (!Number.isFinite(lastSeq))
171
+ lastSeq = -1;
172
+ return streamSSE(c, async (stream) => {
173
+ let closed = false;
174
+ // Use the abort signal from the request to detect disconnects
175
+ c.req.raw.signal.addEventListener("abort", () => {
176
+ closed = true;
177
+ });
178
+ while (!closed) {
179
+ const events = await adapter.listEvents(runId, lastSeq, 200);
180
+ for (const ev of events) {
181
+ lastSeq = ev.seq;
182
+ await stream.writeSSE({
183
+ event: "smithers",
184
+ data: ev.payloadJson,
185
+ id: String(ev.seq),
186
+ });
187
+ }
188
+ // Check if run is terminal
189
+ const runRow = await adapter.getRun(runId);
190
+ if (runRow &&
191
+ ["finished", "failed", "cancelled", "continued"].includes(runRow.status) &&
192
+ events.length === 0) {
193
+ break;
194
+ }
195
+ await new Promise((r) => setTimeout(r, 500));
196
+ }
197
+ });
198
+ });
199
+ // GET /frames
200
+ app.get("/frames", async (c) => {
201
+ const limitParam = c.req.query("limit");
202
+ const limit = limitParam ? Math.max(1, parseInt(limitParam, 10) || 50) : 50;
203
+ const afterParam = c.req.query("afterFrameNo");
204
+ const afterFrameNo = afterParam !== null && afterParam !== undefined
205
+ ? parseInt(afterParam, 10)
206
+ : undefined;
207
+ const frames = await adapter.listFrames(runId, limit, afterFrameNo !== undefined && Number.isFinite(afterFrameNo) && afterFrameNo >= 0
208
+ ? afterFrameNo
209
+ : undefined);
210
+ return c.json(frames);
211
+ });
212
+ // POST /approve/:nodeId
213
+ app.post("/approve/:nodeId", async (c) => {
214
+ const nodeId = c.req.param("nodeId");
215
+ const body = await c.req.json().catch(() => ({}));
216
+ await Effect.runPromise(approveNode(adapter, runId, nodeId, body.iteration ?? 0, body.note, body.decidedBy));
217
+ return c.json({ runId });
218
+ });
219
+ // POST /deny/:nodeId
220
+ app.post("/deny/:nodeId", async (c) => {
221
+ const nodeId = c.req.param("nodeId");
222
+ const body = await c.req.json().catch(() => ({}));
223
+ await Effect.runPromise(denyNode(adapter, runId, nodeId, body.iteration ?? 0, body.note, body.decidedBy));
224
+ return c.json({ runId });
225
+ });
226
+ // POST /cancel
227
+ app.post("/cancel", async (c) => {
228
+ const run = await adapter.getRun(runId);
229
+ if (!run) {
230
+ throw new HttpError(404, "RUN_NOT_FOUND", "Run not found");
231
+ }
232
+ if (run.status === "waiting-approval" || run.status === "waiting-timer") {
233
+ const cancelledAtMs = nowMs();
234
+ const cancelEvent = {
235
+ type: "RunCancelled",
236
+ runId,
237
+ timestampMs: cancelledAtMs,
238
+ };
239
+ if (run.status === "waiting-timer") {
240
+ const nodes = await adapter.listNodes(runId);
241
+ for (const node of nodes.filter((entry) => entry.state === "waiting-timer")) {
242
+ const attempts = await runPromise(adapter.listAttempts(runId, node.nodeId, node.iteration ?? 0));
243
+ const waitingAttempt = attempts.find((attempt) => attempt.state === "waiting-timer");
244
+ if (!waitingAttempt)
245
+ continue;
246
+ await adapter.updateAttempt(runId, node.nodeId, node.iteration ?? 0, waitingAttempt.attempt, { state: "cancelled", finishedAtMs: cancelledAtMs });
247
+ await adapter.insertNode({
248
+ runId,
249
+ nodeId: node.nodeId,
250
+ iteration: node.iteration ?? 0,
251
+ state: "cancelled",
252
+ lastAttempt: waitingAttempt.attempt,
253
+ updatedAtMs: cancelledAtMs,
254
+ outputTable: node.outputTable ?? "",
255
+ label: node.label ?? null,
256
+ });
257
+ const timerCancelledEvent = {
258
+ type: "TimerCancelled",
259
+ runId,
260
+ timerId: node.nodeId,
261
+ timestampMs: cancelledAtMs,
262
+ };
263
+ await adapter.insertEventWithNextSeq({
264
+ runId,
265
+ timestampMs: cancelledAtMs,
266
+ type: "TimerCancelled",
267
+ payloadJson: JSON.stringify(timerCancelledEvent),
268
+ });
269
+ await runPromise(trackEvent(timerCancelledEvent));
270
+ }
271
+ }
272
+ await adapter.updateRun(runId, {
273
+ status: "cancelled",
274
+ finishedAtMs: cancelledAtMs,
275
+ heartbeatAtMs: null,
276
+ runtimeOwnerId: null,
277
+ cancelRequestedAtMs: null,
278
+ });
279
+ await adapter.insertEventWithNextSeq({
280
+ runId,
281
+ timestampMs: cancelledAtMs,
282
+ type: "RunCancelled",
283
+ payloadJson: JSON.stringify(cancelEvent),
284
+ });
285
+ await runPromise(trackEvent(cancelEvent));
286
+ return c.json({ runId });
287
+ }
288
+ if (run.status !== "running" || !isRunHeartbeatFresh(run)) {
289
+ throw new HttpError(409, "RUN_NOT_ACTIVE", "Run is not currently active");
290
+ }
291
+ await adapter.requestRunCancel(runId, nowMs());
292
+ abort.abort();
293
+ return c.json({ runId });
294
+ });
295
+ // GET /metrics
296
+ if (metricsEnabled) {
297
+ app.get("/metrics", (c) => {
298
+ return c.text(renderPrometheusMetrics(), 200, {
299
+ "Content-Type": prometheusContentType,
300
+ });
301
+ });
302
+ }
303
+ // 404 catch-all
304
+ app.notFound((c) => {
305
+ return c.json({ error: { code: "NOT_FOUND", message: "Route not found" } }, 404);
306
+ });
307
+ // Error handler
308
+ app.onError((err, c) => {
309
+ if (err instanceof HttpError) {
310
+ return c.json({ error: { code: err.code, message: err.message } }, err.status);
311
+ }
312
+ return c.json({ error: { code: "SERVER_ERROR", message: err.message ?? "Unknown error" } }, 500);
313
+ });
314
+ return app;
315
+ }
@@ -0,0 +1,63 @@
1
+ import * as WorkflowEngine from "@effect/workflow/WorkflowEngine";
2
+ import { Cause, Effect, Exit, Layer, ManagedRuntime } from "effect";
3
+ import { SchedulerLive, WorkflowSessionLive } from "@smithers-orchestrator/scheduler";
4
+ import { CorrelationContextLive, MetricsServiceLive, TracingServiceLive, createSmithersRuntimeLayer, getCurrentSmithersTraceAnnotations, getCurrentSmithersTraceSpan, } from "@smithers-orchestrator/observability";
5
+ import { toSmithersError } from "@smithers-orchestrator/errors/toSmithersError";
6
+ const ObservabilityLayer = Layer.mergeAll(CorrelationContextLive, MetricsServiceLive, TracingServiceLive);
7
+ const SmithersCoreLayer = Layer.mergeAll(ObservabilityLayer, SchedulerLive.pipe(Layer.provide(ObservabilityLayer)), WorkflowSessionLive);
8
+ const SmithersWorkflowEngineLayer = Layer.suspend(() => WorkflowEngine.layerMemory);
9
+ const SmithersRuntimeLayer = Layer.mergeAll(SmithersCoreLayer, SmithersWorkflowEngineLayer, createSmithersRuntimeLayer()).pipe(Layer.orDie);
10
+ const runtime = ManagedRuntime.make(SmithersRuntimeLayer);
11
+ /**
12
+ * @template A, E, R
13
+ * @param {Effect.Effect<A, E, R>} effect
14
+ */
15
+ function decorate(effect) {
16
+ let program = effect.pipe(Effect.annotateLogs("service", "smithers"), Effect.withTracerEnabled(true));
17
+ const traceAnnotations = getCurrentSmithersTraceAnnotations();
18
+ if (traceAnnotations) {
19
+ program = program.pipe(Effect.annotateLogs(traceAnnotations));
20
+ }
21
+ const parentSpan = getCurrentSmithersTraceSpan();
22
+ if (parentSpan) {
23
+ program = program.pipe(Effect.withParentSpan(parentSpan));
24
+ }
25
+ return program;
26
+ }
27
+ /**
28
+ * @param {unknown} cause
29
+ * @returns {SmithersError}
30
+ */
31
+ function normalizeRejection(cause) {
32
+ return toSmithersError(cause);
33
+ }
34
+ /**
35
+ * @template A, E, R
36
+ * @param {Effect.Effect<A, E, R>} effect
37
+ * @param {{ signal?: AbortSignal }} [options]
38
+ */
39
+ export async function runPromise(effect, options) {
40
+ const exit = await runtime.runPromiseExit(decorate(effect), options);
41
+ if (Exit.isSuccess(exit)) {
42
+ return exit.value;
43
+ }
44
+ const failure = Cause.failureOption(exit.cause);
45
+ if (failure._tag === "Some") {
46
+ throw normalizeRejection(failure.value);
47
+ }
48
+ throw normalizeRejection(Cause.squash(exit.cause));
49
+ }
50
+ /**
51
+ * @template A, E, R
52
+ * @param {Effect.Effect<A, E, R>} effect
53
+ */
54
+ export function runFork(effect) {
55
+ return runtime.runFork(decorate(effect));
56
+ }
57
+ /**
58
+ * @template A, E, R
59
+ * @param {Effect.Effect<A, E, R>} effect
60
+ */
61
+ export function runSync(effect) {
62
+ return runtime.runSync(decorate(effect));
63
+ }