@cosmicdrift/kumiko-bundled-features 0.231.0 → 0.233.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (29) hide show
  1. package/package.json +9 -9
  2. package/src/cap-overview/__tests__/usage-math.test.ts +12 -0
  3. package/src/cap-overview/changes.json +6 -0
  4. package/src/cap-overview/handlers/caps-usage.query.ts +2 -1
  5. package/src/cap-overview/types.ts +5 -2
  6. package/src/cap-overview/usage-math.ts +6 -4
  7. package/src/cap-overview/web/__tests__/cap-cards-panel.test.tsx +53 -0
  8. package/src/cap-overview/web/__tests__/cap-usage-bar.test.tsx +36 -0
  9. package/src/cap-overview/web/cap-cards-panel.tsx +11 -7
  10. package/src/cap-overview/web/cap-usage-bar.tsx +9 -0
  11. package/src/cap-overview/web/cap-usage-cell.tsx +2 -1
  12. package/src/sessions/__tests__/sessions-screens.boot.test.ts +13 -0
  13. package/src/sessions/feature.ts +4 -70
  14. package/src/sessions/screens.ts +84 -0
  15. package/src/tenant/__tests__/members-screens.boot.test.ts +14 -0
  16. package/src/tenant/screens.ts +10 -2
  17. package/src/workflow-runner/__tests__/event-wakeup.integration.test.ts +328 -0
  18. package/src/workflow-runner/__tests__/pending-projection.integration.test.ts +292 -0
  19. package/src/workflow-runner/__tests__/resume-loop.integration.test.ts +277 -0
  20. package/src/workflow-runner/__tests__/workflow-runner.integration.test.ts +6 -11
  21. package/src/workflow-runner/db/queries/due-runs.ts +23 -0
  22. package/src/workflow-runner/event-subscriber.ts +93 -0
  23. package/src/workflow-runner/event-trigger.ts +14 -4
  24. package/src/workflow-runner/feature.ts +62 -5
  25. package/src/workflow-runner/handlers/resume-run.write.ts +277 -0
  26. package/src/workflow-runner/pending-projection.ts +148 -0
  27. package/src/workflow-runner/runner.ts +36 -13
  28. package/src/workflow-runner/tables.ts +95 -0
  29. package/src/workflow-runner/workflow-registry.ts +23 -0
