unfazed 0.5.0 → 0.6.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.
Files changed (77) hide show
  1. package/README.md +321 -0
  2. package/dist/client.d.ts +1 -1
  3. package/dist/client.js +1 -1
  4. package/dist/client.js.map +1 -1
  5. package/dist/errors.d.ts +25 -0
  6. package/dist/errors.js +25 -0
  7. package/dist/errors.js.map +1 -1
  8. package/dist/index.d.ts +21 -0
  9. package/dist/index.js +22 -0
  10. package/dist/index.js.map +1 -1
  11. package/dist/remote/app.d.ts +18 -0
  12. package/dist/remote/app.js +76 -0
  13. package/dist/remote/app.js.map +1 -0
  14. package/dist/remote/documents.d.ts +175 -0
  15. package/dist/remote/documents.js +122 -0
  16. package/dist/remote/documents.js.map +1 -0
  17. package/dist/remote/values.d.ts +67 -0
  18. package/dist/remote/values.js +156 -0
  19. package/dist/remote/values.js.map +1 -0
  20. package/dist/remote/world.d.ts +50 -0
  21. package/dist/remote/world.js +612 -0
  22. package/dist/remote/world.js.map +1 -0
  23. package/dist/workflow/config.d.ts +23 -0
  24. package/dist/workflow/config.js +48 -0
  25. package/dist/workflow/config.js.map +1 -0
  26. package/dist/workflow/errors.d.ts +33 -0
  27. package/dist/workflow/errors.js +16 -0
  28. package/dist/workflow/errors.js.map +1 -0
  29. package/dist/workflow/index.d.ts +30 -0
  30. package/dist/workflow/index.js +23 -0
  31. package/dist/workflow/index.js.map +1 -0
  32. package/dist/workflow/inspect-render.d.ts +93 -0
  33. package/dist/workflow/inspect-render.js +302 -0
  34. package/dist/workflow/inspect-render.js.map +1 -0
  35. package/dist/workflow/inspect.d.ts +52 -0
  36. package/dist/workflow/inspect.js +879 -0
  37. package/dist/workflow/inspect.js.map +1 -0
  38. package/dist/workflow/migration-files.d.ts +13 -0
  39. package/dist/workflow/migration-files.js +52 -0
  40. package/dist/workflow/migration-files.js.map +1 -0
  41. package/dist/workflow/migration-types.d.ts +19 -0
  42. package/dist/workflow/migration-types.js +8 -0
  43. package/dist/workflow/migration-types.js.map +1 -0
  44. package/dist/workflow/migrations.d.ts +12 -0
  45. package/dist/workflow/migrations.generated.d.ts +2 -0
  46. package/dist/workflow/migrations.generated.js +34 -0
  47. package/dist/workflow/migrations.generated.js.map +1 -0
  48. package/dist/workflow/migrations.js +94 -0
  49. package/dist/workflow/migrations.js.map +1 -0
  50. package/dist/workflow/nitro-flow-guard.d.ts +4 -0
  51. package/dist/workflow/nitro-flow-guard.js +19 -0
  52. package/dist/workflow/nitro-flow-guard.js.map +1 -0
  53. package/dist/workflow/nitro-inspect.d.ts +3 -0
  54. package/dist/workflow/nitro-inspect.js +19 -0
  55. package/dist/workflow/nitro-inspect.js.map +1 -0
  56. package/dist/workflow/nitro-plugin.d.ts +1 -0
  57. package/dist/workflow/nitro-plugin.js +13 -0
  58. package/dist/workflow/nitro-plugin.js.map +1 -0
  59. package/dist/workflow/nitro.d.ts +70 -0
  60. package/dist/workflow/nitro.js +213 -0
  61. package/dist/workflow/nitro.js.map +1 -0
  62. package/dist/workflow/public-url.d.ts +27 -0
  63. package/dist/workflow/public-url.js +45 -0
  64. package/dist/workflow/public-url.js.map +1 -0
  65. package/dist/workflow/queue.d.ts +128 -0
  66. package/dist/workflow/queue.js +375 -0
  67. package/dist/workflow/queue.js.map +1 -0
  68. package/dist/workflow/state.d.ts +25 -0
  69. package/dist/workflow/state.js +15 -0
  70. package/dist/workflow/state.js.map +1 -0
  71. package/dist/workflow/transport.d.ts +12 -0
  72. package/dist/workflow/transport.js +29 -0
  73. package/dist/workflow/transport.js.map +1 -0
  74. package/dist/workflow/world.d.ts +123 -0
  75. package/dist/workflow/world.js +375 -0
  76. package/dist/workflow/world.js.map +1 -0
  77. package/package.json +66 -5
