@voyantjs/workflow-runs 0.21.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +44 -0
- package/dist/index.d.ts +24 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +23 -0
- package/dist/recorder.d.ts +75 -0
- package/dist/recorder.d.ts.map +1 -0
- package/dist/recorder.js +217 -0
- package/dist/routes.d.ts +33 -0
- package/dist/routes.d.ts.map +1 -0
- package/dist/routes.js +205 -0
- package/dist/runner.d.ts +103 -0
- package/dist/runner.d.ts.map +1 -0
- package/dist/runner.js +45 -0
- package/dist/schema.d.ts +515 -0
- package/dist/schema.d.ts.map +1 -0
- package/dist/schema.js +96 -0
- package/dist/service.d.ts +32 -0
- package/dist/service.d.ts.map +1 -0
- package/dist/service.js +54 -0
- package/package.json +75 -0
package/README.md
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# @voyantjs/workflow-runs
|
|
2
|
+
|
|
3
|
+
Workflow run recording, admin routes, and rerun/resume dispatch primitives for Voyant operator apps.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
pnpm add @voyantjs/workflow-runs
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
The package is published with the same release-train version as the other installable Voyant packages.
|
|
12
|
+
|
|
13
|
+
## Mount the admin routes
|
|
14
|
+
|
|
15
|
+
`mountWorkflowRunsAdminRoutes` adds the workflow-run list, detail, rerun, and resume endpoints under `/v1/admin/workflow-runs`.
|
|
16
|
+
|
|
17
|
+
```ts
|
|
18
|
+
import { mountWorkflowRunsAdminRoutes, WorkflowRunnerRegistry } from "@voyantjs/workflow-runs"
|
|
19
|
+
|
|
20
|
+
const workflowRunnerRegistry = new WorkflowRunnerRegistry()
|
|
21
|
+
|
|
22
|
+
workflowRunnerRegistry.register({
|
|
23
|
+
name: "checkout-finalize",
|
|
24
|
+
rerun: async ({ run, input }) => {
|
|
25
|
+
await workflowServer.dispatch("checkout-finalize", {
|
|
26
|
+
idempotencyKey: run.idempotencyKey ?? run.id,
|
|
27
|
+
input,
|
|
28
|
+
})
|
|
29
|
+
},
|
|
30
|
+
resume: async ({ run, input, failedStep }) => {
|
|
31
|
+
await workflowServer.dispatch("checkout-finalize", {
|
|
32
|
+
resumeFromStep: failedStep?.stepName ?? null,
|
|
33
|
+
originalRunId: run.id,
|
|
34
|
+
input,
|
|
35
|
+
})
|
|
36
|
+
},
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
mountWorkflowRunsAdminRoutes(hono, {
|
|
40
|
+
runners: workflowRunnerRegistry,
|
|
41
|
+
})
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
For self-hosted workflow services, keep runner registration close to the code that mounts the workflow service. The registry should dispatch to your external workflow server instead of importing worker-only runtime code into the admin API process.
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@voyantjs/workflow-runs` — passive observability for in-process
|
|
3
|
+
* workflows.
|
|
4
|
+
*
|
|
5
|
+
* The package gives templates two things:
|
|
6
|
+
*
|
|
7
|
+
* 1. A `recordWorkflowRun` / recorder API that writes lifecycle
|
|
8
|
+
* rows into `workflow_runs` + `workflow_run_steps` as a
|
|
9
|
+
* workflow runs. Edge-compatible — postgres-js or neon-http.
|
|
10
|
+
* 2. A read-only Hono router (`createWorkflowRunsAdminRoutes`)
|
|
11
|
+
* that serves `/v1/admin/workflow-runs[/:id]` for the standalone
|
|
12
|
+
* dashboard SPA in `apps/workflow-runs-dashboard/` to consume.
|
|
13
|
+
*
|
|
14
|
+
* Distinct from the durable `@voyantjs/workflows` SDK — that one is
|
|
15
|
+
* the Cloud-orchestrated runtime; this one is the lightweight
|
|
16
|
+
* "what just happened?" log every template can ship without a
|
|
17
|
+
* separate worker process.
|
|
18
|
+
*/
|
|
19
|
+
export { type BeginWorkflowRunInput, beginWorkflowRun, type WorkflowRunRecorder, } from "./recorder.js";
|
|
20
|
+
export { mountWorkflowRunsAdminRoutes } from "./routes.js";
|
|
21
|
+
export { type WorkflowIdempotency, type WorkflowRerunContext, type WorkflowResumeContext, type WorkflowRunner, WorkflowRunnerRegistry, } from "./runner.js";
|
|
22
|
+
export { type NewWorkflowRun, type NewWorkflowRunStep, type WorkflowRun, type WorkflowRunErrorPayload, type WorkflowRunStep, workflowRunStatusEnum, workflowRunStepStatusEnum, workflowRunSteps, workflowRuns, } from "./schema.js";
|
|
23
|
+
export { type ListWorkflowRunsQuery, type ListWorkflowRunsResult, workflowRunsService, } from "./service.js";
|
|
24
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AAEH,OAAO,EACL,KAAK,qBAAqB,EAC1B,gBAAgB,EAChB,KAAK,mBAAmB,GACzB,MAAM,eAAe,CAAA;AACtB,OAAO,EAAE,4BAA4B,EAAE,MAAM,aAAa,CAAA;AAC1D,OAAO,EACL,KAAK,mBAAmB,EACxB,KAAK,oBAAoB,EACzB,KAAK,qBAAqB,EAC1B,KAAK,cAAc,EACnB,sBAAsB,GACvB,MAAM,aAAa,CAAA;AACpB,OAAO,EACL,KAAK,cAAc,EACnB,KAAK,kBAAkB,EACvB,KAAK,WAAW,EAChB,KAAK,uBAAuB,EAC5B,KAAK,eAAe,EACpB,qBAAqB,EACrB,yBAAyB,EACzB,gBAAgB,EAChB,YAAY,GACb,MAAM,aAAa,CAAA;AACpB,OAAO,EACL,KAAK,qBAAqB,EAC1B,KAAK,sBAAsB,EAC3B,mBAAmB,GACpB,MAAM,cAAc,CAAA"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@voyantjs/workflow-runs` — passive observability for in-process
|
|
3
|
+
* workflows.
|
|
4
|
+
*
|
|
5
|
+
* The package gives templates two things:
|
|
6
|
+
*
|
|
7
|
+
* 1. A `recordWorkflowRun` / recorder API that writes lifecycle
|
|
8
|
+
* rows into `workflow_runs` + `workflow_run_steps` as a
|
|
9
|
+
* workflow runs. Edge-compatible — postgres-js or neon-http.
|
|
10
|
+
* 2. A read-only Hono router (`createWorkflowRunsAdminRoutes`)
|
|
11
|
+
* that serves `/v1/admin/workflow-runs[/:id]` for the standalone
|
|
12
|
+
* dashboard SPA in `apps/workflow-runs-dashboard/` to consume.
|
|
13
|
+
*
|
|
14
|
+
* Distinct from the durable `@voyantjs/workflows` SDK — that one is
|
|
15
|
+
* the Cloud-orchestrated runtime; this one is the lightweight
|
|
16
|
+
* "what just happened?" log every template can ship without a
|
|
17
|
+
* separate worker process.
|
|
18
|
+
*/
|
|
19
|
+
export { beginWorkflowRun, } from "./recorder.js";
|
|
20
|
+
export { mountWorkflowRunsAdminRoutes } from "./routes.js";
|
|
21
|
+
export { WorkflowRunnerRegistry, } from "./runner.js";
|
|
22
|
+
export { workflowRunStatusEnum, workflowRunStepStatusEnum, workflowRunSteps, workflowRuns, } from "./schema.js";
|
|
23
|
+
export { workflowRunsService, } from "./service.js";
|
|
@@ -0,0 +1,75 @@
|
|
|
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 `@voyantjs/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 type { PostgresJsDatabase } from "drizzle-orm/postgres-js";
|
|
28
|
+
export interface BeginWorkflowRunInput {
|
|
29
|
+
workflowName: string;
|
|
30
|
+
trigger?: string;
|
|
31
|
+
correlationId?: string | null;
|
|
32
|
+
tags?: ReadonlyArray<string>;
|
|
33
|
+
input?: Record<string, unknown> | null;
|
|
34
|
+
/** Set when this run is a rerun/resume of a prior run. */
|
|
35
|
+
parentRunId?: string | null;
|
|
36
|
+
/** User who triggered this run via the dashboard. */
|
|
37
|
+
triggeredByUserId?: string | null;
|
|
38
|
+
/**
|
|
39
|
+
* For resume runs — the step name from which to resume. The
|
|
40
|
+
* executor seeds ctx.results with prior step outputs and skips
|
|
41
|
+
* everything before this step.
|
|
42
|
+
*/
|
|
43
|
+
resumeFromStep?: string | null;
|
|
44
|
+
}
|
|
45
|
+
export interface WorkflowRunRecorder {
|
|
46
|
+
readonly runId: string;
|
|
47
|
+
startStep(name: string): Promise<{
|
|
48
|
+
stepId: string | null;
|
|
49
|
+
}>;
|
|
50
|
+
completeStep(name: string, output?: Record<string, unknown> | null): Promise<void>;
|
|
51
|
+
failStep(name: string, error: unknown): Promise<void>;
|
|
52
|
+
/**
|
|
53
|
+
* Record a step that was skipped by a resume run. Writes a row
|
|
54
|
+
* with `status: "skipped"` and the supplied output (the value the
|
|
55
|
+
* parent run produced). Used by the resume orchestrator so the UI
|
|
56
|
+
* shows the full step list with the source of each output.
|
|
57
|
+
*/
|
|
58
|
+
recordSkippedStep(name: string, output?: Record<string, unknown> | null): Promise<void>;
|
|
59
|
+
complete(result?: Record<string, unknown> | null): Promise<void>;
|
|
60
|
+
fail(error: unknown, opts?: {
|
|
61
|
+
stepName?: string;
|
|
62
|
+
}): Promise<void>;
|
|
63
|
+
cancel(reason?: string): Promise<void>;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Start a run and return a recorder bound to its id. The row is
|
|
67
|
+
* inserted with `status: "running"` and a `startedAt` of "now".
|
|
68
|
+
*
|
|
69
|
+
* Persistence errors are caught — if the table doesn't exist or the
|
|
70
|
+
* DB is unreachable, returns a no-op recorder so the caller's
|
|
71
|
+
* workflow keeps running. The whole point of this layer is
|
|
72
|
+
* observability, not durability.
|
|
73
|
+
*/
|
|
74
|
+
export declare function beginWorkflowRun(db: PostgresJsDatabase, input: BeginWorkflowRunInput): Promise<WorkflowRunRecorder>;
|
|
75
|
+
//# sourceMappingURL=recorder.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"recorder.d.ts","sourceRoot":"","sources":["../src/recorder.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AAGH,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,yBAAyB,CAAA;AAUjE,MAAM,WAAW,qBAAqB;IACpC,YAAY,EAAE,MAAM,CAAA;IACpB,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,aAAa,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IAC7B,IAAI,CAAC,EAAE,aAAa,CAAC,MAAM,CAAC,CAAA;IAC5B,KAAK,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,CAAA;IACtC,0DAA0D;IAC1D,WAAW,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IAC3B,qDAAqD;IACrD,iBAAiB,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IACjC;;;;OAIG;IACH,cAAc,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;CAC/B;AAED,MAAM,WAAW,mBAAmB;IAClC,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAA;IACtB,SAAS,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC;QAAE,MAAM,EAAE,MAAM,GAAG,IAAI,CAAA;KAAE,CAAC,CAAA;IAC3D,YAAY,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;IAClF,QAAQ,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,OAAO,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;IACrD;;;;;OAKG;IACH,iBAAiB,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;IACvF,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;IAChE,IAAI,CAAC,KAAK,EAAE,OAAO,EAAE,IAAI,CAAC,EAAE;QAAE,QAAQ,CAAC,EAAE,MAAM,CAAA;KAAE,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;IACjE,MAAM,CAAC,MAAM,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;CACvC;AAED;;;;;;;;GAQG;AACH,wBAAsB,gBAAgB,CACpC,EAAE,EAAE,kBAAkB,EACtB,KAAK,EAAE,qBAAqB,GAC3B,OAAO,CAAC,mBAAmB,CAAC,CAwH9B"}
|
package/dist/recorder.js
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
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 `@voyantjs/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 { 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) {
|
|
39
|
+
const insert = {
|
|
40
|
+
workflowName: input.workflowName,
|
|
41
|
+
trigger: input.trigger ?? "manual",
|
|
42
|
+
correlationId: input.correlationId ?? null,
|
|
43
|
+
tags: [...(input.tags ?? [])],
|
|
44
|
+
input: input.input ?? null,
|
|
45
|
+
status: "running",
|
|
46
|
+
parentRunId: input.parentRunId ?? null,
|
|
47
|
+
triggeredByUserId: input.triggeredByUserId ?? null,
|
|
48
|
+
resumeFromStep: input.resumeFromStep ?? null,
|
|
49
|
+
};
|
|
50
|
+
let runId = null;
|
|
51
|
+
try {
|
|
52
|
+
const [row] = await db.insert(workflowRuns).values(insert).returning({ id: workflowRuns.id });
|
|
53
|
+
runId = row?.id ?? null;
|
|
54
|
+
}
|
|
55
|
+
catch (err) {
|
|
56
|
+
console.warn(`[workflow-runs] could not record run start for "${input.workflowName}":`, err instanceof Error ? err.message : err);
|
|
57
|
+
}
|
|
58
|
+
if (!runId)
|
|
59
|
+
return noopRecorder();
|
|
60
|
+
const stepStarts = new Map();
|
|
61
|
+
let nextSequence = 1;
|
|
62
|
+
return {
|
|
63
|
+
runId,
|
|
64
|
+
async startStep(name) {
|
|
65
|
+
const sequence = nextSequence++;
|
|
66
|
+
const insertStep = {
|
|
67
|
+
runId,
|
|
68
|
+
stepName: name,
|
|
69
|
+
sequence,
|
|
70
|
+
status: "running",
|
|
71
|
+
};
|
|
72
|
+
try {
|
|
73
|
+
const [row] = await db
|
|
74
|
+
.insert(workflowRunSteps)
|
|
75
|
+
.values(insertStep)
|
|
76
|
+
.returning({ id: workflowRunSteps.id });
|
|
77
|
+
if (row?.id) {
|
|
78
|
+
stepStarts.set(name, { stepId: row.id, sequence, startedAt: Date.now() });
|
|
79
|
+
return { stepId: row.id };
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
catch (err) {
|
|
83
|
+
console.warn(`[workflow-runs] step start "${name}" insert failed:`, err);
|
|
84
|
+
}
|
|
85
|
+
return { stepId: null };
|
|
86
|
+
},
|
|
87
|
+
async completeStep(name, output) {
|
|
88
|
+
const tracking = stepStarts.get(name);
|
|
89
|
+
if (!tracking)
|
|
90
|
+
return;
|
|
91
|
+
const completedAt = new Date();
|
|
92
|
+
try {
|
|
93
|
+
await db
|
|
94
|
+
.update(workflowRunSteps)
|
|
95
|
+
.set({
|
|
96
|
+
status: "succeeded",
|
|
97
|
+
output: output ?? null,
|
|
98
|
+
completedAt,
|
|
99
|
+
durationMs: completedAt.getTime() - tracking.startedAt,
|
|
100
|
+
})
|
|
101
|
+
.where(eq(workflowRunSteps.id, tracking.stepId));
|
|
102
|
+
}
|
|
103
|
+
catch (err) {
|
|
104
|
+
console.warn(`[workflow-runs] step complete "${name}" update failed:`, err);
|
|
105
|
+
}
|
|
106
|
+
},
|
|
107
|
+
async failStep(name, error) {
|
|
108
|
+
const tracking = stepStarts.get(name);
|
|
109
|
+
if (!tracking)
|
|
110
|
+
return;
|
|
111
|
+
const completedAt = new Date();
|
|
112
|
+
try {
|
|
113
|
+
await db
|
|
114
|
+
.update(workflowRunSteps)
|
|
115
|
+
.set({
|
|
116
|
+
status: "failed",
|
|
117
|
+
error: serializeError(error, name),
|
|
118
|
+
completedAt,
|
|
119
|
+
durationMs: completedAt.getTime() - tracking.startedAt,
|
|
120
|
+
})
|
|
121
|
+
.where(eq(workflowRunSteps.id, tracking.stepId));
|
|
122
|
+
}
|
|
123
|
+
catch (err) {
|
|
124
|
+
console.warn(`[workflow-runs] step fail "${name}" update failed:`, err);
|
|
125
|
+
}
|
|
126
|
+
},
|
|
127
|
+
async recordSkippedStep(name, output) {
|
|
128
|
+
const sequence = nextSequence++;
|
|
129
|
+
const now = new Date();
|
|
130
|
+
const insertStep = {
|
|
131
|
+
runId,
|
|
132
|
+
stepName: name,
|
|
133
|
+
sequence,
|
|
134
|
+
status: "skipped",
|
|
135
|
+
output: output ?? null,
|
|
136
|
+
startedAt: now,
|
|
137
|
+
completedAt: now,
|
|
138
|
+
durationMs: 0,
|
|
139
|
+
};
|
|
140
|
+
try {
|
|
141
|
+
await db.insert(workflowRunSteps).values(insertStep);
|
|
142
|
+
}
|
|
143
|
+
catch (err) {
|
|
144
|
+
console.warn(`[workflow-runs] step skipped "${name}" insert failed:`, err);
|
|
145
|
+
}
|
|
146
|
+
},
|
|
147
|
+
complete: (result) => finalize(db, runId, "succeeded", result, null),
|
|
148
|
+
fail: (error, opts) => finalize(db, runId, "failed", null, serializeError(error, opts?.stepName)),
|
|
149
|
+
cancel: (reason) => finalize(db, runId, "cancelled", null, {
|
|
150
|
+
message: reason ?? "Cancelled",
|
|
151
|
+
}),
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
async function finalize(db, runId, status, result, error) {
|
|
155
|
+
const completedAt = new Date();
|
|
156
|
+
try {
|
|
157
|
+
const [existing] = await db
|
|
158
|
+
.select({ startedAt: workflowRuns.startedAt })
|
|
159
|
+
.from(workflowRuns)
|
|
160
|
+
.where(eq(workflowRuns.id, runId))
|
|
161
|
+
.limit(1);
|
|
162
|
+
const durationMs = existing ? completedAt.getTime() - existing.startedAt.getTime() : null;
|
|
163
|
+
await db
|
|
164
|
+
.update(workflowRuns)
|
|
165
|
+
.set({
|
|
166
|
+
status,
|
|
167
|
+
result: result ?? null,
|
|
168
|
+
error: error ?? null,
|
|
169
|
+
completedAt,
|
|
170
|
+
durationMs,
|
|
171
|
+
updatedAt: completedAt,
|
|
172
|
+
})
|
|
173
|
+
.where(eq(workflowRuns.id, runId));
|
|
174
|
+
}
|
|
175
|
+
catch (err) {
|
|
176
|
+
console.warn(`[workflow-runs] finalize ${status} failed for ${runId}:`, err);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
function serializeError(error, stepName) {
|
|
180
|
+
if (error instanceof Error) {
|
|
181
|
+
return {
|
|
182
|
+
message: error.message,
|
|
183
|
+
...(stepName ? { stepName } : {}),
|
|
184
|
+
...(error.stack ? { stack: error.stack } : {}),
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
return {
|
|
188
|
+
message: typeof error === "string" ? error : JSON.stringify(error),
|
|
189
|
+
...(stepName ? { stepName } : {}),
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
function noopRecorder() {
|
|
193
|
+
return {
|
|
194
|
+
runId: "",
|
|
195
|
+
async startStep() {
|
|
196
|
+
return { stepId: null };
|
|
197
|
+
},
|
|
198
|
+
async completeStep() {
|
|
199
|
+
// no-op
|
|
200
|
+
},
|
|
201
|
+
async failStep() {
|
|
202
|
+
// no-op
|
|
203
|
+
},
|
|
204
|
+
async recordSkippedStep() {
|
|
205
|
+
// no-op
|
|
206
|
+
},
|
|
207
|
+
async complete() {
|
|
208
|
+
// no-op
|
|
209
|
+
},
|
|
210
|
+
async fail() {
|
|
211
|
+
// no-op
|
|
212
|
+
},
|
|
213
|
+
async cancel() {
|
|
214
|
+
// no-op
|
|
215
|
+
},
|
|
216
|
+
};
|
|
217
|
+
}
|
package/dist/routes.d.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
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/workflow-runs/:id/rerun → fresh run with the recorded input
|
|
7
|
+
* POST /v1/admin/workflow-runs/:id/resume → re-run starting at the failed step
|
|
8
|
+
*
|
|
9
|
+
* Templates mount via `mountWorkflowRunsAdminRoutes(hono, opts)` —
|
|
10
|
+
* the routes are attached directly to the supplied Hono instance so
|
|
11
|
+
* they inherit the parent's middleware (auth + db). The optional
|
|
12
|
+
* `runners` registry is consulted for rerun/resume; if it's not
|
|
13
|
+
* provided (or doesn't have a runner for the workflow), those
|
|
14
|
+
* endpoints return 501 with a clear error.
|
|
15
|
+
*/
|
|
16
|
+
import type { Hono } from "hono";
|
|
17
|
+
import type { WorkflowRunnerRegistry } from "./runner.js";
|
|
18
|
+
export interface MountWorkflowRunsAdminRoutesOptions {
|
|
19
|
+
/**
|
|
20
|
+
* Registry of executable runners keyed by workflow name. Required
|
|
21
|
+
* for the rerun/resume endpoints; bundles register their runners
|
|
22
|
+
* on bootstrap.
|
|
23
|
+
*/
|
|
24
|
+
runners?: WorkflowRunnerRegistry;
|
|
25
|
+
/**
|
|
26
|
+
* Resolves the acting user id from the request context — used to
|
|
27
|
+
* stamp `triggered_by_user_id` on rerun runs. When omitted, runs
|
|
28
|
+
* are recorded without an actor.
|
|
29
|
+
*/
|
|
30
|
+
resolveUserId?: (c: unknown) => string | null;
|
|
31
|
+
}
|
|
32
|
+
export declare function mountWorkflowRunsAdminRoutes(hono: Hono, opts?: MountWorkflowRunsAdminRoutesOptions): void;
|
|
33
|
+
//# sourceMappingURL=routes.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"routes.d.ts","sourceRoot":"","sources":["../src/routes.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAEH,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,MAAM,CAAA;AAGhC,OAAO,KAAK,EAAE,sBAAsB,EAAE,MAAM,aAAa,CAAA;AAiBzD,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;CAC9C;AAED,wBAAgB,4BAA4B,CAC1C,IAAI,EAAE,IAAI,EACV,IAAI,GAAE,mCAAwC,GAC7C,IAAI,CA0NN"}
|
package/dist/routes.js
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
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/workflow-runs/:id/rerun → fresh run with the recorded input
|
|
7
|
+
* POST /v1/admin/workflow-runs/:id/resume → re-run starting at the failed step
|
|
8
|
+
*
|
|
9
|
+
* Templates mount via `mountWorkflowRunsAdminRoutes(hono, opts)` —
|
|
10
|
+
* the routes are attached directly to the supplied Hono instance so
|
|
11
|
+
* they inherit the parent's middleware (auth + db). The optional
|
|
12
|
+
* `runners` registry is consulted for rerun/resume; if it's not
|
|
13
|
+
* provided (or doesn't have a runner for the workflow), those
|
|
14
|
+
* endpoints return 501 with a clear error.
|
|
15
|
+
*/
|
|
16
|
+
import { z } from "zod";
|
|
17
|
+
import { workflowRunsService } from "./service.js";
|
|
18
|
+
const listQuerySchema = z.object({
|
|
19
|
+
workflowName: z.string().min(1).optional(),
|
|
20
|
+
status: z.enum(["running", "succeeded", "failed", "cancelled"]).optional(),
|
|
21
|
+
tag: z.string().min(1).optional(),
|
|
22
|
+
parentRunId: z.string().min(1).optional(),
|
|
23
|
+
limit: z.coerce.number().int().min(1).max(200).optional(),
|
|
24
|
+
offset: z.coerce.number().int().min(0).optional(),
|
|
25
|
+
});
|
|
26
|
+
const rerunBodySchema = z.object({
|
|
27
|
+
/** Required when runner.idempotency === "unsafe". */
|
|
28
|
+
confirm: z.boolean().optional(),
|
|
29
|
+
});
|
|
30
|
+
export function mountWorkflowRunsAdminRoutes(hono, opts = {}) {
|
|
31
|
+
hono.get("/v1/admin/workflow-runs", async (c) => {
|
|
32
|
+
const params = Object.fromEntries(new URL(c.req.url).searchParams);
|
|
33
|
+
const parsed = listQuerySchema.safeParse(params);
|
|
34
|
+
if (!parsed.success) {
|
|
35
|
+
return c.json({ error: "invalid_query", detail: parsed.error.issues }, 400);
|
|
36
|
+
}
|
|
37
|
+
// biome-ignore lint/suspicious/noExplicitAny: Hono's c.var.db is loosely typed
|
|
38
|
+
const db = c.var.db;
|
|
39
|
+
if (!db) {
|
|
40
|
+
console.error("[workflow-runs] c.var.db is undefined — middleware ordering issue?");
|
|
41
|
+
return c.json({ error: "db_unavailable" }, 500);
|
|
42
|
+
}
|
|
43
|
+
try {
|
|
44
|
+
const result = await workflowRunsService.listRuns(db, parsed.data);
|
|
45
|
+
return c.json(result);
|
|
46
|
+
}
|
|
47
|
+
catch (err) {
|
|
48
|
+
console.error("[workflow-runs] listRuns failed", err);
|
|
49
|
+
return c.json({
|
|
50
|
+
error: "list_failed",
|
|
51
|
+
detail: err instanceof Error ? err.message : String(err),
|
|
52
|
+
}, 500);
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
hono.get("/v1/admin/workflow-runs/:id", async (c) => {
|
|
56
|
+
// biome-ignore lint/suspicious/noExplicitAny: Hono's c.var.db is loosely typed
|
|
57
|
+
const db = c.var.db;
|
|
58
|
+
if (!db)
|
|
59
|
+
return c.json({ error: "db_unavailable" }, 500);
|
|
60
|
+
try {
|
|
61
|
+
const result = await workflowRunsService.getRunById(db, c.req.param("id"));
|
|
62
|
+
if (!result)
|
|
63
|
+
return c.json({ error: "not_found" }, 404);
|
|
64
|
+
return c.json({ data: result });
|
|
65
|
+
}
|
|
66
|
+
catch (err) {
|
|
67
|
+
console.error("[workflow-runs] getRunById failed", err);
|
|
68
|
+
return c.json({
|
|
69
|
+
error: "get_failed",
|
|
70
|
+
detail: err instanceof Error ? err.message : String(err),
|
|
71
|
+
}, 500);
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
hono.post("/v1/admin/workflow-runs/:id/rerun", async (c) => {
|
|
75
|
+
// biome-ignore lint/suspicious/noExplicitAny: Hono's c.var.db is loosely typed
|
|
76
|
+
const db = c.var.db;
|
|
77
|
+
if (!db)
|
|
78
|
+
return c.json({ error: "db_unavailable" }, 500);
|
|
79
|
+
if (!opts.runners) {
|
|
80
|
+
return c.json({ error: "rerun_not_configured", detail: "no WorkflowRunnerRegistry mounted" }, 501);
|
|
81
|
+
}
|
|
82
|
+
const parentId = c.req.param("id");
|
|
83
|
+
const detail = await workflowRunsService.getRunById(db, parentId);
|
|
84
|
+
if (!detail)
|
|
85
|
+
return c.json({ error: "not_found" }, 404);
|
|
86
|
+
const runner = opts.runners.get(detail.run.workflowName);
|
|
87
|
+
if (!runner) {
|
|
88
|
+
return c.json({
|
|
89
|
+
error: "runner_not_registered",
|
|
90
|
+
detail: `No runner registered for workflow "${detail.run.workflowName}"`,
|
|
91
|
+
}, 501);
|
|
92
|
+
}
|
|
93
|
+
if (runner.idempotency === "resume-only") {
|
|
94
|
+
return c.json({
|
|
95
|
+
error: "rerun_not_allowed",
|
|
96
|
+
detail: `Workflow "${runner.name}" is resume-only; use POST /:id/resume instead.`,
|
|
97
|
+
}, 409);
|
|
98
|
+
}
|
|
99
|
+
let body = {};
|
|
100
|
+
try {
|
|
101
|
+
body = rerunBodySchema.parse(await c.req.json().catch(() => ({})));
|
|
102
|
+
}
|
|
103
|
+
catch (err) {
|
|
104
|
+
return c.json({ error: "invalid_body", detail: err instanceof Error ? err.message : String(err) }, 400);
|
|
105
|
+
}
|
|
106
|
+
if (runner.idempotency === "unsafe" && !body.confirm) {
|
|
107
|
+
return c.json({
|
|
108
|
+
error: "confirmation_required",
|
|
109
|
+
detail: `Workflow "${runner.name}" has side effects; pass { confirm: true } to rerun.`,
|
|
110
|
+
idempotency: runner.idempotency,
|
|
111
|
+
}, 409);
|
|
112
|
+
}
|
|
113
|
+
if (runner.canRerun) {
|
|
114
|
+
const guard = await runner.canRerun(detail.run.input);
|
|
115
|
+
if (!guard.ok) {
|
|
116
|
+
return c.json({ error: "rerun_blocked", detail: guard.reason }, 409);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
const userId = opts.resolveUserId?.(c) ?? null;
|
|
120
|
+
try {
|
|
121
|
+
const { runId } = await runner.rerun(detail.run.input, {
|
|
122
|
+
parentRunId: detail.run.id,
|
|
123
|
+
triggeredByUserId: userId,
|
|
124
|
+
correlationId: detail.run.correlationId,
|
|
125
|
+
tags: detail.run.tags,
|
|
126
|
+
});
|
|
127
|
+
return c.json({ data: { runId, parentRunId: detail.run.id } }, 202);
|
|
128
|
+
}
|
|
129
|
+
catch (err) {
|
|
130
|
+
console.error("[workflow-runs] rerun failed", err);
|
|
131
|
+
return c.json({ error: "rerun_failed", detail: err instanceof Error ? err.message : String(err) }, 500);
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
hono.post("/v1/admin/workflow-runs/:id/resume", async (c) => {
|
|
135
|
+
// biome-ignore lint/suspicious/noExplicitAny: Hono's c.var.db is loosely typed
|
|
136
|
+
const db = c.var.db;
|
|
137
|
+
if (!db)
|
|
138
|
+
return c.json({ error: "db_unavailable" }, 500);
|
|
139
|
+
if (!opts.runners) {
|
|
140
|
+
return c.json({ error: "resume_not_configured", detail: "no WorkflowRunnerRegistry mounted" }, 501);
|
|
141
|
+
}
|
|
142
|
+
const parentId = c.req.param("id");
|
|
143
|
+
const detail = await workflowRunsService.getRunById(db, parentId);
|
|
144
|
+
if (!detail)
|
|
145
|
+
return c.json({ error: "not_found" }, 404);
|
|
146
|
+
if (detail.run.status !== "failed") {
|
|
147
|
+
return c.json({
|
|
148
|
+
error: "resume_not_allowed",
|
|
149
|
+
detail: `Cannot resume a run with status "${detail.run.status}"; only failed runs are resumable.`,
|
|
150
|
+
}, 409);
|
|
151
|
+
}
|
|
152
|
+
const runner = opts.runners.get(detail.run.workflowName);
|
|
153
|
+
if (!runner) {
|
|
154
|
+
return c.json({
|
|
155
|
+
error: "runner_not_registered",
|
|
156
|
+
detail: `No runner registered for workflow "${detail.run.workflowName}"`,
|
|
157
|
+
}, 501);
|
|
158
|
+
}
|
|
159
|
+
// Find the failed step and seed prior step outputs.
|
|
160
|
+
const failedStep = detail.steps.find((s) => s.status === "failed");
|
|
161
|
+
if (!failedStep) {
|
|
162
|
+
return c.json({
|
|
163
|
+
error: "no_failed_step",
|
|
164
|
+
detail: "Run is marked failed but has no failed step row to resume from.",
|
|
165
|
+
}, 409);
|
|
166
|
+
}
|
|
167
|
+
const seedResults = {};
|
|
168
|
+
for (const s of detail.steps) {
|
|
169
|
+
if (s.sequence >= failedStep.sequence)
|
|
170
|
+
break;
|
|
171
|
+
if (s.status === "succeeded" || s.status === "skipped") {
|
|
172
|
+
if (s.output && typeof s.output === "object") {
|
|
173
|
+
seedResults[s.stepName] = s.output;
|
|
174
|
+
}
|
|
175
|
+
else {
|
|
176
|
+
// Preserve null/primitive outputs explicitly so downstream
|
|
177
|
+
// steps that read them don't see undefined.
|
|
178
|
+
seedResults[s.stepName] = s.output ?? null;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
else {
|
|
182
|
+
return c.json({
|
|
183
|
+
error: "incomplete_prior_step",
|
|
184
|
+
detail: `Step "${s.stepName}" (sequence ${s.sequence}) is not complete; cannot resume past it.`,
|
|
185
|
+
}, 409);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
const userId = opts.resolveUserId?.(c) ?? null;
|
|
189
|
+
try {
|
|
190
|
+
const { runId } = await runner.resume(detail.run.input, {
|
|
191
|
+
parentRunId: detail.run.id,
|
|
192
|
+
triggeredByUserId: userId,
|
|
193
|
+
correlationId: detail.run.correlationId,
|
|
194
|
+
tags: detail.run.tags,
|
|
195
|
+
resumeFromStep: failedStep.stepName,
|
|
196
|
+
seedResults,
|
|
197
|
+
});
|
|
198
|
+
return c.json({ data: { runId, parentRunId: detail.run.id, resumeFromStep: failedStep.stepName } }, 202);
|
|
199
|
+
}
|
|
200
|
+
catch (err) {
|
|
201
|
+
console.error("[workflow-runs] resume failed", err);
|
|
202
|
+
return c.json({ error: "resume_failed", detail: err instanceof Error ? err.message : String(err) }, 500);
|
|
203
|
+
}
|
|
204
|
+
});
|
|
205
|
+
}
|