dsh-workflow-map 0.0.0-stage → 0.3.2

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/route.js ADDED
@@ -0,0 +1,621 @@
1
+ /**
2
+ * dsh-workflow-map — host HTTP view route + the steer channel.
3
+ *
4
+ * WHY AN HTTP ROUTE WHEN THE VIEW ALREADY REACHES THE PAGE. The projection's `wire` block is
5
+ * pushed through the session stream, so the browser already holds this view. The route is not a
6
+ * second delivery path for the panel; it exists for the things a stream cannot do:
7
+ * * a POLLABLE, revisioned read (`rev`), for a leaf widget that must be able to re-read its
8
+ * state without subscribing to the whole session, and
9
+ * * an OUT-OF-BROWSER probe: `Invoke-WebRequest .../dsh-workflow/state?sessionId=…` is the
10
+ * only acceptance check that does not need the Web UI open.
11
+ *
12
+ * THREE ENDPOINTS, ONE PREFIX. `/state` is the read (GET/HEAD, ETag-conditional). `/steer` is one
13
+ * write (POST): the panel's hot-inject button. `/unsteer` is the OTHER write (POST): the panel's
14
+ * "Revoke" button, which takes back a `user/message` that is still queued in the addressed session's
15
+ * inbox. Both writes touch exactly one thing — one message in, or one message out — and neither
16
+ * ever cancels a turn. The rationale for each (why `steer` and not `followup`/`inject`/`cancel`;
17
+ * why a revoke is `inbox.remove` and not a second steer) sits on `handleSteerRequest` and
18
+ * `handleUnsteerRequest` below.
19
+ *
20
+ * ONE SHAPE, ONE MERGE. `composeView(view, plan)` is the single join point between the folded
21
+ * view and the PAE plan section: ../lib/index.js calls it on the wire path (`wire.view`), and
22
+ * the HTTP source in the same file calls it again for this route. A field added to the plan
23
+ * merge therefore cannot appear on one surface and not the other. It is written to be
24
+ * IDEMPOTENT — composing an already-composed view with its own `plan` section returns an equal
25
+ * view — because that is exactly what the route does.
26
+ *
27
+ * ETAG. FNV-1a over the response body plus the body's byte length. The body IS the panel's
28
+ * state, so any change to it changes the tag, and an unchanged poll costs one 304 with no body.
29
+ * FNV-1a rather than sha1 because this is a cache validator, not a security boundary: paying a
30
+ * crypto hash of the whole view on every poll would be the wrong trade.
31
+ *
32
+ * FAILURE ISOLATION. The host answers a thrown route exception itself, but with a non-JSON body,
33
+ * so every path below is wrapped and every outcome — including "the view source blew up" — is
34
+ * converted into `{ok:false,error}` before the host sees it. Nothing here may throw into HTTP.
35
+ * WHERE THE WRITES ARE. `/state` reads only what its `snapshot` dependency returns; `/steer`
36
+ * calls only its `steer` dependency. No clock, no filesystem, no environment of its own: reading
37
+ * `.pae` remains 's single reader, reached through the view dependency.
38
+ */
39
+ import { applyPlanToView } from "./orchestrator.mjs";
40
+
41
+ /** The one path this plugin claims on the host's shared web server. */
42
+ export const ROUTE_PREFIX = "/dsh-workflow";
43
+ export const STATE_ENDPOINT = "/state";
44
+ export const STATE_PATH = `${ROUTE_PREFIX}${STATE_ENDPOINT}`;
45
+ export const STEER_ENDPOINT = "/steer";
46
+ export const STEER_PATH = `${ROUTE_PREFIX}${STEER_ENDPOINT}`;
47
+ export const UNSTEER_ENDPOINT = "/unsteer";
48
+ export const UNSTEER_PATH = `${ROUTE_PREFIX}${UNSTEER_ENDPOINT}`;
49
+ /** Version of the HTTP surface (body + status vocabulary). Bump when the contract changes. */
50
+ export const ROUTE_VERSION = 1;
51
+
52
+ /** Query parameter carrying the session id. */
53
+ export const SESSION_PARAM = "sessionId";
54
+
55
+ const JSON_TYPE = "application/json; charset=utf-8";
56
+ const ALLOW = "GET, HEAD, POST, OPTIONS";
57
+ const MAX_ERROR_TEXT = 300;
58
+
59
+ const isObject = (value) => value !== null && typeof value === "object";
60
+ const isRecord = (value) => isObject(value) && !Array.isArray(value);
61
+ const isFiniteNumber = (value) => typeof value === "number" && Number.isFinite(value);
62
+
63
+ /** How much text one steer request may carry. The step's own bound: 2000 characters.
64
+ * Beyond it the host answers 413 rather than silently truncating — a half-injected prompt
65
+ * would be worse than a refused one, and this is the only write path the plugin owns. */
66
+ export const STEER_TEXT_LIMIT = 2000;
67
+
68
+ /** How many bytes a steer request BODY may be. 64 KiB is far above 2000 characters in any
69
+ * encoding, so the real limit is STEER_TEXT_LIMIT; this one exists so a hostile or broken
70
+ * client cannot make the host buffer an unbounded body. */
71
+ const STEER_BODY_LIMIT = 64 * 1024;
72
+ // ---------------------------------------------------------------------------
73
+ // the shared shape: view ⊕ plan
74
+ // ---------------------------------------------------------------------------
75
+
76
+ /**
77
+ * Merge the PAE plan into a wire view — the one place both surfaces go through.
78
+ *
79
+ * `lib/index.js` passes the plan it just read from `<cwd>/.pae/<sessionId>/orchestrator.json`;
80
+ * the HTTP path passes the plan section the projection already merged (the view it reads from
81
+ * the snapshot has one). Both resolve to the same call, and the second one is a no-op by
82
+ * construction: `applyPlanToView` only patches fields that differ, so re-applying an already
83
+ * merged plan leaves an equal view. That property is asserted in tools/probe-route.mjs rather
84
+ * than assumed, because it is what makes "same shape on both surfaces" true at runtime.
85
+ *
86
+ * @param {unknown} view - a wire view (possibly without a plan section).
87
+ * @param {unknown} [plan] - a normalized plan section, or null/undefined for "none".
88
+ * @returns {object} the composed view; the inputs are never mutated.
89
+ */
90
+ export function composeView(view, plan) {
91
+ if (!isRecord(view)) return view;
92
+ const supplied = isRecord(plan) ? plan : null;
93
+ // Fall back to the section the view already carries, so the two call sites share one entry
94
+ // point instead of one of them having to know how the other stored it.
95
+ const section = supplied ?? (isRecord(view.plan) ? view.plan : null);
96
+ return applyPlanToView(view, section);
97
+ }
98
+
99
+ // ---------------------------------------------------------------------------
100
+ // cache validator
101
+ // ---------------------------------------------------------------------------
102
+
103
+ /**
104
+ * FNV-1a (32-bit) over bytes.
105
+ *
106
+ * `Math.imul` keeps the multiply in 32-bit space: the plain `hash * prime` form loses precision
107
+ * above 2^53 and would make the tag depend on the platform's float rounding.
108
+ *
109
+ * @param {Uint8Array} bytes
110
+ * @returns {number} unsigned 32-bit hash
111
+ */
112
+ export function fnv1a32(bytes) {
113
+ let hash = 0x811c9dc5;
114
+ for (let index = 0; index < bytes.length; index += 1) {
115
+ hash ^= bytes[index];
116
+ hash = Math.imul(hash, 0x01000193) >>> 0;
117
+ }
118
+ return hash >>> 0;
119
+ }
120
+
121
+ /**
122
+ * A validator for one response body. The byte length rides along so two different bodies with
123
+ * the same 32-bit hash (rare, but that is what a hash is) still get different tags.
124
+ *
125
+ * @param {Uint8Array} bytes
126
+ * @returns {string} a quoted, strong ETag
127
+ */
128
+ export function etagFor(bytes) {
129
+ return `"wfm${ROUTE_VERSION}-${fnv1a32(bytes).toString(16).padStart(8, "0")}-${bytes.length}"`;
130
+ }
131
+
132
+ /**
133
+ * Weak comparison of an `If-None-Match` header against one tag (RFC 9110 §13.1.2 — `If-None-Match`
134
+ * uses the weak comparison function, so `W/"x"` matches `"x"`, and `*` matches any current
135
+ * representation).
136
+ *
137
+ * @param {unknown} header - `req.headers["if-none-match"]` (string here; arrays are tolerated).
138
+ * @param {string} etag
139
+ * @returns {boolean}
140
+ */
141
+ export function etagMatches(header, etag) {
142
+ if (typeof etag !== "string" || etag === "") return false;
143
+ const values = Array.isArray(header) ? header : typeof header === "string" ? header.split(",") : [];
144
+ for (const value of values) {
145
+ const candidate = String(value).trim();
146
+ if (candidate === "") continue;
147
+ if (candidate === "*") return true;
148
+ if (candidate === etag) return true;
149
+ if (candidate.startsWith("W/") && candidate.slice(2).trim() === etag) return true;
150
+ }
151
+ return false;
152
+ }
153
+
154
+ // ---------------------------------------------------------------------------
155
+ // response helpers
156
+ // ---------------------------------------------------------------------------
157
+
158
+ function sendJson(res, status, payload, extraHeaders, headOnly) {
159
+ const bytes = Buffer.from(JSON.stringify(payload));
160
+ const headers = {
161
+ "content-type": JSON_TYPE,
162
+ "cache-control": "no-store",
163
+ "content-length": bytes.length,
164
+ ...(isRecord(extraHeaders) ? extraHeaders : {}),
165
+ };
166
+ res.writeHead(status, headers);
167
+ res.end(headOnly === true ? undefined : bytes);
168
+ }
169
+
170
+ function sendEmpty(res, status, extraHeaders) {
171
+ res.writeHead(status, { "cache-control": "no-store", ...(isRecord(extraHeaders) ? extraHeaders : {}) });
172
+ res.end();
173
+ }
174
+
175
+ /** `req.url` → a parsed URL, or null when the target is missing/unparseable. */
176
+ function readUrl(req) {
177
+ const raw = typeof req.url === "string" ? req.url : "";
178
+ if (raw === "") return null;
179
+ try {
180
+ return new URL(raw, "http://localhost");
181
+ } catch {
182
+ return null;
183
+ }
184
+ }
185
+
186
+ /** The part of the pathname this route owns: "" for the bare prefix, "/state" for the endpoint. */
187
+ function endpointOf(pathname) {
188
+ if (typeof pathname !== "string" || !pathname.startsWith(ROUTE_PREFIX)) return null;
189
+ let rest = pathname.slice(ROUTE_PREFIX.length);
190
+ // `/dsh-workflow/state/` is the same resource as `/dsh-workflow/state`; anything deeper is not.
191
+ while (rest.length > 1 && rest.endsWith("/")) rest = rest.slice(0, -1);
192
+ return rest;
193
+ }
194
+
195
+ function messageOf(error) {
196
+ const text = error instanceof Error ? error.message : String(error);
197
+ return text.length <= MAX_ERROR_TEXT ? text : `${text.slice(0, MAX_ERROR_TEXT - 1)}…`;
198
+ }
199
+
200
+ const FAILURE_STATUSES = new Set([400, 404, 409, 500, 503]);
201
+
202
+ function failureStatus(result) {
203
+ const status = isRecord(result) && Number.isInteger(result.status) ? result.status : 500;
204
+ return FAILURE_STATUSES.has(status) ? status : 500;
205
+ }
206
+
207
+ function failureText(result) {
208
+ return isRecord(result) && typeof result.error === "string" && result.error !== ""
209
+ ? result.error
210
+ : "workflow view unavailable";
211
+ }
212
+
213
+ function revisionOf(value) {
214
+ return isFiniteNumber(value) && value >= 0 ? value : 0;
215
+ }
216
+
217
+ // ---------------------------------------------------------------------------
218
+ // the steer channel
219
+ // ---------------------------------------------------------------------------
220
+
221
+ /**
222
+ * Read a request body as text, bounded. The `data` chunks are accumulated as strings and the
223
+ * byte budget is enforced on the fly, so an oversized upload is answered with 413 as soon as
224
+ * it is detected instead of after it has been buffered whole.
225
+ *
226
+ * @param {object} req
227
+ * @param {(text: string) => void} onText - called exactly once with the full body
228
+ * @param {(status: number, error: string) => void} onError - called at most once, never after onText
229
+ */
230
+ function readBody(req, onText, onError) {
231
+ var received = 0;
232
+ var text = "";
233
+ var settled = false;
234
+
235
+ function finish() {
236
+ if (settled) return;
237
+ settled = true;
238
+ onText(text);
239
+ }
240
+
241
+ function fail(status, error) {
242
+ if (settled) return;
243
+ settled = true;
244
+ onError(status, error);
245
+ }
246
+
247
+ function onData(chunk) {
248
+ if (settled) return;
249
+ var piece = typeof chunk === "string" ? chunk : chunk.toString("utf8");
250
+ received += Buffer.byteLength(piece, "utf8");
251
+ if (received > STEER_BODY_LIMIT) {
252
+ fail(413, "request body too large");
253
+ return;
254
+ }
255
+ text += piece;
256
+ }
257
+
258
+ req.on("data", onData);
259
+ req.on("end", function () {
260
+ if (settled) return;
261
+ finish();
262
+ });
263
+ req.on("aborted", function () {
264
+ if (settled) return;
265
+ fail(400, "request aborted");
266
+ });
267
+ req.on("error", function (error) {
268
+ if (settled) return;
269
+ fail(400, `request stream error: ${messageOf(error)}`);
270
+ });
271
+ }
272
+
273
+ /**
274
+ * `POST /dsh-workflow/steer` — hot-inject one prompt into a LIVE session.
275
+ *
276
+ * WHY `steer` AND NOT `followup`/`cancel`. The agent loop's inbox has two lists:
277
+ * * `next-turn` — claimed at the turn boundary (followup / a normal prompt), and
278
+ * * `next-step` — claimed at the NEXT STEP boundary inside the running turn (steer),
279
+ * and injected WITHOUT waking the driver (inject).
280
+ * `agent.steer(input)` calls `send(input, "next-step", true)`, which appends to `next-step`
281
+ * AND wakes the driver. That is exactly the requested semantics: the text is picked up at the
282
+ * next step of the current turn, and the running turn is NOT cancelled — `cancel` is the
283
+ * only path that aborts, and this route never calls it. (Verified against
284
+ * `@deepseek-ai/dsh-agent-loop/lib/index.js`: `steer`, `followup`, `inject`, `cancel`.)
285
+ *
286
+ * WHY THE SOURCE IS NOT INHERITED. `dsh-tool-goal/README.md` is explicit that a non-human
287
+ * producer must carry its own `source` and may not inherit `{kind:"user"}`; the session's own
288
+ * shape check (`assertMessageEventShape`) only requires a non-empty `source.kind` string, and
289
+ * the loop owns the reserved `"runtime-context"` kind. `"plugin"` is therefore written here,
290
+ * explicitly, with the plugin's own name so the log says WHO injected the text.
291
+ *
292
+ * WHY EVERY OUTCOME IS JSON. The step's discipline, inherited from the GET route: a thrown
293
+ * exception reaching the host's web server would surface as a non-JSON failure. Every branch
294
+ * below answers `{ok:false,error}` and the one success answers `{ok:true}`.
295
+ */
296
+ function handleSteerRequest(req, res, method, steer, note) {
297
+ if (method !== "POST") {
298
+ sendJson(res, 405, { ok: false, error: `method ${method} is not allowed` }, { allow: ALLOW });
299
+ return;
300
+ }
301
+ if (typeof steer !== "function") {
302
+ // The row mounted without an agents service. A host composition problem, not a client one.
303
+ sendJson(res, 503, { ok: false, error: "workflow steer source is unavailable" });
304
+ return;
305
+ }
306
+
307
+ readBody(
308
+ req,
309
+ function (raw) {
310
+ var payload;
311
+ try {
312
+ payload = JSON.parse(raw);
313
+ } catch {
314
+ sendJson(res, 400, { ok: false, error: "body must be JSON" });
315
+ return;
316
+ }
317
+ if (!isRecord(payload)) {
318
+ sendJson(res, 400, { ok: false, error: "body must be a JSON object" });
319
+ return;
320
+ }
321
+
322
+ var sessionId = typeof payload.sessionId === "string" ? payload.sessionId.trim() : "";
323
+ if (sessionId === "") {
324
+ sendJson(res, 400, { ok: false, error: `${SESSION_PARAM} is required` });
325
+ return;
326
+ }
327
+ var text = typeof payload.text === "string" ? payload.text : "";
328
+ if (text.trim() === "") {
329
+ sendJson(res, 400, { ok: false, error: "text is required" });
330
+ return;
331
+ }
332
+ if (text.length > STEER_TEXT_LIMIT) {
333
+ sendJson(res, 413, { ok: false, error: `text exceeds ${STEER_TEXT_LIMIT} characters` });
334
+ return;
335
+ }
336
+
337
+ var outcome;
338
+ try {
339
+ outcome = steer(sessionId, text);
340
+ } catch (error) {
341
+ // A failure INSIDE the source is ours to report: the client still gets JSON.
342
+ note(`route: steer source threw: ${messageOf(error)}`);
343
+ sendJson(res, 500, { ok: false, error: messageOf(error) });
344
+ return;
345
+ }
346
+
347
+ if (!isRecord(outcome) || outcome.ok !== true) {
348
+ sendJson(res, failureStatus(outcome), { ok: false, error: failureText(outcome) });
349
+ return;
350
+ }
351
+ // the id the message was written with travels back to the caller. It is
352
+ // what makes "revoke" addressable at all, and it is the only value in this answer the client
353
+ // cannot derive: the generator (`randomUUID`) lives in the host half. A source that does not
354
+ // report one (a double, or an older host half) keeps the pre- answer.
355
+ sendJson(
356
+ res,
357
+ 200,
358
+ typeof outcome.messageId === "string" && outcome.messageId !== ""
359
+ ? { ok: true, sessionId, messageId: outcome.messageId }
360
+ : { ok: true, sessionId },
361
+ );
362
+ },
363
+ function (status, error) {
364
+ sendJson(res, status, { ok: false, error });
365
+ }
366
+ );
367
+ }
368
+
369
+ /**
370
+ * `POST /dsh-workflow/unsteer` — take ONE not-yet-claimed injection back out of a live session.
371
+ *
372
+ * WHY THIS EXISTS. A `/steer` 200 proves only that the message reached the session's `next-step`
373
+ * inbox; the running turn claims that inbox at its NEXT step boundary. Between those two instants
374
+ * the message is queued and unread, and `agent.inbox.remove(messageId)` is the loop's own API for
375
+ * exactly that window: it locates the id in either pending list, splices it out with
376
+ * `outcome: 'canceled'`, and emits `agent/inbox/discarded` on the agent's dispatch bus. Nothing
377
+ * else moves — no turn is cancelled, no history is rewritten, and once the message HAS been claimed
378
+ * (or admitted as a `user/message`) `remove` answers false: the honest reply is 409, not a pretend
379
+ * success.
380
+ *
381
+ * WHY THE ROUTE ITSELF WRITES NOTHING. `inbox.remove` IS the durable write (it appends
382
+ * `agent/inbox/spliced` with `outcome: 'canceled'`), and the projection folds that event into the
383
+ * row's "revoked" state. A second, plugin-authored event for one fact would be a second source of
384
+ * truth — and the browser would have to guess which one to believe.
385
+ *
386
+ * WHY EVERY OUTCOME IS JSON. Same discipline as `/steer`: a thrown exception must never reach the
387
+ * host's web server as a non-JSON failure, and "that message is gone" is a client-visible state
388
+ * (409), not a server fault.
389
+ */
390
+ function handleUnsteerRequest(req, res, method, unsteer, note) {
391
+ if (method !== "POST") {
392
+ sendJson(res, 405, { ok: false, error: `method ${method} is not allowed` }, { allow: ALLOW });
393
+ return;
394
+ }
395
+ if (typeof unsteer !== "function") {
396
+ // The row mounted without an agents service. A host composition problem, not a client one.
397
+ sendJson(res, 503, { ok: false, error: "workflow unsteer source is unavailable" });
398
+ return;
399
+ }
400
+
401
+ readBody(
402
+ req,
403
+ function (raw) {
404
+ var payload;
405
+ try {
406
+ payload = JSON.parse(raw);
407
+ } catch {
408
+ sendJson(res, 400, { ok: false, error: "body must be JSON" });
409
+ return;
410
+ }
411
+ if (!isRecord(payload)) {
412
+ sendJson(res, 400, { ok: false, error: "body must be a JSON object" });
413
+ return;
414
+ }
415
+
416
+ var sessionId = typeof payload.sessionId === "string" ? payload.sessionId.trim() : "";
417
+ if (sessionId === "") {
418
+ sendJson(res, 400, { ok: false, error: `${SESSION_PARAM} is required` });
419
+ return;
420
+ }
421
+ var messageId = typeof payload.messageId === "string" ? payload.messageId.trim() : "";
422
+ if (messageId === "") {
423
+ sendJson(res, 400, { ok: false, error: "messageId is required" });
424
+ return;
425
+ }
426
+
427
+ var outcome;
428
+ try {
429
+ outcome = unsteer(sessionId, messageId);
430
+ } catch (error) {
431
+ // A failure INSIDE the source is ours to report: the client still gets JSON.
432
+ note(`route: unsteer source threw: ${messageOf(error)}`);
433
+ sendJson(res, 500, { ok: false, error: messageOf(error) });
434
+ return;
435
+ }
436
+
437
+ if (!isRecord(outcome) || outcome.ok !== true) {
438
+ sendJson(res, failureStatus(outcome), { ok: false, error: failureText(outcome) });
439
+ return;
440
+ }
441
+ sendJson(res, 200, { ok: true, sessionId, messageId });
442
+ },
443
+ function (status, error) {
444
+ sendJson(res, status, { ok: false, error });
445
+ }
446
+ );
447
+ }
448
+
449
+ // handler
450
+ // ---------------------------------------------------------------------------
451
+
452
+ /**
453
+ * Build the request handler.
454
+ *
455
+ * @param {{snapshot?: (sessionId: string) => {ok: true, view: unknown, rev: unknown} | {ok: false, status?: number, error?: string}, steer?: (sessionId: string, text: string) => {ok: true, messageId?: string} | {ok: false, status?: number, error?: string}, unsteer?: (sessionId: string, messageId: string) => {ok: true} | {ok: false, status?: number, error?: string}, note?: (text: string) => void}} [options]
456
+ * `snapshot` is the GET route's ONLY dependency: it resolves a session id to a composed view
457
+ * (or to a structured failure) and knows nothing about HTTP. Keeping the session/projection
458
+ * lookup out of this file is what lets tools/probe-route.mjs drive every branch without a
459
+ * live host. `steer` and `unsteer` are the two POST routes' equivalent dependencies —
460
+ * ../lib/index.js wires them to `ctx.agents.get(sessionId).steer(createUserMessage(...))` and
461
+ * `ctx.agents.get(sessionId).inbox.remove(messageId)`, and the offline tests wire them to
462
+ * doubles. Each is resolved defensively, so a host missing one loses only its own endpoint
463
+ * (503) and never the others.
464
+ * @returns {(req: object, res: object) => void} a synchronous handler (no await: the route must
465
+ * never hold a request open on host work, and the view is synchronous by contract).
466
+ */
467
+ export function createViewHandler(options) {
468
+ const source = isRecord(options) ? options : {};
469
+ const snapshot = typeof source.snapshot === "function" ? source.snapshot : null;
470
+ const note = typeof source.note === "function" ? source.note : () => {};
471
+ const steer = typeof source.steer === "function" ? source.steer : null;
472
+ const unsteer = typeof source.unsteer === "function" ? source.unsteer : null;
473
+
474
+ return function handleViewRequest(req, res) {
475
+ try {
476
+ if (!isObject(req) || !isObject(res) || typeof res.writeHead !== "function" || typeof res.end !== "function") {
477
+ note("route: called without a usable (req, res) pair");
478
+ return;
479
+ }
480
+
481
+ const method = typeof req.method === "string" ? req.method.toUpperCase() : "";
482
+
483
+ // Preflight, even in a same-origin deployment: a 204 here keeps a mistaken cross-origin
484
+ // probe from looking like a broken route. No CORS headers are emitted (same-origin only).
485
+ if (method === "OPTIONS") {
486
+ sendEmpty(res, 204, { allow: ALLOW });
487
+ return;
488
+ }
489
+
490
+ const url = readUrl(req);
491
+ if (url === null) {
492
+ sendJson(res, 400, { ok: false, error: "malformed request target" }, { allow: ALLOW }, method === "HEAD");
493
+ return;
494
+ }
495
+
496
+ const endpoint = endpointOf(url.pathname);
497
+ if (endpoint !== STATE_ENDPOINT && endpoint !== STEER_ENDPOINT && endpoint !== UNSTEER_ENDPOINT) {
498
+ // The prefix seat is ours; every sub-path under it is not.
499
+ sendJson(
500
+ res,
501
+ 404,
502
+ { ok: false, error: "not found", path: `${STATE_PATH} | ${STEER_PATH} | ${UNSTEER_PATH}` },
503
+ { allow: ALLOW },
504
+ method === "HEAD",
505
+ );
506
+ return;
507
+ }
508
+
509
+ // The two writes are dispatched before any GET/HEAD assumption is made: a POST to /steer or
510
+ // /unsteer is not "a wrong method on /state".
511
+ if (endpoint === STEER_ENDPOINT) {
512
+ handleSteerRequest(req, res, method, steer, note);
513
+ return;
514
+ }
515
+ if (endpoint === UNSTEER_ENDPOINT) {
516
+ handleUnsteerRequest(req, res, method, unsteer, note);
517
+ return;
518
+ }
519
+ if (method !== "GET" && method !== "HEAD") {
520
+ sendJson(res, 405, { ok: false, error: `method ${method} is not allowed` }, { allow: ALLOW });
521
+ return;
522
+ }
523
+
524
+ // /steer and /unsteer were already dispatched above, so the endpoint is /state here by
525
+ // construction; this check is the belt-and-braces guard that keeps the prefix seat from
526
+ // answering an unknown sub-path with a view.
527
+ if (endpoint !== STATE_ENDPOINT) {
528
+ sendJson(
529
+ res,
530
+ 404,
531
+ { ok: false, error: "not found", path: STATE_PATH },
532
+ { allow: ALLOW },
533
+ method === "HEAD",
534
+ );
535
+ return;
536
+ }
537
+
538
+ const sessionId = url.searchParams.get(SESSION_PARAM);
539
+ if (sessionId === null || sessionId.trim() === "") {
540
+ sendJson(res, 400, { ok: false, error: `${SESSION_PARAM} is required` }, undefined, method === "HEAD");
541
+ return;
542
+ }
543
+
544
+ if (snapshot === null) {
545
+ // The row mounted without a view source. This is a host composition problem, not a
546
+ // client one, so it must not look like "unknown session".
547
+ sendJson(res, 503, { ok: false, error: "workflow view source is unavailable" }, undefined, method === "HEAD");
548
+ return;
549
+ }
550
+
551
+ let result;
552
+ try {
553
+ result = snapshot(sessionId.trim());
554
+ } catch (error) {
555
+ // A failure INSIDE the source is ours to report: the client still gets JSON.
556
+ note(`route: view source threw: ${messageOf(error)}`);
557
+ sendJson(res, 500, { ok: false, error: messageOf(error) }, undefined, method === "HEAD");
558
+ return;
559
+ }
560
+
561
+ if (!isRecord(result) || result.ok !== true) {
562
+ sendJson(res, failureStatus(result), { ok: false, error: failureText(result) }, undefined, method === "HEAD");
563
+ return;
564
+ }
565
+
566
+ const body = JSON.stringify({ ok: true, view: result.view ?? null, rev: revisionOf(result.rev) });
567
+ const bytes = Buffer.from(body);
568
+ const etag = etagFor(bytes);
569
+
570
+ if (etagMatches(req.headers?.["if-none-match"], etag)) {
571
+ // No body and no content-length on a 304: the stored representation is still valid.
572
+ sendEmpty(res, 304, { etag });
573
+ return;
574
+ }
575
+
576
+ res.writeHead(200, {
577
+ "content-type": JSON_TYPE,
578
+ "cache-control": "no-store",
579
+ etag,
580
+ "content-length": bytes.length,
581
+ });
582
+ res.end(method === "HEAD" ? undefined : bytes);
583
+ } catch (error) {
584
+ // Last resort. If headers already went out, all that is left is to close the response
585
+ // cleanly; otherwise the client gets a JSON body like every other failure.
586
+ try {
587
+ note(`route: handler failed: ${messageOf(error)}`);
588
+ if (res.headersSent === true) res.end();
589
+ else sendJson(res, 500, { ok: false, error: "internal error" });
590
+ } catch {
591
+ // The response object itself is broken. Nothing useful can be written, and throwing here
592
+ // would hand a non-JSON failure to the host — the exact outcome this file exists to avoid.
593
+ }
594
+ }
595
+ };
596
+ }
597
+
598
+ /**
599
+ * Register the route on the host's shared web server.
600
+ *
601
+ * The server throws on a duplicate `(kind, path)`, so the caller must own exactly one
602
+ * registration per server and dispose it — see `acquireViewRoute` in ../lib/index.js, which
603
+ * refcounts that seat across hot reloads.
604
+ *
605
+ * @param {unknown} server - `ctx.webServer` (or a compatible `httpServer`).
606
+ * @param {object} [options] - see createViewHandler.
607
+ * @returns { => void} the server's own disposer.
608
+ */
609
+ export function registerViewRoute(server, options) {
610
+ if (server === null || typeof server !== "object") {
611
+ throw new Error("workflow-map route: no web server");
612
+ }
613
+ if (typeof server.register !== "function") {
614
+ throw new Error("workflow-map route: the web server does not expose register()");
615
+ }
616
+ return server.register({
617
+ kind: "prefix",
618
+ path: ROUTE_PREFIX,
619
+ handler: createViewHandler(options),
620
+ });
621
+ }