@@ -0,0 +1,879 @@
1
+ /**
2
+ * The workflow inspection route: what the unfazed dashboard and MCP read runs,
3
+ * steps, events and streams through, and wake or cancel a run with (guest
4
+ * contract v1, unfazed docs/workflow-inspect.md).
5
+ *
6
+ * Everything is read through the app's own World (`getWorld()`), so it works
7
+ * on a cold start and holds no database connection after the request; actions
8
+ * go through the SDK's own `getRun(id).wakeUp()` / `.cancel()`. Payloads are
9
+ * hydrated with `workflow/observability`. Only the platform calls it: it
10
+ * answers 401 to anything without `X-Unfz-Queue-Token` equal to
11
+ * `UNFZ_STATE_TOKEN`, and public ingress strips that header.
12
+ */
13
+ import { Buffer } from "node:buffer";
14
+ import { getEventDataRefFields, isTerminalWorkflowRunStatus, stripEventDataRefs, } from "@workflow/world";
15
+ import { hydrateData, hydrateResourceIO, observabilityRevivers, parseStepName, parseWorkflowName } from "workflow/observability";
16
+ import { VERSION } from "../client.js";
17
+ import { envValue } from "./config.js";
18
+ import { cutUtf8, displayText, eventInfo, failedValue, isoTime, redactWebhookTokens, renderError, renderValue, stepInputDisplay, utf8Length, } from "./inspect-render.js";
19
+ import { DELIVERY_HEADERS, describe, verifyQueueToken } from "./queue.js";
20
+ /** Where `unfazed/workflow/nitro` mounts the route; other frameworks mount {@link createInspectHandler} here. */
21
+ export const INSPECT_BASE_PATH = "/.well-known/workflow/v1/unfazed/inspect/v1";
22
+ export const INSPECT_CONTRACT_VERSION = 1;
23
+ // ---------------------------------------------------------------------------
24
+ // Limits of the contract.
25
+ // ---------------------------------------------------------------------------
26
+ const MAX_BODY_BYTES = 1024 * 1024;
27
+ /** Lists shrink their page (fewer items, `has_more`) to stay under this. */
28
+ const PAGE_BUDGET_BYTES = 900 * 1024;
29
+ const MAX_POST_BYTES = 16 * 1024;
30
+ const MAX_ERROR_MESSAGE_BYTES = 1024;
31
+ const IO_CAP_BYTES = 16 * 1024;
32
+ const EVENT_DATA_CAP_BYTES = 64 * 1024;
33
+ const CHUNK_TEXT_CAP_BYTES = 8 * 1024;
34
+ const RUN_PAGE_STEPS = 200;
35
+ const RUN_PAGE_MAX_EVENTS = 10_000;
36
+ const RUN_PAGE_MAX_STREAMS = 50;
37
+ const RUN_PAGE_STREAM_INFOS = 20;
38
+ /** A run page keeps the latest 500 waits and hooks; `waits_truncated` / `hooks_truncated` say when there were more. */
39
+ const RUN_PAGE_MAX_WAITS = 500;
40
+ const RUN_PAGE_MAX_HOOKS = 500;
41
+ const STEP_PAGE_EVENTS = 200;
42
+ const EVENTS_PAGE_SIZE = 500;
43
+ const RUN_STATUSES = ["pending", "running", "completed", "failed", "cancelled"];
44
+ const ID_PATTERN = /^[A-Za-z0-9_.:-]{1,256}$/;
45
+ const CURSOR_PATTERN = /^[\x21-\x7e]{1,1024}$/;
46
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: the contract refuses control characters
47
+ const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f-\u009f]/;
48
+ class InspectFailure extends Error {
49
+ status;
50
+ code;
51
+ constructor(status, code, message) {
52
+ super(message);
53
+ this.status = status;
54
+ this.code = code;
55
+ }
56
+ }
57
+ const invalid = (message) => new InspectFailure(400, "invalid_request", message);
58
+ const notFound = (message) => new InspectFailure(404, "not_found", message);
59
+ const RESPONSE_HEADERS = { "content-type": "application/json", "cache-control": "no-store" };
60
+ function respond(status, body, headers = {}) {
61
+ return new Response(JSON.stringify(body), { status, headers: { ...RESPONSE_HEADERS, ...headers } });
62
+ }
63
+ function errorResponse(status, code, message, headers) {
64
+ return respond(status, { error: code, message: cutUtf8(redactWebhookTokens(message), MAX_ERROR_MESSAGE_BYTES).text }, headers);
65
+ }
66
+ const idRule = (value, name) => {
67
+ if (!ID_PATTERN.test(value))
68
+ throw invalid(`${name} must be 1 to 256 bytes of [A-Za-z0-9_.:-]`);
69
+ };
70
+ const textRule = (value, name) => {
71
+ const bytes = utf8Length(value);
72
+ if (bytes < 1 || bytes > 1024 || CONTROL_CHARACTERS.test(value)) {
73
+ throw invalid(`${name} must be 1 to 1024 bytes of UTF-8 without control characters`);
74
+ }
75
+ };
76
+ const cursorRule = (value, name) => {
77
+ if (!CURSOR_PATTERN.test(value))
78
+ throw invalid(`${name} must be 1 to 1024 bytes of visible ASCII`);
79
+ };
80
+ const limitRule = (max) => (value, name) => {
81
+ if (!/^\d{1,6}$/.test(value) || Number(value) < 1 || Number(value) > max) {
82
+ throw invalid(`${name} must be a decimal integer from 1 to ${max}`);
83
+ }
84
+ };
85
+ const statusRule = (value, name) => {
86
+ for (const word of value.split(",")) {
87
+ if (!RUN_STATUSES.includes(word)) {
88
+ throw invalid(`${name} must be a comma-separated subset of ${RUN_STATUSES.join(", ")}`);
89
+ }
90
+ }
91
+ };
92
+ const orderRule = (value, name) => {
93
+ if (value !== "asc" && value !== "desc")
94
+ throw invalid(`${name} must be asc or desc`);
95
+ };
96
+ /** The query, refusing unknown, repeated and missing parameters. */
97
+ function parseQuery(url, rules, required) {
98
+ const params = {};
99
+ for (const [name, value] of url.searchParams) {
100
+ const rule = rules[name];
101
+ if (!rule)
102
+ throw invalid(`unknown parameter ${JSON.stringify(name)}`);
103
+ if (name in params)
104
+ throw invalid(`parameter ${name} is repeated`);
105
+ rule(value, name);
106
+ params[name] = value;
107
+ }
108
+ for (const name of required)
109
+ if (!(name in params))
110
+ throw invalid(`missing parameter ${name}`);
111
+ return params;
112
+ }
113
+ async function readBody(req) {
114
+ const type = req.headers.get("content-type") ?? "";
115
+ if (!/^application\/json\s*(;|$)/i.test(type))
116
+ throw invalid("the body must be application/json");
117
+ const declared = Number(req.headers.get("content-length") ?? "0");
118
+ if (declared > MAX_POST_BYTES)
119
+ throw invalid(`the body may be at most ${MAX_POST_BYTES} bytes`);
120
+ const chunks = [];
121
+ let size = 0;
122
+ if (req.body) {
123
+ const reader = req.body.getReader();
124
+ for (;;) {
125
+ const { done, value } = await reader.read();
126
+ if (done)
127
+ break;
128
+ size += value.byteLength;
129
+ if (size > MAX_POST_BYTES) {
130
+ await reader.cancel().catch(() => { });
131
+ throw invalid(`the body may be at most ${MAX_POST_BYTES} bytes`);
132
+ }
133
+ chunks.push(value);
134
+ }
135
+ }
136
+ let parsed;
137
+ try {
138
+ parsed = JSON.parse(Buffer.concat(chunks).toString("utf8"));
139
+ }
140
+ catch {
141
+ throw invalid("the body is not valid JSON");
142
+ }
143
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed))
144
+ throw invalid("the body must be a JSON object");
145
+ return parsed;
146
+ }
147
+ function checkFields(body, allowed) {
148
+ for (const key of Object.keys(body)) {
149
+ if (!allowed.includes(key))
150
+ throw invalid(`unknown field ${JSON.stringify(key)}`);
151
+ }
152
+ }
153
+ function bodyId(body, name) {
154
+ const value = body[name];
155
+ if (typeof value !== "string")
156
+ throw invalid(`${name} must be a string`);
157
+ idRule(value, name);
158
+ return value;
159
+ }
160
+ function names(parsed, raw) {
161
+ return { display_name: parsed?.shortName || raw, module: parsed?.moduleSpecifier ?? "" };
162
+ }
163
+ function runSummary(run) {
164
+ const { display_name, module } = names(parseWorkflowName(run.workflowName), run.workflowName);
165
+ return {
166
+ run_id: run.runId,
167
+ workflow_name: run.workflowName,
168
+ display_name,
169
+ module,
170
+ status: run.status,
171
+ deployment_id: run.deploymentId,
172
+ created_at: isoTime(run.createdAt),
173
+ started_at: isoTime(run.startedAt),
174
+ completed_at: isoTime(run.completedAt),
175
+ updated_at: isoTime(run.updatedAt),
176
+ expired_at: isoTime(run.expiredAt),
177
+ error_code: run.errorCode ?? null,
178
+ attributes: Object.fromEntries(Object.entries(run.attributes ?? {}).map(([k, v]) => [redactWebhookTokens(k), redactWebhookTokens(String(v))])),
179
+ };
180
+ }
181
+ function stepSummary(step) {
182
+ const { display_name, module } = names(parseStepName(step.stepName), step.stepName);
183
+ return {
184
+ step_id: step.stepId,
185
+ step_name: step.stepName,
186
+ display_name,
187
+ module,
188
+ status: step.status,
189
+ attempt: step.attempt,
190
+ created_at: isoTime(step.createdAt),
191
+ started_at: isoTime(step.startedAt),
192
+ completed_at: isoTime(step.completedAt),
193
+ updated_at: isoTime(step.updatedAt),
194
+ retry_after: isoTime(step.retryAfter),
195
+ };
196
+ }
197
+ function inspectEvent(event) {
198
+ const stripped = stripEventDataRefs(event, "none");
199
+ const { info, truncated } = eventInfo(stripped.eventData);
200
+ return {
201
+ event_id: event.eventId,
202
+ event_type: event.eventType,
203
+ correlation_id: event.correlationId ?? null,
204
+ created_at: isoTime(event.createdAt),
205
+ info,
206
+ info_truncated: truncated,
207
+ };
208
+ }
209
+ const pageCursor = (page) => page.hasMore && page.cursor ? page.cursor : null;
210
+ let reviversPromise;
211
+ /**
212
+ * The revivers payloads are hydrated with: Workflow SDK's common revivers
213
+ * (`Date`, `Map`, `Error`, typed arrays, … — without them devalue refuses any
214
+ * payload holding one), display objects for `Request`/`Response`, then
215
+ * `observabilityRevivers` (streams, runs, class instances and step functions
216
+ * as display objects), the set Workflow SDK's own CLI inspector layers.
217
+ */
218
+ function revivers() {
219
+ reviversPromise ??= (async () => {
220
+ let common = {};
221
+ try {
222
+ const serialization = (await import("@workflow/core/serialization"));
223
+ common = serialization.getCommonRevivers?.(globalThis) ?? {};
224
+ }
225
+ catch (error) {
226
+ console.warn(`[unfazed/workflow] inspection: Workflow SDK's common revivers are unavailable (${describe(error)}); ` +
227
+ "payloads holding a Date, Map, Error, … will show as unreadable");
228
+ }
229
+ const display = (kind) => (value) => {
230
+ const out = { $type: kind };
231
+ for (const key of ["method", "url", "status", "statusText", "headers", "body", "redirected"]) {
232
+ if (key in value)
233
+ out[key] = key === "headers" ? headersObject(value[key]) : value[key];
234
+ }
235
+ return out;
236
+ };
237
+ return {
238
+ ...common,
239
+ Request: display("Request"),
240
+ Response: display("Response"),
241
+ ...observabilityRevivers,
242
+ };
243
+ })();
244
+ return reviversPromise;
245
+ }
246
+ function headersObject(value) {
247
+ if (Array.isArray(value))
248
+ return Object.fromEntries(value.filter((e) => Array.isArray(e) && e.length === 2));
249
+ return value;
250
+ }
251
+ /**
252
+ * A hydrated field and, when it could not be hydrated, why:
253
+ * `hydrateResourceIO` leaves a field it cannot read untouched, so a field that
254
+ * comes back as the same serialized value is hydrated again to get the reason.
255
+ */
256
+ function hydratedField(raw, hydrated, rev) {
257
+ if (raw === undefined || raw === null)
258
+ return { value: undefined };
259
+ if (hydrated === raw && (raw instanceof Uint8Array || Array.isArray(raw))) {
260
+ try {
261
+ return { value: hydrateData(raw, rev) };
262
+ }
263
+ catch (error) {
264
+ return { value: undefined, failure: error };
265
+ }
266
+ }
267
+ return { value: hydrated };
268
+ }
269
+ function valueOf(field, cap, secrets) {
270
+ if (field.failure !== undefined)
271
+ return failedValue(field.failure, cap);
272
+ return renderValue(field.value, cap, secrets ? { secrets, dropTokenKeys: true } : {});
273
+ }
274
+ // ---------------------------------------------------------------------------
275
+ // Stream chunks.
276
+ // ---------------------------------------------------------------------------
277
+ const FRAME_FORMATS = new Set(["devl", "gzip", "zstd"]);
278
+ /** The chunk's length-prefixed frames as Workflow SDK's stream writer writes them, or undefined. */
279
+ function sdkFrames(data) {
280
+ const frames = [];
281
+ let offset = 0;
282
+ const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
283
+ while (offset < data.byteLength) {
284
+ if (offset + 8 > data.byteLength)
285
+ return undefined;
286
+ const length = view.getUint32(offset, false);
287
+ if (length < 4 || offset + 4 + length > data.byteLength)
288
+ return undefined;
289
+ const frame = data.subarray(offset + 4, offset + 4 + length);
290
+ if (!FRAME_FORMATS.has(String.fromCharCode(frame[0], frame[1], frame[2], frame[3])))
291
+ return undefined;
292
+ frames.push(frame);
293
+ offset += 4 + length;
294
+ }
295
+ return frames.length > 0 ? frames : undefined;
296
+ }
297
+ /**
298
+ * One chunk as `{index, bytes, encoding, text, truncated}`: `json` when it is
299
+ * Workflow SDK frames (what `getWritable()` writes), rendered as a Value; a
300
+ * frame holding bytes (a byte stream's chunk) shows those bytes, as `utf8`
301
+ * when they are text, else `base64`; anything else is the raw chunk, `utf8`
302
+ * when it is valid UTF-8, else `base64`. `bytes` is always the raw length.
303
+ */
304
+ function renderChunk(index, data, rev) {
305
+ const frames = sdkFrames(data);
306
+ if (frames) {
307
+ try {
308
+ const values = frames.map((frame) => hydrateData(frame, rev));
309
+ // Encrypted frames come back as the very bytes given: not readable here.
310
+ if (!values.some((value, i) => value === frames[i])) {
311
+ if (values.length === 1 && values[0] instanceof Uint8Array)
312
+ return rawChunk(index, data.byteLength, values[0]);
313
+ const cut = cutUtf8(displayText(values.length === 1 ? values[0] : values), CHUNK_TEXT_CAP_BYTES);
314
+ return { index, bytes: data.byteLength, encoding: "json", text: cut.text, truncated: cut.truncated };
315
+ }
316
+ }
317
+ catch {
318
+ // not the SDK's framing after all
319
+ }
320
+ }
321
+ return rawChunk(index, data.byteLength, data);
322
+ }
323
+ function rawChunk(index, bytes, data) {
324
+ try {
325
+ // Redacted before the cut, so a token can never survive half-cut.
326
+ const text = redactWebhookTokens(new TextDecoder("utf-8", { fatal: true }).decode(data));
327
+ const cut = cutUtf8(text, CHUNK_TEXT_CAP_BYTES);
328
+ return { index, bytes, encoding: "utf8", text: cut.text, truncated: cut.truncated };
329
+ }
330
+ catch {
331
+ // Not text, but a webhook URL may still sit in it as ASCII: redact it in
332
+ // the bytes (latin1 maps each byte to one character and back).
333
+ const latin1 = Buffer.from(data.buffer, data.byteOffset, data.byteLength).toString("latin1");
334
+ const redacted = redactWebhookTokens(latin1);
335
+ const base64 = (redacted === latin1 ? Buffer.from(data.buffer, data.byteOffset, data.byteLength) : Buffer.from(redacted, "latin1")).toString("base64");
336
+ const truncated = base64.length > CHUNK_TEXT_CAP_BYTES;
337
+ return { index, bytes, encoding: "base64", text: truncated ? base64.slice(0, CHUNK_TEXT_CAP_BYTES) : base64, truncated };
338
+ }
339
+ }
340
+ // ---------------------------------------------------------------------------
341
+ // World errors.
342
+ // ---------------------------------------------------------------------------
343
+ function isNotFound(error) {
344
+ for (let e = error, depth = 0; e instanceof Error && depth < 4; e = e.cause, depth++) {
345
+ if (e.name === "WorkflowRunNotFoundError" || e.name === "HookNotFoundError")
346
+ return true;
347
+ if (e.status === 404)
348
+ return true;
349
+ if (e.name === "WorkflowWorldError" && /\bnot found\b/i.test(e.message))
350
+ return true;
351
+ }
352
+ return false;
353
+ }
354
+ async function orNotFound(call, message) {
355
+ try {
356
+ return await call;
357
+ }
358
+ catch (error) {
359
+ if (isNotFound(error))
360
+ throw notFound(message);
361
+ throw error;
362
+ }
363
+ }
364
+ /** Shrinks a page until its answer fits the budget; World cursors stay the World's own. */
365
+ async function fitPage(limit, fetchPage) {
366
+ let size = limit;
367
+ for (;;) {
368
+ const answer = await fetchPage(size);
369
+ if (size === 1 || utf8Length(JSON.stringify(answer)) <= PAGE_BUDGET_BYTES)
370
+ return answer;
371
+ size = Math.max(1, Math.floor(size / 2));
372
+ }
373
+ }
374
+ async function assertRun(world, runId) {
375
+ return orNotFound(world.runs.get(runId, { resolveData: "none" }), `no such run ${runId}`);
376
+ }
377
+ async function scanEvents(world, runId) {
378
+ const events = [];
379
+ let cursor;
380
+ for (;;) {
381
+ const page = await world.events.list({
382
+ runId,
383
+ pagination: { limit: Math.min(EVENTS_PAGE_SIZE, RUN_PAGE_MAX_EVENTS - events.length), sortOrder: "asc", ...(cursor ? { cursor } : {}) },
384
+ resolveData: "none",
385
+ });
386
+ events.push(...page.data);
387
+ if (!page.hasMore || !page.cursor)
388
+ return { events, complete: true };
389
+ if (events.length >= RUN_PAGE_MAX_EVENTS)
390
+ return { events, complete: false };
391
+ cursor = page.cursor;
392
+ }
393
+ }
394
+ function waitsOf(events) {
395
+ const waits = new Map();
396
+ for (const event of events) {
397
+ const id = event.correlationId;
398
+ if (!id)
399
+ continue;
400
+ const data = event.eventData;
401
+ if (event.eventType === "wait_created") {
402
+ waits.set(id, {
403
+ correlation_id: id,
404
+ status: "waiting",
405
+ created_at: isoTime(event.createdAt),
406
+ resume_at: isoTime(data?.resumeAt),
407
+ completed_at: null,
408
+ });
409
+ }
410
+ else if (event.eventType === "wait_completed") {
411
+ const wait = waits.get(id);
412
+ if (wait) {
413
+ wait.status = "completed";
414
+ wait.completed_at = isoTime(event.createdAt);
415
+ }
416
+ }
417
+ }
418
+ const all = [...waits.values()];
419
+ return { waits: all.slice(-RUN_PAGE_MAX_WAITS), truncated: all.length > RUN_PAGE_MAX_WAITS };
420
+ }
421
+ function hooksOf(events, entities) {
422
+ const hooks = new Map();
423
+ for (const event of events) {
424
+ const id = event.correlationId;
425
+ if (!id)
426
+ continue;
427
+ const data = event.eventData;
428
+ if (event.eventType === "hook_created") {
429
+ hooks.set(id, {
430
+ hook_id: id,
431
+ kind: "hook",
432
+ status: "active",
433
+ created_at: isoTime(event.createdAt),
434
+ received_count: 0,
435
+ last_received_at: null,
436
+ disposed_at: null,
437
+ ...(typeof data?.isWebhook === "boolean" ? { isWebhook: data.isWebhook } : {}),
438
+ ...(typeof data?.isSystem === "boolean" ? { isSystem: data.isSystem } : {}),
439
+ });
440
+ continue;
441
+ }
442
+ const hook = hooks.get(id);
443
+ if (!hook)
444
+ continue;
445
+ if (event.eventType === "hook_received") {
446
+ hook.received_count++;
447
+ hook.last_received_at = isoTime(event.createdAt);
448
+ }
449
+ else if (event.eventType === "hook_disposed") {
450
+ hook.status = "disposed";
451
+ hook.disposed_at = isoTime(event.createdAt);
452
+ }
453
+ }
454
+ for (const entity of entities) {
455
+ let hook = hooks.get(entity.hookId);
456
+ if (!hook) {
457
+ hook = {
458
+ hook_id: entity.hookId,
459
+ kind: "hook",
460
+ status: "active",
461
+ created_at: isoTime(entity.createdAt),
462
+ received_count: 0,
463
+ last_received_at: null,
464
+ disposed_at: null,
465
+ };
466
+ hooks.set(entity.hookId, hook);
467
+ }
468
+ if (typeof entity.isWebhook === "boolean")
469
+ hook.isWebhook = entity.isWebhook;
470
+ if (typeof entity.isSystem === "boolean")
471
+ hook.isSystem = entity.isSystem;
472
+ }
473
+ const all = [...hooks.values()].sort((a, b) => (a.created_at ?? "").localeCompare(b.created_at ?? ""));
474
+ return {
475
+ hooks: all.slice(-RUN_PAGE_MAX_HOOKS).map(({ isWebhook, isSystem, ...hook }) => ({
476
+ ...hook,
477
+ kind: isSystem ? "system" : isWebhook ? "webhook" : "hook",
478
+ })),
479
+ truncated: all.length > RUN_PAGE_MAX_HOOKS,
480
+ };
481
+ }
482
+ const ENDPOINTS = {
483
+ info: {
484
+ method: "GET",
485
+ async run({ world, options }) {
486
+ const workflows = [...new Set(options.workflows ?? [])].sort().map((name) => ({
487
+ name,
488
+ ...names(parseWorkflowName(name), name),
489
+ }));
490
+ return {
491
+ version: INSPECT_CONTRACT_VERSION,
492
+ package: `unfazed@${VERSION}`,
493
+ workflow: options.workflowVersion || "unknown",
494
+ spec_version: typeof world.specVersion === "number" ? world.specVersion : null,
495
+ workflows,
496
+ };
497
+ },
498
+ },
499
+ runs: {
500
+ method: "GET",
501
+ rules: { status: statusRule, workflow_name: textRule, limit: limitRule(100), cursor: cursorRule },
502
+ async run({ world }, { params }) {
503
+ const status = params.status ? [...new Set(params.status.split(","))] : undefined;
504
+ return fitPage(Number(params.limit ?? 20), async (limit) => {
505
+ const page = await world.runs.list({
506
+ ...(status ? { status: status } : {}),
507
+ ...(params.workflow_name ? { workflowName: params.workflow_name } : {}),
508
+ pagination: { limit, sortOrder: "desc", ...(params.cursor ? { cursor: params.cursor } : {}) },
509
+ resolveData: "none",
510
+ });
511
+ return { runs: page.data.map(runSummary), next_cursor: pageCursor(page), has_more: page.hasMore && pageCursor(page) !== null };
512
+ });
513
+ },
514
+ },
515
+ run: {
516
+ method: "GET",
517
+ rules: { run_id: idRule },
518
+ required: ["run_id"],
519
+ async run({ world }, { params }) {
520
+ const runId = params.run_id;
521
+ const run = await orNotFound(world.runs.get(runId, { resolveData: "all" }), `no such run ${runId}`);
522
+ const [steps, scan, hookEntities, streamNames] = await Promise.all([
523
+ world.steps.list({ runId, pagination: { limit: RUN_PAGE_STEPS, sortOrder: "asc" }, resolveData: "none" }),
524
+ scanEvents(world, runId),
525
+ world.hooks.list({ runId, pagination: { limit: RUN_PAGE_MAX_HOOKS }, resolveData: "none" }),
526
+ world.streams.list(runId),
527
+ ]);
528
+ const streams = streamNames.slice(0, RUN_PAGE_MAX_STREAMS);
529
+ const infos = await Promise.all(streams.slice(0, RUN_PAGE_STREAM_INFOS).map((name) => world.streams.getInfo(runId, name).catch(() => undefined)));
530
+ const waits = waitsOf(scan.events);
531
+ const hooks = hooksOf(scan.events, hookEntities.data);
532
+ const rev = await revivers();
533
+ const hydrated = hydrateResourceIO(run, rev);
534
+ const raw = run;
535
+ const errorField = hydratedField(raw.error, hydrated.error, rev);
536
+ return {
537
+ run: runSummary(run),
538
+ input: valueOf(hydratedField(raw.input, hydrated.input, rev), IO_CAP_BYTES),
539
+ output: valueOf(hydratedField(raw.output, hydrated.output, rev), IO_CAP_BYTES),
540
+ error: run.status === "failed"
541
+ ? renderError(errorField.value ?? null, run.errorCode ?? null, errorField.failure)
542
+ : null,
543
+ steps: steps.data.map(stepSummary),
544
+ steps_next_cursor: pageCursor(steps),
545
+ steps_has_more: steps.hasMore && pageCursor(steps) !== null,
546
+ waits: waits.waits,
547
+ waits_truncated: waits.truncated,
548
+ hooks: hooks.hooks,
549
+ hooks_truncated: hooks.truncated,
550
+ streams: streams.map((name, i) => {
551
+ const info = i < RUN_PAGE_STREAM_INFOS ? infos[i] : undefined;
552
+ return { name, tail_index: info ? info.tailIndex : null, done: info ? info.done : null };
553
+ }),
554
+ events_scanned: scan.events.length,
555
+ events_complete: scan.complete,
556
+ };
557
+ },
558
+ },
559
+ steps: {
560
+ method: "GET",
561
+ rules: { run_id: idRule, limit: limitRule(200), cursor: cursorRule },
562
+ required: ["run_id"],
563
+ async run({ world }, { params }) {
564
+ const runId = params.run_id;
565
+ const [, answer] = await Promise.all([
566
+ assertRun(world, runId),
567
+ fitPage(Number(params.limit ?? 100), async (limit) => {
568
+ const page = await world.steps.list({
569
+ runId,
570
+ pagination: { limit, sortOrder: "asc", ...(params.cursor ? { cursor: params.cursor } : {}) },
571
+ resolveData: "none",
572
+ });
573
+ return { steps: page.data.map(stepSummary), next_cursor: pageCursor(page), has_more: page.hasMore && pageCursor(page) !== null };
574
+ }),
575
+ ]);
576
+ return answer;
577
+ },
578
+ },
579
+ step: {
580
+ method: "GET",
581
+ rules: { run_id: idRule, step_id: idRule },
582
+ required: ["run_id", "step_id"],
583
+ async run({ world }, { params }) {
584
+ const runId = params.run_id;
585
+ const stepId = params.step_id;
586
+ const [step, events] = await Promise.all([
587
+ orNotFound(world.steps.get(runId, stepId, { resolveData: "all" }), `no such step ${stepId} in run ${runId}`),
588
+ world.events.listByCorrelationId({
589
+ runId,
590
+ correlationId: stepId,
591
+ pagination: { limit: STEP_PAGE_EVENTS, sortOrder: "asc" },
592
+ resolveData: "none",
593
+ }),
594
+ ]);
595
+ const rev = await revivers();
596
+ const hydrated = hydrateResourceIO(step, rev);
597
+ const raw = step;
598
+ const errorField = hydratedField(raw.error, hydrated.error, rev);
599
+ const input = hydratedField(raw.input, hydrated.input, rev);
600
+ return {
601
+ step: stepSummary(step),
602
+ input: valueOf({ ...input, value: stepInputDisplay(input.value) }, IO_CAP_BYTES),
603
+ output: valueOf(hydratedField(raw.output, hydrated.output, rev), IO_CAP_BYTES),
604
+ error: raw.error === undefined || raw.error === null
605
+ ? null
606
+ : renderError(errorField.value ?? null, null, errorField.failure),
607
+ events: events.data.map(inspectEvent),
608
+ events_has_more: events.hasMore,
609
+ };
610
+ },
611
+ },
612
+ events: {
613
+ method: "GET",
614
+ rules: { run_id: idRule, correlation_id: idRule, order: orderRule, limit: limitRule(500), cursor: cursorRule },
615
+ required: ["run_id"],
616
+ async run({ world }, { params }) {
617
+ const runId = params.run_id;
618
+ const sortOrder = (params.order ?? "asc");
619
+ const [, answer] = await Promise.all([
620
+ assertRun(world, runId),
621
+ fitPage(Number(params.limit ?? 100), async (limit) => {
622
+ const pagination = { limit, sortOrder, ...(params.cursor ? { cursor: params.cursor } : {}) };
623
+ const page = params.correlation_id
624
+ ? await world.events.listByCorrelationId({ runId, correlationId: params.correlation_id, pagination, resolveData: "none" })
625
+ : await world.events.list({ runId, pagination, resolveData: "none" });
626
+ return { events: page.data.map(inspectEvent), next_cursor: pageCursor(page), has_more: page.hasMore && pageCursor(page) !== null };
627
+ }),
628
+ ]);
629
+ return answer;
630
+ },
631
+ },
632
+ event: {
633
+ method: "GET",
634
+ rules: { run_id: idRule, event_id: idRule },
635
+ required: ["run_id", "event_id"],
636
+ async run({ world }, { params }) {
637
+ const runId = params.run_id;
638
+ const eventId = params.event_id;
639
+ const event = await orNotFound(world.events.get(runId, eventId, { resolveData: "all" }), `no such event ${eventId} in run ${runId}`);
640
+ const field = getEventDataRefFields(event.eventType).find((name) => {
641
+ const data = event.eventData;
642
+ return data !== undefined && data[name] !== undefined && data[name] !== null;
643
+ });
644
+ let data = null;
645
+ if (field) {
646
+ const [rev, secrets] = await Promise.all([revivers(), runHookTokens(world, runId, event)]);
647
+ const raw = event.eventData[field];
648
+ const hydrated = hydrateResourceIO(event, rev).eventData?.[field];
649
+ const value = hydratedField(raw, hydrated, rev);
650
+ // A step's input (step_created, step_started) shows as its arguments.
651
+ if (field === "input" && event.eventType.startsWith("step_"))
652
+ value.value = stepInputDisplay(value.value);
653
+ data = valueOf(value, EVENT_DATA_CAP_BYTES, secrets);
654
+ }
655
+ return { event: inspectEvent(event), data };
656
+ },
657
+ },
658
+ stream: {
659
+ method: "GET",
660
+ rules: { run_id: idRule, name: textRule, limit: limitRule(200), cursor: cursorRule },
661
+ required: ["run_id", "name"],
662
+ async run({ world }, { params }) {
663
+ const runId = params.run_id;
664
+ const name = params.name;
665
+ const [streamNames, info] = await Promise.all([
666
+ orNotFound(world.streams.list(runId), `no such run ${runId}`),
667
+ world.streams.getInfo(runId, name),
668
+ ]);
669
+ if (!streamNames.includes(name))
670
+ throw notFound(`no such stream ${JSON.stringify(name)} in run ${runId}`);
671
+ const rev = await revivers();
672
+ return fitPage(Number(params.limit ?? 50), async (limit) => {
673
+ const page = await world.streams.getChunks(runId, name, { limit, ...(params.cursor ? { cursor: params.cursor } : {}) });
674
+ return {
675
+ name,
676
+ chunks: page.data.map((chunk) => renderChunk(chunk.index, chunk.data, rev)),
677
+ next_cursor: pageCursor(page),
678
+ has_more: page.hasMore && pageCursor(page) !== null,
679
+ done: info.done,
680
+ tail_index: info.tailIndex,
681
+ };
682
+ });
683
+ },
684
+ },
685
+ wake: {
686
+ method: "POST",
687
+ async run({ world, getRun }, { body }) {
688
+ checkFields(body, ["run_id", "correlation_ids"]);
689
+ const runId = bodyId(body, "run_id");
690
+ let correlationIds;
691
+ if (body.correlation_ids !== undefined) {
692
+ const ids = body.correlation_ids;
693
+ if (!Array.isArray(ids) || ids.length < 1 || ids.length > 100) {
694
+ throw invalid("correlation_ids must be a list of 1 to 100 IDs");
695
+ }
696
+ correlationIds = ids.map((id, i) => {
697
+ if (typeof id !== "string")
698
+ throw invalid(`correlation_ids[${i}] must be a string`);
699
+ idRule(id, `correlation_ids[${i}]`);
700
+ return id;
701
+ });
702
+ }
703
+ const run = await assertRun(world, runId);
704
+ if (isTerminalWorkflowRunStatus(run.status))
705
+ throw terminal(run);
706
+ const handle = await getRun(runId);
707
+ const result = await orNotFound(handle.wakeUp(correlationIds ? { correlationIds } : undefined), `no such run ${runId}`);
708
+ return { stopped_count: result.stoppedCount };
709
+ },
710
+ },
711
+ cancel: {
712
+ method: "POST",
713
+ async run({ world, getRun }, { body }) {
714
+ checkFields(body, ["run_id", "reason"]);
715
+ const runId = bodyId(body, "run_id");
716
+ const reason = body.reason;
717
+ // At most 512 characters (code points), as the platform counts them.
718
+ if (reason !== undefined && (typeof reason !== "string" || Array.from(reason).length > 512)) {
719
+ throw invalid("reason must be a string of at most 512 characters");
720
+ }
721
+ const run = await assertRun(world, runId);
722
+ if (isTerminalWorkflowRunStatus(run.status))
723
+ throw terminal(run);
724
+ const handle = await getRun(runId);
725
+ try {
726
+ await handle.cancel(reason !== undefined ? { cancelReason: sdkCancelReason(reason) } : undefined);
727
+ }
728
+ catch (error) {
729
+ // Lost a race with the run ending: say so rather than fail.
730
+ const now = await world.runs.get(runId, { resolveData: "none" }).catch(() => undefined);
731
+ if (now && isTerminalWorkflowRunStatus(now.status))
732
+ throw terminal(now);
733
+ throw error;
734
+ }
735
+ return { run_id: runId, status: "cancelled" };
736
+ },
737
+ },
738
+ };
739
+ /**
740
+ * The reason as Workflow SDK stores it: its schema takes at most 512 UTF-16
741
+ * units, so a reason of 512 characters that holds astral-plane ones (emoji)
742
+ * is cut on a character boundary to fit, rather than failing the cancel.
743
+ */
744
+ export function sdkCancelReason(reason) {
745
+ if (reason.length <= 512)
746
+ return reason;
747
+ let out = "";
748
+ for (const char of reason) {
749
+ if (out.length + char.length > 512)
750
+ break;
751
+ out += char;
752
+ }
753
+ return out;
754
+ }
755
+ function terminal(run) {
756
+ return new InspectFailure(409, "run_terminal", `run ${run.runId} already ended (${run.status})`);
757
+ }
758
+ /**
759
+ * Every hook token of the run, so none of them, nor a webhook URL built from
760
+ * one, leaves in an event's data (a webhook's received `Request` carries its
761
+ * URL, which holds the token): from the hook records and from every event
762
+ * that names one (`hook_created`, `hook_received`, …), over the same 10 000
763
+ * events the run page reads.
764
+ */
765
+ async function runHookTokens(world, runId, event) {
766
+ const tokens = new Set();
767
+ const add = (e) => {
768
+ const token = e?.eventData?.token;
769
+ if (typeof token === "string" && token.length > 0)
770
+ tokens.add(token);
771
+ };
772
+ add(event);
773
+ const records = async () => {
774
+ let cursor;
775
+ for (let page = 0; page < 20; page++) {
776
+ const hooks = await world.hooks.list({
777
+ runId,
778
+ pagination: { limit: RUN_PAGE_MAX_HOOKS, ...(cursor ? { cursor } : {}) },
779
+ resolveData: "none",
780
+ });
781
+ for (const hook of hooks.data)
782
+ if (typeof hook.token === "string" && hook.token.length > 0)
783
+ tokens.add(hook.token);
784
+ if (!hooks.hasMore || !hooks.cursor)
785
+ return;
786
+ cursor = hooks.cursor;
787
+ }
788
+ };
789
+ const [, scan] = await Promise.all([records(), scanEvents(world, runId)]);
790
+ for (const e of scan.events)
791
+ add(e);
792
+ return [...tokens];
793
+ }
794
+ // ---------------------------------------------------------------------------
795
+ // The handler.
796
+ // ---------------------------------------------------------------------------
797
+ /**
798
+ * Creates the inspection route's handler: `(request) => Response` for every
799
+ * request under {@link INSPECT_BASE_PATH}.
800
+ *
801
+ * ```ts
802
+ * // e.g. a catch-all route at /.well-known/workflow/v1/unfazed/inspect/v1/*
803
+ * import { createInspectHandler } from "unfazed/workflow";
804
+ * const inspect = createInspectHandler();
805
+ * export const GET = inspect, POST = inspect;
806
+ * ```
807
+ *
808
+ * `unfazed/workflow/nitro` mounts it for you.
809
+ */
810
+ export function createInspectHandler(options = {}) {
811
+ const timeoutMs = options.timeoutMs ?? 19_000;
812
+ const getWorld = options.getWorld ??
813
+ (async () => {
814
+ const runtime = (await import("workflow/runtime"));
815
+ return runtime.getWorld();
816
+ });
817
+ const getRun = options.getRun
818
+ ? async (runId) => options.getRun(runId)
819
+ : async (runId) => {
820
+ const api = (await import("workflow/api"));
821
+ return api.getRun(runId);
822
+ };
823
+ return async (request) => {
824
+ const expected = options.stateToken ?? envValue(options.env ?? process.env, "UNFZ_STATE_TOKEN")?.trim();
825
+ if (!verifyQueueToken(expected || undefined, request.headers.get(DELIVERY_HEADERS.token))) {
826
+ return errorResponse(401, "unauthorized", "missing or invalid queue token");
827
+ }
828
+ const url = new URL(request.url);
829
+ const prefix = `${INSPECT_BASE_PATH}/`;
830
+ const op = url.pathname.startsWith(prefix) ? url.pathname.slice(prefix.length) : "";
831
+ const endpoint = Object.hasOwn(ENDPOINTS, op) ? ENDPOINTS[op] : undefined;
832
+ if (!endpoint)
833
+ return errorResponse(404, "not_found", `no inspection endpoint at ${url.pathname}`);
834
+ if (request.method !== endpoint.method) {
835
+ return errorResponse(405, "method_not_allowed", `${op} takes ${endpoint.method}`, { allow: endpoint.method });
836
+ }
837
+ let started = false;
838
+ const work = (async () => {
839
+ const params = parseQuery(url, endpoint.rules ?? {}, endpoint.required ?? []);
840
+ const body = endpoint.method === "POST" ? await readBody(request) : {};
841
+ let world;
842
+ try {
843
+ world = await getWorld();
844
+ const ready = world.ready;
845
+ if (typeof ready === "function")
846
+ await ready.call(world);
847
+ }
848
+ catch (error) {
849
+ throw new InspectFailure(503, "world_unavailable", describe(error));
850
+ }
851
+ started = true;
852
+ return endpoint.run({ world, options, getRun }, { params, body });
853
+ })();
854
+ let timer;
855
+ const deadline = new Promise((_, reject) => {
856
+ timer = setTimeout(() => reject(started
857
+ ? new InspectFailure(500, "internal", `the World did not answer within ${Math.round(timeoutMs / 1000)} s`)
858
+ : new InspectFailure(503, "world_unavailable", `the World is still starting after ${Math.round(timeoutMs / 1000)} s`)), timeoutMs);
859
+ });
860
+ work.catch(() => { }); // a late failure after the deadline answered
861
+ try {
862
+ const answer = await Promise.race([work, deadline]);
863
+ const text = JSON.stringify(answer);
864
+ if (utf8Length(text) > MAX_BODY_BYTES) {
865
+ return errorResponse(500, "internal", `the answer would exceed ${MAX_BODY_BYTES} bytes`);
866
+ }
867
+ return new Response(text, { status: 200, headers: RESPONSE_HEADERS });
868
+ }
869
+ catch (error) {
870
+ if (error instanceof InspectFailure)
871
+ return errorResponse(error.status, error.code, error.message);
872
+ return errorResponse(500, "internal", describe(error));
873
+ }
874
+ finally {
875
+ clearTimeout(timer);
876
+ }
877
+ };
878
+ }
879
+ //# sourceMappingURL=inspect.js.map