@cosmicdrift/kumiko-framework 0.362.0 → 0.364.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/AGENTS.md +307 -0
- package/agents/examples/access-control.md +355 -0
- package/agents/examples/anonymous-access-multitenant.md +443 -0
- package/agents/examples/anonymous-access.md +300 -0
- package/agents/examples/apex-landing.md +528 -0
- package/agents/examples/apex-surface-auth.md +355 -0
- package/agents/examples/api-worker-split.md +434 -0
- package/agents/examples/auth-claims.md +351 -0
- package/agents/examples/auth-foundation-providers.md +155 -0
- package/agents/examples/auth-mfa.md +323 -0
- package/agents/examples/basic-entity.md +420 -0
- package/agents/examples/boot-check.md +122 -0
- package/agents/examples/chat-channels.md +254 -0
- package/agents/examples/compliance-profiles-demo.md +307 -0
- package/agents/examples/cross-feature-events.md +317 -0
- package/agents/examples/crypto-shredding-hr.md +314 -0
- package/agents/examples/currencies-global.md +609 -0
- package/agents/examples/currencies-per-tenant.md +453 -0
- package/agents/examples/custom-fields-basic.md +314 -0
- package/agents/examples/custom-handlers.md +397 -0
- package/agents/examples/delivery-notifications.md +432 -0
- package/agents/examples/designer-demo.md +86 -0
- package/agents/examples/e2e-generator.md +211 -0
- package/agents/examples/embedded-entity-form.md +598 -0
- package/agents/examples/embedded.md +649 -0
- package/agents/examples/encrypted-tenant-config.md +405 -0
- package/agents/examples/error-contract.md +539 -0
- package/agents/examples/event-sourcing.md +1287 -0
- package/agents/examples/feature-toggles.md +263 -0
- package/agents/examples/field-access.md +271 -0
- package/agents/examples/files-post-processing.md +433 -0
- package/agents/examples/i18n.md +166 -0
- package/agents/examples/idempotency.md +179 -0
- package/agents/examples/inbound-mail-triage.md +315 -0
- package/agents/examples/lane-routing.md +246 -0
- package/agents/examples/ledger-banking.md +214 -0
- package/agents/examples/ledger-invoicing.md +235 -0
- package/agents/examples/legal-pages.md +393 -0
- package/agents/examples/lifecycle-hooks.md +368 -0
- package/agents/examples/managed-config.md +428 -0
- package/agents/examples/managed-pages.md +353 -0
- package/agents/examples/notes-history-basic.md +345 -0
- package/agents/examples/ownership.md +396 -0
- package/agents/examples/pdf-audit-export.md +270 -0
- package/agents/examples/pipeline-basics.md +792 -0
- package/agents/examples/public-share-token.md +351 -0
- package/agents/examples/rate-limiting.md +211 -0
- package/agents/examples/realtime-sse.md +187 -0
- package/agents/examples/record-detail-layout.md +297 -0
- package/agents/examples/reference-data.md +151 -0
- package/agents/examples/relations.md +280 -0
- package/agents/examples/screens-nav.md +353 -0
- package/agents/examples/search.md +233 -0
- package/agents/examples/secrets-demo.md +417 -0
- package/agents/examples/security-baseline.md +187 -0
- package/agents/examples/seed-migration.md +99 -0
- package/agents/examples/session-revocation.md +259 -0
- package/agents/examples/state-machine.md +920 -0
- package/agents/examples/tags-basic.md +401 -0
- package/agents/examples/tenant-isolation.md +173 -0
- package/agents/examples/tenant-settings.md +275 -0
- package/agents/examples/tier-admin.md +347 -0
- package/agents/examples/timezones.md +366 -0
- package/agents/examples/user-data-rights.md +410 -0
- package/agents/examples/user-profile.md +226 -0
- package/agents/examples/webhook-step.md +597 -0
- package/agents/examples/wizard-form.md +379 -0
- package/agents/examples/workflow-engine.md +1731 -0
- package/agents/examples/writeform-section.md +300 -0
- package/agents/examples.md +74 -0
- package/agents/features.md +768 -0
- package/agents/widgets.md +181 -0
- package/dist/api/auth-middleware.d.ts +2 -0
- package/dist/api/auth-middleware.js +14 -5
- package/dist/api/auth-routes.js +5 -3
- package/dist/api/extra-route.d.ts +3 -0
- package/dist/api/index.d.ts +2 -2
- package/dist/api/index.js +1 -1
- package/dist/api/redis-sse-broker.js +10 -8
- package/dist/api/routes.js +48 -19
- package/dist/api/server.d.ts +2 -1
- package/dist/api/server.js +18 -6
- package/dist/api/session-cookies.d.ts +1 -0
- package/dist/api/session-cookies.js +20 -5
- package/dist/api/sse-route.js +16 -1
- package/dist/bun-db/index.d.ts +1 -1
- package/dist/bun-db/index.js +1 -1
- package/dist/bun-db/query.d.ts +12 -5
- package/dist/bun-db/query.js +88 -94
- package/dist/crypto/kek-source.js +5 -3
- package/dist/crypto/pii-field-encryption.d.ts +4 -1
- package/dist/crypto/pii-field-encryption.js +2 -2
- package/dist/db/apply-entity-event.js +2 -2
- package/dist/db/collect-table-metas.js +11 -7
- package/dist/db/cursor.js +2 -1
- package/dist/db/dialect.d.ts +1 -0
- package/dist/db/dialect.js +28 -13
- package/dist/db/eagerload.js +2 -2
- package/dist/db/entity-table-from-registry.js +1 -1
- package/dist/db/entity-table-meta.js +5 -13
- package/dist/db/event-store-executor-context.d.ts +5 -6
- package/dist/db/event-store-executor-context.js +29 -27
- package/dist/db/event-store-executor-read.js +40 -25
- package/dist/db/event-store-executor-write.js +18 -20
- package/dist/db/index.d.ts +1 -1
- package/dist/db/index.js +1 -1
- package/dist/db/lock-guard.d.ts +4 -0
- package/dist/db/lock-guard.js +14 -0
- package/dist/db/migrate-generator.js +6 -4
- package/dist/db/migrate-runner.js +32 -35
- package/dist/db/parent-ref-clause.js +2 -2
- package/dist/db/queries/backfill-pii.js +11 -9
- package/dist/db/queries/es-ops.js +1 -1
- package/dist/db/queries/event-consumer.js +17 -17
- package/dist/db/queries/event-store-admin.js +6 -6
- package/dist/db/queries/event-store-transfer.js +2 -2
- package/dist/db/queries/event-store.js +2 -2
- package/dist/db/queries/pii-backfill-state.js +2 -2
- package/dist/db/queries/raw-sql.js +1 -1
- package/dist/db/queries/test-stack.js +1 -1
- package/dist/db/reference-data.js +3 -5
- package/dist/db/table-builder.js +6 -4
- package/dist/db/table-introspection.d.ts +16 -0
- package/dist/db/table-introspection.js +76 -0
- package/dist/db/tenant-db-runner.d.ts +4 -0
- package/dist/db/tenant-db-runner.js +17 -0
- package/dist/db/tenant-db.d.ts +5 -2
- package/dist/db/tenant-db.js +34 -14
- package/dist/derivatives/derivatives-context.js +1 -1
- package/dist/derivatives/variant-key.js +2 -1
- package/dist/engine/boot-validator/access-declarations.js +2 -2
- package/dist/engine/boot-validator/api-ext.js +3 -3
- package/dist/engine/boot-validator/boot-check.js +1 -1
- package/dist/engine/boot-validator/config-deps.js +13 -13
- package/dist/engine/boot-validator/detail-screens.js +1 -1
- package/dist/engine/boot-validator/entity-handler.d.ts +1 -0
- package/dist/engine/boot-validator/entity-handler.js +84 -35
- package/dist/engine/boot-validator/entity-list-screens.js +3 -3
- package/dist/engine/boot-validator/handler-field-references.js +19 -9
- package/dist/engine/boot-validator/i18n-keys.js +2 -2
- package/dist/engine/boot-validator/nav.js +1 -1
- package/dist/engine/boot-validator/pii-retention.js +9 -9
- package/dist/engine/boot-validator/projection-list-screens.js +5 -5
- package/dist/engine/boot-validator/query-output-columns.js +7 -7
- package/dist/engine/boot-validator/screens.d.ts +2 -1
- package/dist/engine/boot-validator/screens.js +155 -96
- package/dist/engine/boot-validator/url-prefill-fields.js +54 -35
- package/dist/engine/changeset-changes.js +3 -3
- package/dist/engine/define-handler.d.ts +2 -2
- package/dist/engine/define-handler.js +11 -7
- package/dist/engine/embedded-derived.js +10 -6
- package/dist/engine/extensions/escape-hatch-usage.js +2 -1
- package/dist/engine/factories.js +5 -6
- package/dist/engine/feature-ast/extractors/events.js +43 -33
- package/dist/engine/feature-ast/extractors/handlers.js +2 -0
- package/dist/engine/feature-ast/patcher.d.ts +3 -3
- package/dist/engine/feature-ast/patcher.js +3 -3
- package/dist/engine/feature-ast/pattern-change-schema.d.ts +10 -8
- package/dist/engine/feature-ast/pattern-change-schema.js +4 -3
- package/dist/engine/feature-ast/patterns.d.ts +3 -3
- package/dist/engine/feature-ast/render.js +16 -5
- package/dist/engine/feature-changelog.d.ts +2 -1
- package/dist/engine/feature-changelog.js +8 -6
- package/dist/engine/feature-config-events-jobs.d.ts +17 -7
- package/dist/engine/feature-config-events-jobs.js +20 -5
- package/dist/engine/feature-entity-handlers.d.ts +2 -2
- package/dist/engine/feature-entity-handlers.js +12 -3
- package/dist/engine/feature-ui-extensions.js +2 -3
- package/dist/engine/handler-field-references.d.ts +21 -2
- package/dist/engine/handler-field-references.js +55 -17
- package/dist/engine/index.d.ts +3 -2
- package/dist/engine/index.js +1 -0
- package/dist/engine/ownership.js +7 -20
- package/dist/engine/pattern-library/mixed-schemas.js +0 -1
- package/dist/engine/registry-ingest.d.ts +2 -1
- package/dist/engine/registry-ingest.js +85 -2
- package/dist/engine/registry-validate.d.ts +1 -0
- package/dist/engine/registry-validate.js +38 -8
- package/dist/engine/registry.js +2 -1
- package/dist/engine/schema-builder.js +3 -4
- package/dist/engine/soft-delete-cleanup.js +12 -4
- package/dist/engine/steps/call-feature.js +1 -3
- package/dist/engine/steps/retry.js +2 -1
- package/dist/engine/steps/unsafe-projection-delete.js +2 -2
- package/dist/engine/steps/unsafe-projection-upsert.js +4 -15
- package/dist/engine/template-variables.d.ts +32 -0
- package/dist/engine/template-variables.js +80 -0
- package/dist/engine/tier-resolver-extension.js +2 -3
- package/dist/engine/types/index.d.ts +2 -2
- package/dist/engine/validate-projection-allowlist.js +12 -13
- package/dist/engine/where-rule-lint.js +8 -7
- package/dist/env/_zod-introspect.d.ts +1 -0
- package/dist/errors/classes.js +1 -0
- package/dist/errors/kumiko-error.d.ts +2 -0
- package/dist/errors/kumiko-error.js +2 -0
- package/dist/errors/reasons.d.ts +1 -0
- package/dist/errors/reasons.js +2 -0
- package/dist/errors/write-error-info.js +2 -0
- package/dist/event-store/tenant-db-append.js +2 -2
- package/dist/files/assert-file-refs-belong-to-tenant.d.ts +4 -5
- package/dist/files/assert-file-refs-belong-to-tenant.js +7 -15
- package/dist/files/bind-pending-file-refs.d.ts +2 -0
- package/dist/files/bind-pending-file-refs.js +3 -3
- package/dist/files/bind-referenced-files.write.d.ts +1 -1
- package/dist/files/bind-referenced-files.write.js +22 -13
- package/dist/files/create-file-ref-from-bytes.d.ts +6 -3
- package/dist/files/create-file-ref-from-bytes.js +67 -4
- package/dist/files/create-file-ref.d.ts +22 -0
- package/dist/files/create-file-ref.js +53 -25
- package/dist/files/file-handle.d.ts +1 -1
- package/dist/files/file-handle.js +8 -0
- package/dist/files/file-routes.js +2 -2
- package/dist/files/index.d.ts +2 -2
- package/dist/files/index.js +1 -1
- package/dist/files/load-save-file-refs.d.ts +10 -0
- package/dist/files/load-save-file-refs.js +47 -0
- package/dist/files/local-provider.js +2 -1
- package/dist/files/purge-expired-file-ref-bytes.d.ts +4 -1
- package/dist/files/purge-expired-file-ref-bytes.js +45 -19
- package/dist/i18n/index.d.ts +1 -1
- package/dist/i18n/index.js +1 -1
- package/dist/i18n/mail-registry.d.ts +2 -0
- package/dist/i18n/mail-registry.js +7 -0
- package/dist/jobs/index.d.ts +1 -1
- package/dist/jobs/job-runner.d.ts +4 -1
- package/dist/jobs/job-runner.js +13 -9
- package/dist/observability/escape-hatch-report.d.ts +1 -1
- package/dist/observability/escape-hatch-report.js +17 -5
- package/dist/pipeline/cascade-handler.js +5 -2
- package/dist/pipeline/dispatch-batch.js +79 -31
- package/dist/pipeline/dispatch-query.js +2 -3
- package/dist/pipeline/dispatch-shared.d.ts +7 -2
- package/dist/pipeline/dispatch-shared.js +142 -34
- package/dist/pipeline/dispatch-write.js +34 -22
- package/dist/pipeline/event-dispatcher-delivery.js +2 -3
- package/dist/pipeline/index.d.ts +2 -0
- package/dist/pipeline/index.js +2 -0
- package/dist/pipeline/member-read-only-transaction.js +1 -1
- package/dist/pipeline/notify-scope.d.ts +13 -0
- package/dist/pipeline/notify-scope.js +32 -0
- package/dist/pipeline/pre-transaction-reservations.d.ts +3 -1
- package/dist/pipeline/pre-transaction-reservations.js +28 -9
- package/dist/pipeline/system-hooks.js +2 -1
- package/dist/pipeline/system-identity-switch.js +2 -2
- package/dist/rate-limit/bucket.d.ts +3 -3
- package/dist/rate-limit/bucket.js +14 -4
- package/dist/redis/cache-sync-bus.js +9 -3
- package/dist/search/meilisearch-adapter.js +39 -13
- package/dist/secrets/stored-envelope.js +7 -7
- package/dist/stack/push-entity-projection-tables.js +2 -3
- package/dist/stack/table-helpers.js +10 -26
- package/dist/stack/test-stack.js +2 -5
- package/dist/testing/multipart-helper.d.ts +0 -6
- package/dist/testing/multipart-helper.js +5 -5
- package/dist/testing/without-ambient-temporal.js +4 -4
- package/dist/utils/is-plain-object.d.ts +1 -2
- package/dist/utils/is-plain-object.js +1 -4
- package/package.json +7 -5
- package/src/changes.json +251 -8
|
@@ -0,0 +1,1731 @@
|
|
|
1
|
+
<!-- Generated by `bun run gen:agent-docs` from samples/recipes/workflow-engine. Do not edit. -->
|
|
2
|
+
|
|
3
|
+
# workflow-engine
|
|
4
|
+
|
|
5
|
+
M.4 Tier-3 step-vocabulary showcase: defineWorkflow with wait/waitForEvent/retry steps
|
|
6
|
+
|
|
7
|
+
## README
|
|
8
|
+
|
|
9
|
+
# Workflow Engine
|
|
10
|
+
|
|
11
|
+
Tier-3 `defineWorkflow` vocabulary: wait, branch, mail, webhook.
|
|
12
|
+
|
|
13
|
+
## What it shows
|
|
14
|
+
|
|
15
|
+
- Real runnable pipelines (no empty `build: () => []` stubs)
|
|
16
|
+
- Workflow-run lifecycle across wait / waitForEvent
|
|
17
|
+
- `webhook.send` is delivered once by the step-dispatcher; a failed delivery ends as `step.dispatch-failed`. Wrapping it in `r.step.retry` would not repeat it, because the step only enqueues the request and never throws on a delivery error
|
|
18
|
+
|
|
19
|
+
## Source
|
|
20
|
+
|
|
21
|
+
Feature entry point: `src/feature.ts`.
|
|
22
|
+
|
|
23
|
+
## Tests
|
|
24
|
+
|
|
25
|
+
Needs a running Postgres and `TEST_DATABASE_URL` set (e.g. `postgres://postgres:postgres@127.0.0.1:5432/postgres`, see `demo/.env.example`).
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
cd samples/recipes/workflow-engine
|
|
29
|
+
bun test
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Source files
|
|
33
|
+
|
|
34
|
+
### src/__tests__/workflow-integration.integration.test.ts
|
|
35
|
+
|
|
36
|
+
```ts
|
|
37
|
+
// Workflow-engine event-store integration test.
|
|
38
|
+
//
|
|
39
|
+
// Verifies the Postgres-backed fetcher against real rows:
|
|
40
|
+
// - finds expired WAITING events
|
|
41
|
+
// - hydrates SuspendableRun with the trigger-event snapshot + Q7
|
|
42
|
+
// definitionFingerprint from the event payload
|
|
43
|
+
// - returns a no-op for fingerprint-less legacy rows
|
|
44
|
+
//
|
|
45
|
+
// End-to-end MSP-trigger → workflow execution is a separate followup
|
|
46
|
+
// (requires wiring a dispatcher-fire-able event-shape into the test
|
|
47
|
+
// stack); the unit-test file covers the Pipeline/resume-loop logic
|
|
48
|
+
// against the in-memory fetcher.
|
|
49
|
+
|
|
50
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
51
|
+
import { insertOne, selectMany } from "@cosmicdrift/kumiko-framework/db";
|
|
52
|
+
import type { WorkflowDefinition } from "@cosmicdrift/kumiko-framework/engine";
|
|
53
|
+
import {
|
|
54
|
+
computeDefinitionFingerprint,
|
|
55
|
+
defineWorkflow,
|
|
56
|
+
stepsPipeline,
|
|
57
|
+
WORKFLOW_AGGREGATE_TYPE,
|
|
58
|
+
WORKFLOW_RESUMED_TYPE,
|
|
59
|
+
WORKFLOW_RUN_STARTED_TYPE,
|
|
60
|
+
WORKFLOW_WAITING_TYPE,
|
|
61
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
62
|
+
import { append, eventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
63
|
+
import { setupTestStack, type TestStack, TestUsers } from "@cosmicdrift/kumiko-framework/stack";
|
|
64
|
+
import { createSuspendedRunFetcher } from "../postgres-resume-loop";
|
|
65
|
+
|
|
66
|
+
let stack: TestStack;
|
|
67
|
+
const admin = TestUsers.admin;
|
|
68
|
+
|
|
69
|
+
function buildTestWorkflow(name: string): WorkflowDefinition {
|
|
70
|
+
return defineWorkflow({
|
|
71
|
+
name,
|
|
72
|
+
trigger: { kind: "event", eventType: "demo.fired" },
|
|
73
|
+
steps: stepsPipeline(({ r }) => [
|
|
74
|
+
r.step.wait({ for: "PT1H" }),
|
|
75
|
+
r.step.return({ isSuccess: true, data: undefined }),
|
|
76
|
+
]),
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
describe("workflow-engine event-store roundtrip", () => {
|
|
81
|
+
beforeAll(async () => {
|
|
82
|
+
stack = await setupTestStack({ features: [] });
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
afterAll(async () => {
|
|
86
|
+
await stack.cleanup();
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("write and read WORKFLOW_WAITING_TYPE via event store", async () => {
|
|
90
|
+
const runId = crypto.randomUUID();
|
|
91
|
+
|
|
92
|
+
await insertOne(stack.db, eventsTable, {
|
|
93
|
+
aggregateId: runId,
|
|
94
|
+
aggregateType: WORKFLOW_AGGREGATE_TYPE,
|
|
95
|
+
tenantId: admin.tenantId,
|
|
96
|
+
version: 1,
|
|
97
|
+
type: WORKFLOW_RUN_STARTED_TYPE,
|
|
98
|
+
eventVersion: 1,
|
|
99
|
+
payload: { workflowName: "test-workflow", triggerEventType: "demo.fired" },
|
|
100
|
+
metadata: { userId: admin.id },
|
|
101
|
+
createdBy: admin.id,
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
await insertOne(stack.db, eventsTable, {
|
|
105
|
+
aggregateId: runId,
|
|
106
|
+
aggregateType: WORKFLOW_AGGREGATE_TYPE,
|
|
107
|
+
tenantId: admin.tenantId,
|
|
108
|
+
version: 2,
|
|
109
|
+
type: WORKFLOW_WAITING_TYPE,
|
|
110
|
+
eventVersion: 1,
|
|
111
|
+
payload: {
|
|
112
|
+
workflowName: "test-workflow",
|
|
113
|
+
stepIndex: 2,
|
|
114
|
+
wakeAt: new Date(Date.now() - 5000).toISOString(),
|
|
115
|
+
},
|
|
116
|
+
metadata: { userId: admin.id },
|
|
117
|
+
createdBy: admin.id,
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
const rows = await selectMany(
|
|
121
|
+
stack.db,
|
|
122
|
+
eventsTable,
|
|
123
|
+
{ aggregateId: runId },
|
|
124
|
+
{ orderBy: { col: "version", direction: "asc" } },
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
expect(rows).toHaveLength(2);
|
|
128
|
+
expect(rows[0]!["type"]).toBe(WORKFLOW_RUN_STARTED_TYPE);
|
|
129
|
+
expect(rows[1]!["type"]).toBe(WORKFLOW_WAITING_TYPE);
|
|
130
|
+
expect(rows[1]!["payload"]["workflowName"]).toBe("test-workflow");
|
|
131
|
+
expect(rows[1]!["payload"]["stepIndex"]).toBe(2);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test("fetcher hydrates SuspendableRun with workflow + trigger snapshot + Q7 fingerprint", async () => {
|
|
135
|
+
const runId = crypto.randomUUID();
|
|
136
|
+
const workflow = buildTestWorkflow("test-workflow-hydration");
|
|
137
|
+
const fingerprint = computeDefinitionFingerprint(workflow);
|
|
138
|
+
|
|
139
|
+
const sourceAggregateId = crypto.randomUUID();
|
|
140
|
+
const source = await append(stack.db, {
|
|
141
|
+
aggregateId: sourceAggregateId,
|
|
142
|
+
aggregateType: "demo-source",
|
|
143
|
+
tenantId: admin.tenantId,
|
|
144
|
+
expectedVersion: 0,
|
|
145
|
+
type: "demo.fired",
|
|
146
|
+
payload: { signal: "abc" },
|
|
147
|
+
metadata: { userId: admin.id },
|
|
148
|
+
});
|
|
149
|
+
await append(stack.db, {
|
|
150
|
+
aggregateId: runId,
|
|
151
|
+
aggregateType: WORKFLOW_AGGREGATE_TYPE,
|
|
152
|
+
tenantId: admin.tenantId,
|
|
153
|
+
expectedVersion: 0,
|
|
154
|
+
type: WORKFLOW_RUN_STARTED_TYPE,
|
|
155
|
+
payload: {
|
|
156
|
+
workflowName: "test-workflow-hydration",
|
|
157
|
+
triggerEventType: "demo.fired",
|
|
158
|
+
triggerEventRef: { eventId: source.id, aggregateId: sourceAggregateId, version: 1 },
|
|
159
|
+
definitionFingerprint: fingerprint,
|
|
160
|
+
},
|
|
161
|
+
metadata: { userId: admin.id },
|
|
162
|
+
});
|
|
163
|
+
await append(stack.db, {
|
|
164
|
+
aggregateId: runId,
|
|
165
|
+
aggregateType: WORKFLOW_AGGREGATE_TYPE,
|
|
166
|
+
tenantId: admin.tenantId,
|
|
167
|
+
expectedVersion: 1,
|
|
168
|
+
type: WORKFLOW_WAITING_TYPE,
|
|
169
|
+
payload: {
|
|
170
|
+
workflowName: "test-workflow-hydration",
|
|
171
|
+
stepIndex: 1,
|
|
172
|
+
wakeAt: new Date(Date.now() - 5000).toISOString(),
|
|
173
|
+
definitionFingerprint: fingerprint,
|
|
174
|
+
},
|
|
175
|
+
metadata: { userId: admin.id },
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
const registry = new Map([["test-workflow-hydration", workflow]]);
|
|
179
|
+
const fetchRuns = createSuspendedRunFetcher(stack.db, registry);
|
|
180
|
+
const suspended = await fetchRuns();
|
|
181
|
+
|
|
182
|
+
const match = suspended.find((r) => r.runId === runId);
|
|
183
|
+
expect(match).toBeDefined();
|
|
184
|
+
expect(match!.workflowName).toBe("test-workflow-hydration");
|
|
185
|
+
expect(match!.stepIndex).toBe(1);
|
|
186
|
+
expect(match!.workflow).toBe(workflow);
|
|
187
|
+
expect(match!.definitionFingerprint).toBe(fingerprint);
|
|
188
|
+
expect(match!.triggerEvent).toMatchObject({
|
|
189
|
+
type: "demo.fired",
|
|
190
|
+
aggregateId: sourceAggregateId,
|
|
191
|
+
payload: { signal: "abc" },
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
test("fetcher skips workflows that are not in the registry", async () => {
|
|
196
|
+
const runId = crypto.randomUUID();
|
|
197
|
+
|
|
198
|
+
await insertOne(stack.db, eventsTable, {
|
|
199
|
+
aggregateId: runId,
|
|
200
|
+
aggregateType: WORKFLOW_AGGREGATE_TYPE,
|
|
201
|
+
tenantId: admin.tenantId,
|
|
202
|
+
version: 1,
|
|
203
|
+
type: WORKFLOW_WAITING_TYPE,
|
|
204
|
+
eventVersion: 1,
|
|
205
|
+
payload: {
|
|
206
|
+
workflowName: "unregistered-workflow",
|
|
207
|
+
stepIndex: 0,
|
|
208
|
+
wakeAt: new Date(Date.now() - 5000).toISOString(),
|
|
209
|
+
},
|
|
210
|
+
metadata: { userId: admin.id },
|
|
211
|
+
createdBy: admin.id,
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
const fetchRuns = createSuspendedRunFetcher(stack.db, new Map());
|
|
215
|
+
const suspended = await fetchRuns();
|
|
216
|
+
|
|
217
|
+
expect(suspended.find((r) => r.runId === runId)).toBeUndefined();
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
test("WORKFLOW_RESUMED_TYPE appends after WAITING on the same stream", async () => {
|
|
221
|
+
const runId = crypto.randomUUID();
|
|
222
|
+
|
|
223
|
+
await insertOne(stack.db, eventsTable, {
|
|
224
|
+
aggregateId: runId,
|
|
225
|
+
aggregateType: WORKFLOW_AGGREGATE_TYPE,
|
|
226
|
+
tenantId: admin.tenantId,
|
|
227
|
+
version: 1,
|
|
228
|
+
type: WORKFLOW_WAITING_TYPE,
|
|
229
|
+
eventVersion: 1,
|
|
230
|
+
payload: {
|
|
231
|
+
workflowName: "test-workflow",
|
|
232
|
+
stepIndex: 2,
|
|
233
|
+
wakeAt: new Date(Date.now() - 5000).toISOString(),
|
|
234
|
+
},
|
|
235
|
+
metadata: { userId: admin.id },
|
|
236
|
+
createdBy: admin.id,
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
await insertOne(stack.db, eventsTable, {
|
|
240
|
+
aggregateId: runId,
|
|
241
|
+
aggregateType: WORKFLOW_AGGREGATE_TYPE,
|
|
242
|
+
tenantId: admin.tenantId,
|
|
243
|
+
version: 2,
|
|
244
|
+
type: WORKFLOW_RESUMED_TYPE,
|
|
245
|
+
eventVersion: 1,
|
|
246
|
+
payload: { stepIndex: 2 },
|
|
247
|
+
metadata: { userId: admin.id },
|
|
248
|
+
createdBy: admin.id,
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
const rows = await selectMany(
|
|
252
|
+
stack.db,
|
|
253
|
+
eventsTable,
|
|
254
|
+
{ aggregateId: runId },
|
|
255
|
+
{ orderBy: { col: "version", direction: "asc" } },
|
|
256
|
+
);
|
|
257
|
+
|
|
258
|
+
expect(rows).toHaveLength(2);
|
|
259
|
+
expect(rows[0]!["type"]).toBe(WORKFLOW_WAITING_TYPE);
|
|
260
|
+
expect(rows[1]!["type"]).toBe(WORKFLOW_RESUMED_TYPE);
|
|
261
|
+
});
|
|
262
|
+
});
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
### src/__tests__/workflow-integration.test.ts
|
|
266
|
+
|
|
267
|
+
```ts
|
|
268
|
+
import { describe, expect, it, mock } from "bun:test";
|
|
269
|
+
import {
|
|
270
|
+
computeDefinitionFingerprint,
|
|
271
|
+
defineWorkflow,
|
|
272
|
+
STEP_DISPATCH_REQUESTED_TYPE,
|
|
273
|
+
stepsPipeline,
|
|
274
|
+
type WorkflowDefinition,
|
|
275
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
276
|
+
import { VersionConflictError } from "@cosmicdrift/kumiko-framework/event-store";
|
|
277
|
+
import { type CronWorkflow, nextCronDate, runDueCronWorkflows } from "../cron-scheduler";
|
|
278
|
+
import { registerEventTrigger } from "../event-trigger";
|
|
279
|
+
import {
|
|
280
|
+
dailyReportWorkflow,
|
|
281
|
+
userOnboardingSteps,
|
|
282
|
+
userOnboardingWorkflow,
|
|
283
|
+
webhookDeliveryWorkflow,
|
|
284
|
+
} from "../feature";
|
|
285
|
+
import { createInMemorySuspendedRunFetcher } from "../postgres-resume-loop";
|
|
286
|
+
import { runResumeLoop, type SuspendableRun } from "../resume-loop";
|
|
287
|
+
import { startAndRunWorkflow } from "../workflow-runner";
|
|
288
|
+
|
|
289
|
+
function makeWaitWorkflow(name: string) {
|
|
290
|
+
return defineWorkflow({
|
|
291
|
+
name,
|
|
292
|
+
trigger: { kind: "event", eventType: "demo.fired" },
|
|
293
|
+
steps: stepsPipeline(({ r }) => [
|
|
294
|
+
r.step.wait({ for: "PT1H" }),
|
|
295
|
+
r.step.return({ isSuccess: true, data: { result: "resumed" } }),
|
|
296
|
+
]),
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function makeReturnOnlyWorkflow(name: string) {
|
|
301
|
+
return defineWorkflow({
|
|
302
|
+
name,
|
|
303
|
+
trigger: { kind: "event", eventType: "demo.fired" },
|
|
304
|
+
steps: stepsPipeline(({ r }) => [r.step.return({ isSuccess: true, data: undefined })]),
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
describe("workflow-engine", () => {
|
|
309
|
+
it("defineWorkflow returns a valid WorkflowDefinition", () => {
|
|
310
|
+
expect(userOnboardingWorkflow.__kind).toBe("workflow");
|
|
311
|
+
expect(userOnboardingWorkflow.name).toBe("user-onboarding");
|
|
312
|
+
const trigger = userOnboardingWorkflow.trigger;
|
|
313
|
+
if (trigger.kind !== "event") throw new Error("expected event trigger");
|
|
314
|
+
expect(trigger.eventType).toBe("user.signed-up");
|
|
315
|
+
expect(typeof userOnboardingWorkflow.idempotencyKey).toBe("function");
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
it("defines a webhook-delivery workflow with event trigger", () => {
|
|
319
|
+
expect(webhookDeliveryWorkflow.__kind).toBe("workflow");
|
|
320
|
+
expect(webhookDeliveryWorkflow.name).toBe("webhook-delivery");
|
|
321
|
+
const trigger = webhookDeliveryWorkflow.trigger;
|
|
322
|
+
if (trigger.kind !== "event") throw new Error("expected event trigger");
|
|
323
|
+
expect(trigger.eventType).toBe("data.processed");
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
it("webhook-delivery completes with exactly one dispatch request", async () => {
|
|
327
|
+
const ctx = { unsafeAppendEvent: mock().mockResolvedValue(undefined) };
|
|
328
|
+
|
|
329
|
+
const result = await startAndRunWorkflow({
|
|
330
|
+
runId: "wf-webhook-delivery-1",
|
|
331
|
+
workflow: webhookDeliveryWorkflow as unknown as WorkflowDefinition,
|
|
332
|
+
triggerEvent: {
|
|
333
|
+
aggregateId: "agg_1",
|
|
334
|
+
type: "data.processed",
|
|
335
|
+
payload: { data: { id: 7 }, webhookUrl: "https://hooks.example.com/in" },
|
|
336
|
+
} as never,
|
|
337
|
+
handlerCtx: ctx as never,
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
expect(result.outcome).toBe("completed");
|
|
341
|
+
const dispatchRequests = ctx.unsafeAppendEvent.mock.calls
|
|
342
|
+
.map(([event]) => event as { type: string; payload: Record<string, unknown> })
|
|
343
|
+
.filter((event) => event.type === STEP_DISPATCH_REQUESTED_TYPE);
|
|
344
|
+
expect(dispatchRequests).toHaveLength(1);
|
|
345
|
+
expect(dispatchRequests[0]?.payload["url"]).toBe("https://hooks.example.com/in");
|
|
346
|
+
expect(dispatchRequests[0]?.payload["bodyJson"]).toBe(JSON.stringify({ id: 7 }));
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
it("defines a daily-report workflow with cron trigger", () => {
|
|
350
|
+
expect(dailyReportWorkflow.__kind).toBe("workflow");
|
|
351
|
+
expect(dailyReportWorkflow.name).toBe("daily-report");
|
|
352
|
+
const trigger = dailyReportWorkflow.trigger;
|
|
353
|
+
if (trigger.kind !== "cron") throw new Error("expected cron trigger");
|
|
354
|
+
expect(trigger.schedule).toBe("0 9 * * *");
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
it("workflow stores an idempotency key resolver for event-triggered workflows", () => {
|
|
358
|
+
const key = (
|
|
359
|
+
userOnboardingWorkflow.idempotencyKey as (e: { payload: { userId: string } }) => string
|
|
360
|
+
)({
|
|
361
|
+
payload: { userId: "user_123" },
|
|
362
|
+
});
|
|
363
|
+
expect(key).toBe("onboarding:user_123");
|
|
364
|
+
});
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
describe("workflow-runner", () => {
|
|
368
|
+
function makeAppendOnlyCtx() {
|
|
369
|
+
return { unsafeAppendEvent: mock().mockResolvedValue(undefined) };
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
it("startAndRunWorkflow writes run.started with Q7 snapshot fingerprint, then runs the pipeline to completion when no suspension", async () => {
|
|
373
|
+
const workflow = defineWorkflow({
|
|
374
|
+
name: "demo-sync",
|
|
375
|
+
trigger: { kind: "event", eventType: "demo.fired" },
|
|
376
|
+
steps: stepsPipeline(({ r }) => [r.step.return({ isSuccess: true, data: undefined })]),
|
|
377
|
+
});
|
|
378
|
+
const ctx = makeAppendOnlyCtx();
|
|
379
|
+
|
|
380
|
+
const result = await startAndRunWorkflow({
|
|
381
|
+
runId: "wf-demo-sync-1",
|
|
382
|
+
workflow,
|
|
383
|
+
triggerEvent: { aggregateId: "agg_1", type: "demo.fired", payload: {} } as never,
|
|
384
|
+
handlerCtx: ctx as never,
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
expect(result.outcome).toBe("completed");
|
|
388
|
+
expect(ctx.unsafeAppendEvent).toHaveBeenCalledTimes(2);
|
|
389
|
+
const started = ctx.unsafeAppendEvent.mock.calls[0]![0] as {
|
|
390
|
+
type: string;
|
|
391
|
+
payload: Record<string, unknown>;
|
|
392
|
+
};
|
|
393
|
+
expect(started.type).toBe("kumiko:system:workflow.run-started");
|
|
394
|
+
expect(started.payload["definitionFingerprint"]).toBe(computeDefinitionFingerprint(workflow));
|
|
395
|
+
expect(started.payload["triggerEventType"]).toBe("demo.fired");
|
|
396
|
+
const completed = ctx.unsafeAppendEvent.mock.calls[1]![0] as { type: string };
|
|
397
|
+
expect(completed.type).toBe("kumiko:system:workflow.run-completed");
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
it("startAndRunWorkflow returns outcome=suspended and stamps fingerprint into the waiting event", async () => {
|
|
401
|
+
const workflow = defineWorkflow({
|
|
402
|
+
name: "demo-wait",
|
|
403
|
+
trigger: { kind: "event", eventType: "demo.fired" },
|
|
404
|
+
steps: stepsPipeline(({ r }) => [
|
|
405
|
+
r.step.wait({ for: "PT1H" }),
|
|
406
|
+
r.step.return({ isSuccess: true, data: undefined }),
|
|
407
|
+
]),
|
|
408
|
+
});
|
|
409
|
+
const ctx = makeAppendOnlyCtx();
|
|
410
|
+
|
|
411
|
+
const result = await startAndRunWorkflow({
|
|
412
|
+
runId: "wf-demo-wait-1",
|
|
413
|
+
workflow,
|
|
414
|
+
triggerEvent: { aggregateId: "agg_1", type: "demo.fired", payload: {} } as never,
|
|
415
|
+
handlerCtx: ctx as never,
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
expect(result.outcome).toBe("suspended");
|
|
419
|
+
// run.started + workflow.step.waiting — no run-completed yet
|
|
420
|
+
expect(ctx.unsafeAppendEvent).toHaveBeenCalledTimes(2);
|
|
421
|
+
const waiting = ctx.unsafeAppendEvent.mock.calls[1]![0] as {
|
|
422
|
+
type: string;
|
|
423
|
+
payload: Record<string, unknown>;
|
|
424
|
+
};
|
|
425
|
+
expect(waiting.type).toBe("kumiko:system:workflow.step.waiting");
|
|
426
|
+
expect(waiting.payload["definitionFingerprint"]).toBe(computeDefinitionFingerprint(workflow));
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
it("idempotencyKey ends up in the run.started payload when provided", async () => {
|
|
430
|
+
const workflow = defineWorkflow({
|
|
431
|
+
name: "demo-idem",
|
|
432
|
+
trigger: { kind: "event", eventType: "demo.fired" },
|
|
433
|
+
idempotencyKey: ({ payload }) => `id:${(payload as { x: string }).x}`,
|
|
434
|
+
steps: stepsPipeline(({ r }) => [r.step.return({ isSuccess: true, data: undefined })]),
|
|
435
|
+
});
|
|
436
|
+
const ctx = makeAppendOnlyCtx();
|
|
437
|
+
|
|
438
|
+
await startAndRunWorkflow({
|
|
439
|
+
runId: "wf-demo-idem-1",
|
|
440
|
+
workflow,
|
|
441
|
+
triggerEvent: { aggregateId: "agg_1", type: "demo.fired", payload: { x: "abc" } } as never,
|
|
442
|
+
idempotencyKey: "id:abc",
|
|
443
|
+
handlerCtx: ctx as never,
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
const started = ctx.unsafeAppendEvent.mock.calls[0]![0] as { payload: Record<string, unknown> };
|
|
447
|
+
expect(started.payload["idempotencyKey"]).toBe("id:abc");
|
|
448
|
+
});
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
describe("userOnboardingWorkflow (end-to-end, in-memory)", () => {
|
|
452
|
+
it("suspends on the first wait, then resume-loop runs it through to completion", async () => {
|
|
453
|
+
const ctx = { unsafeAppendEvent: mock().mockResolvedValue(undefined) };
|
|
454
|
+
const trigger = {
|
|
455
|
+
aggregateId: "agg_user_42",
|
|
456
|
+
type: "user.signed-up",
|
|
457
|
+
payload: { email: "x@example.com", userId: "active-42" },
|
|
458
|
+
};
|
|
459
|
+
|
|
460
|
+
const result = await startAndRunWorkflow({
|
|
461
|
+
runId: "wf-onboarding-active-42",
|
|
462
|
+
workflow: userOnboardingWorkflow as unknown as WorkflowDefinition,
|
|
463
|
+
triggerEvent: trigger as never,
|
|
464
|
+
triggerEventRef: { eventId: "1", aggregateId: trigger.aggregateId, version: 1 },
|
|
465
|
+
idempotencyKey: "onboarding:active-42",
|
|
466
|
+
handlerCtx: ctx as never,
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
expect(result.outcome).toBe("suspended");
|
|
470
|
+
// Calls: run.started, mail.send dispatch-requested, workflow.step.waiting
|
|
471
|
+
expect(ctx.unsafeAppendEvent).toHaveBeenCalledTimes(3);
|
|
472
|
+
const startedCall = ctx.unsafeAppendEvent.mock.calls[0]![0] as {
|
|
473
|
+
type: string;
|
|
474
|
+
payload: Record<string, unknown>;
|
|
475
|
+
};
|
|
476
|
+
const mailCall = ctx.unsafeAppendEvent.mock.calls[1]![0] as {
|
|
477
|
+
type: string;
|
|
478
|
+
payload: Record<string, unknown>;
|
|
479
|
+
};
|
|
480
|
+
const waitingCall = ctx.unsafeAppendEvent.mock.calls[2]![0] as {
|
|
481
|
+
type: string;
|
|
482
|
+
payload: Record<string, unknown>;
|
|
483
|
+
};
|
|
484
|
+
expect(startedCall.type).toBe("kumiko:system:workflow.run-started");
|
|
485
|
+
expect(mailCall.type).toBe("kumiko:system:step.dispatch-requested");
|
|
486
|
+
expect((mailCall.payload as { stepKind: string }).stepKind).toBe("mail.send");
|
|
487
|
+
expect(waitingCall.type).toBe("kumiko:system:workflow.step.waiting");
|
|
488
|
+
expect(waitingCall.payload["definitionFingerprint"]).toBe(
|
|
489
|
+
computeDefinitionFingerprint(userOnboardingWorkflow as unknown as WorkflowDefinition),
|
|
490
|
+
);
|
|
491
|
+
|
|
492
|
+
// The run stream holds a reference, never a copy of the trigger payload.
|
|
493
|
+
expect(startedCall.payload["triggerEventRef"]).toEqual({
|
|
494
|
+
eventId: "1",
|
|
495
|
+
aggregateId: "agg_user_42",
|
|
496
|
+
version: 1,
|
|
497
|
+
});
|
|
498
|
+
expect(startedCall.payload).not.toHaveProperty("triggerPayload");
|
|
499
|
+
expect(waitingCall.payload).not.toHaveProperty("triggerPayload");
|
|
500
|
+
|
|
501
|
+
// Re-feed the run through the resume-loop using the fingerprint the
|
|
502
|
+
// waiting event carries — the loop runs the rest of the pipeline. The
|
|
503
|
+
// trigger event is what the event store returns for the reference.
|
|
504
|
+
const triggerSnapshot = trigger;
|
|
505
|
+
const suspendedRun: SuspendableRun = {
|
|
506
|
+
runId: "wf-onboarding-active-42",
|
|
507
|
+
workflowName: "user-onboarding",
|
|
508
|
+
stepIndex: waitingCall.payload["stepIndex"] as number,
|
|
509
|
+
wakeAt: Temporal.Now.instant().subtract({ seconds: 1 }).toString(),
|
|
510
|
+
suspensionEventType: "kumiko:system:workflow.step.waiting",
|
|
511
|
+
workflow: userOnboardingWorkflow as unknown as WorkflowDefinition,
|
|
512
|
+
triggerEvent: triggerSnapshot as never,
|
|
513
|
+
definitionFingerprint: waitingCall.payload["definitionFingerprint"] as string,
|
|
514
|
+
};
|
|
515
|
+
|
|
516
|
+
const resumeCtx = { unsafeAppendEvent: mock().mockResolvedValue(undefined) };
|
|
517
|
+
const count = await runResumeLoop(
|
|
518
|
+
createInMemorySuspendedRunFetcher([suspendedRun]),
|
|
519
|
+
resumeCtx as never,
|
|
520
|
+
);
|
|
521
|
+
|
|
522
|
+
expect(count).toBe(1);
|
|
523
|
+
// Resume sequence: RESUMED, branch fires the tips-mail (engaged=true
|
|
524
|
+
// because userId starts with "active-"), second WAITING for P7D.
|
|
525
|
+
// The run is suspended again before the webhook, so no RUN_COMPLETED
|
|
526
|
+
// yet — this is one resume-cycle, not the whole workflow.
|
|
527
|
+
const resumeTypes = resumeCtx.unsafeAppendEvent.mock.calls.map(
|
|
528
|
+
(c) => (c[0] as { type: string }).type,
|
|
529
|
+
);
|
|
530
|
+
expect(resumeTypes).toEqual([
|
|
531
|
+
"kumiko:system:workflow.step.resumed",
|
|
532
|
+
"kumiko:system:step.dispatch-requested",
|
|
533
|
+
"kumiko:system:workflow.step.waiting",
|
|
534
|
+
]);
|
|
535
|
+
const tipsMail = resumeCtx.unsafeAppendEvent.mock.calls[1]![0] as {
|
|
536
|
+
payload: { subject: string };
|
|
537
|
+
};
|
|
538
|
+
expect(tipsMail.payload.subject).toBe("Engagement tips");
|
|
539
|
+
});
|
|
540
|
+
|
|
541
|
+
it("not-engaged path picks the reminder branch", async () => {
|
|
542
|
+
const ctx = { unsafeAppendEvent: mock().mockResolvedValue(undefined) };
|
|
543
|
+
const trigger = {
|
|
544
|
+
aggregateId: "agg_user_cold",
|
|
545
|
+
type: "user.signed-up",
|
|
546
|
+
payload: { email: "cold@example.com", userId: "cold-1" },
|
|
547
|
+
};
|
|
548
|
+
await startAndRunWorkflow({
|
|
549
|
+
runId: "wf-onboarding-cold-1",
|
|
550
|
+
workflow: userOnboardingWorkflow as unknown as WorkflowDefinition,
|
|
551
|
+
triggerEvent: trigger as never,
|
|
552
|
+
triggerEventRef: { eventId: "2", aggregateId: trigger.aggregateId, version: 1 },
|
|
553
|
+
handlerCtx: ctx as never,
|
|
554
|
+
});
|
|
555
|
+
const waiting = ctx.unsafeAppendEvent.mock.calls[2]![0] as { payload: Record<string, unknown> };
|
|
556
|
+
const suspendedRun: SuspendableRun = {
|
|
557
|
+
runId: "wf-onboarding-cold-1",
|
|
558
|
+
workflowName: "user-onboarding",
|
|
559
|
+
stepIndex: waiting.payload["stepIndex"] as number,
|
|
560
|
+
wakeAt: Temporal.Now.instant().subtract({ seconds: 1 }).toString(),
|
|
561
|
+
suspensionEventType: "kumiko:system:workflow.step.waiting",
|
|
562
|
+
workflow: userOnboardingWorkflow as unknown as WorkflowDefinition,
|
|
563
|
+
triggerEvent: trigger as never,
|
|
564
|
+
definitionFingerprint: waiting.payload["definitionFingerprint"] as string,
|
|
565
|
+
};
|
|
566
|
+
const resumeCtx = { unsafeAppendEvent: mock().mockResolvedValue(undefined) };
|
|
567
|
+
await runResumeLoop(createInMemorySuspendedRunFetcher([suspendedRun]), resumeCtx as never);
|
|
568
|
+
|
|
569
|
+
const reminderMail = resumeCtx.unsafeAppendEvent.mock.calls[1]![0] as {
|
|
570
|
+
payload: { subject: string };
|
|
571
|
+
};
|
|
572
|
+
expect(reminderMail.payload.subject).toBe("Getting started");
|
|
573
|
+
});
|
|
574
|
+
});
|
|
575
|
+
|
|
576
|
+
describe("computeDefinitionFingerprint", () => {
|
|
577
|
+
it("returns the same fingerprint for the same definition", () => {
|
|
578
|
+
const a = defineWorkflow({
|
|
579
|
+
name: "wf",
|
|
580
|
+
trigger: { kind: "event", eventType: "x" },
|
|
581
|
+
steps: stepsPipeline(({ r }) => [r.step.return({ isSuccess: true, data: undefined })]),
|
|
582
|
+
});
|
|
583
|
+
const b = defineWorkflow({
|
|
584
|
+
name: "wf",
|
|
585
|
+
trigger: { kind: "event", eventType: "x" },
|
|
586
|
+
steps: stepsPipeline(({ r }) => [r.step.return({ isSuccess: true, data: undefined })]),
|
|
587
|
+
});
|
|
588
|
+
expect(computeDefinitionFingerprint(a)).toBe(computeDefinitionFingerprint(b));
|
|
589
|
+
});
|
|
590
|
+
|
|
591
|
+
it("returns a different fingerprint when the pipeline source changes", () => {
|
|
592
|
+
const a = defineWorkflow({
|
|
593
|
+
name: "wf",
|
|
594
|
+
trigger: { kind: "event", eventType: "x" },
|
|
595
|
+
steps: stepsPipeline(({ r }) => [r.step.return({ isSuccess: true, data: undefined })]),
|
|
596
|
+
});
|
|
597
|
+
const b = defineWorkflow({
|
|
598
|
+
name: "wf",
|
|
599
|
+
trigger: { kind: "event", eventType: "x" },
|
|
600
|
+
steps: stepsPipeline(({ r }) => [
|
|
601
|
+
r.step.compute("noop", () => 1),
|
|
602
|
+
r.step.return({ isSuccess: true, data: undefined }),
|
|
603
|
+
]),
|
|
604
|
+
});
|
|
605
|
+
expect(computeDefinitionFingerprint(a)).not.toBe(computeDefinitionFingerprint(b));
|
|
606
|
+
});
|
|
607
|
+
|
|
608
|
+
it("returns a different fingerprint when the trigger changes", () => {
|
|
609
|
+
const a = defineWorkflow({
|
|
610
|
+
name: "wf",
|
|
611
|
+
trigger: { kind: "event", eventType: "x" },
|
|
612
|
+
steps: stepsPipeline(({ r }) => [r.step.return({ isSuccess: true, data: undefined })]),
|
|
613
|
+
});
|
|
614
|
+
const b = defineWorkflow({
|
|
615
|
+
name: "wf",
|
|
616
|
+
trigger: { kind: "event", eventType: "y" },
|
|
617
|
+
steps: stepsPipeline(({ r }) => [r.step.return({ isSuccess: true, data: undefined })]),
|
|
618
|
+
});
|
|
619
|
+
expect(computeDefinitionFingerprint(a)).not.toBe(computeDefinitionFingerprint(b));
|
|
620
|
+
});
|
|
621
|
+
});
|
|
622
|
+
|
|
623
|
+
describe("event-trigger", () => {
|
|
624
|
+
it("registerEventTrigger creates MSP for event-kind workflows", () => {
|
|
625
|
+
const registrations: unknown[] = [];
|
|
626
|
+
const mockRegistrar = {
|
|
627
|
+
multiStreamProjection: (def: unknown) => {
|
|
628
|
+
registrations.push(def);
|
|
629
|
+
},
|
|
630
|
+
};
|
|
631
|
+
|
|
632
|
+
registerEventTrigger(
|
|
633
|
+
mockRegistrar as never,
|
|
634
|
+
userOnboardingWorkflow as unknown as WorkflowDefinition,
|
|
635
|
+
);
|
|
636
|
+
|
|
637
|
+
expect(registrations).toHaveLength(1);
|
|
638
|
+
const msp = registrations[0] as Record<string, unknown>;
|
|
639
|
+
expect(msp["name"]).toBe("workflow-user-onboarding");
|
|
640
|
+
expect(typeof (msp["apply"] as Record<string, unknown>)["user.signed-up"]).toBe("function");
|
|
641
|
+
});
|
|
642
|
+
|
|
643
|
+
it("registerEventTrigger skips non-event workflows", () => {
|
|
644
|
+
const registrations: unknown[] = [];
|
|
645
|
+
const mockRegistrar = {
|
|
646
|
+
multiStreamProjection: (def: unknown) => {
|
|
647
|
+
registrations.push(def);
|
|
648
|
+
},
|
|
649
|
+
};
|
|
650
|
+
|
|
651
|
+
registerEventTrigger(
|
|
652
|
+
mockRegistrar as never,
|
|
653
|
+
dailyReportWorkflow as unknown as WorkflowDefinition,
|
|
654
|
+
);
|
|
655
|
+
|
|
656
|
+
expect(registrations).toHaveLength(0);
|
|
657
|
+
});
|
|
658
|
+
|
|
659
|
+
it("userOnboardingSteps is a valid pipeline", () => {
|
|
660
|
+
expect(userOnboardingSteps.__kind).toBe("pipeline");
|
|
661
|
+
expect(typeof userOnboardingSteps.build).toBe("function");
|
|
662
|
+
});
|
|
663
|
+
});
|
|
664
|
+
|
|
665
|
+
describe("cron-scheduler", () => {
|
|
666
|
+
it("nextCronDate computes the next run time", () => {
|
|
667
|
+
const base = Temporal.Instant.from("2025-06-01T00:00:00Z");
|
|
668
|
+
const next = nextCronDate("30 9 * * *", base);
|
|
669
|
+
expect(next).not.toBeNull();
|
|
670
|
+
const zdt = next!.toZonedDateTimeISO("UTC");
|
|
671
|
+
expect(zdt.hour).toBe(9);
|
|
672
|
+
expect(zdt.minute).toBe(30);
|
|
673
|
+
expect(zdt.day).toBe(1);
|
|
674
|
+
});
|
|
675
|
+
|
|
676
|
+
it("nextCronDate returns null for invalid cron expressions", () => {
|
|
677
|
+
expect(nextCronDate("invalid", Temporal.Now.instant())).toBeNull();
|
|
678
|
+
expect(nextCronDate("", Temporal.Now.instant())).toBeNull();
|
|
679
|
+
});
|
|
680
|
+
|
|
681
|
+
it("nextCronDate advances to next day if time already passed", () => {
|
|
682
|
+
const base = Temporal.Instant.from("2025-06-01T10:00:00Z");
|
|
683
|
+
const next = nextCronDate("30 9 * * *", base);
|
|
684
|
+
const zdt = next!.toZonedDateTimeISO("UTC");
|
|
685
|
+
// 09:30 already passed on June 1 → next is June 2 at 09:30
|
|
686
|
+
expect(zdt.day).toBe(2);
|
|
687
|
+
expect(zdt.hour).toBe(9);
|
|
688
|
+
expect(zdt.minute).toBe(30);
|
|
689
|
+
});
|
|
690
|
+
|
|
691
|
+
it("runDueCronWorkflows skips not-yet-due workflows", async () => {
|
|
692
|
+
const wf = defineWorkflow({
|
|
693
|
+
name: "test-daily",
|
|
694
|
+
trigger: { kind: "cron", schedule: "0 9 * * *" },
|
|
695
|
+
steps: stepsPipeline(({ r }) => [r.step.return({ isSuccess: true, data: undefined })]),
|
|
696
|
+
});
|
|
697
|
+
|
|
698
|
+
const handlerCtx = { unsafeAppendEvent: mock() };
|
|
699
|
+
const lastRuns = new Map<string, Temporal.Instant>();
|
|
700
|
+
const now = Temporal.Instant.from("2025-06-01T08:00:00Z");
|
|
701
|
+
|
|
702
|
+
const count = await runDueCronWorkflows(
|
|
703
|
+
[wf as CronWorkflow],
|
|
704
|
+
lastRuns,
|
|
705
|
+
now,
|
|
706
|
+
handlerCtx as never,
|
|
707
|
+
);
|
|
708
|
+
|
|
709
|
+
expect(count).toBe(0);
|
|
710
|
+
expect(handlerCtx.unsafeAppendEvent).not.toHaveBeenCalled();
|
|
711
|
+
});
|
|
712
|
+
|
|
713
|
+
it("runDueCronWorkflows runs due workflows through the full start path", async () => {
|
|
714
|
+
const wf = defineWorkflow({
|
|
715
|
+
name: "test-daily",
|
|
716
|
+
trigger: { kind: "cron", schedule: "0 9 * * *" },
|
|
717
|
+
steps: stepsPipeline(({ r }) => [r.step.return({ isSuccess: true, data: undefined })]),
|
|
718
|
+
});
|
|
719
|
+
|
|
720
|
+
const handlerCtx = { unsafeAppendEvent: mock().mockResolvedValue(undefined) };
|
|
721
|
+
const lastRuns = new Map<string, Temporal.Instant>();
|
|
722
|
+
const lastRun = Temporal.Instant.from("2025-05-31T08:00:00Z");
|
|
723
|
+
lastRuns.set("test-daily", lastRun);
|
|
724
|
+
const now = Temporal.Instant.from("2025-06-01T09:01:00Z");
|
|
725
|
+
|
|
726
|
+
const count = await runDueCronWorkflows(
|
|
727
|
+
[wf as CronWorkflow],
|
|
728
|
+
lastRuns,
|
|
729
|
+
now,
|
|
730
|
+
handlerCtx as never,
|
|
731
|
+
);
|
|
732
|
+
|
|
733
|
+
expect(count).toBe(1);
|
|
734
|
+
// run.started + run-completed (no suspension, pipeline returns immediately).
|
|
735
|
+
expect(handlerCtx.unsafeAppendEvent).toHaveBeenCalledTimes(2);
|
|
736
|
+
const started = handlerCtx.unsafeAppendEvent.mock.calls[0]![0] as Record<string, unknown>;
|
|
737
|
+
expect(started["type"]).toBe("kumiko:system:workflow.run-started");
|
|
738
|
+
const completed = handlerCtx.unsafeAppendEvent.mock.calls[1]![0] as Record<string, unknown>;
|
|
739
|
+
expect(completed["type"]).toBe("kumiko:system:workflow.run-completed");
|
|
740
|
+
});
|
|
741
|
+
});
|
|
742
|
+
|
|
743
|
+
describe("resume-loop", () => {
|
|
744
|
+
it("returns 0 when no suspended runs exist", async () => {
|
|
745
|
+
const fetchRuns = createInMemorySuspendedRunFetcher([]);
|
|
746
|
+
const handlerCtx = { unsafeAppendEvent: mock() };
|
|
747
|
+
|
|
748
|
+
const count = await runResumeLoop(fetchRuns, handlerCtx as never);
|
|
749
|
+
|
|
750
|
+
expect(count).toBe(0);
|
|
751
|
+
expect(handlerCtx.unsafeAppendEvent).not.toHaveBeenCalled();
|
|
752
|
+
});
|
|
753
|
+
|
|
754
|
+
it("resumes a suspended run", async () => {
|
|
755
|
+
const workflow = makeWaitWorkflow("test-workflow");
|
|
756
|
+
const suspendedRun: SuspendableRun = {
|
|
757
|
+
runId: "wf-test-run_123",
|
|
758
|
+
workflowName: "test-workflow",
|
|
759
|
+
stepIndex: 0,
|
|
760
|
+
wakeAt: Temporal.Now.instant().subtract({ seconds: 1 }).toString(),
|
|
761
|
+
suspensionEventType: "kumiko:system:workflow.step.waiting",
|
|
762
|
+
workflow,
|
|
763
|
+
triggerEvent: { aggregateId: "agg_1", type: "demo.fired", payload: {} } as never,
|
|
764
|
+
definitionFingerprint: computeDefinitionFingerprint(workflow),
|
|
765
|
+
};
|
|
766
|
+
|
|
767
|
+
const fetchRuns = createInMemorySuspendedRunFetcher([suspendedRun]);
|
|
768
|
+
const handlerCtx = { unsafeAppendEvent: mock().mockResolvedValue(undefined) };
|
|
769
|
+
|
|
770
|
+
const count = await runResumeLoop(fetchRuns, handlerCtx as never);
|
|
771
|
+
|
|
772
|
+
expect(count).toBe(1);
|
|
773
|
+
expect(handlerCtx.unsafeAppendEvent).toHaveBeenCalledTimes(2);
|
|
774
|
+
expect(handlerCtx.unsafeAppendEvent).toHaveBeenNthCalledWith(1, {
|
|
775
|
+
aggregateId: "wf-test-run_123",
|
|
776
|
+
aggregateType: "workflow-run",
|
|
777
|
+
type: "kumiko:system:workflow.step.resumed",
|
|
778
|
+
payload: { stepIndex: 0, retryAttempt: undefined },
|
|
779
|
+
});
|
|
780
|
+
expect(handlerCtx.unsafeAppendEvent).toHaveBeenNthCalledWith(2, {
|
|
781
|
+
aggregateId: "wf-test-run_123",
|
|
782
|
+
aggregateType: "workflow-run",
|
|
783
|
+
type: "kumiko:system:workflow.run-completed",
|
|
784
|
+
payload: { stepIndex: 0 },
|
|
785
|
+
});
|
|
786
|
+
});
|
|
787
|
+
|
|
788
|
+
it("fails loud (RUN_FAILED + reason workflow_definition_changed) when the fingerprint diverges", async () => {
|
|
789
|
+
const workflow = makeWaitWorkflow("test-workflow-drift");
|
|
790
|
+
const suspendedRun: SuspendableRun = {
|
|
791
|
+
runId: "wf-drift-run",
|
|
792
|
+
workflowName: "test-workflow-drift",
|
|
793
|
+
stepIndex: 0,
|
|
794
|
+
wakeAt: Temporal.Now.instant().subtract({ seconds: 1 }).toString(),
|
|
795
|
+
suspensionEventType: "kumiko:system:workflow.step.waiting",
|
|
796
|
+
workflow,
|
|
797
|
+
triggerEvent: { aggregateId: "agg_y", type: "demo.fired", payload: {} } as never,
|
|
798
|
+
definitionFingerprint: "stale-fingerprint-from-an-older-deploy",
|
|
799
|
+
};
|
|
800
|
+
|
|
801
|
+
const fetchRuns = createInMemorySuspendedRunFetcher([suspendedRun]);
|
|
802
|
+
const handlerCtx = { unsafeAppendEvent: mock().mockResolvedValue(undefined) };
|
|
803
|
+
|
|
804
|
+
const count = await runResumeLoop(fetchRuns, handlerCtx as never);
|
|
805
|
+
|
|
806
|
+
expect(count).toBe(0);
|
|
807
|
+
expect(handlerCtx.unsafeAppendEvent).toHaveBeenCalledTimes(1);
|
|
808
|
+
const failed = handlerCtx.unsafeAppendEvent.mock.calls[0]![0] as {
|
|
809
|
+
type: string;
|
|
810
|
+
payload: Record<string, unknown>;
|
|
811
|
+
};
|
|
812
|
+
expect(failed.type).toBe("kumiko:system:workflow.run-failed");
|
|
813
|
+
expect(failed.payload["reason"]).toBe("workflow_definition_changed");
|
|
814
|
+
expect(failed.payload["error"]).toContain("definition changed");
|
|
815
|
+
});
|
|
816
|
+
|
|
817
|
+
it("skips a run silently when the resume-claim hits VersionConflictError", async () => {
|
|
818
|
+
const workflow = makeWaitWorkflow("test-workflow-conflict");
|
|
819
|
+
const suspendedRun: SuspendableRun = {
|
|
820
|
+
runId: "wf-conflict-run",
|
|
821
|
+
workflowName: "test-workflow-conflict",
|
|
822
|
+
stepIndex: 0,
|
|
823
|
+
wakeAt: Temporal.Now.instant().subtract({ seconds: 1 }).toString(),
|
|
824
|
+
suspensionEventType: "kumiko:system:workflow.step.waiting",
|
|
825
|
+
workflow,
|
|
826
|
+
triggerEvent: { aggregateId: "agg_x", type: "demo.fired", payload: {} } as never,
|
|
827
|
+
definitionFingerprint: computeDefinitionFingerprint(workflow),
|
|
828
|
+
};
|
|
829
|
+
|
|
830
|
+
const fetchRuns = createInMemorySuspendedRunFetcher([suspendedRun]);
|
|
831
|
+
const handlerCtx = {
|
|
832
|
+
unsafeAppendEvent: mock()
|
|
833
|
+
.mockRejectedValueOnce(new VersionConflictError("wf-conflict-run", 1))
|
|
834
|
+
.mockResolvedValue(undefined),
|
|
835
|
+
};
|
|
836
|
+
|
|
837
|
+
const count = await runResumeLoop(fetchRuns, handlerCtx as never);
|
|
838
|
+
|
|
839
|
+
expect(count).toBe(0);
|
|
840
|
+
expect(handlerCtx.unsafeAppendEvent).toHaveBeenCalledTimes(1);
|
|
841
|
+
expect((handlerCtx.unsafeAppendEvent.mock.calls[0]![0] as { type: string }).type).toBe(
|
|
842
|
+
"kumiko:system:workflow.step.resumed",
|
|
843
|
+
);
|
|
844
|
+
});
|
|
845
|
+
|
|
846
|
+
it("createInMemorySuspendedRunFetcher returns runs with workflow intact", async () => {
|
|
847
|
+
const workflow = makeReturnOnlyWorkflow("test-fetcher");
|
|
848
|
+
const run: SuspendableRun = {
|
|
849
|
+
runId: "wf-test-pipeline",
|
|
850
|
+
workflowName: "test-fetcher",
|
|
851
|
+
stepIndex: 0,
|
|
852
|
+
wakeAt: "2025-06-01T00:00:00Z",
|
|
853
|
+
workflow,
|
|
854
|
+
triggerEvent: { aggregateId: "a", type: "demo.fired", payload: {} } as never,
|
|
855
|
+
suspensionEventType: "kumiko:system:workflow.step.waiting",
|
|
856
|
+
};
|
|
857
|
+
|
|
858
|
+
const fetchRuns = createInMemorySuspendedRunFetcher([run]);
|
|
859
|
+
const result = await fetchRuns();
|
|
860
|
+
|
|
861
|
+
expect(result).toHaveLength(1);
|
|
862
|
+
expect(typeof result[0]!.workflow.pipelineDef.build).toBe("function");
|
|
863
|
+
});
|
|
864
|
+
});
|
|
865
|
+
```
|
|
866
|
+
|
|
867
|
+
### src/cron-scheduler.ts
|
|
868
|
+
|
|
869
|
+
```ts
|
|
870
|
+
// cron-scheduler — schedules and triggers cron-based workflows.
|
|
871
|
+
//
|
|
872
|
+
// Each tick (default 60s) checks whether a cron workflow is due and runs it
|
|
873
|
+
// through the same `startAndRunWorkflow` path the event-trigger uses.
|
|
874
|
+
// Production setups should swap the tick-loop for a persistent scheduler
|
|
875
|
+
// (pg_cron, BullMQ) — the sample provides Bun.Timer + a manual entrypoint.
|
|
876
|
+
|
|
877
|
+
import type { HandlerContext, WorkflowDefinition } from "@cosmicdrift/kumiko-framework/engine";
|
|
878
|
+
import { v4 as uuid } from "uuid";
|
|
879
|
+
import { startAndRunWorkflow } from "./workflow-runner";
|
|
880
|
+
|
|
881
|
+
export type CronWorkflow = WorkflowDefinition & {
|
|
882
|
+
readonly trigger: { readonly kind: "cron"; readonly schedule: string };
|
|
883
|
+
};
|
|
884
|
+
|
|
885
|
+
// Parse a cron expression in the format "minute hour * * *" and return
|
|
886
|
+
// the next scheduled Instant (UTC), or null if invalid. Wildcards for
|
|
887
|
+
// day/month/weekday are accepted; numeric minute+hour required.
|
|
888
|
+
export function nextCronDate(schedule: string, since: Temporal.Instant): Temporal.Instant | null {
|
|
889
|
+
const parts = schedule.trim().split(/\s+/);
|
|
890
|
+
if (parts.length < 5) return null;
|
|
891
|
+
const [minStr, hourStr] = parts;
|
|
892
|
+
if (minStr === undefined || hourStr === undefined) return null;
|
|
893
|
+
|
|
894
|
+
const cronMin = parseInt(minStr, 10);
|
|
895
|
+
const cronHour = parseInt(hourStr, 10);
|
|
896
|
+
if (Number.isNaN(cronMin) || Number.isNaN(cronHour)) return null;
|
|
897
|
+
|
|
898
|
+
// Move to the same-day candidate at cronHour:cronMin (UTC), then bump
|
|
899
|
+
// by one day until strictly after `since`.
|
|
900
|
+
let candidate = since.toZonedDateTimeISO("UTC").with({
|
|
901
|
+
hour: cronHour,
|
|
902
|
+
minute: cronMin,
|
|
903
|
+
second: 0,
|
|
904
|
+
millisecond: 0,
|
|
905
|
+
microsecond: 0,
|
|
906
|
+
nanosecond: 0,
|
|
907
|
+
});
|
|
908
|
+
while (Temporal.Instant.compare(candidate.toInstant(), since) <= 0) {
|
|
909
|
+
candidate = candidate.add({ days: 1 });
|
|
910
|
+
}
|
|
911
|
+
return candidate.toInstant();
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
/**
|
|
915
|
+
* Check if a cron workflow is due to run and execute it via the shared
|
|
916
|
+
* start-and-run path. Returns the number of workflow runs started.
|
|
917
|
+
*/
|
|
918
|
+
export async function runDueCronWorkflows(
|
|
919
|
+
workflows: readonly CronWorkflow[],
|
|
920
|
+
lastRuns: Map<string, Temporal.Instant>,
|
|
921
|
+
now: Temporal.Instant,
|
|
922
|
+
handlerCtx: HandlerContext,
|
|
923
|
+
): Promise<number> {
|
|
924
|
+
let count = 0;
|
|
925
|
+
|
|
926
|
+
for (const wf of workflows) {
|
|
927
|
+
const lastRun = lastRuns.get(wf.name) ?? now;
|
|
928
|
+
const next = nextCronDate(wf.trigger.schedule, lastRun);
|
|
929
|
+
|
|
930
|
+
if (next && Temporal.Instant.compare(next, now) <= 0) {
|
|
931
|
+
const runId = `wf-${wf.name}-cron-${uuid()}`;
|
|
932
|
+
await startAndRunWorkflow({
|
|
933
|
+
runId,
|
|
934
|
+
workflow: wf,
|
|
935
|
+
triggerEvent: { aggregateId: runId, type: "cron", payload: {} } as never,
|
|
936
|
+
handlerCtx,
|
|
937
|
+
});
|
|
938
|
+
lastRuns.set(wf.name, now);
|
|
939
|
+
count++;
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
return count;
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
/**
|
|
947
|
+
* Create a Bun.Timer-based cron scheduler that periodically checks for
|
|
948
|
+
* due workflows and runs them.
|
|
949
|
+
*/
|
|
950
|
+
export function startCronScheduler(
|
|
951
|
+
workflows: readonly CronWorkflow[],
|
|
952
|
+
handlerCtx: HandlerContext,
|
|
953
|
+
intervalMs = 60_000,
|
|
954
|
+
): { stop: () => void } {
|
|
955
|
+
const lastRuns = new Map<string, Temporal.Instant>();
|
|
956
|
+
let running = false;
|
|
957
|
+
let timer: ReturnType<typeof setInterval> | null = null;
|
|
958
|
+
|
|
959
|
+
async function tick() {
|
|
960
|
+
if (running) return;
|
|
961
|
+
running = true;
|
|
962
|
+
try {
|
|
963
|
+
await runDueCronWorkflows(workflows, lastRuns, Temporal.Now.instant(), handlerCtx);
|
|
964
|
+
} catch {
|
|
965
|
+
// Logged by the caller
|
|
966
|
+
} finally {
|
|
967
|
+
running = false;
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
timer = setInterval(tick, intervalMs);
|
|
972
|
+
void tick();
|
|
973
|
+
|
|
974
|
+
return {
|
|
975
|
+
stop: () => {
|
|
976
|
+
if (timer) clearInterval(timer);
|
|
977
|
+
},
|
|
978
|
+
};
|
|
979
|
+
}
|
|
980
|
+
```
|
|
981
|
+
|
|
982
|
+
### src/db/queries/suspended-runs.ts
|
|
983
|
+
|
|
984
|
+
```ts
|
|
985
|
+
import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
986
|
+
import type { DbRunner } from "@cosmicdrift/kumiko-framework/db";
|
|
987
|
+
|
|
988
|
+
export async function selectExpiredSuspensionEvents(
|
|
989
|
+
db: DbRunner,
|
|
990
|
+
eventTypes: readonly string[],
|
|
991
|
+
): Promise<readonly Record<string, unknown>[]> {
|
|
992
|
+
return (await asRawClient(db).unsafe(
|
|
993
|
+
`SELECT * FROM "kumiko_events"
|
|
994
|
+
WHERE "type" = ANY($1::text[])
|
|
995
|
+
AND (("payload"->>'wakeAt')::timestamptz < now() OR ("payload"->>'timeoutAt')::timestamptz < now())`,
|
|
996
|
+
[eventTypes as unknown as string[]],
|
|
997
|
+
)) as Record<string, unknown>[];
|
|
998
|
+
}
|
|
999
|
+
```
|
|
1000
|
+
|
|
1001
|
+
### src/event-trigger.ts
|
|
1002
|
+
|
|
1003
|
+
```ts
|
|
1004
|
+
// event-trigger — MultiStreamProjection that listens for domain events and
|
|
1005
|
+
// starts + runs a workflow when the event matches a trigger.
|
|
1006
|
+
//
|
|
1007
|
+
// Registration pattern:
|
|
1008
|
+
// registerEventTrigger(r, userOnboardingWorkflow)
|
|
1009
|
+
//
|
|
1010
|
+
// The MSP apply-fn runs in the dispatcher's tx, so the run.started event
|
|
1011
|
+
// plus the synchronous portion of the pipeline land atomically. If the
|
|
1012
|
+
// pipeline hits a Tier-3 suspension (wait/waitForEvent/retry), the apply
|
|
1013
|
+
// commits with the WAITING event written; the resume-loop wakes it later.
|
|
1014
|
+
|
|
1015
|
+
import type {
|
|
1016
|
+
FeatureRegistrar,
|
|
1017
|
+
MultiStreamProjectionDefinition,
|
|
1018
|
+
WorkflowDefinition,
|
|
1019
|
+
WriteEvent,
|
|
1020
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
1021
|
+
import {
|
|
1022
|
+
describeWorkflowStepError,
|
|
1023
|
+
WORKFLOW_AGGREGATE_TYPE,
|
|
1024
|
+
WORKFLOW_RUN_FAILED_TYPE,
|
|
1025
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
1026
|
+
import { v4 as uuid } from "uuid";
|
|
1027
|
+
import { startAndRunWorkflow } from "./workflow-runner";
|
|
1028
|
+
|
|
1029
|
+
export function registerEventTrigger(r: FeatureRegistrar, workflow: WorkflowDefinition): void {
|
|
1030
|
+
if (workflow.trigger.kind !== "event") return;
|
|
1031
|
+
|
|
1032
|
+
const eventType = workflow.trigger.eventType;
|
|
1033
|
+
|
|
1034
|
+
r.multiStreamProjection({
|
|
1035
|
+
name: `workflow-${workflow.name}`,
|
|
1036
|
+
apply: {
|
|
1037
|
+
[eventType]: async (event, _tx, ctx) => {
|
|
1038
|
+
if (workflow.trigger.kind !== "event") return;
|
|
1039
|
+
if (workflow.trigger.filter) {
|
|
1040
|
+
const matches = workflow.trigger.filter(event as never);
|
|
1041
|
+
if (!matches) return;
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
// @cast-boundary msp-to-write-event — MSP receives StoredEvent
|
|
1045
|
+
// (event-store shape), workflow runner expects WriteEvent (handler
|
|
1046
|
+
// shape). The fields workflow steps read (type, payload) overlap
|
|
1047
|
+
// exactly; the missing `.user` field on StoredEvent is acceptable
|
|
1048
|
+
// because workflow triggers run system-level, not user-scoped.
|
|
1049
|
+
const triggerEvent = event as unknown as WriteEvent;
|
|
1050
|
+
let idempotencyKey: string | undefined;
|
|
1051
|
+
if (typeof workflow.idempotencyKey === "function") {
|
|
1052
|
+
idempotencyKey = workflow.idempotencyKey(triggerEvent);
|
|
1053
|
+
} else if (typeof workflow.idempotencyKey === "string") {
|
|
1054
|
+
idempotencyKey = workflow.idempotencyKey;
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
const runId = idempotencyKey
|
|
1058
|
+
? `wf-${workflow.name}-${idempotencyKey}`
|
|
1059
|
+
: `wf-${workflow.name}-${uuid()}`;
|
|
1060
|
+
|
|
1061
|
+
try {
|
|
1062
|
+
await startAndRunWorkflow({
|
|
1063
|
+
runId,
|
|
1064
|
+
workflow,
|
|
1065
|
+
triggerEvent,
|
|
1066
|
+
triggerEventRef: {
|
|
1067
|
+
eventId: event.id,
|
|
1068
|
+
aggregateId: event.aggregateId,
|
|
1069
|
+
version: event.version,
|
|
1070
|
+
},
|
|
1071
|
+
...(idempotencyKey && { idempotencyKey }),
|
|
1072
|
+
handlerCtx: ctx as never,
|
|
1073
|
+
});
|
|
1074
|
+
} catch (error) {
|
|
1075
|
+
await (
|
|
1076
|
+
ctx as never as { unsafeAppendEvent: (a: unknown) => Promise<void> }
|
|
1077
|
+
).unsafeAppendEvent({
|
|
1078
|
+
aggregateId: runId,
|
|
1079
|
+
aggregateType: WORKFLOW_AGGREGATE_TYPE,
|
|
1080
|
+
type: WORKFLOW_RUN_FAILED_TYPE,
|
|
1081
|
+
payload: {
|
|
1082
|
+
workflowName: workflow.name,
|
|
1083
|
+
stepIndex: 0,
|
|
1084
|
+
error: describeWorkflowStepError(error),
|
|
1085
|
+
},
|
|
1086
|
+
});
|
|
1087
|
+
throw error;
|
|
1088
|
+
}
|
|
1089
|
+
},
|
|
1090
|
+
},
|
|
1091
|
+
} satisfies MultiStreamProjectionDefinition);
|
|
1092
|
+
}
|
|
1093
|
+
```
|
|
1094
|
+
|
|
1095
|
+
### src/feature.ts
|
|
1096
|
+
|
|
1097
|
+
```ts
|
|
1098
|
+
// workflow-engine Sample — M.4 Tier-3 step-vocabulary showcase.
|
|
1099
|
+
//
|
|
1100
|
+
// Demonstrates defineWorkflow with wait, branch, mail.send, webhook.send
|
|
1101
|
+
// and the workflow-run lifecycle. Each workflow below is a real,
|
|
1102
|
+
// runnable pipeline — no empty `build: () => []` stubs. The
|
|
1103
|
+
// integration-tests in __tests__/ exercise the suspension/resume cycle
|
|
1104
|
+
// against the in-memory fetcher and (separately) the postgres event-store.
|
|
1105
|
+
//
|
|
1106
|
+
// Mounting:
|
|
1107
|
+
// - Event-triggered workflows register an MSP via `registerEventTrigger`
|
|
1108
|
+
// (see feature() below). The MSP starts + runs the pipeline up to the
|
|
1109
|
+
// first suspension; the resume-loop wakes it later.
|
|
1110
|
+
// - Cron-triggered workflows are started by the `cron-scheduler` tick.
|
|
1111
|
+
//
|
|
1112
|
+
// M.4 Limitations (Followups, will land with M.5 / future slices):
|
|
1113
|
+
// - The MSP apply-ctx exposes `unsafeAppendEvent` + `db` but not the
|
|
1114
|
+
// full HandlerContext surface. `r.step.read.findOne` works (db is
|
|
1115
|
+
// present), `r.step.callFeature` does NOT yet (no `write`/`writeAs`
|
|
1116
|
+
// on apply-ctx). Pipelines should stick to compute / branch / wait /
|
|
1117
|
+
// mail.send / webhook.send for now.
|
|
1118
|
+
// - The fetcher reads every suspension row whose wakeAt has expired
|
|
1119
|
+
// (no `workflow_run_pending` read-side projection yet). Concurrency
|
|
1120
|
+
// is safe via the event-store version-conflict path; performance is
|
|
1121
|
+
// adequate for the demo, not for high-volume production.
|
|
1122
|
+
|
|
1123
|
+
import type { PipelineCtx, WorkflowDefinition } from "@cosmicdrift/kumiko-framework/engine";
|
|
1124
|
+
import { defineFeature, defineWorkflow, stepsPipeline } from "@cosmicdrift/kumiko-framework/engine";
|
|
1125
|
+
import { registerEventTrigger } from "./event-trigger";
|
|
1126
|
+
|
|
1127
|
+
/**
|
|
1128
|
+
* User-onboarding workflow: welcome-mail → wait 1 day → branch on a
|
|
1129
|
+
* computed engagement flag → either tips or reminder → wait 7 days →
|
|
1130
|
+
* webhook to the CRM. Triggered by `user.signed-up`, deduped per userId.
|
|
1131
|
+
*
|
|
1132
|
+
* The engagement-flag is computed inline (no read.findOne in M.4 yet —
|
|
1133
|
+
* see Limitations above). A real app would resolve the flag from the
|
|
1134
|
+
* user-store via a Tier-1 read step once the apply-ctx → HandlerContext
|
|
1135
|
+
* bridge lands.
|
|
1136
|
+
*/
|
|
1137
|
+
export const userOnboardingWorkflow: WorkflowDefinition<{ email: string; userId: string }, void> =
|
|
1138
|
+
defineWorkflow({
|
|
1139
|
+
name: "user-onboarding",
|
|
1140
|
+
trigger: { kind: "event", eventType: "user.signed-up" },
|
|
1141
|
+
idempotencyKey: ({ payload }) => `onboarding:${payload.userId}`,
|
|
1142
|
+
|
|
1143
|
+
steps: stepsPipeline<{ email: string; userId: string }, void>(({ r }) => [
|
|
1144
|
+
r.step.mail.send({
|
|
1145
|
+
to: (ctx) => (ctx.event.payload as { email: string }).email,
|
|
1146
|
+
subject: "Welcome!",
|
|
1147
|
+
body: "Thanks for signing up — we'll check in soon.",
|
|
1148
|
+
mode: "deferred",
|
|
1149
|
+
}),
|
|
1150
|
+
|
|
1151
|
+
r.step.wait({ for: "P1D" }),
|
|
1152
|
+
|
|
1153
|
+
// Engagement gate: demo-only inline flag. In a production sample this
|
|
1154
|
+
// would be `r.step.read.findOne("user", { ... })` followed by a
|
|
1155
|
+
// branch on `lastSeenAt`; deferred to the M.5 read-bridge.
|
|
1156
|
+
r.step.compute("engaged", (ctx) => {
|
|
1157
|
+
const userId = (ctx.event.payload as { userId: string }).userId;
|
|
1158
|
+
return userId.startsWith("active-");
|
|
1159
|
+
}),
|
|
1160
|
+
|
|
1161
|
+
r.step.branch({
|
|
1162
|
+
if: (ctx) => (ctx.steps["engaged"] as boolean) === true,
|
|
1163
|
+
onTrue: [
|
|
1164
|
+
r.step.mail.send({
|
|
1165
|
+
to: (ctx) => (ctx.event.payload as { email: string }).email,
|
|
1166
|
+
subject: "Engagement tips",
|
|
1167
|
+
body: "You're off to a great start — try these next.",
|
|
1168
|
+
mode: "deferred",
|
|
1169
|
+
}),
|
|
1170
|
+
],
|
|
1171
|
+
onFalse: [
|
|
1172
|
+
r.step.mail.send({
|
|
1173
|
+
to: (ctx) => (ctx.event.payload as { email: string }).email,
|
|
1174
|
+
subject: "Getting started",
|
|
1175
|
+
body: "Need a hand? Here's how to begin.",
|
|
1176
|
+
mode: "deferred",
|
|
1177
|
+
}),
|
|
1178
|
+
],
|
|
1179
|
+
}),
|
|
1180
|
+
|
|
1181
|
+
r.step.wait({ for: "P7D" }),
|
|
1182
|
+
|
|
1183
|
+
r.step.webhook.send({
|
|
1184
|
+
url: "https://hooks.example.com/crm-onboarding",
|
|
1185
|
+
method: "POST",
|
|
1186
|
+
body: (ctx: PipelineCtx) => ({
|
|
1187
|
+
userId: (ctx.event.payload as { userId: string }).userId,
|
|
1188
|
+
stage: "day8",
|
|
1189
|
+
}),
|
|
1190
|
+
mode: "deferred",
|
|
1191
|
+
}),
|
|
1192
|
+
|
|
1193
|
+
r.step.return({ isSuccess: true, data: undefined }),
|
|
1194
|
+
]),
|
|
1195
|
+
});
|
|
1196
|
+
|
|
1197
|
+
/**
|
|
1198
|
+
* Webhook-delivery workflow: one deferred webhook per `data.processed` event.
|
|
1199
|
+
* `webhook.send` only enqueues a dispatch request; the step-dispatcher
|
|
1200
|
+
* delivers it once and a failed delivery ends as `step.dispatch-failed`. A
|
|
1201
|
+
* `retry` around it would never fire, because the step itself cannot throw
|
|
1202
|
+
* on a delivery error.
|
|
1203
|
+
*/
|
|
1204
|
+
export const webhookDeliveryWorkflow: WorkflowDefinition<
|
|
1205
|
+
{ data: unknown; webhookUrl: string },
|
|
1206
|
+
void
|
|
1207
|
+
> = defineWorkflow({
|
|
1208
|
+
name: "webhook-delivery",
|
|
1209
|
+
trigger: { kind: "event", eventType: "data.processed" },
|
|
1210
|
+
|
|
1211
|
+
steps: stepsPipeline<{ data: unknown; webhookUrl: string }, void>(({ r }) => [
|
|
1212
|
+
r.step.webhook.send({
|
|
1213
|
+
url: (ctx: PipelineCtx) => (ctx.event.payload as { webhookUrl: string }).webhookUrl,
|
|
1214
|
+
body: (ctx: PipelineCtx) => (ctx.event.payload as { data: unknown }).data,
|
|
1215
|
+
mode: "deferred",
|
|
1216
|
+
}),
|
|
1217
|
+
r.step.return({ isSuccess: true, data: undefined }),
|
|
1218
|
+
]),
|
|
1219
|
+
});
|
|
1220
|
+
|
|
1221
|
+
/**
|
|
1222
|
+
* Daily-report workflow: cron-triggered, single deferred webhook.
|
|
1223
|
+
*/
|
|
1224
|
+
export const dailyReportWorkflow: WorkflowDefinition<void, void> = defineWorkflow({
|
|
1225
|
+
name: "daily-report",
|
|
1226
|
+
trigger: { kind: "cron", schedule: "0 9 * * *" },
|
|
1227
|
+
|
|
1228
|
+
steps: stepsPipeline<void, void>(({ r }) => [
|
|
1229
|
+
r.step.webhook.send({
|
|
1230
|
+
url: "https://hooks.example.com/daily-report",
|
|
1231
|
+
body: () => ({ ts: Temporal.Now.instant().toString() }),
|
|
1232
|
+
mode: "deferred",
|
|
1233
|
+
}),
|
|
1234
|
+
r.step.return({ isSuccess: true, data: undefined }),
|
|
1235
|
+
]),
|
|
1236
|
+
});
|
|
1237
|
+
|
|
1238
|
+
/**
|
|
1239
|
+
* Workflow-engine feature registers MSP subscriptions for every event-
|
|
1240
|
+
* triggered workflow. Cron-triggered workflows are picked up by the
|
|
1241
|
+
* cron-scheduler.tick() in production wiring; the feature itself doesn't
|
|
1242
|
+
* own them.
|
|
1243
|
+
*/
|
|
1244
|
+
export const workflowEngineFeature = defineFeature("workflowEngine", (r) => {
|
|
1245
|
+
// @cast-boundary workflow-payload-variance — registerEventTrigger's
|
|
1246
|
+
// WorkflowDefinition<unknown> can't directly accept WorkflowDefinition<{...}>
|
|
1247
|
+
// because the embedded pipeline closure is contravariant in TPayload.
|
|
1248
|
+
// The runtime only touches trigger/name/idempotencyKey + executes
|
|
1249
|
+
// the closure with the real event payload — payload-agnostic.
|
|
1250
|
+
registerEventTrigger(r, userOnboardingWorkflow as unknown as WorkflowDefinition);
|
|
1251
|
+
registerEventTrigger(r, webhookDeliveryWorkflow as unknown as WorkflowDefinition);
|
|
1252
|
+
// dailyReportWorkflow is cron-triggered — skip MSP registration
|
|
1253
|
+
});
|
|
1254
|
+
|
|
1255
|
+
/**
|
|
1256
|
+
* Compat-Export — the unit-test file references this. It's just the
|
|
1257
|
+
* onboarding workflow's pipelineDef pulled out so the test can assert
|
|
1258
|
+
* its shape independently from the workflow definition.
|
|
1259
|
+
*/
|
|
1260
|
+
export const userOnboardingSteps = userOnboardingWorkflow.pipelineDef;
|
|
1261
|
+
```
|
|
1262
|
+
|
|
1263
|
+
### src/postgres-resume-loop.ts
|
|
1264
|
+
|
|
1265
|
+
```ts
|
|
1266
|
+
// postgres-resume-loop — fetchSuspendedRuns implementations.
|
|
1267
|
+
//
|
|
1268
|
+
// Two flavours:
|
|
1269
|
+
// 1. createSuspendedRunFetcher(db, workflowRegistry) — Postgres-backed
|
|
1270
|
+
// 2. createInMemorySuspendedRunFetcher(runs) — for unit tests
|
|
1271
|
+
//
|
|
1272
|
+
// The fetcher reads the Q7 fingerprint from the suspension event payload and
|
|
1273
|
+
// re-loads the trigger event from the event store through the
|
|
1274
|
+
// triggerEventRef on the run's own run.started event (no payload copy lives
|
|
1275
|
+
// on the run stream).
|
|
1276
|
+
|
|
1277
|
+
import type { DbRunner } from "@cosmicdrift/kumiko-framework/db";
|
|
1278
|
+
import type { TenantId, WorkflowDefinition } from "@cosmicdrift/kumiko-framework/engine";
|
|
1279
|
+
import {
|
|
1280
|
+
WORKFLOW_RETRY_SCHEDULED_TYPE,
|
|
1281
|
+
WORKFLOW_RUN_STARTED_TYPE,
|
|
1282
|
+
WORKFLOW_WAITING_FOR_EVENT_TYPE,
|
|
1283
|
+
WORKFLOW_WAITING_TYPE,
|
|
1284
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
1285
|
+
import { loadAggregate } from "@cosmicdrift/kumiko-framework/event-store";
|
|
1286
|
+
import { selectExpiredSuspensionEvents } from "./db/queries/suspended-runs";
|
|
1287
|
+
import type { SuspendableRun } from "./resume-loop";
|
|
1288
|
+
import type { TriggerEventRef } from "./workflow-runner";
|
|
1289
|
+
|
|
1290
|
+
export type WorkflowRegistry = ReadonlyMap<string, WorkflowDefinition>;
|
|
1291
|
+
|
|
1292
|
+
const SUSPEND_EVENT_TYPES = [
|
|
1293
|
+
WORKFLOW_WAITING_TYPE,
|
|
1294
|
+
WORKFLOW_WAITING_FOR_EVENT_TYPE,
|
|
1295
|
+
WORKFLOW_RETRY_SCHEDULED_TYPE,
|
|
1296
|
+
] as const;
|
|
1297
|
+
|
|
1298
|
+
function isTriggerEventRef(value: unknown): value is TriggerEventRef {
|
|
1299
|
+
return (
|
|
1300
|
+
typeof value === "object" &&
|
|
1301
|
+
value !== null &&
|
|
1302
|
+
"eventId" in value &&
|
|
1303
|
+
typeof value.eventId === "string" &&
|
|
1304
|
+
"aggregateId" in value &&
|
|
1305
|
+
typeof value.aggregateId === "string" &&
|
|
1306
|
+
"version" in value &&
|
|
1307
|
+
typeof value.version === "number"
|
|
1308
|
+
);
|
|
1309
|
+
}
|
|
1310
|
+
|
|
1311
|
+
// undefined when the run has no usable reference or the trigger event can no
|
|
1312
|
+
// longer be loaded (archived stream): the run is skipped, never resumed on
|
|
1313
|
+
// made-up data.
|
|
1314
|
+
async function loadTriggerEvent(
|
|
1315
|
+
db: DbRunner,
|
|
1316
|
+
tenantId: TenantId,
|
|
1317
|
+
runId: string,
|
|
1318
|
+
): Promise<{ aggregateId: string; type: string; payload: unknown } | undefined> {
|
|
1319
|
+
const runEvents = await loadAggregate(db, runId, tenantId);
|
|
1320
|
+
const started = runEvents.find((e) => e.type === WORKFLOW_RUN_STARTED_TYPE);
|
|
1321
|
+
if (!started) return undefined;
|
|
1322
|
+
const ref = started.payload["triggerEventRef"];
|
|
1323
|
+
if (ref === undefined) {
|
|
1324
|
+
// Cron run: the synthetic trigger event was never stored.
|
|
1325
|
+
return { aggregateId: runId, type: String(started.payload["triggerEventType"]), payload: {} };
|
|
1326
|
+
}
|
|
1327
|
+
if (!isTriggerEventRef(ref)) return undefined;
|
|
1328
|
+
const source = (await loadAggregate(db, ref.aggregateId, tenantId)).find(
|
|
1329
|
+
(e) => e.id === ref.eventId,
|
|
1330
|
+
);
|
|
1331
|
+
return source && { aggregateId: source.aggregateId, type: source.type, payload: source.payload };
|
|
1332
|
+
}
|
|
1333
|
+
|
|
1334
|
+
/**
|
|
1335
|
+
* Postgres-backed fetcher. Queries kumiko_events for suspension events with
|
|
1336
|
+
* expired wakeAt/timeoutAt timestamps. The resume-loop applies the Q7
|
|
1337
|
+
* fingerprint check + concurrency-claim per row.
|
|
1338
|
+
*
|
|
1339
|
+
* Known limitation (M.4 followup): the WHERE clause currently picks up
|
|
1340
|
+
* every WAITING-row whose wakeAt has expired, including those that have
|
|
1341
|
+
* already been resumed. The resume-loop silently skips them via the
|
|
1342
|
+
* VersionConflictError path on the RESUMED-claim. A future optimisation
|
|
1343
|
+
* is the workflow_run_pending read-side projection (Plan-Doc Sample 2,
|
|
1344
|
+
* "Resume-Loop" section).
|
|
1345
|
+
*/
|
|
1346
|
+
export function createSuspendedRunFetcher(
|
|
1347
|
+
db: DbRunner,
|
|
1348
|
+
workflowRegistry: WorkflowRegistry,
|
|
1349
|
+
): () => Promise<SuspendableRun[]> {
|
|
1350
|
+
return async () => {
|
|
1351
|
+
const rows = await selectExpiredSuspensionEvents(db, SUSPEND_EVENT_TYPES);
|
|
1352
|
+
|
|
1353
|
+
const results: SuspendableRun[] = [];
|
|
1354
|
+
|
|
1355
|
+
for (const row of rows) {
|
|
1356
|
+
const aggregateId = (row["aggregateId"] ?? row["aggregate_id"]) as string;
|
|
1357
|
+
const type = row["type"] as string;
|
|
1358
|
+
const payload = row["payload"] as Record<string, unknown>;
|
|
1359
|
+
|
|
1360
|
+
const workflowName = payload["workflowName"] as string | undefined;
|
|
1361
|
+
if (!workflowName) continue;
|
|
1362
|
+
|
|
1363
|
+
const workflow = workflowRegistry.get(workflowName);
|
|
1364
|
+
if (!workflow) continue;
|
|
1365
|
+
|
|
1366
|
+
const stepIndex = payload["stepIndex"] as number | undefined;
|
|
1367
|
+
if (stepIndex === undefined) continue;
|
|
1368
|
+
|
|
1369
|
+
const wakeAt = (payload["wakeAt"] ?? payload["timeoutAt"]) as string | undefined;
|
|
1370
|
+
if (!wakeAt) continue;
|
|
1371
|
+
|
|
1372
|
+
const triggerEvent = await loadTriggerEvent(
|
|
1373
|
+
db,
|
|
1374
|
+
(row["tenantId"] ?? row["tenant_id"]) as TenantId, // @cast-boundary raw-sql-row
|
|
1375
|
+
aggregateId,
|
|
1376
|
+
);
|
|
1377
|
+
if (!triggerEvent) continue;
|
|
1378
|
+
|
|
1379
|
+
results.push({
|
|
1380
|
+
runId: aggregateId,
|
|
1381
|
+
workflowName,
|
|
1382
|
+
stepIndex,
|
|
1383
|
+
wakeAt,
|
|
1384
|
+
retryAttempt: payload["attempt"] as number | undefined,
|
|
1385
|
+
suspensionEventType: type,
|
|
1386
|
+
workflow,
|
|
1387
|
+
triggerEvent,
|
|
1388
|
+
definitionFingerprint: payload["definitionFingerprint"] as string | undefined,
|
|
1389
|
+
});
|
|
1390
|
+
}
|
|
1391
|
+
|
|
1392
|
+
return results;
|
|
1393
|
+
};
|
|
1394
|
+
}
|
|
1395
|
+
|
|
1396
|
+
/**
|
|
1397
|
+
* In-memory fetchSuspendedRuns for unit testing. Returns a fresh copy of
|
|
1398
|
+
* runs on each call.
|
|
1399
|
+
*/
|
|
1400
|
+
export function createInMemorySuspendedRunFetcher(
|
|
1401
|
+
runs: readonly SuspendableRun[],
|
|
1402
|
+
): () => Promise<SuspendableRun[]> {
|
|
1403
|
+
return async () => runs.map((r) => ({ ...r }));
|
|
1404
|
+
}
|
|
1405
|
+
```
|
|
1406
|
+
|
|
1407
|
+
### src/resume-loop.ts
|
|
1408
|
+
|
|
1409
|
+
```ts
|
|
1410
|
+
// resume-loop — polls for suspended workflow runs with expired wake times
|
|
1411
|
+
// and re-enters the pipeline at the appropriate step.
|
|
1412
|
+
//
|
|
1413
|
+
// The Resume-Loop is the runtime that makes Tier-3 steps work:
|
|
1414
|
+
// 1. Reads the event store for WORKFLOW_WAITING_TYPE events
|
|
1415
|
+
// 2. Filters on `wakeAt < now()` — runs whose wait period has expired
|
|
1416
|
+
// 3. Writes a WORKFLOW_RESUMED_TYPE event to the run's stream
|
|
1417
|
+
// 4. Re-executes the pipeline from the suspended step + 1
|
|
1418
|
+
//
|
|
1419
|
+
// For WORKFLOW_RETRY_SCHEDULED_TYPE events:
|
|
1420
|
+
// 1. Matches step with expired backoff
|
|
1421
|
+
// 2. Writes a resumed event with retryAttempt = previous + 1
|
|
1422
|
+
// 3. Re-executes the pipeline at the same step index
|
|
1423
|
+
//
|
|
1424
|
+
// The loop is designed to run as a Bun worker / cron job (e.g. every 30s).
|
|
1425
|
+
|
|
1426
|
+
import type { HandlerContext, WorkflowDefinition } from "@cosmicdrift/kumiko-framework/engine";
|
|
1427
|
+
import {
|
|
1428
|
+
buildPipelineSteps,
|
|
1429
|
+
computeDefinitionFingerprint,
|
|
1430
|
+
describeWorkflowStepError,
|
|
1431
|
+
runStepList,
|
|
1432
|
+
WORKFLOW_AGGREGATE_TYPE,
|
|
1433
|
+
WORKFLOW_RESUMED_TYPE,
|
|
1434
|
+
WORKFLOW_RETRY_SCHEDULED_TYPE,
|
|
1435
|
+
WORKFLOW_RUN_COMPLETED_TYPE,
|
|
1436
|
+
WORKFLOW_RUN_FAILED_TYPE,
|
|
1437
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
1438
|
+
import { VersionConflictError } from "@cosmicdrift/kumiko-framework/event-store";
|
|
1439
|
+
import { createFallbackLogger } from "@cosmicdrift/kumiko-framework/logging";
|
|
1440
|
+
|
|
1441
|
+
const log = createFallbackLogger("workflow-runner");
|
|
1442
|
+
|
|
1443
|
+
export type SuspendableRun = {
|
|
1444
|
+
runId: string;
|
|
1445
|
+
workflowName: string;
|
|
1446
|
+
stepIndex: number;
|
|
1447
|
+
wakeAt: string;
|
|
1448
|
+
/** Previous retry attempt count — set by the retry step's suspension event. */
|
|
1449
|
+
retryAttempt?: number;
|
|
1450
|
+
/** The event type that caused the suspension — used by the resume-loop
|
|
1451
|
+
* to decide whether to skip or re-execute the suspended step. */
|
|
1452
|
+
suspensionEventType: string;
|
|
1453
|
+
/** The workflow the suspension belongs to — resolved by the fetcher from
|
|
1454
|
+
* the registry. The resume-loop validates its current fingerprint
|
|
1455
|
+
* against `definitionFingerprint` below before resuming. */
|
|
1456
|
+
workflow: WorkflowDefinition;
|
|
1457
|
+
/** The original trigger event payload captured at run.started time
|
|
1458
|
+
* (Q7 Snapshot-at-Start). Re-fed into the pipeline on resume so
|
|
1459
|
+
* resolvers that read `event.payload` see what they saw originally. */
|
|
1460
|
+
triggerEvent: unknown;
|
|
1461
|
+
/** Fingerprint stamped on the suspension event when the run started.
|
|
1462
|
+
* Resume-loop compares this against the current workflow definition's
|
|
1463
|
+
* fingerprint; a mismatch surfaces as RUN_FAILED with the reason
|
|
1464
|
+
* `workflow_definition_changed`. */
|
|
1465
|
+
definitionFingerprint?: string;
|
|
1466
|
+
};
|
|
1467
|
+
|
|
1468
|
+
export class WorkflowDefinitionChangedError extends Error {
|
|
1469
|
+
constructor(
|
|
1470
|
+
readonly runId: string,
|
|
1471
|
+
readonly workflowName: string,
|
|
1472
|
+
readonly expectedFingerprint: string,
|
|
1473
|
+
readonly currentFingerprint: string,
|
|
1474
|
+
) {
|
|
1475
|
+
super(
|
|
1476
|
+
`Workflow "${workflowName}" definition changed since run ${runId} started ` +
|
|
1477
|
+
`(expected ${expectedFingerprint.slice(0, 12)}…, current ${currentFingerprint.slice(0, 12)}…). ` +
|
|
1478
|
+
`Cancel the run or wait for it to complete before deploying definition changes.`,
|
|
1479
|
+
);
|
|
1480
|
+
this.name = "WorkflowDefinitionChangedError";
|
|
1481
|
+
}
|
|
1482
|
+
}
|
|
1483
|
+
|
|
1484
|
+
/**
|
|
1485
|
+
* Poll for suspended runs with expired wake times and resume them.
|
|
1486
|
+
* Called periodically (e.g. every 30s) by a scheduler or Bun.Timer.
|
|
1487
|
+
*
|
|
1488
|
+
* @param fetchSuspendedRuns - async function that reads the event store
|
|
1489
|
+
* for WORKFLOW_WAITING_TYPE events where wakeAt < now()
|
|
1490
|
+
* @param handlerCtx - the HandlerContext to pass to the pipeline
|
|
1491
|
+
* @returns the number of runs resumed
|
|
1492
|
+
*/
|
|
1493
|
+
export async function runResumeLoop(
|
|
1494
|
+
fetchSuspendedRuns: () => Promise<SuspendableRun[]>,
|
|
1495
|
+
handlerCtx: HandlerContext,
|
|
1496
|
+
): Promise<number> {
|
|
1497
|
+
const suspended = await fetchSuspendedRuns();
|
|
1498
|
+
let resumed = 0;
|
|
1499
|
+
|
|
1500
|
+
for (const run of suspended) {
|
|
1501
|
+
// Q7 fingerprint check FIRST — before we claim the run, before any
|
|
1502
|
+
// expensive work, before we burn a stream-version. If the workflow
|
|
1503
|
+
// definition changed since the run started, fail loud (do NOT silent-
|
|
1504
|
+
// skip — a stale stuck run is worse than an explicit failure).
|
|
1505
|
+
if (run.definitionFingerprint !== undefined) {
|
|
1506
|
+
const currentFingerprint = computeDefinitionFingerprint(run.workflow);
|
|
1507
|
+
if (currentFingerprint !== run.definitionFingerprint) {
|
|
1508
|
+
await handlerCtx.unsafeAppendEvent({
|
|
1509
|
+
aggregateId: run.runId,
|
|
1510
|
+
aggregateType: WORKFLOW_AGGREGATE_TYPE,
|
|
1511
|
+
type: WORKFLOW_RUN_FAILED_TYPE,
|
|
1512
|
+
payload: {
|
|
1513
|
+
workflowName: run.workflowName,
|
|
1514
|
+
stepIndex: run.stepIndex,
|
|
1515
|
+
error: new WorkflowDefinitionChangedError(
|
|
1516
|
+
run.runId,
|
|
1517
|
+
run.workflowName,
|
|
1518
|
+
run.definitionFingerprint,
|
|
1519
|
+
currentFingerprint,
|
|
1520
|
+
).message,
|
|
1521
|
+
reason: "workflow_definition_changed",
|
|
1522
|
+
},
|
|
1523
|
+
});
|
|
1524
|
+
continue;
|
|
1525
|
+
}
|
|
1526
|
+
}
|
|
1527
|
+
|
|
1528
|
+
// Resume-claim: append WORKFLOW_RESUMED first. The event-store appends
|
|
1529
|
+
// against expectedVersion = current stream-version; a competing
|
|
1530
|
+
// resume-loop instance racing the same run loses the UNIQUE
|
|
1531
|
+
// (tenant_id, aggregate_id, version) check and gets a
|
|
1532
|
+
// VersionConflictError. That's the concurrency-guard — silent skip,
|
|
1533
|
+
// no FAILED-event. Stale WAITING-rows in the fetcher result also
|
|
1534
|
+
// hit this branch on the second poll-tick.
|
|
1535
|
+
try {
|
|
1536
|
+
await handlerCtx.unsafeAppendEvent({
|
|
1537
|
+
aggregateId: run.runId,
|
|
1538
|
+
aggregateType: WORKFLOW_AGGREGATE_TYPE,
|
|
1539
|
+
type: WORKFLOW_RESUMED_TYPE,
|
|
1540
|
+
payload: {
|
|
1541
|
+
stepIndex: run.stepIndex,
|
|
1542
|
+
retryAttempt: run.retryAttempt,
|
|
1543
|
+
},
|
|
1544
|
+
});
|
|
1545
|
+
} catch (error) {
|
|
1546
|
+
if (error instanceof VersionConflictError) {
|
|
1547
|
+
continue;
|
|
1548
|
+
}
|
|
1549
|
+
throw error;
|
|
1550
|
+
}
|
|
1551
|
+
|
|
1552
|
+
try {
|
|
1553
|
+
const workflowCtx = {
|
|
1554
|
+
runId: run.runId,
|
|
1555
|
+
workflowName: run.workflowName,
|
|
1556
|
+
stepIndex: run.stepIndex,
|
|
1557
|
+
...(run.retryAttempt ? { retryAttempt: run.retryAttempt + 1 } : {}),
|
|
1558
|
+
...(run.definitionFingerprint && {
|
|
1559
|
+
definitionFingerprint: run.definitionFingerprint,
|
|
1560
|
+
}),
|
|
1561
|
+
};
|
|
1562
|
+
|
|
1563
|
+
// runStepList skip semantics: indices `i < resumeFrom` are skipped,
|
|
1564
|
+
// `i >= resumeFrom` execute. Wait/waitForEvent already wrote their
|
|
1565
|
+
// suspension event in the original run → resume past them (stepIndex + 1).
|
|
1566
|
+
// Retry re-enters at the same index because the retry step itself
|
|
1567
|
+
// wraps the sub-pipeline that must run again.
|
|
1568
|
+
const resumeFrom =
|
|
1569
|
+
run.suspensionEventType === WORKFLOW_RETRY_SCHEDULED_TYPE
|
|
1570
|
+
? run.stepIndex
|
|
1571
|
+
: run.stepIndex + 1;
|
|
1572
|
+
|
|
1573
|
+
const steps = buildPipelineSteps(run.workflow.pipelineDef, run.triggerEvent as never);
|
|
1574
|
+
const outcome = await runStepList(
|
|
1575
|
+
steps,
|
|
1576
|
+
run.triggerEvent as never,
|
|
1577
|
+
handlerCtx,
|
|
1578
|
+
{},
|
|
1579
|
+
{},
|
|
1580
|
+
workflowCtx,
|
|
1581
|
+
resumeFrom,
|
|
1582
|
+
);
|
|
1583
|
+
|
|
1584
|
+
// Only write run-completed when the pipeline actually completed or
|
|
1585
|
+
// returned. A second suspension (e.g. `wait` further down the
|
|
1586
|
+
// pipeline) leaves the run pending for the next resume-loop tick.
|
|
1587
|
+
if (outcome.kind !== "suspended") {
|
|
1588
|
+
await handlerCtx.unsafeAppendEvent({
|
|
1589
|
+
aggregateId: run.runId,
|
|
1590
|
+
aggregateType: WORKFLOW_AGGREGATE_TYPE,
|
|
1591
|
+
type: WORKFLOW_RUN_COMPLETED_TYPE,
|
|
1592
|
+
payload: { stepIndex: run.stepIndex },
|
|
1593
|
+
});
|
|
1594
|
+
}
|
|
1595
|
+
|
|
1596
|
+
resumed++;
|
|
1597
|
+
} catch (error) {
|
|
1598
|
+
// The failed event keeps only a short description; log the raw error so
|
|
1599
|
+
// the cause stays diagnosable.
|
|
1600
|
+
log.warn("workflow run failed", {
|
|
1601
|
+
runId: run.runId,
|
|
1602
|
+
stepIndex: run.stepIndex,
|
|
1603
|
+
error: String(error),
|
|
1604
|
+
});
|
|
1605
|
+
await handlerCtx.unsafeAppendEvent({
|
|
1606
|
+
aggregateId: run.runId,
|
|
1607
|
+
aggregateType: WORKFLOW_AGGREGATE_TYPE,
|
|
1608
|
+
type: WORKFLOW_RUN_FAILED_TYPE,
|
|
1609
|
+
payload: {
|
|
1610
|
+
stepIndex: run.stepIndex,
|
|
1611
|
+
error: describeWorkflowStepError(error),
|
|
1612
|
+
},
|
|
1613
|
+
});
|
|
1614
|
+
}
|
|
1615
|
+
}
|
|
1616
|
+
|
|
1617
|
+
return resumed;
|
|
1618
|
+
}
|
|
1619
|
+
```
|
|
1620
|
+
|
|
1621
|
+
### src/workflow-runner.ts
|
|
1622
|
+
|
|
1623
|
+
```ts
|
|
1624
|
+
// workflow-runner — starts a workflow-run by writing run.started and
|
|
1625
|
+
// executing the pipeline until first suspension or completion.
|
|
1626
|
+
//
|
|
1627
|
+
// Q7 (Snapshot-at-Start): the run.started payload carries
|
|
1628
|
+
// `definitionFingerprint` plus the trigger fields the resume-loop needs
|
|
1629
|
+
// to re-hydrate state. The fingerprint travels on into every suspension
|
|
1630
|
+
// event (wait/waitForEvent/retry payloads) so the resume-loop validates
|
|
1631
|
+
// it without a separate per-run lookup.
|
|
1632
|
+
//
|
|
1633
|
+
// On first suspension: just return — the resume-loop wakes the run later.
|
|
1634
|
+
// On full completion (pipeline ran to r.step.return): we write
|
|
1635
|
+
// WORKFLOW_RUN_COMPLETED here. Throws bubble up to the caller (event-
|
|
1636
|
+
// trigger / cron-scheduler) which translates them into RUN_FAILED.
|
|
1637
|
+
|
|
1638
|
+
import type {
|
|
1639
|
+
HandlerContext,
|
|
1640
|
+
WorkflowDefinition,
|
|
1641
|
+
WriteEvent,
|
|
1642
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
1643
|
+
import {
|
|
1644
|
+
buildPipelineSteps,
|
|
1645
|
+
computeDefinitionFingerprint,
|
|
1646
|
+
runStepList,
|
|
1647
|
+
WORKFLOW_AGGREGATE_TYPE,
|
|
1648
|
+
WORKFLOW_RUN_COMPLETED_TYPE,
|
|
1649
|
+
WORKFLOW_RUN_STARTED_TYPE,
|
|
1650
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
1651
|
+
|
|
1652
|
+
// Reference into the event store; the run stream never copies a foreign
|
|
1653
|
+
// payload, so erasing the source event also covers the workflow.
|
|
1654
|
+
export type TriggerEventRef = {
|
|
1655
|
+
readonly eventId: string;
|
|
1656
|
+
readonly aggregateId: string;
|
|
1657
|
+
readonly version: number;
|
|
1658
|
+
};
|
|
1659
|
+
|
|
1660
|
+
export type WorkflowRunStartedPayload = {
|
|
1661
|
+
readonly workflowName: string;
|
|
1662
|
+
readonly triggerEventType: string;
|
|
1663
|
+
// Absent for cron runs: their synthetic trigger event has no stored source.
|
|
1664
|
+
readonly triggerEventRef?: TriggerEventRef;
|
|
1665
|
+
readonly definitionFingerprint: string;
|
|
1666
|
+
readonly idempotencyKey?: string;
|
|
1667
|
+
};
|
|
1668
|
+
|
|
1669
|
+
/**
|
|
1670
|
+
* Start + execute a workflow run.
|
|
1671
|
+
*
|
|
1672
|
+
* 1. Append `workflow.run.started` with the snapshot fingerprint.
|
|
1673
|
+
* 2. Run the pipeline. Tier-3 steps may suspend it via SUSPEND_SENTINEL.
|
|
1674
|
+
* 3. If the pipeline completes without suspension → write
|
|
1675
|
+
* `workflow.run-completed`. If it suspends → leave the resume-loop
|
|
1676
|
+
* to wake it. If it throws → bubble up; the caller writes FAILED.
|
|
1677
|
+
*/
|
|
1678
|
+
export async function startAndRunWorkflow(args: {
|
|
1679
|
+
readonly runId: string;
|
|
1680
|
+
readonly workflow: WorkflowDefinition;
|
|
1681
|
+
readonly triggerEvent: WriteEvent;
|
|
1682
|
+
readonly triggerEventRef?: TriggerEventRef;
|
|
1683
|
+
readonly idempotencyKey?: string;
|
|
1684
|
+
readonly handlerCtx: HandlerContext;
|
|
1685
|
+
}): Promise<{ readonly outcome: "completed" | "suspended" }> {
|
|
1686
|
+
const fingerprint = computeDefinitionFingerprint(args.workflow);
|
|
1687
|
+
|
|
1688
|
+
const startedPayload: WorkflowRunStartedPayload = {
|
|
1689
|
+
workflowName: args.workflow.name,
|
|
1690
|
+
triggerEventType: args.triggerEvent.type,
|
|
1691
|
+
...(args.triggerEventRef && { triggerEventRef: args.triggerEventRef }),
|
|
1692
|
+
definitionFingerprint: fingerprint,
|
|
1693
|
+
...(args.idempotencyKey && { idempotencyKey: args.idempotencyKey }),
|
|
1694
|
+
};
|
|
1695
|
+
|
|
1696
|
+
await args.handlerCtx.unsafeAppendEvent({
|
|
1697
|
+
aggregateId: args.runId,
|
|
1698
|
+
aggregateType: WORKFLOW_AGGREGATE_TYPE,
|
|
1699
|
+
type: WORKFLOW_RUN_STARTED_TYPE,
|
|
1700
|
+
payload: startedPayload as unknown as Record<string, unknown>,
|
|
1701
|
+
});
|
|
1702
|
+
|
|
1703
|
+
const steps = buildPipelineSteps(args.workflow.pipelineDef, args.triggerEvent);
|
|
1704
|
+
|
|
1705
|
+
const outcome = await runStepList(
|
|
1706
|
+
steps,
|
|
1707
|
+
args.triggerEvent,
|
|
1708
|
+
args.handlerCtx,
|
|
1709
|
+
{},
|
|
1710
|
+
{},
|
|
1711
|
+
{
|
|
1712
|
+
runId: args.runId,
|
|
1713
|
+
workflowName: args.workflow.name,
|
|
1714
|
+
stepIndex: 0,
|
|
1715
|
+
definitionFingerprint: fingerprint,
|
|
1716
|
+
},
|
|
1717
|
+
);
|
|
1718
|
+
|
|
1719
|
+
if (outcome.kind === "suspended") {
|
|
1720
|
+
return { outcome: "suspended" };
|
|
1721
|
+
}
|
|
1722
|
+
|
|
1723
|
+
await args.handlerCtx.unsafeAppendEvent({
|
|
1724
|
+
aggregateId: args.runId,
|
|
1725
|
+
aggregateType: WORKFLOW_AGGREGATE_TYPE,
|
|
1726
|
+
type: WORKFLOW_RUN_COMPLETED_TYPE,
|
|
1727
|
+
payload: { workflowName: args.workflow.name },
|
|
1728
|
+
});
|
|
1729
|
+
return { outcome: "completed" };
|
|
1730
|
+
}
|
|
1731
|
+
```
|