@@ -0,0 +1,277 @@
1
+ // resume-loop — the time-based resume path (framework#2513 Phase 2)
2
+ // against real Postgres.
3
+ //
4
+ // Drives `resume-due-runs` directly via stack.registry.getJob(...) (same
5
+ // style as data-retention/__tests__/retention-cleanup.integration.test.ts)
6
+ // with `write` wired to the real dispatcher — this exercises the full
7
+ // resume-run handler (access gate, Q7 check, claim, pipeline re-entry), not
8
+ // just runResumeDueRunsJob's own SELECT.
9
+ //
10
+ // wait's `for` is a resolver reading the trigger payload, so both the due
11
+ // and not-yet-due cases share one workflow fixture — no need to fake the
12
+ // clock or sleep past a real backoff. The retry fixture's backoff can't be
13
+ // steered the same way (only `wait`'s wait-length step-arg is resolver-
14
+ // controlled), so its due-case tampers `wake_at` directly, same as the Q7
15
+ // mismatch test tampers `definition_fingerprint` directly — neither touches
16
+ // the workflow-registry, keeping "registry overwrite" out of the test
17
+ // contract.
18
+ //
19
+ // pending-projection.ts and the event-trigger MSP both run off
20
+ // stack.eventDispatcher.runOnce() — this file's own tests are unaffected by
21
+ // (and must not touch) whatever separately-named test file exercises the
22
+ // projection itself.
23
+
24
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
25
+ import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
26
+ import { insertOne, selectMany } from "@cosmicdrift/kumiko-framework/db";
27
+ import {
28
+ createSystemUser,
29
+ defineFeature,
30
+ defineWorkflow,
31
+ type JobContext,
32
+ stepsPipeline,
33
+ WORKFLOW_RESUMED_TYPE,
34
+ WORKFLOW_RETRY_SCHEDULED_TYPE,
35
+ WORKFLOW_RUN_COMPLETED_TYPE,
36
+ WORKFLOW_RUN_FAILED_TYPE,
37
+ WORKFLOW_RUN_STARTED_TYPE,
38
+ WORKFLOW_WAITING_TYPE,
39
+ type WorkflowDefinition,
40
+ } from "@cosmicdrift/kumiko-framework/engine";
41
+ import { eventsTable } from "@cosmicdrift/kumiko-framework/event-store";
42
+ import { setupTestStack, type TestStack, TestUsers } from "@cosmicdrift/kumiko-framework/stack";
43
+ import { bridgeStub } from "@cosmicdrift/kumiko-framework/testing";
44
+ import { getTemporal } from "@cosmicdrift/kumiko-framework/time";
45
+ import { workflowRunAggregateId } from "../aggregate-id";
46
+ import { registerEventTrigger } from "../event-trigger";
47
+ import { workflowRunnerFeature } from "../feature";
48
+
49
+ let stack: TestStack;
50
+ const admin = TestUsers.admin;
51
+ const T = getTemporal();
52
+
53
+ // Uniquely prefixed ("rl-") — the workflow-registry is a process-global
54
+ // module singleton, and workflow-runner.integration.test.ts's own fixtures
55
+ // ("wr-integration-*") already live in the same test process.
56
+ const waitWorkflow: WorkflowDefinition = defineWorkflow({
57
+ name: "rl-wait",
58
+ trigger: { kind: "event", eventType: "rl-test.wait" },
59
+ idempotencyKey: ({ payload }) => (payload as { runKey: string }).runKey,
60
+ steps: stepsPipeline(({ r }) => [
61
+ r.step.wait({ for: (ctx) => (ctx.event.payload as { forIso: string }).forIso }),
62
+ r.step.return({ isSuccess: true, data: undefined }),
63
+ ]),
64
+ });
65
+
66
+ const retryWorkflow: WorkflowDefinition = defineWorkflow({
67
+ name: "rl-retry",
68
+ trigger: { kind: "event", eventType: "rl-test.retry" },
69
+ idempotencyKey: ({ payload }) => (payload as { runKey: string }).runKey,
70
+ steps: stepsPipeline(({ r }) => [
71
+ r.step.retry({
72
+ times: 2,
73
+ backoff: "linear",
74
+ do: [
75
+ r.step.compute("gate", (ctx) => {
76
+ const attempt = ctx.workflow?.retryAttempt ?? 1;
77
+ if (attempt === 1) throw new Error("first-attempt-fails");
78
+ return attempt;
79
+ }),
80
+ ],
81
+ }),
82
+ r.step.return({ isSuccess: true, data: undefined }),
83
+ ]),
84
+ });
85
+
86
+ const testTriggersFeature = defineFeature("workflow-runner-resume-loop-test-triggers", (r) => {
87
+ registerEventTrigger(r, waitWorkflow);
88
+ registerEventTrigger(r, retryWorkflow);
89
+ });
90
+
91
+ const noopLogger: JobContext["log"] = {
92
+ info() {},
93
+ warn() {},
94
+ error() {},
95
+ debug() {},
96
+ child() {
97
+ return noopLogger;
98
+ },
99
+ };
100
+
101
+ async function fireTrigger(eventType: string, payload: Record<string, unknown>): Promise<void> {
102
+ await insertOne(stack.db, eventsTable, {
103
+ aggregateId: crypto.randomUUID(),
104
+ aggregateType: "rl-test-source",
105
+ tenantId: admin.tenantId,
106
+ version: 1,
107
+ type: eventType,
108
+ eventVersion: 1,
109
+ payload,
110
+ metadata: { userId: admin.id },
111
+ createdBy: admin.id,
112
+ });
113
+ // Two passes: event-trigger's MSP consumes this event and writes the
114
+ // run-started/suspension events on the first pass; pending-projection is a
115
+ // separate MSP consumer that only observes those newly-written events
116
+ // (and materialises the pending row) on a pass after they exist.
117
+ await stack.eventDispatcher?.runOnce();
118
+ await stack.eventDispatcher?.runOnce();
119
+ }
120
+
121
+ async function loadRunEvents(runId: string) {
122
+ return selectMany(
123
+ stack.db,
124
+ eventsTable,
125
+ { aggregateId: runId },
126
+ { orderBy: { col: "version", direction: "asc" } },
127
+ );
128
+ }
129
+
130
+ async function pendingRowExists(runId: string, stepIndex: number): Promise<boolean> {
131
+ const rows = (await asRawClient(stack.db).unsafe(
132
+ `SELECT 1 FROM workflow_run_pending WHERE run_id = $1 AND step_index = $2`,
133
+ [runId, stepIndex],
134
+ )) as ReadonlyArray<unknown>;
135
+ return rows.length > 0;
136
+ }
137
+
138
+ async function tamperWakeAtToPast(runId: string, stepIndex: number): Promise<void> {
139
+ await asRawClient(stack.db).unsafe(
140
+ `UPDATE workflow_run_pending SET wake_at = now() - interval '1 hour' WHERE run_id = $1 AND step_index = $2`,
141
+ [runId, stepIndex],
142
+ );
143
+ }
144
+
145
+ async function tamperFingerprint(runId: string, stepIndex: number, value: string): Promise<void> {
146
+ await asRawClient(stack.db).unsafe(
147
+ `UPDATE workflow_run_pending SET definition_fingerprint = $1 WHERE run_id = $2 AND step_index = $3`,
148
+ [value, runId, stepIndex],
149
+ );
150
+ }
151
+
152
+ async function runResumeDueRunsJob(): Promise<void> {
153
+ const job = stack.registry.getJob("workflow-runner:job:resume-due-runs");
154
+ expect(job).toBeDefined();
155
+ if (!job) return;
156
+
157
+ const systemUser = createSystemUser(admin.tenantId);
158
+ const ctx: JobContext = {
159
+ db: stack.db,
160
+ registry: stack.registry,
161
+ systemUser,
162
+ log: noopLogger,
163
+ triggeredBy: null,
164
+ ...bridgeStub({ user: systemUser }),
165
+ write: (qn, payload) => stack.dispatcher.write(qn, payload, systemUser),
166
+ };
167
+ await job.handler({}, ctx);
168
+ }
169
+
170
+ describe("workflow-runner resume loop", () => {
171
+ beforeAll(async () => {
172
+ stack = await setupTestStack({ features: [workflowRunnerFeature, testTriggersFeature] });
173
+ });
174
+
175
+ afterAll(async () => {
176
+ await stack.cleanup();
177
+ });
178
+
179
+ test("due wait: resume-due-runs resumes it to completion, pending row deleted", async () => {
180
+ const runKey = crypto.randomUUID();
181
+ const runId = workflowRunAggregateId(waitWorkflow.name, runKey);
182
+ const pastIso = T.Now.instant().subtract({ hours: 1 }).toString();
183
+
184
+ await fireTrigger("rl-test.wait", { runKey, forIso: pastIso });
185
+ expect(await pendingRowExists(runId, 0)).toBe(true);
186
+
187
+ await runResumeDueRunsJob();
188
+
189
+ const rows = await loadRunEvents(runId);
190
+ expect(rows.map((row) => row["type"])).toEqual([
191
+ WORKFLOW_RUN_STARTED_TYPE,
192
+ WORKFLOW_WAITING_TYPE,
193
+ WORKFLOW_RESUMED_TYPE,
194
+ WORKFLOW_RUN_COMPLETED_TYPE,
195
+ ]);
196
+ expect(rows[3]!["payload"]).toEqual({ workflowName: waitWorkflow.name, stepIndex: 2 });
197
+
198
+ // pending-projection deletes on WORKFLOW_RESUMED via a separate MSP pass.
199
+ await stack.eventDispatcher?.runOnce();
200
+ expect(await pendingRowExists(runId, 0)).toBe(false);
201
+ });
202
+
203
+ test("not-yet-due wait: resume-due-runs leaves the row and run untouched", async () => {
204
+ const runKey = crypto.randomUUID();
205
+ const runId = workflowRunAggregateId(waitWorkflow.name, runKey);
206
+ const futureIso = T.Now.instant().add({ hours: 1 }).toString();
207
+
208
+ await fireTrigger("rl-test.wait", { runKey, forIso: futureIso });
209
+ expect(await pendingRowExists(runId, 0)).toBe(true);
210
+
211
+ await runResumeDueRunsJob();
212
+
213
+ const rows = await loadRunEvents(runId);
214
+ expect(rows.map((row) => row["type"])).toEqual([
215
+ WORKFLOW_RUN_STARTED_TYPE,
216
+ WORKFLOW_WAITING_TYPE,
217
+ ]);
218
+ expect(await pendingRowExists(runId, 0)).toBe(true);
219
+ });
220
+
221
+ test("changed workflow definition: resume-run fails loud with reason workflow_definition_changed, no resume", async () => {
222
+ const runKey = crypto.randomUUID();
223
+ const runId = workflowRunAggregateId(waitWorkflow.name, runKey);
224
+ const pastIso = T.Now.instant().subtract({ hours: 1 }).toString();
225
+
226
+ await fireTrigger("rl-test.wait", { runKey, forIso: pastIso });
227
+ await tamperFingerprint(runId, 0, "tampered-fingerprint-value");
228
+
229
+ await runResumeDueRunsJob();
230
+
231
+ const rows = await loadRunEvents(runId);
232
+ expect(rows.map((row) => row["type"])).toEqual([
233
+ WORKFLOW_RUN_STARTED_TYPE,
234
+ WORKFLOW_WAITING_TYPE,
235
+ WORKFLOW_RUN_FAILED_TYPE,
236
+ ]);
237
+ expect(rows[2]!["payload"]).toMatchObject({
238
+ workflowName: waitWorkflow.name,
239
+ stepIndex: 0,
240
+ reason: "workflow_definition_changed",
241
+ });
242
+
243
+ await stack.eventDispatcher?.runOnce();
244
+ expect(await pendingRowExists(runId, 0)).toBe(false);
245
+ });
246
+
247
+ test("retry suspension: resumeFrom is the same stepIndex — the step repeats with retryAttempt threaded, then completes", async () => {
248
+ const runKey = crypto.randomUUID();
249
+ const runId = workflowRunAggregateId(retryWorkflow.name, runKey);
250
+
251
+ await fireTrigger("rl-test.retry", { runKey });
252
+
253
+ const rowsAfterSuspend = await loadRunEvents(runId);
254
+ expect(rowsAfterSuspend.map((row) => row["type"])).toEqual([
255
+ WORKFLOW_RUN_STARTED_TYPE,
256
+ WORKFLOW_RETRY_SCHEDULED_TYPE,
257
+ ]);
258
+ expect(rowsAfterSuspend[1]!["payload"]).toMatchObject({ stepIndex: 0, attempt: 1 });
259
+
260
+ await tamperWakeAtToPast(runId, 0);
261
+ await runResumeDueRunsJob();
262
+
263
+ const rowsAfterResume = await loadRunEvents(runId);
264
+ expect(rowsAfterResume.map((row) => row["type"])).toEqual([
265
+ WORKFLOW_RUN_STARTED_TYPE,
266
+ WORKFLOW_RETRY_SCHEDULED_TYPE,
267
+ WORKFLOW_RESUMED_TYPE,
268
+ WORKFLOW_RUN_COMPLETED_TYPE,
269
+ ]);
270
+ // Only one retry-scheduled ever: proves retryAttempt was threaded into
271
+ // the resumed pass (attempt=2 clears the gate) instead of the gate
272
+ // throwing again and scheduling a second retry at attempt=1.
273
+ expect(
274
+ rowsAfterResume.filter((row) => row["type"] === WORKFLOW_RETRY_SCHEDULED_TYPE),
275
+ ).toHaveLength(1);
276
+ });
277
+ });
@@ -15,6 +15,7 @@ import {
15
15
  WORKFLOW_RUN_COMPLETED_TYPE,
16
16
  WORKFLOW_RUN_FAILED_TYPE,
17
17
  WORKFLOW_RUN_STARTED_TYPE,
18
+ WORKFLOW_WAITING_TYPE,
18
19
  type WorkflowDefinition,
19
20
  } from "@cosmicdrift/kumiko-framework/engine";
