unfazed 0.5.0 → 0.6.1
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/README.md +399 -6
- package/dist/cli/login.d.ts +129 -0
- package/dist/cli/login.js +596 -0
- package/dist/cli/login.js.map +1 -0
- package/dist/cli/workflow.d.ts +31 -0
- package/dist/cli/workflow.js +157 -0
- package/dist/cli/workflow.js.map +1 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +173 -0
- package/dist/cli.js.map +1 -0
- package/dist/client.d.ts +26 -5
- package/dist/client.js +93 -48
- package/dist/client.js.map +1 -1
- package/dist/credentials.d.ts +123 -0
- package/dist/credentials.js +317 -0
- package/dist/credentials.js.map +1 -0
- package/dist/errors.d.ts +25 -0
- package/dist/errors.js +25 -0
- package/dist/errors.js.map +1 -1
- package/dist/index.d.ts +32 -1
- package/dist/index.js +32 -1
- package/dist/index.js.map +1 -1
- package/dist/remote/app.d.ts +18 -0
- package/dist/remote/app.js +77 -0
- package/dist/remote/app.js.map +1 -0
- package/dist/remote/documents.d.ts +175 -0
- package/dist/remote/documents.js +122 -0
- package/dist/remote/documents.js.map +1 -0
- package/dist/remote/values.d.ts +67 -0
- package/dist/remote/values.js +156 -0
- package/dist/remote/values.js.map +1 -0
- package/dist/remote/world.d.ts +50 -0
- package/dist/remote/world.js +628 -0
- package/dist/remote/world.js.map +1 -0
- package/dist/unfazed.d.ts +1 -1
- package/dist/unfazed.js +1 -1
- package/dist/unfazed.js.map +1 -1
- package/dist/workflow/config.d.ts +23 -0
- package/dist/workflow/config.js +48 -0
- package/dist/workflow/config.js.map +1 -0
- package/dist/workflow/errors.d.ts +33 -0
- package/dist/workflow/errors.js +16 -0
- package/dist/workflow/errors.js.map +1 -0
- package/dist/workflow/index.d.ts +30 -0
- package/dist/workflow/index.js +23 -0
- package/dist/workflow/index.js.map +1 -0
- package/dist/workflow/inspect-render.d.ts +93 -0
- package/dist/workflow/inspect-render.js +302 -0
- package/dist/workflow/inspect-render.js.map +1 -0
- package/dist/workflow/inspect.d.ts +52 -0
- package/dist/workflow/inspect.js +879 -0
- package/dist/workflow/inspect.js.map +1 -0
- package/dist/workflow/migration-files.d.ts +13 -0
- package/dist/workflow/migration-files.js +52 -0
- package/dist/workflow/migration-files.js.map +1 -0
- package/dist/workflow/migration-types.d.ts +19 -0
- package/dist/workflow/migration-types.js +8 -0
- package/dist/workflow/migration-types.js.map +1 -0
- package/dist/workflow/migrations.d.ts +12 -0
- package/dist/workflow/migrations.generated.d.ts +2 -0
- package/dist/workflow/migrations.generated.js +34 -0
- package/dist/workflow/migrations.generated.js.map +1 -0
- package/dist/workflow/migrations.js +94 -0
- package/dist/workflow/migrations.js.map +1 -0
- package/dist/workflow/nitro-flow-guard.d.ts +4 -0
- package/dist/workflow/nitro-flow-guard.js +19 -0
- package/dist/workflow/nitro-flow-guard.js.map +1 -0
- package/dist/workflow/nitro-inspect.d.ts +3 -0
- package/dist/workflow/nitro-inspect.js +19 -0
- package/dist/workflow/nitro-inspect.js.map +1 -0
- package/dist/workflow/nitro-plugin.d.ts +1 -0
- package/dist/workflow/nitro-plugin.js +13 -0
- package/dist/workflow/nitro-plugin.js.map +1 -0
- package/dist/workflow/nitro.d.ts +70 -0
- package/dist/workflow/nitro.js +213 -0
- package/dist/workflow/nitro.js.map +1 -0
- package/dist/workflow/public-url.d.ts +27 -0
- package/dist/workflow/public-url.js +45 -0
- package/dist/workflow/public-url.js.map +1 -0
- package/dist/workflow/queue.d.ts +128 -0
- package/dist/workflow/queue.js +375 -0
- package/dist/workflow/queue.js.map +1 -0
- package/dist/workflow/state.d.ts +25 -0
- package/dist/workflow/state.js +15 -0
- package/dist/workflow/state.js.map +1 -0
- package/dist/workflow/transport.d.ts +12 -0
- package/dist/workflow/transport.js +29 -0
- package/dist/workflow/transport.js.map +1 -0
- package/dist/workflow/world.d.ts +123 -0
- package/dist/workflow/world.js +375 -0
- package/dist/workflow/world.js.map +1 -0
- package/package.json +79 -6
|
@@ -0,0 +1,628 @@
|
|
|
1
|
+
import { ENDPOINT_ENV_VAR, TOKEN_ENV_VAR, UnfazedClient, VERSION } from "../client.js";
|
|
2
|
+
import { LOGIN_COMMAND } from "../credentials.js";
|
|
3
|
+
import { UnfazedAuthError, UnfazedGraphQLError, UnfazedHTTPError, UnfazedInspectError } from "../errors.js";
|
|
4
|
+
import { APP_ENV_VAR, currentDirectory, resolveAlias } from "./app.js";
|
|
5
|
+
import * as docs from "./documents.js";
|
|
6
|
+
import { chunkValue, EVENT_PAYLOAD_FIELDS, eventData, frame, serializedValue, structuredError, toDate, } from "./values.js";
|
|
7
|
+
/**
|
|
8
|
+
* `SPEC_VERSION_CURRENT` of the `@workflow/world` this backend was written
|
|
9
|
+
* against (a test keeps the two equal). Informational: this World creates
|
|
10
|
+
* and replays nothing.
|
|
11
|
+
*/
|
|
12
|
+
export const REMOTE_SPEC_VERSION = 8;
|
|
13
|
+
/** A run page is reused for this long, so one CLI or web UI view costs one relay. */
|
|
14
|
+
const RUN_PAGE_TTL_MS = 2_000;
|
|
15
|
+
/** Active runs searched for a hook that no run page read so far named. */
|
|
16
|
+
const HOOK_SCAN_RUNS = 20;
|
|
17
|
+
/** Pages of 200 steps read to serve `steps.list` newest first (the API lists oldest first). */
|
|
18
|
+
const STEP_PAGES_MAX = 10;
|
|
19
|
+
const STREAM_PAGE = 50;
|
|
20
|
+
const STREAM_POLL_MIN_MS = 1_000;
|
|
21
|
+
const CONCURRENCY = 4;
|
|
22
|
+
/** A wake-up's own re-enqueue happens inside the app; the SDK's follow-up `queue()` within this window is a no-op. */
|
|
23
|
+
const WAKE_QUEUE_WINDOW_MS = 60_000;
|
|
24
|
+
/** Hook tokens never leave the app (contract: hooks are shown by ID, kind and state). */
|
|
25
|
+
const REDACTED = "[redacted]";
|
|
26
|
+
/**
|
|
27
|
+
* Creates the CLI backend's World for one app. Throws when there is no token
|
|
28
|
+
* or no app to read, saying what to set.
|
|
29
|
+
*/
|
|
30
|
+
export function createRemoteWorld(options = {}) {
|
|
31
|
+
const env = options.env ?? globalThis.process?.env ?? {};
|
|
32
|
+
const endpointEnv = env[ENDPOINT_ENV_VAR];
|
|
33
|
+
let client;
|
|
34
|
+
try {
|
|
35
|
+
// The token is `token`, else UNFZ_API_TOKEN, else the stored login of
|
|
36
|
+
// `npx unfazed login` (refreshed as it expires), all read from `env`.
|
|
37
|
+
client = new UnfazedClient({
|
|
38
|
+
...(options.token ? { token: options.token } : {}),
|
|
39
|
+
env,
|
|
40
|
+
endpoint: options.endpoint ?? (endpointEnv ? normalizeEndpoint(endpointEnv) : undefined),
|
|
41
|
+
timeoutMs: options.timeoutMs ?? 60_000,
|
|
42
|
+
userAgent: `unfazed-sdk/${VERSION} (workflow-cli-backend)`,
|
|
43
|
+
...(options.fetch ? { fetch: options.fetch } : {}),
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
catch (error) {
|
|
47
|
+
if (!(error instanceof UnfazedAuthError))
|
|
48
|
+
throw error;
|
|
49
|
+
const inApp = env["UNFZ_STATE_URL"]
|
|
50
|
+
? ' Inside an unfazed app the World is unfazed/workflow (its Nitro module, or setWorld(createWorld()) from "unfazed/workflow"); ' +
|
|
51
|
+
"WORKFLOW_TARGET_WORLD=unfazed selects this read-only CLI backend instead."
|
|
52
|
+
: "";
|
|
53
|
+
// A login for another control plane says which command fixes it; keep that.
|
|
54
|
+
const message = error.message.includes("the stored login is for")
|
|
55
|
+
? error.message
|
|
56
|
+
: `unfazed: not signed in. Run \`${LOGIN_COMMAND}\` to inspect your apps' workflow runs with your own account, ` +
|
|
57
|
+
`or set ${TOKEN_ENV_VAR} (a token with the unfz:read scope; unfz:deploy too for cancel and wake; ` +
|
|
58
|
+
`an agent gets one at https://unfazed.dev/auth.md).`;
|
|
59
|
+
throw new UnfazedAuthError(message + inApp, { cause: error });
|
|
60
|
+
}
|
|
61
|
+
const alias = resolveAlias({ alias: options.alias, env, cwd: options.cwd ?? currentDirectory() });
|
|
62
|
+
const authSource = client.authSource;
|
|
63
|
+
const pollMaxMs = Math.max(1, options.streamPollMs ?? 5_000);
|
|
64
|
+
const pollMinMs = Math.min(STREAM_POLL_MIN_MS, pollMaxMs);
|
|
65
|
+
let closed = false;
|
|
66
|
+
/** Pending waits of stream readers; `close()` ends them so no timer outlives the World. */
|
|
67
|
+
const sleepers = new Set();
|
|
68
|
+
const sleep = (ms) => new Promise((resolve) => {
|
|
69
|
+
const wake = () => {
|
|
70
|
+
clearTimeout(timer);
|
|
71
|
+
sleepers.delete(wake);
|
|
72
|
+
resolve();
|
|
73
|
+
};
|
|
74
|
+
const timer = setTimeout(wake, ms);
|
|
75
|
+
sleepers.add(wake);
|
|
76
|
+
});
|
|
77
|
+
// ---- Transport and errors ----
|
|
78
|
+
async function call(document, variables, runId) {
|
|
79
|
+
try {
|
|
80
|
+
return await client.request(document, variables);
|
|
81
|
+
}
|
|
82
|
+
catch (error) {
|
|
83
|
+
throw translate(error, alias, runId, authSource);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
// ---- Run pages: one relay serves runs.get, hooks, streams.list ----
|
|
87
|
+
const runPages = new Map();
|
|
88
|
+
/** hookId → runId, from every run page read: how `hooks.get(hookId)` finds the run. */
|
|
89
|
+
const hookRuns = new Map();
|
|
90
|
+
function runPage(runId) {
|
|
91
|
+
const cached = runPages.get(runId);
|
|
92
|
+
if (cached && cached.expires > Date.now())
|
|
93
|
+
return cached.page;
|
|
94
|
+
// Never expires while in flight (a relay may wait out a cold start); reused for a moment after.
|
|
95
|
+
const entry = { expires: Number.POSITIVE_INFINITY };
|
|
96
|
+
entry.page = call(docs.RUN, { alias, runID: runId }, runId).then(({ workflowRun }) => {
|
|
97
|
+
for (const hook of workflowRun.hooks)
|
|
98
|
+
hookRuns.set(hook.hookID, runId);
|
|
99
|
+
entry.expires = Date.now() + RUN_PAGE_TTL_MS;
|
|
100
|
+
return workflowRun;
|
|
101
|
+
}, (error) => {
|
|
102
|
+
if (runPages.get(runId) === entry)
|
|
103
|
+
runPages.delete(runId);
|
|
104
|
+
throw error;
|
|
105
|
+
});
|
|
106
|
+
runPages.set(runId, entry);
|
|
107
|
+
return entry.page;
|
|
108
|
+
}
|
|
109
|
+
// ---- Records in the World's shapes ----
|
|
110
|
+
function runRecord(run, page, withData = false) {
|
|
111
|
+
const record = {
|
|
112
|
+
runId: run.runID,
|
|
113
|
+
status: run.status.toLowerCase(),
|
|
114
|
+
deploymentId: run.deploymentID,
|
|
115
|
+
workflowName: run.workflowName,
|
|
116
|
+
attributes: Object.fromEntries(run.attributes.map(({ key, value }) => [key, value])),
|
|
117
|
+
createdAt: toDate(run.createdAt),
|
|
118
|
+
updatedAt: toDate(run.updatedAt),
|
|
119
|
+
};
|
|
120
|
+
setDate(record, "startedAt", run.startedAt);
|
|
121
|
+
setDate(record, "completedAt", run.completedAt);
|
|
122
|
+
setDate(record, "expiredAt", run.expiredAt);
|
|
123
|
+
if (run.errorCode !== null)
|
|
124
|
+
record.errorCode = run.errorCode;
|
|
125
|
+
if (page && withData) {
|
|
126
|
+
setDefined(record, "input", serializedValue(page.input));
|
|
127
|
+
setDefined(record, "output", serializedValue(page.output));
|
|
128
|
+
}
|
|
129
|
+
if (page?.error)
|
|
130
|
+
record.error = structuredError(page.error);
|
|
131
|
+
return record;
|
|
132
|
+
}
|
|
133
|
+
function stepRecord(runId, step, detail, withData = false) {
|
|
134
|
+
const record = {
|
|
135
|
+
runId,
|
|
136
|
+
stepId: step.stepID,
|
|
137
|
+
stepName: step.stepName,
|
|
138
|
+
status: step.status,
|
|
139
|
+
attempt: step.attempt,
|
|
140
|
+
createdAt: toDate(step.createdAt),
|
|
141
|
+
updatedAt: toDate(step.updatedAt),
|
|
142
|
+
};
|
|
143
|
+
setDate(record, "startedAt", step.startedAt);
|
|
144
|
+
setDate(record, "completedAt", step.completedAt);
|
|
145
|
+
setDate(record, "retryAfter", step.retryAfter);
|
|
146
|
+
if (detail && withData) {
|
|
147
|
+
setDefined(record, "input", serializedValue(detail.input));
|
|
148
|
+
setDefined(record, "output", serializedValue(detail.output));
|
|
149
|
+
}
|
|
150
|
+
if (detail?.error)
|
|
151
|
+
record.error = structuredError(detail.error);
|
|
152
|
+
return record;
|
|
153
|
+
}
|
|
154
|
+
function eventRecord(runId, event, data) {
|
|
155
|
+
const record = {
|
|
156
|
+
runId,
|
|
157
|
+
eventId: event.eventID,
|
|
158
|
+
eventType: event.eventType,
|
|
159
|
+
createdAt: toDate(event.createdAt),
|
|
160
|
+
eventData: eventData(event.info),
|
|
161
|
+
};
|
|
162
|
+
if (event.correlationID !== null)
|
|
163
|
+
record.correlationId = event.correlationID;
|
|
164
|
+
const field = EVENT_PAYLOAD_FIELDS[event.eventType];
|
|
165
|
+
if (field && data)
|
|
166
|
+
record.eventData[field] = serializedValue(data);
|
|
167
|
+
return record;
|
|
168
|
+
}
|
|
169
|
+
function hookRecord(runId, hook) {
|
|
170
|
+
const record = {
|
|
171
|
+
runId,
|
|
172
|
+
hookId: hook.hookID,
|
|
173
|
+
token: REDACTED,
|
|
174
|
+
ownerId: "",
|
|
175
|
+
projectId: alias,
|
|
176
|
+
environment: "production",
|
|
177
|
+
createdAt: toDate(hook.createdAt),
|
|
178
|
+
isWebhook: hook.kind === "webhook",
|
|
179
|
+
isSystem: hook.kind === "system",
|
|
180
|
+
// What the inspection API adds: the hook's state from its run's events.
|
|
181
|
+
status: hook.status,
|
|
182
|
+
receivedCount: hook.receivedCount,
|
|
183
|
+
};
|
|
184
|
+
setDate(record, "lastReceivedAt", hook.lastReceivedAt);
|
|
185
|
+
setDate(record, "disposedAt", hook.disposedAt);
|
|
186
|
+
return record;
|
|
187
|
+
}
|
|
188
|
+
// ---- Lists ----
|
|
189
|
+
async function allSteps(runId) {
|
|
190
|
+
const steps = [];
|
|
191
|
+
let cursor = null;
|
|
192
|
+
for (let page = 0; page < STEP_PAGES_MAX; page++) {
|
|
193
|
+
const { workflowSteps } = await call(docs.STEPS, { alias, runID: runId, limit: 200, cursor }, runId);
|
|
194
|
+
steps.push(...workflowSteps.steps);
|
|
195
|
+
if (!workflowSteps.hasMore || !workflowSteps.nextCursor)
|
|
196
|
+
break;
|
|
197
|
+
cursor = workflowSteps.nextCursor;
|
|
198
|
+
}
|
|
199
|
+
return steps;
|
|
200
|
+
}
|
|
201
|
+
async function activeRuns(limit, cursor) {
|
|
202
|
+
const { workflowRuns } = await call(docs.RUNS, {
|
|
203
|
+
alias,
|
|
204
|
+
status: ["RUNNING", "PENDING"],
|
|
205
|
+
limit,
|
|
206
|
+
cursor,
|
|
207
|
+
});
|
|
208
|
+
const pages = await mapLimit(workflowRuns.runs, CONCURRENCY, (run) => runPage(run.runID));
|
|
209
|
+
return { pages, nextCursor: workflowRuns.nextCursor, hasMore: workflowRuns.hasMore };
|
|
210
|
+
}
|
|
211
|
+
function hooksOf(page, onlyActive) {
|
|
212
|
+
return page.hooks
|
|
213
|
+
.filter((hook) => !onlyActive || hook.status === "active")
|
|
214
|
+
.map((hook) => hookRecord(page.run.runID, hook));
|
|
215
|
+
}
|
|
216
|
+
// ---- Writes the inspection API offers ----
|
|
217
|
+
/** Runs woken through this World, for the SDK's follow-up `queue()` (see {@link WAKE_QUEUE_WINDOW_MS}). */
|
|
218
|
+
const woken = new Map();
|
|
219
|
+
/**
|
|
220
|
+
* Cancels a run; resolves `"already_cancelled"` for a run cancelled before,
|
|
221
|
+
* which, as in the upstream Worlds, is not an error. A run that completed or
|
|
222
|
+
* failed is refused with the platform's "run … already ended (…)".
|
|
223
|
+
*/
|
|
224
|
+
async function cancel(runId, reason) {
|
|
225
|
+
try {
|
|
226
|
+
await call(docs.CANCEL,
|
|
227
|
+
// The platform takes at most 512 characters (code points); cut on a character boundary.
|
|
228
|
+
{ input: { alias, runID: runId, ...(reason !== undefined ? { reason: Array.from(reason).slice(0, 512).join("") } : {}) } }, runId);
|
|
229
|
+
return "cancelled";
|
|
230
|
+
}
|
|
231
|
+
catch (error) {
|
|
232
|
+
if (endedAs(error) === "cancelled")
|
|
233
|
+
return "already_cancelled";
|
|
234
|
+
throw error;
|
|
235
|
+
}
|
|
236
|
+
finally {
|
|
237
|
+
runPages.delete(runId);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
// ---- The World ----
|
|
241
|
+
const world = {
|
|
242
|
+
alias,
|
|
243
|
+
specVersion: REMOTE_SPEC_VERSION,
|
|
244
|
+
capabilities: {},
|
|
245
|
+
runs: {
|
|
246
|
+
async get(id, params) {
|
|
247
|
+
const page = await runPage(id);
|
|
248
|
+
return runRecord(page.run, page, params?.resolveData !== "none");
|
|
249
|
+
},
|
|
250
|
+
async list(params = {}) {
|
|
251
|
+
if ((params.pagination?.sortOrder ?? "desc") !== "desc") {
|
|
252
|
+
throw invalidArgument("runs.list", "sortOrder", "the unfazed backend lists runs newest first only; drop --sort asc");
|
|
253
|
+
}
|
|
254
|
+
const status = params.status === undefined
|
|
255
|
+
? null
|
|
256
|
+
: (Array.isArray(params.status) ? params.status : [params.status]).map((s) => s.toUpperCase());
|
|
257
|
+
const { workflowRuns } = await call(docs.RUNS, {
|
|
258
|
+
alias,
|
|
259
|
+
status,
|
|
260
|
+
workflowName: params.workflowName ?? null,
|
|
261
|
+
limit: clamp(params.pagination?.limit ?? 20, 1, 100),
|
|
262
|
+
cursor: params.pagination?.cursor || null,
|
|
263
|
+
});
|
|
264
|
+
return {
|
|
265
|
+
data: workflowRuns.runs.map((run) => runRecord(run)),
|
|
266
|
+
cursor: workflowRuns.hasMore ? workflowRuns.nextCursor : null,
|
|
267
|
+
hasMore: workflowRuns.hasMore,
|
|
268
|
+
};
|
|
269
|
+
},
|
|
270
|
+
async cancelMany(request) {
|
|
271
|
+
const results = await mapLimit(request.runIds, CONCURRENCY, async (runId) => {
|
|
272
|
+
try {
|
|
273
|
+
return { runId, outcome: await cancel(runId, request.cancelReason) };
|
|
274
|
+
}
|
|
275
|
+
catch (error) {
|
|
276
|
+
if (error instanceof UnfazedInspectError && (error.status === 401 || error.status === 403))
|
|
277
|
+
throw error;
|
|
278
|
+
const ended = endedAs(error);
|
|
279
|
+
if (ended)
|
|
280
|
+
return { runId, outcome: "not_cancellable", status: ended };
|
|
281
|
+
if (error instanceof UnfazedInspectError && error.name === "WorkflowRunNotFoundError") {
|
|
282
|
+
return { runId, outcome: "not_found" };
|
|
283
|
+
}
|
|
284
|
+
return { runId, outcome: "failed", code: "internal_error", retryable: true };
|
|
285
|
+
}
|
|
286
|
+
});
|
|
287
|
+
const count = (outcome) => results.filter((r) => r.outcome === outcome).length;
|
|
288
|
+
return {
|
|
289
|
+
summary: {
|
|
290
|
+
requested: results.length,
|
|
291
|
+
cancelled: count("cancelled"),
|
|
292
|
+
alreadyCancelled: count("already_cancelled"),
|
|
293
|
+
notCancellable: count("not_cancellable"),
|
|
294
|
+
notFound: count("not_found"),
|
|
295
|
+
failed: count("failed"),
|
|
296
|
+
},
|
|
297
|
+
results,
|
|
298
|
+
};
|
|
299
|
+
},
|
|
300
|
+
},
|
|
301
|
+
steps: {
|
|
302
|
+
async get(runId, stepId, params) {
|
|
303
|
+
const { workflowStep } = await call(docs.STEP_DETAIL, { alias, runID: runId, stepID: stepId }, runId);
|
|
304
|
+
return stepRecord(runId, workflowStep.step, workflowStep, params?.resolveData !== "none");
|
|
305
|
+
},
|
|
306
|
+
async list(params) {
|
|
307
|
+
const { runId, pagination } = params;
|
|
308
|
+
const limit = clamp(pagination?.limit ?? 20, 1, 200);
|
|
309
|
+
if ((pagination?.sortOrder ?? "desc") === "asc") {
|
|
310
|
+
if (pagination?.cursor?.startsWith(CURSOR_PREFIX))
|
|
311
|
+
throw badCursor("steps.list");
|
|
312
|
+
const { workflowSteps } = await call(docs.STEPS, { alias, runID: runId, limit, cursor: pagination?.cursor || null }, runId);
|
|
313
|
+
return {
|
|
314
|
+
data: workflowSteps.steps.map((step) => stepRecord(runId, step)),
|
|
315
|
+
cursor: workflowSteps.hasMore ? workflowSteps.nextCursor : null,
|
|
316
|
+
hasMore: workflowSteps.hasMore,
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
// Newest first: the API pages oldest first, so read the run's steps and page them here.
|
|
320
|
+
const offset = decodeOffset(pagination?.cursor, "steps.list");
|
|
321
|
+
const steps = (await allSteps(runId)).reverse();
|
|
322
|
+
return offsetPage(steps.map((step) => stepRecord(runId, step)), offset, limit);
|
|
323
|
+
},
|
|
324
|
+
},
|
|
325
|
+
events: {
|
|
326
|
+
async get(runId, eventId, params) {
|
|
327
|
+
const { workflowEvent } = await call(docs.EVENT_DETAIL, { alias, runID: runId, eventID: eventId }, runId);
|
|
328
|
+
return eventRecord(runId, workflowEvent.event, params?.resolveData === "none" ? null : workflowEvent.data);
|
|
329
|
+
},
|
|
330
|
+
async list(params) {
|
|
331
|
+
return listEvents(params.runId, null, params.pagination);
|
|
332
|
+
},
|
|
333
|
+
async listByCorrelationId(params) {
|
|
334
|
+
if (!params.runId) {
|
|
335
|
+
throw invalidArgument("events.listByCorrelationId", "runId", "the unfazed backend reads events per run: pass the run ID");
|
|
336
|
+
}
|
|
337
|
+
return listEvents(params.runId, params.correlationId, params.pagination);
|
|
338
|
+
},
|
|
339
|
+
async create(runId, data) {
|
|
340
|
+
if (runId && data.eventType === "run_cancelled") {
|
|
341
|
+
const reason = data.eventData?.cancelReason;
|
|
342
|
+
await cancel(runId, typeof reason === "string" ? reason : undefined);
|
|
343
|
+
return { event: syntheticEvent(runId, data) };
|
|
344
|
+
}
|
|
345
|
+
if (runId && data.eventType === "wait_completed" && data.correlationId) {
|
|
346
|
+
await call(docs.WAKE, { input: { alias, runID: runId, correlationIDs: [data.correlationId] } }, runId);
|
|
347
|
+
woken.set(runId, Date.now() + WAKE_QUEUE_WINDOW_MS);
|
|
348
|
+
runPages.delete(runId);
|
|
349
|
+
return { event: syntheticEvent(runId, data) };
|
|
350
|
+
}
|
|
351
|
+
throw readOnly(`write a ${data.eventType} event`);
|
|
352
|
+
},
|
|
353
|
+
},
|
|
354
|
+
hooks: {
|
|
355
|
+
async get(hookId) {
|
|
356
|
+
const known = hookRuns.get(hookId);
|
|
357
|
+
if (known) {
|
|
358
|
+
const hook = (await runPage(known)).hooks.find((h) => h.hookID === hookId);
|
|
359
|
+
if (hook)
|
|
360
|
+
return hookRecord(known, hook);
|
|
361
|
+
}
|
|
362
|
+
const { pages } = await activeRuns(HOOK_SCAN_RUNS, null);
|
|
363
|
+
for (const page of pages) {
|
|
364
|
+
const hook = page.hooks.find((h) => h.hookID === hookId);
|
|
365
|
+
if (hook)
|
|
366
|
+
return hookRecord(page.run.runID, hook);
|
|
367
|
+
}
|
|
368
|
+
throw new UnfazedInspectError(`Hook ${hookId} not found. The unfazed backend finds a hook through its run, and no run read so far, ` +
|
|
369
|
+
`nor any of the ${HOOK_SCAN_RUNS} newest active runs, has it. List a run's hooks with ` +
|
|
370
|
+
"`workflow inspect hooks --runId <run-id>`.", { name: "HookNotFoundError", status: 404 });
|
|
371
|
+
},
|
|
372
|
+
async getByToken() {
|
|
373
|
+
throw readOnly("look a hook up by its token (hook tokens never leave the app)");
|
|
374
|
+
},
|
|
375
|
+
async list(params) {
|
|
376
|
+
const { runId, pagination } = params;
|
|
377
|
+
const desc = pagination?.sortOrder === "desc";
|
|
378
|
+
const byCreated = (a, b) => (a.createdAt.getTime() - b.createdAt.getTime()) * (desc ? -1 : 1);
|
|
379
|
+
if (runId) {
|
|
380
|
+
const hooks = hooksOf(await runPage(runId), false).sort(byCreated);
|
|
381
|
+
return offsetPage(hooks, decodeOffset(pagination?.cursor, "hooks.list"), clamp(pagination?.limit ?? 100, 1, 500));
|
|
382
|
+
}
|
|
383
|
+
// Every hook of the app: those of its active runs (a finished run's hooks are gone), a page of runs at a time.
|
|
384
|
+
const runsCursor = decodeWrapped(pagination?.cursor, "runs", "hooks.list");
|
|
385
|
+
const { pages, nextCursor, hasMore } = await activeRuns(clamp(pagination?.limit ?? 20, 1, HOOK_SCAN_RUNS), runsCursor);
|
|
386
|
+
return {
|
|
387
|
+
data: pages.flatMap((page) => hooksOf(page, true)).sort(byCreated),
|
|
388
|
+
cursor: hasMore && nextCursor ? `${CURSOR_PREFIX}runs:${nextCursor}` : null,
|
|
389
|
+
hasMore: hasMore && nextCursor !== null,
|
|
390
|
+
};
|
|
391
|
+
},
|
|
392
|
+
},
|
|
393
|
+
streams: {
|
|
394
|
+
async list(runId) {
|
|
395
|
+
return (await runPage(runId)).streams.map((stream) => stream.name);
|
|
396
|
+
},
|
|
397
|
+
async getInfo(runId, name) {
|
|
398
|
+
const { workflowStream } = await call(docs.STREAM, { alias, runID: runId, name, limit: 1 }, runId);
|
|
399
|
+
return { tailIndex: workflowStream.tailIndex, done: workflowStream.done };
|
|
400
|
+
},
|
|
401
|
+
async getChunks(runId, name, options) {
|
|
402
|
+
const { workflowStream } = await call(docs.STREAM, { alias, runID: runId, name, limit: clamp(options?.limit ?? 100, 1, 200), cursor: options?.cursor || null }, runId);
|
|
403
|
+
return {
|
|
404
|
+
data: workflowStream.chunks.map((chunk) => ({ index: chunk.index, data: frame(chunkValue(chunk)) })),
|
|
405
|
+
cursor: workflowStream.hasMore ? workflowStream.nextCursor : null,
|
|
406
|
+
hasMore: workflowStream.hasMore,
|
|
407
|
+
done: workflowStream.done,
|
|
408
|
+
};
|
|
409
|
+
},
|
|
410
|
+
/**
|
|
411
|
+
* The stream from `startIndex` (negative: from the end), each chunk one
|
|
412
|
+
* frame, read a page at a time and, while the run is still writing it,
|
|
413
|
+
* polled (1 s, doubling up to `streamPollMs`) until it is done.
|
|
414
|
+
*/
|
|
415
|
+
async get(runId, name, startIndex = 0) {
|
|
416
|
+
let pageCursor = null;
|
|
417
|
+
let position;
|
|
418
|
+
let delay = pollMinMs;
|
|
419
|
+
let cancelled = false;
|
|
420
|
+
return new ReadableStream({
|
|
421
|
+
async pull(controller) {
|
|
422
|
+
for (;;) {
|
|
423
|
+
if (cancelled || closed) {
|
|
424
|
+
controller.close();
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
427
|
+
const { workflowStream } = await call(docs.STREAM, { alias, runID: runId, name, limit: STREAM_PAGE, cursor: pageCursor }, runId);
|
|
428
|
+
position ??= startIndex < 0 ? Math.max(0, workflowStream.tailIndex + 1 + startIndex) : startIndex;
|
|
429
|
+
const fresh = workflowStream.chunks.filter((chunk) => chunk.index >= position);
|
|
430
|
+
// A page with more after it is never read again; the last page is re-read for new chunks.
|
|
431
|
+
if (workflowStream.hasMore && workflowStream.nextCursor)
|
|
432
|
+
pageCursor = workflowStream.nextCursor;
|
|
433
|
+
if (fresh.length > 0) {
|
|
434
|
+
for (const chunk of fresh)
|
|
435
|
+
controller.enqueue(frame(chunkValue(chunk)));
|
|
436
|
+
position = fresh[fresh.length - 1].index + 1;
|
|
437
|
+
delay = pollMinMs;
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
if (workflowStream.hasMore)
|
|
441
|
+
continue;
|
|
442
|
+
if (workflowStream.done) {
|
|
443
|
+
controller.close();
|
|
444
|
+
return;
|
|
445
|
+
}
|
|
446
|
+
await sleep(delay);
|
|
447
|
+
delay = Math.min(delay * 2, pollMaxMs);
|
|
448
|
+
}
|
|
449
|
+
},
|
|
450
|
+
cancel() {
|
|
451
|
+
cancelled = true;
|
|
452
|
+
},
|
|
453
|
+
});
|
|
454
|
+
},
|
|
455
|
+
async write() {
|
|
456
|
+
throw readOnly("write to a stream");
|
|
457
|
+
},
|
|
458
|
+
async writeMulti() {
|
|
459
|
+
throw readOnly("write to a stream");
|
|
460
|
+
},
|
|
461
|
+
async close() {
|
|
462
|
+
throw readOnly("close a stream");
|
|
463
|
+
},
|
|
464
|
+
},
|
|
465
|
+
async getDeploymentId() {
|
|
466
|
+
throw readOnly("start runs (it has no deployment of its own)");
|
|
467
|
+
},
|
|
468
|
+
async queue(_queueName, message) {
|
|
469
|
+
const runId = message?.runId;
|
|
470
|
+
const until = typeof runId === "string" ? woken.get(runId) : undefined;
|
|
471
|
+
// `wakeUp()` re-enqueues the run after completing its waits; the app already did both.
|
|
472
|
+
if (until !== undefined && until > Date.now())
|
|
473
|
+
return { messageId: null };
|
|
474
|
+
throw readOnly("enqueue messages");
|
|
475
|
+
},
|
|
476
|
+
createQueueHandler() {
|
|
477
|
+
throw readOnly("handle queue deliveries");
|
|
478
|
+
},
|
|
479
|
+
async close() {
|
|
480
|
+
closed = true;
|
|
481
|
+
for (const wake of [...sleepers])
|
|
482
|
+
wake();
|
|
483
|
+
},
|
|
484
|
+
};
|
|
485
|
+
async function listEvents(runId, correlationId, pagination) {
|
|
486
|
+
const { workflowEvents } = await call(docs.EVENTS, {
|
|
487
|
+
alias,
|
|
488
|
+
runID: runId,
|
|
489
|
+
correlationID: correlationId,
|
|
490
|
+
newestFirst: pagination?.sortOrder === "desc",
|
|
491
|
+
limit: clamp(pagination?.limit ?? 100, 1, 500),
|
|
492
|
+
cursor: pagination?.cursor || null,
|
|
493
|
+
}, runId);
|
|
494
|
+
return {
|
|
495
|
+
data: workflowEvents.events.map((event) => eventRecord(runId, event)),
|
|
496
|
+
cursor: workflowEvents.hasMore ? workflowEvents.nextCursor : null,
|
|
497
|
+
hasMore: workflowEvents.hasMore,
|
|
498
|
+
};
|
|
499
|
+
}
|
|
500
|
+
// Every member exists on Workflow SDK's World (no misspelt or invented method); parameters
|
|
501
|
+
// are the World's own types above. The overloaded return types are not checked here.
|
|
502
|
+
const keys = true;
|
|
503
|
+
const namespaces = [true, true, true, true, true];
|
|
504
|
+
void keys;
|
|
505
|
+
void namespaces;
|
|
506
|
+
return world;
|
|
507
|
+
}
|
|
508
|
+
/** Cursors this backend mints itself (offset pages, wrapped run cursors); the API's own pass through untouched. */
|
|
509
|
+
const CURSOR_PREFIX = "unfz:";
|
|
510
|
+
function offsetPage(items, offset, limit) {
|
|
511
|
+
const end = offset + limit;
|
|
512
|
+
const hasMore = end < items.length;
|
|
513
|
+
return { data: items.slice(offset, end), cursor: hasMore ? `${CURSOR_PREFIX}offset:${end}` : null, hasMore };
|
|
514
|
+
}
|
|
515
|
+
function decodeOffset(cursor, method) {
|
|
516
|
+
if (!cursor)
|
|
517
|
+
return 0;
|
|
518
|
+
const match = /^unfz:offset:(\d{1,9})$/.exec(cursor);
|
|
519
|
+
if (!match)
|
|
520
|
+
throw badCursor(method);
|
|
521
|
+
return Number(match[1]);
|
|
522
|
+
}
|
|
523
|
+
function decodeWrapped(cursor, kind, method) {
|
|
524
|
+
if (!cursor)
|
|
525
|
+
return null;
|
|
526
|
+
const prefix = `${CURSOR_PREFIX}${kind}:`;
|
|
527
|
+
if (!cursor.startsWith(prefix) || cursor.length === prefix.length)
|
|
528
|
+
throw badCursor(method);
|
|
529
|
+
return cursor.slice(prefix.length);
|
|
530
|
+
}
|
|
531
|
+
function badCursor(method) {
|
|
532
|
+
return invalidArgument(method, "cursor", "this cursor came from another listing or sort order");
|
|
533
|
+
}
|
|
534
|
+
function invalidArgument(method, field, why) {
|
|
535
|
+
return new UnfazedInspectError(`unfazed: ${method}: ${field}: ${why}.`, { code: "INVALID_ARGUMENT" });
|
|
536
|
+
}
|
|
537
|
+
function readOnly(what) {
|
|
538
|
+
return new UnfazedInspectError(`unfazed: the \`unfazed\` CLI backend is read-only and cannot ${what}. It inspects an app's runs from your ` +
|
|
539
|
+
"terminal (workflow inspect, workflow cancel, the web UI). Apps use unfazed/workflow: its Nitro module, " +
|
|
540
|
+
'or setWorld(createWorld()) from "unfazed/workflow", not WORKFLOW_TARGET_WORLD=unfazed.', { code: "READ_ONLY" });
|
|
541
|
+
}
|
|
542
|
+
function syntheticEvent(runId, data) {
|
|
543
|
+
const { correlationId, eventData } = data;
|
|
544
|
+
return {
|
|
545
|
+
runId,
|
|
546
|
+
eventId: "",
|
|
547
|
+
eventType: data.eventType,
|
|
548
|
+
createdAt: new Date(),
|
|
549
|
+
...(correlationId ? { correlationId } : {}),
|
|
550
|
+
...(eventData ? { eventData } : {}),
|
|
551
|
+
};
|
|
552
|
+
}
|
|
553
|
+
/**
|
|
554
|
+
* The platform's error as Workflow SDK's tooling expects a World's: a
|
|
555
|
+
* missing run is `WorkflowRunNotFoundError` (404), a refused token says how
|
|
556
|
+
* to get one, a token without the deploy scope says what needs it.
|
|
557
|
+
*/
|
|
558
|
+
function translate(error, alias, runId, authSource) {
|
|
559
|
+
const refused = (status, cause) => new UnfazedInspectError(authSource === "login"
|
|
560
|
+
? `unfazed: the platform refused the stored login (HTTP ${status}). Run \`${LOGIN_COMMAND}\` again.`
|
|
561
|
+
: `unfazed: the platform refused the API token (HTTP ${status}). Check ${TOKEN_ENV_VAR}; ` +
|
|
562
|
+
`sign in with \`${LOGIN_COMMAND}\` instead, or get an agent token at https://unfazed.dev/auth.md.`, { status, cause });
|
|
563
|
+
if (error instanceof UnfazedHTTPError) {
|
|
564
|
+
if (error.status === 401 || error.status === 403)
|
|
565
|
+
return refused(error.status, error);
|
|
566
|
+
return new UnfazedInspectError(error.message, { status: error.status, cause: error });
|
|
567
|
+
}
|
|
568
|
+
if (!(error instanceof UnfazedGraphQLError))
|
|
569
|
+
return error;
|
|
570
|
+
const message = error.errors.map((e) => e.message).join("; ");
|
|
571
|
+
if (message.trim() === "authentication required")
|
|
572
|
+
return refused(401, error);
|
|
573
|
+
if (/^no such run\b/.test(message)) {
|
|
574
|
+
return new UnfazedInspectError(runId ? `Workflow run "${runId}" not found` : `unfazed: ${message}`, {
|
|
575
|
+
name: "WorkflowRunNotFoundError",
|
|
576
|
+
status: 404,
|
|
577
|
+
runId,
|
|
578
|
+
cause: error,
|
|
579
|
+
});
|
|
580
|
+
}
|
|
581
|
+
if (/^no such (step|event|stream)\b/.test(message)) {
|
|
582
|
+
return new UnfazedInspectError(`unfazed: ${message}`, { status: 404, cause: error });
|
|
583
|
+
}
|
|
584
|
+
if (message.trim() === "deployment was not found") {
|
|
585
|
+
return new UnfazedInspectError(`unfazed: no app "${alias}" that this token can see. Check ${APP_ENV_VAR} (the app's alias) and that ` +
|
|
586
|
+
"the token's account owns the app or shares its team.", { status: 404, cause: error });
|
|
587
|
+
}
|
|
588
|
+
const scope = /missing OAuth scope (\S+)/.exec(message);
|
|
589
|
+
if (scope) {
|
|
590
|
+
const needs = scope[1] === "unfz:deploy" ? " (cancel and wake need it, like restarting the app)" : "";
|
|
591
|
+
return new UnfazedInspectError(`unfazed: this API token lacks the ${scope[1]} scope${needs}.`, { status: 403, cause: error });
|
|
592
|
+
}
|
|
593
|
+
return new UnfazedInspectError(`unfazed: ${message}`, { cause: error });
|
|
594
|
+
}
|
|
595
|
+
/** The status of the "run … already ended (<status>)" refusal (409 `run_terminal`), else undefined. */
|
|
596
|
+
function endedAs(error) {
|
|
597
|
+
return /already ended \(([a-z]+)\)/.exec(error instanceof Error ? error.message : "")?.[1];
|
|
598
|
+
}
|
|
599
|
+
function normalizeEndpoint(value) {
|
|
600
|
+
const trimmed = value.replace(/\/+$/, "");
|
|
601
|
+
return /\/query$|\/graphql$/.test(trimmed) ? trimmed : `${trimmed}/query`;
|
|
602
|
+
}
|
|
603
|
+
function clamp(value, min, max) {
|
|
604
|
+
return Number.isFinite(value) ? Math.min(max, Math.max(min, Math.trunc(value))) : min;
|
|
605
|
+
}
|
|
606
|
+
function setDate(record, key, value) {
|
|
607
|
+
const date = toDate(value);
|
|
608
|
+
if (date)
|
|
609
|
+
record[key] = date;
|
|
610
|
+
}
|
|
611
|
+
function setDefined(record, key, value) {
|
|
612
|
+
if (value !== undefined)
|
|
613
|
+
record[key] = value;
|
|
614
|
+
}
|
|
615
|
+
/** `fn` over `items`, at most `limit` at a time, results in order. */
|
|
616
|
+
async function mapLimit(items, limit, fn) {
|
|
617
|
+
const results = new Array(items.length);
|
|
618
|
+
let next = 0;
|
|
619
|
+
const worker = async () => {
|
|
620
|
+
while (next < items.length) {
|
|
621
|
+
const index = next++;
|
|
622
|
+
results[index] = await fn(items[index]);
|
|
623
|
+
}
|
|
624
|
+
};
|
|
625
|
+
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
|
|
626
|
+
return results;
|
|
627
|
+
}
|
|
628
|
+
//# sourceMappingURL=world.js.map
|