@cosmicdrift/kumiko-bundled-features 0.223.0 → 0.224.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/package.json +11 -10
- package/src/workflow-runner/__tests__/workflow-runner.integration.test.ts +155 -0
- package/src/workflow-runner/aggregate-id.ts +20 -0
- package/src/workflow-runner/changes.json +1 -0
- package/src/workflow-runner/event-trigger.ts +95 -0
- package/src/workflow-runner/feature.ts +32 -0
- package/src/workflow-runner/index.ts +12 -0
- package/src/workflow-runner/runner.ts +128 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-bundled-features",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.224.0",
|
|
4
4
|
"description": "Built-in features — tenant, user, auth, delivery. The stuff you'd rewrite anyway, already typed.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -124,15 +124,16 @@
|
|
|
124
124
|
"./managed-pages/web": "./src/managed-pages/web/index.ts",
|
|
125
125
|
"./managed-pages/seeding": "./src/managed-pages/seeding.ts",
|
|
126
126
|
"./seo": "./src/seo/index.ts",
|
|
127
|
-
"./step-dispatcher": "./src/step-dispatcher/index.ts"
|
|
127
|
+
"./step-dispatcher": "./src/step-dispatcher/index.ts",
|
|
128
|
+
"./workflow-runner": "./src/workflow-runner/index.ts"
|
|
128
129
|
},
|
|
129
130
|
"dependencies": {
|
|
130
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
131
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
132
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
133
|
-
"@cosmicdrift/kumiko-renderer": "0.
|
|
134
|
-
"@cosmicdrift/kumiko-renderer-web": "0.
|
|
135
|
-
"@cosmicdrift/kumiko-types": "0.
|
|
131
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.224.0",
|
|
132
|
+
"@cosmicdrift/kumiko-framework": "0.224.0",
|
|
133
|
+
"@cosmicdrift/kumiko-headless": "0.224.0",
|
|
134
|
+
"@cosmicdrift/kumiko-renderer": "0.224.0",
|
|
135
|
+
"@cosmicdrift/kumiko-renderer-web": "0.224.0",
|
|
136
|
+
"@cosmicdrift/kumiko-types": "0.224.0",
|
|
136
137
|
"@mollie/api-client": "^4.5.0",
|
|
137
138
|
"@node-rs/argon2": "^2.0.2",
|
|
138
139
|
"@types/mailparser": "^3.4.6",
|
|
@@ -161,7 +162,7 @@
|
|
|
161
162
|
"devDependencies": {
|
|
162
163
|
"@testing-library/user-event": "^14.6.1",
|
|
163
164
|
"@types/qrcode": "^1.5.5",
|
|
164
|
-
"@cosmicdrift/kumiko-locale-de": "0.
|
|
165
|
-
"@cosmicdrift/kumiko-locale-es": "0.
|
|
165
|
+
"@cosmicdrift/kumiko-locale-de": "0.224.0",
|
|
166
|
+
"@cosmicdrift/kumiko-locale-es": "0.224.0"
|
|
166
167
|
}
|
|
167
168
|
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
// workflow-runner — event-triggered run-envelope integration test.
|
|
2
|
+
//
|
|
3
|
+
// Exercises registerEventTrigger end to end through a real event-dispatcher
|
|
4
|
+
// pass: a raw domain event is inserted, stack.eventDispatcher.runOnce()
|
|
5
|
+
// delivers it to the MSP, and the resulting run-envelope rows on the
|
|
6
|
+
// workflow-run aggregate stream are asserted against real Postgres rows.
|
|
7
|
+
|
|
8
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
9
|
+
import { insertOne, selectMany } from "@cosmicdrift/kumiko-framework/db";
|
|
10
|
+
import {
|
|
11
|
+
computeDefinitionFingerprint,
|
|
12
|
+
defineFeature,
|
|
13
|
+
defineWorkflow,
|
|
14
|
+
stepsPipeline,
|
|
15
|
+
WORKFLOW_RUN_COMPLETED_TYPE,
|
|
16
|
+
WORKFLOW_RUN_FAILED_TYPE,
|
|
17
|
+
WORKFLOW_RUN_STARTED_TYPE,
|
|
18
|
+
type WorkflowDefinition,
|
|
19
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
20
|
+
import { eventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
21
|
+
import { setupTestStack, type TestStack, TestUsers } from "@cosmicdrift/kumiko-framework/stack";
|
|
22
|
+
import { workflowRunAggregateId } from "../aggregate-id";
|
|
23
|
+
import { registerEventTrigger } from "../event-trigger";
|
|
24
|
+
import { workflowRunnerFeature } from "../feature";
|
|
25
|
+
|
|
26
|
+
let stack: TestStack;
|
|
27
|
+
const admin = TestUsers.admin;
|
|
28
|
+
|
|
29
|
+
const happyWorkflow: WorkflowDefinition = defineWorkflow({
|
|
30
|
+
name: "wr-integration-happy",
|
|
31
|
+
trigger: { kind: "event", eventType: "wr-test.happy" },
|
|
32
|
+
idempotencyKey: ({ payload }) => (payload as { runKey: string }).runKey,
|
|
33
|
+
steps: stepsPipeline(({ r }) => [
|
|
34
|
+
r.step.compute("doubled", (ctx) => (ctx.event.payload as { n: number }).n * 2),
|
|
35
|
+
r.step.return({ isSuccess: true, data: undefined }),
|
|
36
|
+
]),
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
const failingWorkflow: WorkflowDefinition = defineWorkflow({
|
|
40
|
+
name: "wr-integration-failure",
|
|
41
|
+
trigger: { kind: "event", eventType: "wr-test.failure" },
|
|
42
|
+
idempotencyKey: ({ payload }) => (payload as { runKey: string }).runKey,
|
|
43
|
+
steps: stepsPipeline(({ r }) => [
|
|
44
|
+
r.step.compute("boom", () => {
|
|
45
|
+
throw new Error("boom-explicit-failure");
|
|
46
|
+
}),
|
|
47
|
+
r.step.return({ isSuccess: true, data: undefined }),
|
|
48
|
+
]),
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
const suspendingWorkflow: WorkflowDefinition = defineWorkflow({
|
|
52
|
+
name: "wr-integration-suspend",
|
|
53
|
+
trigger: { kind: "event", eventType: "wr-test.suspend" },
|
|
54
|
+
idempotencyKey: ({ payload }) => (payload as { runKey: string }).runKey,
|
|
55
|
+
steps: stepsPipeline(({ r }) => [
|
|
56
|
+
r.step.wait({ for: "PT1H" }),
|
|
57
|
+
r.step.return({ isSuccess: true, data: undefined }),
|
|
58
|
+
]),
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
const testTriggersFeature = defineFeature("workflow-runner-integration-test-triggers", (r) => {
|
|
62
|
+
registerEventTrigger(r, happyWorkflow);
|
|
63
|
+
registerEventTrigger(r, failingWorkflow);
|
|
64
|
+
registerEventTrigger(r, suspendingWorkflow);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
async function fireTrigger(eventType: string, payload: Record<string, unknown>): Promise<void> {
|
|
68
|
+
await insertOne(stack.db, eventsTable, {
|
|
69
|
+
aggregateId: crypto.randomUUID(),
|
|
70
|
+
aggregateType: "wr-test-source",
|
|
71
|
+
tenantId: admin.tenantId,
|
|
72
|
+
version: 1,
|
|
73
|
+
type: eventType,
|
|
74
|
+
eventVersion: 1,
|
|
75
|
+
payload,
|
|
76
|
+
metadata: { userId: admin.id },
|
|
77
|
+
createdBy: admin.id,
|
|
78
|
+
});
|
|
79
|
+
await stack.eventDispatcher?.runOnce();
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function loadRunEvents(runId: string) {
|
|
83
|
+
return selectMany(
|
|
84
|
+
stack.db,
|
|
85
|
+
eventsTable,
|
|
86
|
+
{ aggregateId: runId },
|
|
87
|
+
{ orderBy: { col: "version", direction: "asc" } },
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
describe("workflow-runner event-trigger", () => {
|
|
92
|
+
beforeAll(async () => {
|
|
93
|
+
stack = await setupTestStack({ features: [workflowRunnerFeature, testTriggersFeature] });
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
afterAll(async () => {
|
|
97
|
+
await stack.cleanup();
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("happy path: no suspension writes run-started then run-completed with canonical payloads", async () => {
|
|
101
|
+
const runKey = crypto.randomUUID();
|
|
102
|
+
const runId = workflowRunAggregateId(happyWorkflow.name, runKey);
|
|
103
|
+
|
|
104
|
+
await fireTrigger("wr-test.happy", { runKey, n: 21 });
|
|
105
|
+
|
|
106
|
+
const rows = await loadRunEvents(runId);
|
|
107
|
+
expect(rows).toHaveLength(2);
|
|
108
|
+
expect(rows[0]!["type"]).toBe(WORKFLOW_RUN_STARTED_TYPE);
|
|
109
|
+
expect(rows[0]!["payload"]).toMatchObject({
|
|
110
|
+
workflowName: happyWorkflow.name,
|
|
111
|
+
triggerEventType: "wr-test.happy",
|
|
112
|
+
triggerPayload: { runKey, n: 21 },
|
|
113
|
+
definitionFingerprint: computeDefinitionFingerprint(happyWorkflow),
|
|
114
|
+
});
|
|
115
|
+
expect(rows[1]!["type"]).toBe(WORKFLOW_RUN_COMPLETED_TYPE);
|
|
116
|
+
expect(rows[1]!["payload"]).toEqual({
|
|
117
|
+
workflowName: happyWorkflow.name,
|
|
118
|
+
stepIndex: 2,
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("error path: a throwing step writes run-failed with the error text, no run-completed", async () => {
|
|
123
|
+
const runKey = crypto.randomUUID();
|
|
124
|
+
const runId = workflowRunAggregateId(failingWorkflow.name, runKey);
|
|
125
|
+
|
|
126
|
+
await fireTrigger("wr-test.failure", { runKey });
|
|
127
|
+
|
|
128
|
+
const rows = await loadRunEvents(runId);
|
|
129
|
+
expect(rows.map((row) => row["type"])).toEqual([
|
|
130
|
+
WORKFLOW_RUN_STARTED_TYPE,
|
|
131
|
+
WORKFLOW_RUN_FAILED_TYPE,
|
|
132
|
+
]);
|
|
133
|
+
expect(rows[1]!["payload"]).toMatchObject({ workflowName: failingWorkflow.name, stepIndex: 0 });
|
|
134
|
+
expect(String(rows[1]!["payload"]["error"])).toContain("boom-explicit-failure");
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test("suspension: a wait step fails the run instead of leaving it silently pending, no run-completed", async () => {
|
|
138
|
+
const runKey = crypto.randomUUID();
|
|
139
|
+
const runId = workflowRunAggregateId(suspendingWorkflow.name, runKey);
|
|
140
|
+
|
|
141
|
+
await fireTrigger("wr-test.suspend", { runKey });
|
|
142
|
+
|
|
143
|
+
const rows = await loadRunEvents(runId);
|
|
144
|
+
const types = rows.map((row) => row["type"]);
|
|
145
|
+
expect(types).toContain(WORKFLOW_RUN_STARTED_TYPE);
|
|
146
|
+
expect(types).not.toContain(WORKFLOW_RUN_COMPLETED_TYPE);
|
|
147
|
+
expect(types).toContain(WORKFLOW_RUN_FAILED_TYPE);
|
|
148
|
+
|
|
149
|
+
const failed = rows.find((row) => row["type"] === WORKFLOW_RUN_FAILED_TYPE)!;
|
|
150
|
+
const errorText = String(failed["payload"]["error"]);
|
|
151
|
+
expect(errorText).toContain("WorkflowSuspensionUnsupportedError");
|
|
152
|
+
expect(errorText).toContain(suspendingWorkflow.name);
|
|
153
|
+
expect(errorText).toContain("resume-loop");
|
|
154
|
+
});
|
|
155
|
+
});
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { v5 as uuidv5 } from "uuid";
|
|
2
|
+
|
|
3
|
+
// Fixed UUID namespace for workflow-run aggregate-id derivation. Frozen —
|
|
4
|
+
// changing it would re-key every existing run stream on replay.
|
|
5
|
+
const WORKFLOW_RUN_NAMESPACE = "1f8a6c3e-7d2b-4a91-9e3f-5c8b2a41d6f0";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Deterministic aggregate-id for a workflow run keyed by (workflowName,
|
|
9
|
+
* idempotencyKey). `aggregate_id` is a `uuid` column — a human-readable
|
|
10
|
+
* `wf-<name>-<key>` string isn't a valid value. Same key always maps to the
|
|
11
|
+
* same stream, which is what lets two *concurrent* triggers for that key
|
|
12
|
+
* race on `expectedVersion` and have the loser fail the unique constraint
|
|
13
|
+
* instead of silently double-running. It does NOT dedup sequential
|
|
14
|
+
* re-triggers of the same key — those land as version 2, 3, ... on the same
|
|
15
|
+
* stream and each starts its own run; that's out of scope for #2478.
|
|
16
|
+
*/
|
|
17
|
+
// @wrapper-known uuid-domain
|
|
18
|
+
export function workflowRunAggregateId(workflowName: string, key: string): string {
|
|
19
|
+
return uuidv5(`${workflowName}|${key}`, WORKFLOW_RUN_NAMESPACE);
|
|
20
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
[]
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// event-trigger — MultiStreamProjection that listens for domain events and
|
|
2
|
+
// starts + runs a workflow when the event matches its trigger.
|
|
3
|
+
//
|
|
4
|
+
// Registration pattern:
|
|
5
|
+
// registerEventTrigger(r, myWorkflow)
|
|
6
|
+
//
|
|
7
|
+
// The MSP apply-fn runs in the dispatcher's own tx, so `workflow.run-started`
|
|
8
|
+
// plus the synchronous portion of the pipeline land atomically. Any throw
|
|
9
|
+
// from startAndRunWorkflow — including WorkflowSuspensionUnsupportedError,
|
|
10
|
+
// since no resume-loop is mounted yet (framework#2480) — is recorded as
|
|
11
|
+
// `workflow.run-failed` and rethrown so the dispatcher's retry/dead-letter
|
|
12
|
+
// handling still applies.
|
|
13
|
+
|
|
14
|
+
import type {
|
|
15
|
+
FeatureRegistrar,
|
|
16
|
+
MultiStreamProjectionDefinition,
|
|
17
|
+
WorkflowDefinition,
|
|
18
|
+
WriteEvent,
|
|
19
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
20
|
+
import {
|
|
21
|
+
WORKFLOW_AGGREGATE_TYPE,
|
|
22
|
+
WORKFLOW_RUN_FAILED_TYPE,
|
|
23
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
24
|
+
import { workflowRunAggregateId } from "./aggregate-id";
|
|
25
|
+
import { startAndRunWorkflow, type WorkflowRunFailedPayload } from "./runner";
|
|
26
|
+
|
|
27
|
+
export function registerEventTrigger(r: FeatureRegistrar, workflow: WorkflowDefinition): void {
|
|
28
|
+
// skip: cron-triggered workflows have no domain event to project off — they
|
|
29
|
+
// need a scheduler, not an MSP, so there is nothing to register here.
|
|
30
|
+
if (workflow.trigger.kind !== "event") return;
|
|
31
|
+
|
|
32
|
+
const eventType = workflow.trigger.eventType;
|
|
33
|
+
|
|
34
|
+
r.multiStreamProjection({
|
|
35
|
+
name: `workflow-${workflow.name}`,
|
|
36
|
+
apply: {
|
|
37
|
+
[eventType]: async (event, _tx, ctx) => {
|
|
38
|
+
// skip: unreachable — the guard above already established this, but the
|
|
39
|
+
// closure does not carry that narrowing, and re-narrowing here keeps
|
|
40
|
+
// `trigger.filter` below typed without a cast.
|
|
41
|
+
if (workflow.trigger.kind !== "event") return;
|
|
42
|
+
if (workflow.trigger.filter) {
|
|
43
|
+
const matches = workflow.trigger.filter(event as never);
|
|
44
|
+
// skip: the workflow's own trigger filter rejected this event — not
|
|
45
|
+
// this run's concern, so no run is started and nothing is recorded.
|
|
46
|
+
if (!matches) return;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// @cast-boundary msp-to-write-event — the MSP delivers a StoredEvent
|
|
50
|
+
// (event-store shape); the workflow runner expects a WriteEvent
|
|
51
|
+
// (handler shape). The fields workflow steps read (type, payload)
|
|
52
|
+
// overlap exactly — the missing `.user` field is acceptable because
|
|
53
|
+
// workflow triggers run system-level, not user-scoped.
|
|
54
|
+
const triggerEvent = event as unknown as WriteEvent;
|
|
55
|
+
let idempotencyKey: string | undefined;
|
|
56
|
+
if (typeof workflow.idempotencyKey === "function") {
|
|
57
|
+
idempotencyKey = workflow.idempotencyKey(triggerEvent);
|
|
58
|
+
} else if (typeof workflow.idempotencyKey === "string") {
|
|
59
|
+
idempotencyKey = workflow.idempotencyKey;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const runId = idempotencyKey
|
|
63
|
+
? workflowRunAggregateId(workflow.name, idempotencyKey)
|
|
64
|
+
: crypto.randomUUID();
|
|
65
|
+
|
|
66
|
+
try {
|
|
67
|
+
await startAndRunWorkflow({
|
|
68
|
+
runId,
|
|
69
|
+
workflow,
|
|
70
|
+
triggerEvent,
|
|
71
|
+
...(idempotencyKey && { idempotencyKey }),
|
|
72
|
+
// @cast-boundary msp-to-handler-ctx — MultiStreamApplyContext only
|
|
73
|
+
// exposes unsafeAppendEvent/loadAggregate, a subset of
|
|
74
|
+
// HandlerContext. The step vocabulary this runner drives (wait,
|
|
75
|
+
// compute, return) only ever calls unsafeAppendEvent on it.
|
|
76
|
+
handlerCtx: ctx as never,
|
|
77
|
+
});
|
|
78
|
+
} catch (error) {
|
|
79
|
+
const failedPayload: WorkflowRunFailedPayload = {
|
|
80
|
+
workflowName: workflow.name,
|
|
81
|
+
stepIndex: 0,
|
|
82
|
+
error: String(error),
|
|
83
|
+
};
|
|
84
|
+
await ctx.unsafeAppendEvent({
|
|
85
|
+
aggregateId: runId,
|
|
86
|
+
aggregateType: WORKFLOW_AGGREGATE_TYPE,
|
|
87
|
+
type: WORKFLOW_RUN_FAILED_TYPE,
|
|
88
|
+
payload: failedPayload,
|
|
89
|
+
});
|
|
90
|
+
throw error;
|
|
91
|
+
}
|
|
92
|
+
},
|
|
93
|
+
},
|
|
94
|
+
} satisfies MultiStreamProjectionDefinition);
|
|
95
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// kumiko-feature-version: 1
|
|
2
|
+
//
|
|
3
|
+
// workflow-runner — writes the run-envelope (run-started / run-completed /
|
|
4
|
+
// run-failed) for a workflow run onto its workflow-run aggregate stream.
|
|
5
|
+
//
|
|
6
|
+
// This feature registers no workflows of its own — consumers define a
|
|
7
|
+
// WorkflowDefinition via `defineWorkflow` and wire it up with
|
|
8
|
+
// `registerEventTrigger(r, myWorkflow)` inside their own feature. Mounting
|
|
9
|
+
// `workflowRunnerFeature` documents that dependency and reserves the
|
|
10
|
+
// picker/manifest slot; it has no MSPs or entities to register itself.
|
|
11
|
+
//
|
|
12
|
+
// No resume-loop is bundled yet (framework#2480) — workflows that use
|
|
13
|
+
// suspending steps (wait / waitForEvent / retry) fail loud via
|
|
14
|
+
// WorkflowSuspensionUnsupportedError instead of hanging silently.
|
|
15
|
+
|
|
16
|
+
import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
|
|
17
|
+
|
|
18
|
+
// Kebab-case matching the "./workflow-runner" export subpath — every other
|
|
19
|
+
// bundled feature's runtime name follows this convention, and
|
|
20
|
+
// FEATURE_IMPORT_REGISTRY (use-all-bundled) keys off it.
|
|
21
|
+
const FEATURE_NAME = "workflow-runner";
|
|
22
|
+
|
|
23
|
+
export const workflowRunnerFeature = defineFeature(FEATURE_NAME, (r) => {
|
|
24
|
+
r.describe(
|
|
25
|
+
"Writes the run-envelope (`workflow.run-started` / `workflow.run-completed` / `workflow.run-failed`) for a workflow run onto its `workflow-run` aggregate stream. Provides `startAndRunWorkflow` and `registerEventTrigger` for consumer features to wire up `defineWorkflow`-based workflows against domain events. Registers no workflows itself. No resume-loop is bundled yet (framework#2480) — suspending steps (wait/waitForEvent/retry) fail the run instead of pausing it.",
|
|
26
|
+
);
|
|
27
|
+
r.uiHints({
|
|
28
|
+
displayLabel: "Workflow Runner",
|
|
29
|
+
category: "infrastructure",
|
|
30
|
+
recommended: false,
|
|
31
|
+
});
|
|
32
|
+
});
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// Public API of the workflow-runner bundled-feature.
|
|
2
|
+
|
|
3
|
+
export { workflowRunAggregateId } from "./aggregate-id";
|
|
4
|
+
export { registerEventTrigger } from "./event-trigger";
|
|
5
|
+
export { workflowRunnerFeature } from "./feature";
|
|
6
|
+
export {
|
|
7
|
+
startAndRunWorkflow,
|
|
8
|
+
type WorkflowRunCompletedPayload,
|
|
9
|
+
type WorkflowRunFailedPayload,
|
|
10
|
+
type WorkflowRunStartedPayload,
|
|
11
|
+
WorkflowSuspensionUnsupportedError,
|
|
12
|
+
} from "./runner";
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
// runner — starts a workflow-run by writing run-started, executing the
|
|
2
|
+
// pipeline until first suspension or completion, and writing run-completed.
|
|
3
|
+
//
|
|
4
|
+
// No resume-loop is mounted yet (framework#2480): a suspending step
|
|
5
|
+
// (wait / waitForEvent / retry) would leave the run stuck forever with
|
|
6
|
+
// nothing to wake it. startAndRunWorkflow surfaces that case as a loud
|
|
7
|
+
// WorkflowSuspensionUnsupportedError instead of returning a silent
|
|
8
|
+
// "suspended" outcome — callers (registerEventTrigger) treat it like any
|
|
9
|
+
// other pipeline failure and record it as a failed run.
|
|
10
|
+
|
|
11
|
+
import type {
|
|
12
|
+
HandlerContext,
|
|
13
|
+
WorkflowDefinition,
|
|
14
|
+
WriteEvent,
|
|
15
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
16
|
+
import {
|
|
17
|
+
buildPipelineSteps,
|
|
18
|
+
computeDefinitionFingerprint,
|
|
19
|
+
runStepList,
|
|
20
|
+
WORKFLOW_AGGREGATE_TYPE,
|
|
21
|
+
WORKFLOW_RUN_COMPLETED_TYPE,
|
|
22
|
+
WORKFLOW_RUN_STARTED_TYPE,
|
|
23
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
24
|
+
|
|
25
|
+
export type WorkflowRunStartedPayload = {
|
|
26
|
+
readonly workflowName: string;
|
|
27
|
+
readonly triggerEventType: string;
|
|
28
|
+
readonly triggerPayload: unknown;
|
|
29
|
+
readonly definitionFingerprint: string;
|
|
30
|
+
readonly idempotencyKey?: string;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
// Canonical run-completed shape — the sample this was hoisted from had two
|
|
34
|
+
// writers disagree (`{workflowName}` here, `{stepIndex}` in the not-yet-
|
|
35
|
+
// mounted resume-loop). `stepIndex` is the pipeline's top-level step count
|
|
36
|
+
// (sub-lists inside branch/forEach aren't counted); every run that reaches
|
|
37
|
+
// this event ran to completion in one pass (suspension throws instead of
|
|
38
|
+
// returning here).
|
|
39
|
+
export type WorkflowRunCompletedPayload = {
|
|
40
|
+
readonly workflowName: string;
|
|
41
|
+
readonly stepIndex: number;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export type WorkflowRunFailedPayload = {
|
|
45
|
+
readonly workflowName: string;
|
|
46
|
+
readonly stepIndex: number;
|
|
47
|
+
readonly error: string;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
export class WorkflowSuspensionUnsupportedError extends Error {
|
|
51
|
+
constructor(
|
|
52
|
+
readonly workflowName: string,
|
|
53
|
+
readonly stepIndex: number,
|
|
54
|
+
) {
|
|
55
|
+
super(
|
|
56
|
+
`Workflow "${workflowName}" suspended at step ${stepIndex}, but no resume-loop is mounted to wake it (framework#2480) — the run would hang forever. Use only synchronous steps until a resume-loop is mounted.`,
|
|
57
|
+
);
|
|
58
|
+
this.name = "WorkflowSuspensionUnsupportedError";
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Start + execute a workflow run.
|
|
64
|
+
*
|
|
65
|
+
* 1. Append `workflow.run-started` with the Q7 snapshot fingerprint.
|
|
66
|
+
* 2. Run the pipeline. If it suspends, throw — see module doc.
|
|
67
|
+
* 3. On completion, append `workflow.run-completed`. Throws from the
|
|
68
|
+
* pipeline itself bubble up to the caller (event-trigger), which
|
|
69
|
+
* records `workflow.run-failed`.
|
|
70
|
+
*/
|
|
71
|
+
export async function startAndRunWorkflow(args: {
|
|
72
|
+
readonly runId: string;
|
|
73
|
+
readonly workflow: WorkflowDefinition;
|
|
74
|
+
readonly triggerEvent: WriteEvent;
|
|
75
|
+
readonly idempotencyKey?: string;
|
|
76
|
+
readonly handlerCtx: HandlerContext;
|
|
77
|
+
}): Promise<{ readonly outcome: "completed" }> {
|
|
78
|
+
const fingerprint = computeDefinitionFingerprint(args.workflow);
|
|
79
|
+
|
|
80
|
+
const startedPayload: WorkflowRunStartedPayload = {
|
|
81
|
+
workflowName: args.workflow.name,
|
|
82
|
+
triggerEventType: args.triggerEvent.type,
|
|
83
|
+
triggerPayload: args.triggerEvent.payload,
|
|
84
|
+
definitionFingerprint: fingerprint,
|
|
85
|
+
...(args.idempotencyKey && { idempotencyKey: args.idempotencyKey }),
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
await args.handlerCtx.unsafeAppendEvent({
|
|
89
|
+
aggregateId: args.runId,
|
|
90
|
+
aggregateType: WORKFLOW_AGGREGATE_TYPE,
|
|
91
|
+
type: WORKFLOW_RUN_STARTED_TYPE,
|
|
92
|
+
payload: startedPayload,
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
const steps = buildPipelineSteps(args.workflow.pipelineDef, args.triggerEvent);
|
|
96
|
+
|
|
97
|
+
const outcome = await runStepList(
|
|
98
|
+
steps,
|
|
99
|
+
args.triggerEvent,
|
|
100
|
+
args.handlerCtx,
|
|
101
|
+
{},
|
|
102
|
+
{},
|
|
103
|
+
{
|
|
104
|
+
runId: args.runId,
|
|
105
|
+
workflowName: args.workflow.name,
|
|
106
|
+
stepIndex: 0,
|
|
107
|
+
definitionFingerprint: fingerprint,
|
|
108
|
+
},
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
if (outcome.kind === "suspended") {
|
|
112
|
+
throw new WorkflowSuspensionUnsupportedError(args.workflow.name, outcome.stepIndex);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const completedPayload: WorkflowRunCompletedPayload = {
|
|
116
|
+
workflowName: args.workflow.name,
|
|
117
|
+
stepIndex: steps.length,
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
await args.handlerCtx.unsafeAppendEvent({
|
|
121
|
+
aggregateId: args.runId,
|
|
122
|
+
aggregateType: WORKFLOW_AGGREGATE_TYPE,
|
|
123
|
+
type: WORKFLOW_RUN_COMPLETED_TYPE,
|
|
124
|
+
payload: completedPayload,
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
return { outcome: "completed" };
|
|
128
|
+
}
|