20
21
  import { eventsTable } from "@cosmicdrift/kumiko-framework/event-store";
@@ -134,22 +135,16 @@ describe("workflow-runner event-trigger", () => {
134
135
  expect(String(rows[1]!["payload"]["error"])).toContain("boom-explicit-failure");
135
136
  });
136
137
 
137
- test("suspension: a wait step fails the run instead of leaving it silently pending, no run-completed", async () => {
138
+ test("suspension: a wait step suspends the run instead of failing it — resumable by framework#2513 Phase 2, no run-completed yet", async () => {
138
139
  const runKey = crypto.randomUUID();
139
140
  const runId = workflowRunAggregateId(suspendingWorkflow.name, runKey);
140
141
 
141
142
  await fireTrigger("wr-test.suspend", { runKey });
142
143
 
143
144
  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");
145
+ expect(rows.map((row) => row["type"])).toEqual([
146
+ WORKFLOW_RUN_STARTED_TYPE,
147
+ WORKFLOW_WAITING_TYPE,
148
+ ]);
154
149
  });
155
150
  });
@@ -0,0 +1,23 @@
1
+ import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
2
+ import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
3
+ import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
4
+
5
+ export type DueRow = { readonly run_id: string; readonly step_index: number };
6
+
7
+ // Standalone statement (no db.begin) — FOR UPDATE SKIP LOCKED only
8
+ // needs to survive this one SELECT to reduce redundant dispatches
9
+ // under true concurrency; it auto-commits and releases the row locks
10
+ // immediately after. The real correctness guarantee is resume-run's
11
+ // VersionConflictError-checked claim (ctx.tryAppendEvent on
12
+ // WORKFLOW_RESUMED) — a second dispatch for the same row just loses
13
+ // that race and no-ops, same as the sample this was adapted from.
14
+ export async function selectDueWorkflowRunPending(
15
+ db: DbConnection,
16
+ tenantId: TenantId,
17
+ ): Promise<readonly DueRow[]> {
18
+ // kumiko-lint-ignore raw-sql tenant-scoped due-row pickup with FOR UPDATE SKIP LOCKED
19
+ return (await asRawClient(db).unsafe(
20
+ `SELECT run_id, step_index FROM workflow_run_pending WHERE tenant_id = $1 AND wake_at < now() FOR UPDATE SKIP LOCKED`,
21
+ [tenantId],
22
+ )) as readonly DueRow[];
23
+ }
@@ -0,0 +1,93 @@
1
+ // event-subscriber — wakes up pending waitForEvent suspensions when their
2
+ // awaited domain event arrives (framework#2513 Phase 3b, D4 in
3
+ // workflow-resume-loop.md).
4
+ //
5
+ // registerEventTrigger (event-trigger.ts) calls registerEventWakeup for
6
+ // every workflow it registers, building ONE MultiStreamProjection PER
7
+ // WORKFLOW from that workflow's own `awaits` declaration — not a single
8
+ // shared MSP built by scanning the process-global workflow-registry.
9
+ // Reason: the registrar throws synchronously if an MSP's apply-map is
10
+ // empty at r.multiStreamProjection() call time (feature-ui-extensions.ts).
11
+ // workflow-runner's own feature.ts is imported independently of whatever
12
+ // app-specific feature module calls registerEventTrigger — a shared MSP
13
+ // built by reading the registry inside feature.ts's registration would
14
+ // depend on every awaits-declaring feature already having run
15
+ // registerEventTrigger() first, which is an unenforced app-import-order
16
+ // hazard. Building the map right here, from the `workflow` argument
17
+ // already in hand, has no such hazard — same reasoning event-trigger.ts
18
+ // already applies to the run-start MSP.
19
+ //
20
+ // This subscriber never resumes a run itself (D3, workflow-resume-loop.md):
21
+ // the MultiStreamApplyContext it runs in has no callFeature/runStepList
22
+ // access, only unsafeAppendEvent/loadAggregate. It writes triggerEventType
23
+ // + triggerPayload + wakeAt=now() onto the matching pending row(s); the
24
+ // existing resume-due-runs job (Phase 2) picks the row up on its next
25
+ // cron tick — the one resume path stays exactly the one Phase 2 built.
26
+
27
+ import { selectMany, updateMany } from "@cosmicdrift/kumiko-framework/bun-db";
28
+ import type {
29
+ FeatureRegistrar,
30
+ MultiStreamApplyFn,
31
+ MultiStreamProjectionDefinition,
32
+ WorkflowDefinition,
33
+ } from "@cosmicdrift/kumiko-framework/engine";
34
+ import { evaluateEventMatch } from "@cosmicdrift/kumiko-framework/engine";
35
+ import { workflowRunPendingTable } from "./tables";
36
+
37
+ type CandidateRow = {
38
+ readonly runId: string;
39
+ readonly tenantId: string;
40
+ readonly stepIndex: number;
41
+ readonly matchExpr: unknown | null;
42
+ };
43
+
44
+ const wakeupApply: MultiStreamApplyFn = async (event, tx) => {
45
+ const rows = await selectMany<CandidateRow>(tx, workflowRunPendingTable, {
46
+ tenantId: event.tenantId,
47
+ waitEventType: event.type,
48
+ });
49
+
50
+ for (const row of rows) {
51
+ const matches = row.matchExpr
52
+ ? evaluateEventMatch(
53
+ // @cast-boundary engine-payload — matchExpr round-trips through
54
+ // jsonb as the EventMatch AST waitForEvent's `match` resolver
55
+ // persisted (see steps/wait-for-event.ts); never an unchecked
56
+ // external value.
57
+ row.matchExpr as Parameters<typeof evaluateEventMatch>[0],
58
+ event.payload,
59
+ )
60
+ : true;
61
+ // skip: this row's matchExpr rejected the event — not this row's
62
+ // resume, leave wakeAt/triggerEventType untouched.
63
+ if (!matches) continue;
64
+
65
+ await updateMany(
66
+ tx,
67
+ workflowRunPendingTable,
68
+ {
69
+ triggerEventType: event.type,
70
+ triggerPayload: event.payload,
71
+ wakeAt: Temporal.Now.instant().toString(),
72
+ },
73
+ { tenantId: row.tenantId, runId: row.runId, stepIndex: row.stepIndex },
74
+ );
75
+ }
76
+ };
77
+
78
+ export function registerEventWakeup(r: FeatureRegistrar, workflow: WorkflowDefinition): void {
79
+ const eventTypes = Object.values(workflow.awaits ?? {});
80
+ // skip: no `awaits` declared — nothing to subscribe to, and an empty
81
+ // apply-map throws at r.multiStreamProjection() registration time.
82
+ if (eventTypes.length === 0) return;
83
+
84
+ const apply: Record<string, MultiStreamApplyFn> = {};
85
+ for (const eventType of eventTypes) {
86
+ apply[eventType] = wakeupApply;
87
+ }
88
+
89
+ r.multiStreamProjection({
90
+ name: `workflow-${workflow.name}-wakeup`,
91
+ apply,
92
+ } satisfies MultiStreamProjectionDefinition);
93
+ }
@@ -6,10 +6,8 @@
6
6
  //
