@voyant-travel/workflow-runs 0.107.10

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.
@@ -0,0 +1,242 @@
1
+ /**
2
+ * Recorder API — wraps an in-process workflow run with start / step
3
+ * lifecycle / finish writes against the `workflow_runs` +
4
+ * `workflow_run_steps` tables.
5
+ *
6
+ * Designed for the `@voyant-travel/core/workflows` saga primitive:
7
+ * callers do
8
+ *
9
+ * const recorder = await beginWorkflowRun(db, { workflowName, ... })
10
+ * try {
11
+ * const result = await runFn(recorder)
12
+ * await recorder.complete(result)
13
+ * } catch (err) {
14
+ * await recorder.fail(err)
15
+ * throw err
16
+ * }
17
+ *
18
+ * — and inside `runFn` the caller (or, more naturally, an
19
+ * instrumented version of `runCheckoutFinalize`) calls
20
+ * `recorder.startStep(name)` / `recorder.completeStep(name, output)` /
21
+ * `recorder.failStep(name, error)` per step.
22
+ *
23
+ * The recorder is fire-and-forget around the actual run — it logs
24
+ * persistence failures rather than rethrowing, so observability
25
+ * outages never break the underlying business operation.
26
+ */
27
+ import { and, desc, eq } from "drizzle-orm";
28
+ import { workflowRunSteps, workflowRuns, } from "./schema.js";
29
+ /**
30
+ * Start a run and return a recorder bound to its id. The row is
31
+ * inserted with `status: "running"` and a `startedAt` of "now".
32
+ *
33
+ * Persistence errors are caught — if the table doesn't exist or the
34
+ * DB is unreachable, returns a no-op recorder so the caller's
35
+ * workflow keeps running. The whole point of this layer is
36
+ * observability, not durability.
37
+ */
38
+ export async function beginWorkflowRun(db, input, options = {}) {
39
+ if (options.reuseRunningRun && input.correlationId) {
40
+ const existingRunId = await findRunningWorkflowRun(db, input);
41
+ if (existingRunId)
42
+ return workflowRunRecorder(db, existingRunId);
43
+ }
44
+ const insert = {
45
+ workflowName: input.workflowName,
46
+ trigger: input.trigger ?? "manual",
47
+ correlationId: input.correlationId ?? null,
48
+ tags: [...(input.tags ?? [])],
49
+ input: input.input ?? null,
50
+ status: "running",
51
+ parentRunId: input.parentRunId ?? null,
52
+ triggeredByUserId: input.triggeredByUserId ?? null,
53
+ resumeFromStep: input.resumeFromStep ?? null,
54
+ };
55
+ let runId = null;
56
+ try {
57
+ const [row] = await db.insert(workflowRuns).values(insert).returning({ id: workflowRuns.id });
58
+ runId = row?.id ?? null;
59
+ }
60
+ catch (err) {
61
+ console.warn(`[workflow-runs] could not record run start for "${input.workflowName}":`, err instanceof Error ? err.message : err);
62
+ }
63
+ if (!runId)
64
+ return noopRecorder();
65
+ return workflowRunRecorder(db, runId);
66
+ }
67
+ async function findRunningWorkflowRun(db, input) {
68
+ const correlationId = input.correlationId;
69
+ if (!correlationId)
70
+ return null;
71
+ try {
72
+ const [row] = await db
73
+ .select({ id: workflowRuns.id })
74
+ .from(workflowRuns)
75
+ .where(and(eq(workflowRuns.workflowName, input.workflowName), eq(workflowRuns.correlationId, correlationId), eq(workflowRuns.status, "running")))
76
+ .orderBy(desc(workflowRuns.startedAt))
77
+ .limit(1);
78
+ return row?.id ?? null;
79
+ }
80
+ catch {
81
+ return null;
82
+ }
83
+ }
84
+ function workflowRunRecorder(db, runId) {
85
+ const stepStarts = new Map();
86
+ let nextSequence = 1;
87
+ return {
88
+ runId,
89
+ async startStep(name) {
90
+ const sequence = nextSequence++;
91
+ const insertStep = {
92
+ runId,
93
+ stepName: name,
94
+ sequence,
95
+ status: "running",
96
+ };
97
+ try {
98
+ const [row] = await db
99
+ .insert(workflowRunSteps)
100
+ .values(insertStep)
101
+ .returning({ id: workflowRunSteps.id });
102
+ if (row?.id) {
103
+ stepStarts.set(name, { stepId: row.id, sequence, startedAt: Date.now() });
104
+ return { stepId: row.id };
105
+ }
106
+ }
107
+ catch (err) {
108
+ console.warn(`[workflow-runs] step start "${name}" insert failed:`, err);
109
+ }
110
+ return { stepId: null };
111
+ },
112
+ async completeStep(name, output) {
113
+ const tracking = stepStarts.get(name);
114
+ if (!tracking)
115
+ return;
116
+ const completedAt = new Date();
117
+ try {
118
+ await db
119
+ .update(workflowRunSteps)
120
+ .set({
121
+ status: "succeeded",
122
+ output: output ?? null,
123
+ completedAt,
124
+ durationMs: completedAt.getTime() - tracking.startedAt,
125
+ })
126
+ .where(eq(workflowRunSteps.id, tracking.stepId));
127
+ }
128
+ catch (err) {
129
+ console.warn(`[workflow-runs] step complete "${name}" update failed:`, err);
130
+ }
131
+ },
132
+ async failStep(name, error) {
133
+ const tracking = stepStarts.get(name);
134
+ if (!tracking)
135
+ return;
136
+ const completedAt = new Date();
137
+ try {
138
+ await db
139
+ .update(workflowRunSteps)
140
+ .set({
141
+ status: "failed",
142
+ error: serializeError(error, name),
143
+ completedAt,
144
+ durationMs: completedAt.getTime() - tracking.startedAt,
145
+ })
146
+ .where(eq(workflowRunSteps.id, tracking.stepId));
147
+ }
148
+ catch (err) {
149
+ console.warn(`[workflow-runs] step fail "${name}" update failed:`, err);
150
+ }
151
+ },
152
+ async recordSkippedStep(name, output) {
153
+ const sequence = nextSequence++;
154
+ const now = new Date();
155
+ const insertStep = {
156
+ runId,
157
+ stepName: name,
158
+ sequence,
159
+ status: "skipped",
160
+ output: output ?? null,
161
+ startedAt: now,
162
+ completedAt: now,
163
+ durationMs: 0,
164
+ };
165
+ try {
166
+ await db.insert(workflowRunSteps).values(insertStep);
167
+ }
168
+ catch (err) {
169
+ console.warn(`[workflow-runs] step skipped "${name}" insert failed:`, err);
170
+ }
171
+ },
172
+ complete: (result) => finalize(db, runId, "succeeded", result, null),
173
+ fail: (error, opts) => finalize(db, runId, "failed", null, serializeError(error, opts?.stepName)),
174
+ cancel: (reason) => finalize(db, runId, "cancelled", null, {
175
+ message: reason ?? "Cancelled",
176
+ }),
177
+ };
178
+ }
179
+ async function finalize(db, runId, status, result, error) {
180
+ const completedAt = new Date();
181
+ try {
182
+ const [existing] = await db
183
+ .select({ startedAt: workflowRuns.startedAt })
184
+ .from(workflowRuns)
185
+ .where(eq(workflowRuns.id, runId))
186
+ .limit(1);
187
+ const durationMs = existing ? completedAt.getTime() - existing.startedAt.getTime() : null;
188
+ await db
189
+ .update(workflowRuns)
190
+ .set({
191
+ status,
192
+ result: result ?? null,
193
+ error: error ?? null,
194
+ completedAt,
195
+ durationMs,
196
+ updatedAt: completedAt,
197
+ })
198
+ .where(eq(workflowRuns.id, runId));
199
+ }
200
+ catch (err) {
201
+ console.warn(`[workflow-runs] finalize ${status} failed for ${runId}:`, err);
202
+ }
203
+ }
204
+ function serializeError(error, stepName) {
205
+ if (error instanceof Error) {
206
+ return {
207
+ message: error.message,
208
+ ...(stepName ? { stepName } : {}),
209
+ ...(error.stack ? { stack: error.stack } : {}),
210
+ };
211
+ }
212
+ return {
213
+ message: typeof error === "string" ? error : JSON.stringify(error),
214
+ ...(stepName ? { stepName } : {}),
215
+ };
216
+ }
217
+ function noopRecorder() {
218
+ return {
219
+ runId: "",
220
+ async startStep() {
221
+ return { stepId: null };
222
+ },
223
+ async completeStep() {
224
+ // no-op
225
+ },
226
+ async failStep() {
227
+ // no-op
228
+ },
229
+ async recordSkippedStep() {
230
+ // no-op
231
+ },
232
+ async complete() {
233
+ // no-op
234
+ },
235
+ async fail() {
236
+ // no-op
237
+ },
238
+ async cancel() {
239
+ // no-op
240
+ },
241
+ };
242
+ }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Hono routes for the workflow-runs admin surface.
3
+ *
4
+ * GET /v1/admin/workflow-runs → list (filter by workflow / status / tag)
5
+ * GET /v1/admin/workflow-runs/:id → run + ordered steps
6
+ * POST /v1/admin/workflows/:name/runs → trigger registered workflow by name
7
+ * POST /v1/admin/workflow-runs/:id/rerun → fresh run with the recorded input
8
+ * POST /v1/admin/workflow-runs/:id/resume → re-run starting at the failed step
9
+ *
10
+ * Templates mount via `mountWorkflowRunsAdminRoutes(hono, opts)` —
11
+ * the routes are attached directly to the supplied Hono instance so
12
+ * they inherit the parent's middleware (auth + db). The optional
13
+ * `runners` registry is consulted for rerun/resume; if it's not
14
+ * provided (or doesn't have a runner for the workflow), those
15
+ * endpoints return 501 with a clear error.
16
+ */
17
+ import type { Hono } from "hono";
18
+ import type { WorkflowRunnerRegistry } from "./runner.js";
19
+ export type WorkflowAdminSurface = "tenant" | "cloud" | "disabled";
20
+ export declare function resolveWorkflowAdminSurface(value: string | undefined): WorkflowAdminSurface;
21
+ export interface MountWorkflowRunsAdminRoutesOptions {
22
+ /**
23
+ * Registry of executable runners keyed by workflow name. Required
24
+ * for the rerun/resume endpoints; bundles register their runners
25
+ * on bootstrap.
26
+ */
27
+ runners?: WorkflowRunnerRegistry;
28
+ /**
29
+ * Resolves the acting user id from the request context — used to
30
+ * stamp `triggered_by_user_id` on rerun runs. When omitted, runs
31
+ * are recorded without an actor.
32
+ */
33
+ resolveUserId?: (c: unknown) => string | null;
34
+ /**
35
+ * Controls whether tenant-admin workflow management actions are exposed.
36
+ * `tenant` preserves local/self-host behavior. `cloud` and `disabled`
37
+ * reject tenant-admin trigger/rerun/resume routes server-side while leaving
38
+ * read paths mounted for observability consumers that still need them.
39
+ */
40
+ adminSurface?: WorkflowAdminSurface;
41
+ }
42
+ export declare function mountWorkflowRunsAdminRoutes(hono: Hono, opts?: MountWorkflowRunsAdminRoutesOptions): void;
43
+ //# sourceMappingURL=routes.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"routes.d.ts","sourceRoot":"","sources":["../src/routes.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAGH,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,MAAM,CAAA;AAGhC,OAAO,KAAK,EAAE,sBAAsB,EAAE,MAAM,aAAa,CAAA;AA0BzD,MAAM,MAAM,oBAAoB,GAAG,QAAQ,GAAG,OAAO,GAAG,UAAU,CAAA;AAElE,wBAAgB,2BAA2B,CAAC,KAAK,EAAE,MAAM,GAAG,SAAS,GAAG,oBAAoB,CAQ3F;AAmBD,MAAM,WAAW,mCAAmC;IAClD;;;;OAIG;IACH,OAAO,CAAC,EAAE,sBAAsB,CAAA;IAChC;;;;OAIG;IACH,aAAa,CAAC,EAAE,CAAC,CAAC,EAAE,OAAO,KAAK,MAAM,GAAG,IAAI,CAAA;IAC7C;;;;;OAKG;IACH,YAAY,CAAC,EAAE,oBAAoB,CAAA;CACpC;AAED,wBAAgB,4BAA4B,CAC1C,IAAI,EAAE,IAAI,EACV,IAAI,GAAE,mCAAwC,GAC7C,IAAI,CAiSN"}
package/dist/routes.js ADDED
@@ -0,0 +1,311 @@
1
+ /**
2
+ * Hono routes for the workflow-runs admin surface.
3
+ *
4
+ * GET /v1/admin/workflow-runs → list (filter by workflow / status / tag)
5
+ * GET /v1/admin/workflow-runs/:id → run + ordered steps
6
+ * POST /v1/admin/workflows/:name/runs → trigger registered workflow by name
7
+ * POST /v1/admin/workflow-runs/:id/rerun → fresh run with the recorded input
8
+ * POST /v1/admin/workflow-runs/:id/resume → re-run starting at the failed step
9
+ *
10
+ * Templates mount via `mountWorkflowRunsAdminRoutes(hono, opts)` —
11
+ * the routes are attached directly to the supplied Hono instance so
12
+ * they inherit the parent's middleware (auth + db). The optional
13
+ * `runners` registry is consulted for rerun/resume; if it's not
14
+ * provided (or doesn't have a runner for the workflow), those
15
+ * endpoints return 501 with a clear error.
16
+ */
17
+ import { handleApiError, parseJsonBody } from "@voyant-travel/hono";
18
+ import { z } from "zod";
19
+ import { workflowRunsService } from "./service.js";
20
+ const listQuerySchema = z.object({
21
+ workflowName: z.string().min(1).optional(),
22
+ status: z.enum(["running", "succeeded", "failed", "cancelled"]).optional(),
23
+ tag: z.string().min(1).optional(),
24
+ parentRunId: z.string().min(1).optional(),
25
+ limit: z.coerce.number().int().min(1).max(200).optional(),
26
+ offset: z.coerce.number().int().min(0).optional(),
27
+ });
28
+ const rerunBodySchema = z.object({
29
+ /** Required when runner.idempotency === "unsafe". */
30
+ confirm: z.boolean().optional(),
31
+ });
32
+ const triggerWorkflowBodySchema = z
33
+ .object({
34
+ input: z.unknown().optional(),
35
+ idempotencyKey: z.string().trim().min(1).max(255).optional(),
36
+ correlationId: z.string().trim().min(1).max(255).optional(),
37
+ tags: z.array(z.string().trim().min(1).max(128)).max(50).optional(),
38
+ })
39
+ .strict();
40
+ export function resolveWorkflowAdminSurface(value) {
41
+ if (value === "tenant" || value === "cloud" || value === "disabled")
42
+ return value;
43
+ if (value !== undefined && value.trim().length > 0) {
44
+ throw new Error(`Invalid workflow admin surface "${value}". Expected tenant, cloud, or disabled.`);
45
+ }
46
+ return "tenant";
47
+ }
48
+ function hasWorkflowTriggerScope(scopes) {
49
+ if (!Array.isArray(scopes) || !scopes.every((scope) => typeof scope === "string")) {
50
+ return false;
51
+ }
52
+ return scopes.some((scope) => {
53
+ const normalized = scope.trim().toLowerCase();
54
+ return (normalized === "*" ||
55
+ normalized === "*:*" ||
56
+ normalized === "*:trigger" ||
57
+ normalized === "workflows:*" ||
58
+ normalized === "workflows:trigger");
59
+ });
60
+ }
61
+ export function mountWorkflowRunsAdminRoutes(hono, opts = {}) {
62
+ const adminSurface = opts.adminSurface ?? defaultWorkflowAdminSurface();
63
+ hono.get("/v1/admin/workflow-runs", async (c) => {
64
+ const params = Object.fromEntries(new URL(c.req.url).searchParams);
65
+ const parsed = listQuerySchema.safeParse(params);
66
+ if (!parsed.success) {
67
+ return c.json({ error: "invalid_query", detail: parsed.error.issues }, 400);
68
+ }
69
+ // biome-ignore lint/suspicious/noExplicitAny: Hono's c.var.db is loosely typed -- owner: workflow-runs; existing suppression is intentional pending typed cleanup.
70
+ const db = c.var.db;
71
+ if (!db) {
72
+ console.error("[workflow-runs] c.var.db is undefined — middleware ordering issue?");
73
+ return c.json({ error: "db_unavailable" }, 500);
74
+ }
75
+ try {
76
+ const result = await workflowRunsService.listRuns(db, parsed.data);
77
+ return c.json(result);
78
+ }
79
+ catch (err) {
80
+ console.error("[workflow-runs] listRuns failed", err);
81
+ return c.json({
82
+ error: "list_failed",
83
+ detail: err instanceof Error ? err.message : String(err),
84
+ }, 500);
85
+ }
86
+ });
87
+ hono.get("/v1/admin/workflow-runs/:id", async (c) => {
88
+ // biome-ignore lint/suspicious/noExplicitAny: Hono's c.var.db is loosely typed -- owner: workflow-runs; existing suppression is intentional pending typed cleanup.
89
+ const db = c.var.db;
90
+ if (!db)
91
+ return c.json({ error: "db_unavailable" }, 500);
92
+ try {
93
+ const result = await workflowRunsService.getRunById(db, c.req.param("id"));
94
+ if (!result)
95
+ return c.json({ error: "not_found" }, 404);
96
+ return c.json({ data: result });
97
+ }
98
+ catch (err) {
99
+ console.error("[workflow-runs] getRunById failed", err);
100
+ return c.json({
101
+ error: "get_failed",
102
+ detail: err instanceof Error ? err.message : String(err),
103
+ }, 500);
104
+ }
105
+ });
106
+ hono.post("/v1/admin/workflows/:name/runs", async (c) => {
107
+ const blocked = rejectWorkflowAdminAction(adminSurface, c);
108
+ if (blocked)
109
+ return blocked;
110
+ if (!opts.runners) {
111
+ return c.json({ error: "trigger_not_configured", detail: "no WorkflowRunnerRegistry mounted" }, 501);
112
+ }
113
+ let body;
114
+ try {
115
+ body = await parseJsonBody(c, triggerWorkflowBodySchema);
116
+ }
117
+ catch (err) {
118
+ return handleApiError(err, c);
119
+ }
120
+ const workflowName = c.req.param("name");
121
+ const runner = opts.runners.get(workflowName);
122
+ if (!runner) {
123
+ return c.json({
124
+ error: "runner_not_registered",
125
+ detail: `No runner registered for workflow "${workflowName}"`,
126
+ }, 404);
127
+ }
128
+ if (!runner.trigger) {
129
+ return c.json({
130
+ error: "trigger_not_supported",
131
+ detail: `Workflow "${runner.name}" does not expose admin trigger support.`,
132
+ }, 501);
133
+ }
134
+ const auth = c;
135
+ if (auth.get("callerType") === "api_key" && !hasWorkflowTriggerScope(auth.get("scopes"))) {
136
+ return c.json({ error: "Forbidden: API key missing workflows:trigger permission" }, 403);
137
+ }
138
+ const userId = opts.resolveUserId?.(c) ?? null;
139
+ try {
140
+ const input = Object.hasOwn(body, "input") ? body.input : {};
141
+ const { runId } = await runner.trigger(input, {
142
+ triggeredByUserId: userId,
143
+ correlationId: body.correlationId ?? null,
144
+ tags: body.tags ?? [],
145
+ idempotencyKey: body.idempotencyKey ?? null,
146
+ });
147
+ return c.json({ data: { runId, workflowName: runner.name, status: "queued" } }, 202);
148
+ }
149
+ catch (err) {
150
+ console.error("[workflow-runs] trigger failed", err);
151
+ return c.json({ error: "trigger_failed", detail: err instanceof Error ? err.message : String(err) }, 500);
152
+ }
153
+ });
154
+ hono.post("/v1/admin/workflow-runs/:id/rerun", async (c) => {
155
+ const blocked = rejectWorkflowAdminAction(adminSurface, c);
156
+ if (blocked)
157
+ return blocked;
158
+ // biome-ignore lint/suspicious/noExplicitAny: Hono's c.var.db is loosely typed -- owner: workflow-runs; existing suppression is intentional pending typed cleanup.
159
+ const db = c.var.db;
160
+ if (!db)
161
+ return c.json({ error: "db_unavailable" }, 500);
162
+ if (!opts.runners) {
163
+ return c.json({ error: "rerun_not_configured", detail: "no WorkflowRunnerRegistry mounted" }, 501);
164
+ }
165
+ const parentId = c.req.param("id");
166
+ const detail = await workflowRunsService.getRunById(db, parentId);
167
+ if (!detail)
168
+ return c.json({ error: "not_found" }, 404);
169
+ const runner = opts.runners.get(detail.run.workflowName);
170
+ if (!runner) {
171
+ return c.json({
172
+ error: "runner_not_registered",
173
+ detail: `No runner registered for workflow "${detail.run.workflowName}"`,
174
+ }, 501);
175
+ }
176
+ if (runner.idempotency === "resume-only") {
177
+ return c.json({
178
+ error: "rerun_not_allowed",
179
+ detail: `Workflow "${runner.name}" is resume-only; use POST /:id/resume instead.`,
180
+ }, 409);
181
+ }
182
+ let body = {};
183
+ try {
184
+ body = rerunBodySchema.parse(await c.req.json().catch(() => ({})));
185
+ }
186
+ catch (err) {
187
+ return c.json({ error: "invalid_body", detail: err instanceof Error ? err.message : String(err) }, 400);
188
+ }
189
+ if (runner.idempotency === "unsafe" && !body.confirm) {
190
+ return c.json({
191
+ error: "confirmation_required",
192
+ detail: `Workflow "${runner.name}" has side effects; pass { confirm: true } to rerun.`,
193
+ idempotency: runner.idempotency,
194
+ }, 409);
195
+ }
196
+ if (runner.canRerun) {
197
+ const guard = await runner.canRerun(detail.run.input);
198
+ if (!guard.ok) {
199
+ return c.json({ error: "rerun_blocked", detail: guard.reason }, 409);
200
+ }
201
+ }
202
+ const userId = opts.resolveUserId?.(c) ?? null;
203
+ try {
204
+ const { runId } = await runner.rerun(detail.run.input, {
205
+ parentRunId: detail.run.id,
206
+ triggeredByUserId: userId,
207
+ correlationId: detail.run.correlationId,
208
+ tags: detail.run.tags,
209
+ });
210
+ return c.json({ data: { runId, parentRunId: detail.run.id } }, 202);
211
+ }
212
+ catch (err) {
213
+ console.error("[workflow-runs] rerun failed", err);
214
+ return c.json({ error: "rerun_failed", detail: err instanceof Error ? err.message : String(err) }, 500);
215
+ }
216
+ });
217
+ hono.post("/v1/admin/workflow-runs/:id/resume", async (c) => {
218
+ const blocked = rejectWorkflowAdminAction(adminSurface, c);
219
+ if (blocked)
220
+ return blocked;
221
+ // biome-ignore lint/suspicious/noExplicitAny: Hono's c.var.db is loosely typed -- owner: workflow-runs; existing suppression is intentional pending typed cleanup.
222
+ const db = c.var.db;
223
+ if (!db)
224
+ return c.json({ error: "db_unavailable" }, 500);
225
+ if (!opts.runners) {
226
+ return c.json({ error: "resume_not_configured", detail: "no WorkflowRunnerRegistry mounted" }, 501);
227
+ }
228
+ const parentId = c.req.param("id");
229
+ const detail = await workflowRunsService.getRunById(db, parentId);
230
+ if (!detail)
231
+ return c.json({ error: "not_found" }, 404);
232
+ if (detail.run.status !== "failed") {
233
+ return c.json({
234
+ error: "resume_not_allowed",
235
+ detail: `Cannot resume a run with status "${detail.run.status}"; only failed runs are resumable.`,
236
+ }, 409);
237
+ }
238
+ const runner = opts.runners.get(detail.run.workflowName);
239
+ if (!runner) {
240
+ return c.json({
241
+ error: "runner_not_registered",
242
+ detail: `No runner registered for workflow "${detail.run.workflowName}"`,
243
+ }, 501);
244
+ }
245
+ // Find the failed step and seed prior step outputs.
246
+ const failedStep = detail.steps.find((s) => s.status === "failed");
247
+ if (!failedStep) {
248
+ return c.json({
249
+ error: "no_failed_step",
250
+ detail: "Run is marked failed but has no failed step row to resume from.",
251
+ }, 409);
252
+ }
253
+ const seedResults = {};
254
+ for (const s of detail.steps) {
255
+ if (s.sequence >= failedStep.sequence)
256
+ break;
257
+ if (s.status === "succeeded" || s.status === "skipped") {
258
+ if (s.output && typeof s.output === "object") {
259
+ seedResults[s.stepName] = s.output;
260
+ }
261
+ else {
262
+ // Preserve null/primitive outputs explicitly so downstream
263
+ // steps that read them don't see undefined.
264
+ seedResults[s.stepName] = s.output ?? null;
265
+ }
266
+ }
267
+ else {
268
+ return c.json({
269
+ error: "incomplete_prior_step",
270
+ detail: `Step "${s.stepName}" (sequence ${s.sequence}) is not complete; cannot resume past it.`,
271
+ }, 409);
272
+ }
273
+ }
274
+ const userId = opts.resolveUserId?.(c) ?? null;
275
+ try {
276
+ const { runId } = await runner.resume(detail.run.input, {
277
+ parentRunId: detail.run.id,
278
+ triggeredByUserId: userId,
279
+ correlationId: detail.run.correlationId,
280
+ tags: detail.run.tags,
281
+ resumeFromStep: failedStep.stepName,
282
+ seedResults,
283
+ });
284
+ return c.json({ data: { runId, parentRunId: detail.run.id, resumeFromStep: failedStep.stepName } }, 202);
285
+ }
286
+ catch (err) {
287
+ console.error("[workflow-runs] resume failed", err);
288
+ return c.json({ error: "resume_failed", detail: err instanceof Error ? err.message : String(err) }, 500);
289
+ }
290
+ });
291
+ }
292
+ function rejectWorkflowAdminAction(surface, c) {
293
+ if (surface === "tenant")
294
+ return undefined;
295
+ return c.json({
296
+ error: "workflow_admin_surface_restricted",
297
+ detail: surface === "cloud"
298
+ ? "Workflow management actions are owned by Voyant Cloud for this deployment."
299
+ : "Workflow management actions are disabled for this deployment.",
300
+ surface,
301
+ }, 403);
302
+ }
303
+ function defaultWorkflowAdminSurface() {
304
+ const env = globalThis.process?.env;
305
+ if (env?.VOYANT_WORKFLOW_ADMIN_SURFACE !== undefined) {
306
+ return resolveWorkflowAdminSurface(env.VOYANT_WORKFLOW_ADMIN_SURFACE);
307
+ }
308
+ if (env?.VOYANT_CLOUD_WORKFLOWS_URL || env?.VOYANT_CLOUD_APP_SLUG)
309
+ return "cloud";
310
+ return "tenant";
311
+ }