@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/LICENSE +21 -0
- package/package.json +46 -0
- package/src/ConnectRequest.ts +17 -0
- package/src/EventFrame.ts +7 -0
- package/src/GatewayAuthConfig.ts +26 -0
- package/src/GatewayDefaults.ts +3 -0
- package/src/GatewayOptions.ts +13 -0
- package/src/GatewayTokenGrant.ts +5 -0
- package/src/GatewayWebhookConfig.ts +10 -0
- package/src/GatewayWebhookRunConfig.ts +4 -0
- package/src/GatewayWebhookSignalConfig.ts +6 -0
- package/src/HelloResponse.ts +18 -0
- package/src/RequestFrame.ts +6 -0
- package/src/ResponseFrame.ts +10 -0
- package/src/ServeOptions.ts +11 -0
- package/src/ServerOptions.ts +8 -0
- package/src/gateway.js +3402 -0
- package/src/gatewayRoutes/DiffSummary.ts +6 -0
- package/src/gatewayRoutes/GetNodeDiffRouteResult.ts +23 -0
- package/src/gatewayRoutes/NODE_OUTPUT_MAX_BYTES.js +1 -0
- package/src/gatewayRoutes/NODE_OUTPUT_WARN_BYTES.js +1 -0
- package/src/gatewayRoutes/NodeOutputResponse.ts +22 -0
- package/src/gatewayRoutes/NodeOutputRouteError.js +14 -0
- package/src/gatewayRoutes/getDevToolsSnapshot.js +428 -0
- package/src/gatewayRoutes/getNodeDiff.js +609 -0
- package/src/gatewayRoutes/getNodeOutput.js +504 -0
- package/src/gatewayRoutes/jumpToFrame.js +84 -0
- package/src/gatewayRoutes/streamDevTools.js +525 -0
- package/src/index.d.ts +953 -0
- package/src/index.js +1240 -0
- package/src/serve.js +315 -0
- package/src/smithersRuntime.js +63 -0
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
|
+
}
|