7
7
  // The MSP apply-fn runs in the dispatcher's own tx, so `workflow.run-started`
8
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.
9
+ // from startAndRunWorkflow is recorded as `workflow.run-failed` and
10
+ // rethrown so the dispatcher's retry/dead-letter handling still applies.
13
11
 
14
12
  import type {
15
13
  FeatureRegistrar,
@@ -22,9 +20,21 @@ import {
22
20
  WORKFLOW_RUN_FAILED_TYPE,
23
21
  } from "@cosmicdrift/kumiko-framework/engine";
24
22
  import { workflowRunAggregateId } from "./aggregate-id";
23
+ import { registerEventWakeup } from "./event-subscriber";
25
24
  import { startAndRunWorkflow, type WorkflowRunFailedPayload } from "./runner";
25
+ import { registerWorkflow } from "./workflow-registry";
26
26
 
27
27
  export function registerEventTrigger(r: FeatureRegistrar, workflow: WorkflowDefinition): void {
28
+ // Populate the workflow-registry unconditionally, before the event-trigger
29
+ // guard below — resume-run (framework#2513 Phase 2) looks workflows up by
30
+ // name regardless of trigger kind, and a cron-triggered workflow that never
31
+ // reaches the MSP branch still needs to be resumable.
32
+ registerWorkflow(workflow);
33
+
34
+ // Independent of trigger.kind — a cron-triggered workflow can still have
35
+ // waitForEvent steps in its pipeline (framework#2513 Phase 3b, D4).
36
+ registerEventWakeup(r, workflow);
37
+
28
38
  // skip: cron-triggered workflows have no domain event to project off — they
29
39
  // need a scheduler, not an MSP, so there is nothing to register here.
30
40
  if (workflow.trigger.kind !== "event") return;
@@ -7,13 +7,28 @@
7
7
  // WorkflowDefinition via `defineWorkflow` and wire it up with
8
8
  // `registerEventTrigger(r, myWorkflow)` inside their own feature. Mounting
9
9
  // `workflowRunnerFeature` documents that dependency and reserves the
10
- // picker/manifest slot; it has no MSPs or entities to register itself.
10
+ // picker/manifest slot.
11
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.
12
+ // It also registers the resume loop (framework#2513 Phase 2 + 3b):
13
+ // - the workflow_run_pending MSP (Phase 1) materialises suspended
14
+ // wait/waitForEvent/retry steps into a pending-set table
15
+ // - the `resume-due-runs` cron job (perTenant) scans that table for
16
+ // `wakeAt < now()` rows and dispatches `resume-run` per row — the job
17
+ // itself does no resume logic, only SELECT + dispatch
18
+ // - the `resume-run` write handler (r.systemScope(), SYSTEM_ROLE-only)
19
+ // does the actual Q7-check + claim + pipeline re-entry
20
+ // - `registerEventTrigger` (event-trigger.ts) additionally registers an
21
+ // event-wakeup MSP per workflow that declares `awaits` — it marks the
22
+ // matching pending row due as soon as the awaited event arrives,
23
+ // instead of waiting out the full timeout (event-subscriber.ts)
24
+ //
25
+ // wait/retry/waitForEvent suspensions all resume automatically now.
15
26
 
16
27
  import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
28
+ import { selectDueWorkflowRunPending } from "./db/queries/due-runs";
29
+ import { resumeRunHandler } from "./handlers/resume-run.write";
30
+ import { registerWorkflowRunPendingProjection } from "./pending-projection";
31
+ import { workflowRunPendingTableMeta } from "./tables";
17
32
 
18
33
  // Kebab-case matching the "./workflow-runner" export subpath — every other
19
34
  // bundled feature's runtime name follows this convention, and
@@ -22,11 +37,53 @@ const FEATURE_NAME = "workflow-runner";
22
37
 
23
38
  export const workflowRunnerFeature = defineFeature(FEATURE_NAME, (r) => {
24
39
  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.",
40
+ "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. Runs the resume loop for suspended `wait`/`retry`/`waitForEvent` steps (`resume-due-runs` job + `resume-run` handler); `registerEventTrigger` wires an event-wakeup subscriber per workflow that declares `awaits` so a `waitForEvent` step resumes as soon as its awaited event arrives, not just on timeout.",
26
41
  );
27
42
  r.uiHints({
28
43
  displayLabel: "Workflow Runner",
29
44
  category: "infrastructure",
30
45
  recommended: false,
31
46
  });
47
+ // resume-run needs a full HandlerContext + ctx.systemDb (the pending-row
48
+ // scan crosses whatever tenant the row belongs to relative to the job's
49
+ // own tenant loop iteration) — same reasoning as tenant/feature.ts.
50
+ r.systemScope();
51
+ r.storeTable(workflowRunPendingTableMeta, {
52
+ reason: "resume_loop.pending_suspended_steps",
53
+ });
54
+ registerWorkflowRunPendingProjection(r);
55
+ r.writeHandler(resumeRunHandler);
56
+
57
+ r.job(
58
+ "resume-due-runs",
59
+ { trigger: { cron: "* * * * *" }, perTenant: true, concurrency: "skip" },
60
+ async (_payload, ctx) => {
61
+ if (!ctx.db) {
62
+ throw new Error("resume-due-runs: ctx.db required (JobContext incomplete)");
63
+ }
64
+ const tenantId = ctx.systemUser?.tenantId ?? ctx._tenantId;
65
+ if (tenantId === undefined) {
66
+ // skip: cron fired without a perTenant fan-out tenant — nothing scoped
67
+ return;
68
+ }
69
+
70
+ const dueRows = await selectDueWorkflowRunPending(ctx.db, tenantId);
71
+
72
+ for (const row of dueRows) {
73
+ try {
74
+ await ctx.write("workflow-runner:write:resume-run", {
75
+ runId: row.run_id,
76
+ stepIndex: row.step_index,
77
+ });
78
+ } catch (error) {
79
+ // skip: one row's dispatch failing (infra hiccup, not a business
80
+ // outcome — resume-run itself never throws for expected cases)
81
+ // must not stop the rest of this tenant's due rows from resuming.
82
+ ctx.log.warn(
83
+ `[workflow-runner:resume-due-runs] runId=${row.run_id} stepIndex=${row.step_index} dispatch failed: ${String(error)}`,
84
+ );
85
+ }
86
+ }
87
+ },
88
+ );
32
89
  });