@cosmicdrift/kumiko-bundled-features 0.326.0 → 0.327.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +12 -13
- package/src/mail-transport-smtp/changes.json +6 -0
- package/src/step-dispatcher/__tests__/step-dispatcher-pii.integration.test.ts +268 -0
- package/src/step-dispatcher/__tests__/webhook-runner.test.ts +15 -1
- package/src/step-dispatcher/changes.json +6 -0
- package/src/step-dispatcher/feature.ts +208 -33
- package/src/step-dispatcher/webhook-runner.ts +6 -2
- package/src/workflow-runner/__tests__/resume-loop.integration.test.ts +4 -1
- package/src/workflow-runner/__tests__/workflow-runner.integration.test.ts +6 -3
- package/src/workflow-runner/changes.json +8 -1
- package/src/workflow-runner/event-trigger.ts +11 -1
- package/src/workflow-runner/handlers/resume-run.write.ts +6 -1
- package/src/workflow-runner/runner.ts +3 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-bundled-features",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.327.0",
|
|
4
4
|
"description": "Built-in features — tenant, user, auth, delivery. The stuff you'd rewrite anyway, already typed.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -134,23 +134,22 @@
|
|
|
134
134
|
"./workflow-runner": "./src/workflow-runner/index.ts"
|
|
135
135
|
},
|
|
136
136
|
"dependencies": {
|
|
137
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
138
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
139
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
140
|
-
"@cosmicdrift/kumiko-renderer": "0.
|
|
141
|
-
"@cosmicdrift/kumiko-renderer-web": "0.
|
|
142
|
-
"@cosmicdrift/kumiko-types": "0.
|
|
137
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.327.0",
|
|
138
|
+
"@cosmicdrift/kumiko-framework": "0.327.0",
|
|
139
|
+
"@cosmicdrift/kumiko-headless": "0.327.0",
|
|
140
|
+
"@cosmicdrift/kumiko-renderer": "0.327.0",
|
|
141
|
+
"@cosmicdrift/kumiko-renderer-web": "0.327.0",
|
|
142
|
+
"@cosmicdrift/kumiko-types": "0.327.0",
|
|
143
143
|
"@mollie/api-client": "^4.5.0",
|
|
144
144
|
"@node-rs/argon2": "^2.0.2",
|
|
145
145
|
"@types/mailparser": "^3.4.6",
|
|
146
|
-
"@types/nodemailer": "^8.0.0",
|
|
147
146
|
"@types/qrcode": "^1.5.5",
|
|
148
147
|
"clsx": "^2.1.1",
|
|
149
|
-
"imapflow": "^1.
|
|
148
|
+
"imapflow": "^1.7.8",
|
|
150
149
|
"lucide-react": "^1.14.0",
|
|
151
|
-
"mailparser": "^3.9.
|
|
150
|
+
"mailparser": "^3.9.32",
|
|
152
151
|
"marked": "^18.0.3",
|
|
153
|
-
"nodemailer": "^
|
|
152
|
+
"nodemailer": "^10.0.13",
|
|
154
153
|
"qrcode": "^1.5.4",
|
|
155
154
|
"react": "^19.2.6",
|
|
156
155
|
"sharp": "^0.35.4",
|
|
@@ -168,8 +167,8 @@
|
|
|
168
167
|
],
|
|
169
168
|
"devDependencies": {
|
|
170
169
|
"@testing-library/user-event": "^14.6.1",
|
|
171
|
-
"@cosmicdrift/kumiko-locale-de": "0.
|
|
172
|
-
"@cosmicdrift/kumiko-locale-es": "0.
|
|
170
|
+
"@cosmicdrift/kumiko-locale-de": "0.327.0",
|
|
171
|
+
"@cosmicdrift/kumiko-locale-es": "0.327.0",
|
|
173
172
|
"jsqr": "^1.4.0"
|
|
174
173
|
}
|
|
175
174
|
}
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.326.1",
|
|
4
|
+
"type": "fix",
|
|
5
|
+
"title": "Mail stack on patched nodemailer 10, imapflow and mailparser",
|
|
6
|
+
"detail": "Bumps nodemailer to ^10.0.13 (GHSA-v53p-9fqp-m79j, high), mailparser to ^3.9.32 and imapflow to ^1.7.8 so no dependency pins a vulnerable nodemailer 9.x any more. nodemailer 10 ships its own types, so @types/nodemailer is dropped; it requires Node.js >= 20. No API change for kumiko apps."
|
|
7
|
+
},
|
|
2
8
|
{
|
|
3
9
|
"version": "0.320.0",
|
|
4
10
|
"type": "breaking",
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
// step-dispatcher crypto-shredding (fw#2057): the dispatch-requested payload
|
|
2
|
+
// carries recipient/subject/body/url/headers as ciphertext under a
|
|
3
|
+
// per-dispatch record key; the dispatcher decrypts to send, then erases the
|
|
4
|
+
// key once the outcome is recorded — redelivery after that must be a no-op.
|
|
5
|
+
|
|
6
|
+
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, mock, test } from "bun:test";
|
|
7
|
+
import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
8
|
+
import {
|
|
9
|
+
configurePiiSubjectKms,
|
|
10
|
+
decryptPiiValueForSubject,
|
|
11
|
+
InMemoryKmsAdapter,
|
|
12
|
+
isPiiCiphertext,
|
|
13
|
+
KeyErasedError,
|
|
14
|
+
PII_CIPHERTEXT_PREFIX,
|
|
15
|
+
PII_ERASED_SENTINEL,
|
|
16
|
+
} from "@cosmicdrift/kumiko-framework/crypto";
|
|
17
|
+
import { selectMany } from "@cosmicdrift/kumiko-framework/db";
|
|
18
|
+
import {
|
|
19
|
+
defineFeature,
|
|
20
|
+
defineWriteHandler,
|
|
21
|
+
stepsPipeline,
|
|
22
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
23
|
+
import { eventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
24
|
+
import {
|
|
25
|
+
createTestUser,
|
|
26
|
+
resetEventStore,
|
|
27
|
+
setupTestStack,
|
|
28
|
+
type TestStack,
|
|
29
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
30
|
+
import { resetPiiSubjectKmsForTests } from "@cosmicdrift/kumiko-framework/testing";
|
|
31
|
+
import * as z from "zod";
|
|
32
|
+
import { createSecretsFeature } from "../../secrets";
|
|
33
|
+
import {
|
|
34
|
+
createStepDispatcherFeature,
|
|
35
|
+
type MailDispatchResult,
|
|
36
|
+
type MailSpec,
|
|
37
|
+
setMailRunner,
|
|
38
|
+
setWebhookFetch,
|
|
39
|
+
WEBHOOK_ALLOWED_PRIVATE_HOSTS_ENV_VAR,
|
|
40
|
+
} from "../index";
|
|
41
|
+
|
|
42
|
+
const DISPATCH_REQUESTED = "kumiko:system:step.dispatch-requested";
|
|
43
|
+
const DISPATCHED = "kumiko:system:step.dispatched";
|
|
44
|
+
const DISPATCH_FAILED = "kumiko:system:step.dispatch-failed";
|
|
45
|
+
|
|
46
|
+
const probeFeature = defineFeature("step-pii-probe", (r) => {
|
|
47
|
+
r.requires.step("mail.send");
|
|
48
|
+
r.requires.step("webhook.send");
|
|
49
|
+
|
|
50
|
+
r.writeHandler(
|
|
51
|
+
defineWriteHandler({
|
|
52
|
+
name: "notify-mail",
|
|
53
|
+
schema: z.object({ to: z.string(), subject: z.string(), body: z.string() }),
|
|
54
|
+
access: { roles: ["Admin"] },
|
|
55
|
+
perform: stepsPipeline<{ to: string; subject: string; body: string }, { ok: true }>(
|
|
56
|
+
({ event, r }) => [
|
|
57
|
+
r.step.mail.send({
|
|
58
|
+
to: () => event.payload.to,
|
|
59
|
+
subject: () => event.payload.subject,
|
|
60
|
+
body: () => event.payload.body,
|
|
61
|
+
mode: "deferred",
|
|
62
|
+
}),
|
|
63
|
+
r.step.return(() => ({ isSuccess: true as const, data: { ok: true as const } })),
|
|
64
|
+
],
|
|
65
|
+
),
|
|
66
|
+
}),
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
r.writeHandler(
|
|
70
|
+
defineWriteHandler({
|
|
71
|
+
name: "notify-webhook",
|
|
72
|
+
schema: z.object({ url: z.string(), token: z.string() }),
|
|
73
|
+
access: { roles: ["Admin"] },
|
|
74
|
+
perform: stepsPipeline<{ url: string; token: string }, { ok: true }>(({ event, r }) => [
|
|
75
|
+
r.step.webhook.send({
|
|
76
|
+
url: () => event.payload.url,
|
|
77
|
+
headers: () => ({ "x-probe-token": event.payload.token }),
|
|
78
|
+
body: () => ({ secretNote: "webhook-body-secret" }),
|
|
79
|
+
mode: "deferred",
|
|
80
|
+
}),
|
|
81
|
+
r.step.return(() => ({ isSuccess: true as const, data: { ok: true as const } })),
|
|
82
|
+
]),
|
|
83
|
+
}),
|
|
84
|
+
);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
const admin = createTestUser({ roles: ["Admin"] });
|
|
88
|
+
const fetchMock = mock<typeof fetch>();
|
|
89
|
+
const mailMock = mock<(spec: MailSpec) => Promise<MailDispatchResult>>();
|
|
90
|
+
const originalAllowedPrivateHostsEnv = process.env[WEBHOOK_ALLOWED_PRIVATE_HOSTS_ENV_VAR];
|
|
91
|
+
|
|
92
|
+
let stack: TestStack;
|
|
93
|
+
let kms: InMemoryKmsAdapter;
|
|
94
|
+
|
|
95
|
+
type EventRow = { aggregateId: string; payload: Record<string, unknown> };
|
|
96
|
+
|
|
97
|
+
async function eventsOfType(type: string): Promise<EventRow[]> {
|
|
98
|
+
return (await selectMany(stack.db, eventsTable, { type })) as unknown as EventRow[];
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async function drain(): Promise<void> {
|
|
102
|
+
await stack.eventDispatcher?.runOnce();
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function requestedRow(): Promise<EventRow> {
|
|
106
|
+
const rows = await eventsOfType(DISPATCH_REQUESTED);
|
|
107
|
+
expect(rows).toHaveLength(1);
|
|
108
|
+
return rows[0]!;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function expectKeyErased(aggregateId: string): Promise<void> {
|
|
112
|
+
await expect(
|
|
113
|
+
kms.getKey({ kind: "record", entity: "step-dispatch", id: aggregateId }),
|
|
114
|
+
).rejects.toBeInstanceOf(KeyErasedError);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
beforeAll(async () => {
|
|
118
|
+
// hooks.example is a placeholder host — operator allowlist instead of DNS.
|
|
119
|
+
process.env[WEBHOOK_ALLOWED_PRIVATE_HOSTS_ENV_VAR] = "hooks.example";
|
|
120
|
+
setWebhookFetch(fetchMock as unknown as typeof fetch);
|
|
121
|
+
setMailRunner(async (spec: MailSpec) => mailMock(spec));
|
|
122
|
+
stack = await setupTestStack({
|
|
123
|
+
features: [createStepDispatcherFeature(), createSecretsFeature(), probeFeature],
|
|
124
|
+
systemHooks: [],
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
afterAll(async () => {
|
|
129
|
+
if (originalAllowedPrivateHostsEnv === undefined) {
|
|
130
|
+
delete process.env[WEBHOOK_ALLOWED_PRIVATE_HOSTS_ENV_VAR];
|
|
131
|
+
} else {
|
|
132
|
+
process.env[WEBHOOK_ALLOWED_PRIVATE_HOSTS_ENV_VAR] = originalAllowedPrivateHostsEnv;
|
|
133
|
+
}
|
|
134
|
+
await stack.cleanup();
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
beforeEach(async () => {
|
|
138
|
+
fetchMock.mockReset();
|
|
139
|
+
mailMock.mockReset();
|
|
140
|
+
mailMock.mockResolvedValue({ ok: true, status: 202 });
|
|
141
|
+
kms = new InMemoryKmsAdapter();
|
|
142
|
+
configurePiiSubjectKms(kms);
|
|
143
|
+
await resetEventStore(stack, []);
|
|
144
|
+
await stack.redis.flushNamespace();
|
|
145
|
+
await stack.eventDispatcher?.ensureRegistered();
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
afterEach(() => {
|
|
149
|
+
resetPiiSubjectKmsForTests();
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
const MAIL_INPUT = { to: "ops@example.com", subject: "Server down", body: "Disk is full" };
|
|
153
|
+
|
|
154
|
+
describe("step-dispatcher payload crypto-shredding", () => {
|
|
155
|
+
test("mail.send: ciphertext at rest, plaintext to the runner, key erased after dispatched", async () => {
|
|
156
|
+
await stack.http.writeOk("step-pii-probe:write:notify-mail", MAIL_INPUT, admin);
|
|
157
|
+
|
|
158
|
+
const requested = await requestedRow();
|
|
159
|
+
const keyPrefix = `${PII_CIPHERTEXT_PREFIX}record:step-dispatch:${requested.aggregateId}:`;
|
|
160
|
+
for (const field of ["to", "subject", "body"]) {
|
|
161
|
+
const stored = String(requested.payload[field]);
|
|
162
|
+
expect(stored.startsWith(keyPrefix)).toBe(true);
|
|
163
|
+
}
|
|
164
|
+
expect(JSON.stringify(requested.payload)).not.toContain("ops@example.com");
|
|
165
|
+
expect(JSON.stringify(requested.payload)).not.toContain("Disk is full");
|
|
166
|
+
|
|
167
|
+
await drain();
|
|
168
|
+
|
|
169
|
+
expect(mailMock).toHaveBeenCalledTimes(1);
|
|
170
|
+
expect(mailMock).toHaveBeenCalledWith({
|
|
171
|
+
to: "ops@example.com",
|
|
172
|
+
subject: "Server down",
|
|
173
|
+
body: "Disk is full",
|
|
174
|
+
});
|
|
175
|
+
expect(await eventsOfType(DISPATCHED)).toHaveLength(1);
|
|
176
|
+
await expectKeyErased(requested.aggregateId);
|
|
177
|
+
const afterErase = await decryptPiiValueForSubject(
|
|
178
|
+
kms,
|
|
179
|
+
String(requested.payload["to"]),
|
|
180
|
+
{ requestId: "test" },
|
|
181
|
+
"to",
|
|
182
|
+
);
|
|
183
|
+
expect(afterErase).toBe(PII_ERASED_SENTINEL);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
test("failed delivery erases the key too", async () => {
|
|
187
|
+
mailMock.mockResolvedValue({ ok: false, error: "550 <ops@example.com> rejected" });
|
|
188
|
+
await stack.http.writeOk("step-pii-probe:write:notify-mail", MAIL_INPUT, admin);
|
|
189
|
+
const requested = await requestedRow();
|
|
190
|
+
|
|
191
|
+
await drain();
|
|
192
|
+
|
|
193
|
+
const failed = await eventsOfType(DISPATCH_FAILED);
|
|
194
|
+
expect(failed).toHaveLength(1);
|
|
195
|
+
expect(failed[0]?.payload["error"]).toBe("mail delivery failed");
|
|
196
|
+
expect(JSON.stringify(failed[0]?.payload)).not.toContain("ops@example.com");
|
|
197
|
+
await expectKeyErased(requested.aggregateId);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
test("redelivery after the erase neither re-sends nor throws nor records a second outcome", async () => {
|
|
201
|
+
await stack.http.writeOk("step-pii-probe:write:notify-mail", MAIL_INPUT, admin);
|
|
202
|
+
await drain();
|
|
203
|
+
expect(mailMock).toHaveBeenCalledTimes(1);
|
|
204
|
+
|
|
205
|
+
await asRawClient(stack.db).unsafe(
|
|
206
|
+
`UPDATE kumiko_event_consumers SET last_processed_event_id = 0 WHERE name LIKE '%step-dispatcher%'`,
|
|
207
|
+
);
|
|
208
|
+
await drain();
|
|
209
|
+
|
|
210
|
+
expect(mailMock).toHaveBeenCalledTimes(1);
|
|
211
|
+
expect(await eventsOfType(DISPATCHED)).toHaveLength(1);
|
|
212
|
+
expect(await eventsOfType(DISPATCH_FAILED)).toHaveLength(0);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
test("a key erased before any outcome ends as a generic dispatch-failed, without sending", async () => {
|
|
216
|
+
await stack.http.writeOk("step-pii-probe:write:notify-mail", MAIL_INPUT, admin);
|
|
217
|
+
const requested = await requestedRow();
|
|
218
|
+
await kms.eraseKey({ kind: "record", entity: "step-dispatch", id: requested.aggregateId });
|
|
219
|
+
|
|
220
|
+
await drain();
|
|
221
|
+
|
|
222
|
+
expect(mailMock).not.toHaveBeenCalled();
|
|
223
|
+
const failed = await eventsOfType(DISPATCH_FAILED);
|
|
224
|
+
expect(failed).toHaveLength(1);
|
|
225
|
+
expect(failed[0]?.payload["error"]).toBe(
|
|
226
|
+
"dispatch payload erased before an outcome was recorded",
|
|
227
|
+
);
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
test("ciphertext without a configured KMS ends as generic dispatch-failed", async () => {
|
|
231
|
+
await stack.http.writeOk("step-pii-probe:write:notify-mail", MAIL_INPUT, admin);
|
|
232
|
+
resetPiiSubjectKmsForTests();
|
|
233
|
+
|
|
234
|
+
await drain();
|
|
235
|
+
|
|
236
|
+
expect(mailMock).not.toHaveBeenCalled();
|
|
237
|
+
const failed = await eventsOfType(DISPATCH_FAILED);
|
|
238
|
+
expect(failed).toHaveLength(1);
|
|
239
|
+
expect(JSON.stringify(failed[0]?.payload)).not.toContain("ops@example.com");
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
test("webhook.send: url, headers and body are ciphertext at rest and plaintext to fetch", async () => {
|
|
243
|
+
fetchMock.mockResolvedValueOnce(new Response(null, { status: 200 }));
|
|
244
|
+
await stack.http.writeOk(
|
|
245
|
+
"step-pii-probe:write:notify-webhook",
|
|
246
|
+
{ url: "https://hooks.example/secret-path", token: "probe-token" },
|
|
247
|
+
admin,
|
|
248
|
+
);
|
|
249
|
+
|
|
250
|
+
const requested = await requestedRow();
|
|
251
|
+
for (const field of ["url", "headersJson", "bodyJson"]) {
|
|
252
|
+
expect(isPiiCiphertext(requested.payload[field])).toBe(true);
|
|
253
|
+
}
|
|
254
|
+
expect(JSON.stringify(requested.payload)).not.toContain("secret-path");
|
|
255
|
+
expect(JSON.stringify(requested.payload)).not.toContain("probe-token");
|
|
256
|
+
expect(JSON.stringify(requested.payload)).not.toContain("webhook-body-secret");
|
|
257
|
+
|
|
258
|
+
await drain();
|
|
259
|
+
|
|
260
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
261
|
+
const [calledUrl, init] = fetchMock.mock.calls[0]!;
|
|
262
|
+
expect(calledUrl).toBe("https://hooks.example/secret-path");
|
|
263
|
+
expect(new Headers(init?.headers).get("x-probe-token")).toBe("probe-token");
|
|
264
|
+
expect(JSON.parse(init?.body as string)).toEqual({ secretNote: "webhook-body-secret" });
|
|
265
|
+
expect(await eventsOfType(DISPATCHED)).toHaveLength(1);
|
|
266
|
+
await expectKeyErased(requested.aggregateId);
|
|
267
|
+
});
|
|
268
|
+
});
|
|
@@ -276,8 +276,22 @@ describe("performWebhookDispatch — auth.secret resolution", () => {
|
|
|
276
276
|
{ tenantId: TEST_TENANT_ID, userId: TEST_USER_ID, secrets },
|
|
277
277
|
);
|
|
278
278
|
|
|
279
|
-
expect(result
|
|
279
|
+
expect(result).toEqual({ ok: false, error: "invalid url" });
|
|
280
280
|
expect(get).not.toHaveBeenCalled();
|
|
281
281
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
282
282
|
});
|
|
283
|
+
|
|
284
|
+
test("a failing request never echoes the request url into the delivery error", async () => {
|
|
285
|
+
setWebhookHostLookup(fakeLookupFor({ "secret-host.example": "203.0.113.9" }));
|
|
286
|
+
setWebhookFetch((async () => {
|
|
287
|
+
throw new TypeError("connect ECONNREFUSED https://secret-host.example/hook?token=abc");
|
|
288
|
+
}) as unknown as typeof fetch);
|
|
289
|
+
|
|
290
|
+
const result = await performWebhookDispatch(
|
|
291
|
+
{ url: "https://secret-host.example/hook?token=abc", method: "POST", headers: {} },
|
|
292
|
+
{ tenantId: TEST_TENANT_ID, userId: TEST_USER_ID, secrets: undefined },
|
|
293
|
+
);
|
|
294
|
+
|
|
295
|
+
expect(result).toEqual({ ok: false, error: "webhook request failed (TypeError)" });
|
|
296
|
+
});
|
|
283
297
|
});
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.327.0",
|
|
4
|
+
"type": "breaking",
|
|
5
|
+
"title": "step.dispatch-requested payloads are flat and crypto-shredded; kumiko:system:* events have a declared PII stance",
|
|
6
|
+
"migration": "No code change is needed for r.step.mail.send / r.step.webhook.send callers. The step.dispatch-requested payload changed from a nested `spec` object to flat fields (mail.send: to as JSON string, subject, body, from; webhook.send: url, method, headersJson, bodyJson, auth, retry). With a subject KMS configured, those fields are encrypted under a per-dispatch record key (record:step-dispatch:<aggregateId>) that the step-dispatcher erases once the outcome is recorded (Art. 17 via crypto-shredding). There is no legacy branch: a dispatch-requested event still in flight at upgrade (old nested shape) ends as step.dispatch-failed with error \"invalid dispatch payload\", so drain the queue before deploying or accept the one-time failed event. Webhook delivery errors no longer echo the URL or the raw request error. mail.send delivery errors are recorded as a generic \"mail delivery failed\"; the adapter's raw error goes to the step-dispatcher log. Code reading dispatch-requested payloads directly must switch to the flat shape. A new kumiko:system:* event type must be added to SYSTEM_EVENT_PII_STANCES (crypto/system-event-pii.ts); encryptEventPayloadPii throws for an undeclared system type. SYSTEM_EVENT_PREFIX and AGGREGATE_TRANSFERRED_EVENT_TYPE now live in crypto/system-event-pii.ts, and the STEP_DISPATCH_* constants are exported from @cosmicdrift/kumiko-framework/engine instead of engine/steps/webhook-send."
|
|
7
|
+
},
|
|
2
8
|
{
|
|
3
9
|
"version": "0.321.0",
|
|
4
10
|
"type": "breaking",
|
|
@@ -2,21 +2,39 @@
|
|
|
2
2
|
// requests (webhook.send, mail.send, ...) after their TX commits.
|
|
3
3
|
//
|
|
4
4
|
// Listens on the `kumiko:system:step.dispatch-requested` system event
|
|
5
|
-
// (registry-bypassed, see
|
|
5
|
+
// (registry-bypassed, see SYSTEM_EVENT_PREFIX).
|
|
6
6
|
// Performs the side-effect and emits `kumiko:system:step.dispatched`
|
|
7
7
|
// or `kumiko:system:step.dispatch-failed` back onto the same stream so
|
|
8
8
|
// the audit trail lives in the event log only — no separate status table.
|
|
9
9
|
|
|
10
|
-
import {
|
|
10
|
+
import { requestContext } from "@cosmicdrift/kumiko-framework/api";
|
|
11
|
+
import {
|
|
12
|
+
configuredPiiSubjectKms,
|
|
13
|
+
decryptPiiValueForSubject,
|
|
14
|
+
isPiiCiphertext,
|
|
15
|
+
PII_ERASED_SENTINEL,
|
|
16
|
+
} from "@cosmicdrift/kumiko-framework/crypto";
|
|
17
|
+
import {
|
|
18
|
+
defineFeature,
|
|
19
|
+
type FeatureDefinition,
|
|
20
|
+
STEP_DISPATCH_AGGREGATE_TYPE,
|
|
21
|
+
STEP_DISPATCH_FAILED_TYPE,
|
|
22
|
+
STEP_DISPATCH_REQUESTED_TYPE,
|
|
23
|
+
STEP_DISPATCHED_TYPE,
|
|
24
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
25
|
+
import { createFallbackLogger } from "@cosmicdrift/kumiko-framework/logging";
|
|
11
26
|
import { SYSTEM_USER_ID } from "@cosmicdrift/kumiko-types/identifiers";
|
|
12
27
|
import * as z from "zod";
|
|
13
|
-
import { mailSpecSchema, performMailDispatch } from "./mail-runner";
|
|
28
|
+
import { type MailSpec, mailSpecSchema, performMailDispatch } from "./mail-runner";
|
|
14
29
|
import {
|
|
15
30
|
performWebhookDispatch,
|
|
16
31
|
WEBHOOK_ALLOWED_PRIVATE_HOSTS_ENV_VAR,
|
|
32
|
+
type WebhookSpec,
|
|
17
33
|
webhookSpecSchema,
|
|
18
34
|
} from "./webhook-runner";
|
|
19
35
|
|
|
36
|
+
const log = createFallbackLogger("step-dispatcher");
|
|
37
|
+
|
|
20
38
|
export const stepDispatcherEnvSchema = z.object({
|
|
21
39
|
[WEBHOOK_ALLOWED_PRIVATE_HOSTS_ENV_VAR]: z
|
|
22
40
|
.string()
|
|
@@ -26,26 +44,43 @@ export const stepDispatcherEnvSchema = z.object({
|
|
|
26
44
|
),
|
|
27
45
|
});
|
|
28
46
|
|
|
29
|
-
export
|
|
30
|
-
export const STEP_DISPATCH_REQUESTED_TYPE = "kumiko:system:step.dispatch-requested";
|
|
31
|
-
export const STEP_DISPATCHED_TYPE = "kumiko:system:step.dispatched";
|
|
32
|
-
export const STEP_DISPATCH_FAILED_TYPE = "kumiko:system:step.dispatch-failed";
|
|
47
|
+
export { STEP_DISPATCH_AGGREGATE_TYPE };
|
|
33
48
|
|
|
49
|
+
// PII fields of the flat payload are ciphertext under the per-dispatch
|
|
50
|
+
// record key (system-event-pii.ts). `to`/`headersJson`/`bodyJson` are JSON
|
|
51
|
+
// strings because event PII encryption only handles top-level strings.
|
|
34
52
|
// Runtime-validated instead of cast — `event.payload` is `unknown` at the
|
|
35
|
-
// MSP-apply boundary
|
|
36
|
-
//
|
|
53
|
+
// MSP-apply boundary, so a payload in another shape must end as
|
|
54
|
+
// dispatch-failed, never reach the runners.
|
|
37
55
|
const dispatchRequestedPayloadSchema = z.discriminatedUnion("stepKind", [
|
|
38
56
|
z.object({
|
|
39
57
|
stepKind: z.literal("webhook.send"),
|
|
40
|
-
|
|
58
|
+
url: z.string(),
|
|
59
|
+
method: webhookSpecSchema.shape.method,
|
|
60
|
+
headersJson: z.string(),
|
|
61
|
+
bodyJson: z.string().optional(),
|
|
62
|
+
auth: webhookSpecSchema.shape.auth,
|
|
41
63
|
retry: z.object({ times: z.number(), backoff: z.enum(["exponential", "linear"]) }).optional(),
|
|
42
64
|
}),
|
|
43
|
-
z.object({
|
|
65
|
+
z.object({
|
|
66
|
+
stepKind: z.literal("mail.send"),
|
|
67
|
+
to: z.string(),
|
|
68
|
+
subject: z.string(),
|
|
69
|
+
body: z.string(),
|
|
70
|
+
from: z.string().optional(),
|
|
71
|
+
}),
|
|
44
72
|
]);
|
|
45
73
|
|
|
74
|
+
type DispatchRequestedPayload = z.infer<typeof dispatchRequestedPayloadSchema>;
|
|
75
|
+
|
|
46
76
|
// zod issue messages can echo the invalid value (e.g. a rejected url) back
|
|
47
77
|
// into the tenant-visible dispatch-failed event — keep this generic.
|
|
48
78
|
const INVALID_DISPATCH_PAYLOAD_ERROR = "invalid dispatch payload";
|
|
79
|
+
const PAYLOAD_UNREADABLE_ERROR = "dispatch payload is not readable";
|
|
80
|
+
// Adapter errors (e.g. SMTP 550) usually echo the recipient address; the
|
|
81
|
+
// dispatch-failed text outlives the payload erase, so only this is persisted.
|
|
82
|
+
const MAIL_DELIVERY_FAILED_ERROR = "mail delivery failed";
|
|
83
|
+
const PAYLOAD_ERASED_ERROR = "dispatch payload erased before an outcome was recorded";
|
|
49
84
|
|
|
50
85
|
const rawStepKindSchema = z.object({ stepKind: z.string() });
|
|
51
86
|
|
|
@@ -54,6 +89,105 @@ function rawStepKindOf(payload: unknown): string {
|
|
|
54
89
|
return parsed.success ? parsed.data.stepKind : "unknown";
|
|
55
90
|
}
|
|
56
91
|
|
|
92
|
+
const jsonStringSchema = z.string().transform((raw, refinementCtx) => {
|
|
93
|
+
try {
|
|
94
|
+
const parsed: unknown = JSON.parse(raw);
|
|
95
|
+
return parsed;
|
|
96
|
+
} catch {
|
|
97
|
+
refinementCtx.addIssue({ code: "custom", message: "invalid json" });
|
|
98
|
+
return z.NEVER;
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
const headersJsonSchema = jsonStringSchema.pipe(z.record(z.string(), z.string()));
|
|
103
|
+
const mailToJsonSchema = jsonStringSchema.pipe(mailSpecSchema.shape.to);
|
|
104
|
+
|
|
105
|
+
type PayloadFieldName = "to" | "subject" | "body" | "from" | "url" | "headersJson" | "bodyJson";
|
|
106
|
+
|
|
107
|
+
function piiFieldsOf(payload: DispatchRequestedPayload): readonly PayloadFieldName[] {
|
|
108
|
+
return payload.stepKind === "mail.send"
|
|
109
|
+
? ["to", "subject", "body", "from"]
|
|
110
|
+
: ["url", "headersJson", "bodyJson"];
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function payloadFieldValue(
|
|
114
|
+
payload: DispatchRequestedPayload,
|
|
115
|
+
field: PayloadFieldName,
|
|
116
|
+
): string | undefined {
|
|
117
|
+
const values: Readonly<Partial<Record<PayloadFieldName, string>>> =
|
|
118
|
+
payload.stepKind === "mail.send"
|
|
119
|
+
? { to: payload.to, subject: payload.subject, body: payload.body, from: payload.from }
|
|
120
|
+
: { url: payload.url, headersJson: payload.headersJson, bodyJson: payload.bodyJson };
|
|
121
|
+
return values[field];
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
type ReadPayloadResult =
|
|
125
|
+
| { readonly kind: "ready"; readonly fields: Readonly<Partial<Record<PayloadFieldName, string>>> }
|
|
126
|
+
| { readonly kind: "erased" }
|
|
127
|
+
| { readonly kind: "unreadable" };
|
|
128
|
+
|
|
129
|
+
async function readPayloadFields(payload: DispatchRequestedPayload): Promise<ReadPayloadResult> {
|
|
130
|
+
const kms = configuredPiiSubjectKms();
|
|
131
|
+
const requestId = requestContext.get()?.requestId ?? "step-dispatcher";
|
|
132
|
+
const fields: Partial<Record<PayloadFieldName, string>> = {};
|
|
133
|
+
for (const field of piiFieldsOf(payload)) {
|
|
134
|
+
const value = payloadFieldValue(payload, field);
|
|
135
|
+
if (value === undefined) continue;
|
|
136
|
+
if (!isPiiCiphertext(value)) {
|
|
137
|
+
fields[field] = value;
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
if (!kms) return { kind: "unreadable" };
|
|
141
|
+
const plain = await decryptPiiValueForSubject(kms, value, { requestId }, field);
|
|
142
|
+
if (plain === PII_ERASED_SENTINEL) return { kind: "erased" };
|
|
143
|
+
fields[field] = plain;
|
|
144
|
+
}
|
|
145
|
+
return { kind: "ready", fields };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
type DispatchSpec =
|
|
149
|
+
| { readonly stepKind: "mail.send"; readonly spec: MailSpec }
|
|
150
|
+
| { readonly stepKind: "webhook.send"; readonly spec: WebhookSpec };
|
|
151
|
+
|
|
152
|
+
// Parse failures return null — the caller records a generic error, never the
|
|
153
|
+
// (decrypted) values.
|
|
154
|
+
function buildDispatchSpec(
|
|
155
|
+
payload: DispatchRequestedPayload,
|
|
156
|
+
fields: Readonly<Partial<Record<PayloadFieldName, string>>>,
|
|
157
|
+
): DispatchSpec | null {
|
|
158
|
+
if (payload.stepKind === "mail.send") {
|
|
159
|
+
const to = mailToJsonSchema.safeParse(fields.to);
|
|
160
|
+
if (!to.success || fields.subject === undefined || fields.body === undefined) return null;
|
|
161
|
+
return {
|
|
162
|
+
stepKind: "mail.send",
|
|
163
|
+
spec: {
|
|
164
|
+
to: to.data,
|
|
165
|
+
subject: fields.subject,
|
|
166
|
+
body: fields.body,
|
|
167
|
+
...(fields.from !== undefined && { from: fields.from }),
|
|
168
|
+
},
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
const headers = headersJsonSchema.safeParse(fields.headersJson);
|
|
172
|
+
if (!headers.success || fields.url === undefined) return null;
|
|
173
|
+
let body: unknown;
|
|
174
|
+
if (fields.bodyJson !== undefined) {
|
|
175
|
+
const parsedBody = jsonStringSchema.safeParse(fields.bodyJson);
|
|
176
|
+
if (!parsedBody.success) return null;
|
|
177
|
+
body = parsedBody.data;
|
|
178
|
+
}
|
|
179
|
+
return {
|
|
180
|
+
stepKind: "webhook.send",
|
|
181
|
+
spec: {
|
|
182
|
+
url: fields.url,
|
|
183
|
+
method: payload.method,
|
|
184
|
+
headers: headers.data,
|
|
185
|
+
...(body !== undefined && { body }),
|
|
186
|
+
...(payload.auth && { auth: payload.auth }),
|
|
187
|
+
},
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
|
|
57
191
|
export function createStepDispatcherFeature(): FeatureDefinition {
|
|
58
192
|
return defineFeature("step-dispatcher", (r) => {
|
|
59
193
|
r.describe(
|
|
@@ -71,44 +205,85 @@ export function createStepDispatcherFeature(): FeatureDefinition {
|
|
|
71
205
|
name: "step-dispatcher",
|
|
72
206
|
apply: {
|
|
73
207
|
[STEP_DISPATCH_REQUESTED_TYPE]: async (event, _tx, ctx) => {
|
|
74
|
-
const
|
|
75
|
-
|
|
208
|
+
const kms = configuredPiiSubjectKms();
|
|
209
|
+
const requestId = requestContext.get()?.requestId ?? "step-dispatcher";
|
|
210
|
+
|
|
211
|
+
// Outcome events are plaintext and generic; the request payload's
|
|
212
|
+
// per-dispatch key is erased right after, so the PII dies with the
|
|
213
|
+
// dispatch instead of living in the event log.
|
|
214
|
+
const recordOutcome = async (
|
|
215
|
+
type: typeof STEP_DISPATCHED_TYPE | typeof STEP_DISPATCH_FAILED_TYPE,
|
|
216
|
+
payload: Record<string, unknown>,
|
|
217
|
+
): Promise<void> => {
|
|
76
218
|
await ctx.unsafeAppendEvent({
|
|
77
219
|
aggregateId: event.aggregateId,
|
|
78
220
|
aggregateType: STEP_DISPATCH_AGGREGATE_TYPE,
|
|
79
|
-
type
|
|
80
|
-
payload
|
|
81
|
-
stepKind: rawStepKindOf(event.payload),
|
|
82
|
-
error: INVALID_DISPATCH_PAYLOAD_ERROR,
|
|
83
|
-
attempt: 1,
|
|
84
|
-
},
|
|
221
|
+
type,
|
|
222
|
+
payload,
|
|
85
223
|
});
|
|
224
|
+
await kms?.eraseKey(
|
|
225
|
+
{ kind: "record", entity: STEP_DISPATCH_AGGREGATE_TYPE, id: event.aggregateId },
|
|
226
|
+
{ requestId, eraseReason: "step-dispatch-outcome-recorded" },
|
|
227
|
+
);
|
|
228
|
+
};
|
|
229
|
+
const recordFailure = (stepKind: string, error: string) =>
|
|
230
|
+
recordOutcome(STEP_DISPATCH_FAILED_TYPE, { stepKind, error, attempt: 1 });
|
|
231
|
+
|
|
232
|
+
const parsed = dispatchRequestedPayloadSchema.safeParse(event.payload);
|
|
233
|
+
if (!parsed.success) {
|
|
234
|
+
await recordFailure(rawStepKindOf(event.payload), INVALID_DISPATCH_PAYLOAD_ERROR);
|
|
86
235
|
// skip: invalid payload already recorded via step.dispatch-failed above, nothing left to dispatch
|
|
87
236
|
return;
|
|
88
237
|
}
|
|
89
238
|
const payload = parsed.data;
|
|
239
|
+
|
|
240
|
+
const read = await readPayloadFields(payload);
|
|
241
|
+
if (read.kind === "unreadable") {
|
|
242
|
+
await recordFailure(payload.stepKind, PAYLOAD_UNREADABLE_ERROR);
|
|
243
|
+
// skip: unreadable payload already recorded via step.dispatch-failed above
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
if (read.kind === "erased") {
|
|
247
|
+
const stream = await ctx.loadAggregate(event.aggregateId);
|
|
248
|
+
const hasOutcome = stream.some(
|
|
249
|
+
(e) => e.type === STEP_DISPATCHED_TYPE || e.type === STEP_DISPATCH_FAILED_TYPE,
|
|
250
|
+
);
|
|
251
|
+
// skip: redelivery after the key was erased — the outcome is already recorded
|
|
252
|
+
if (hasOutcome) return;
|
|
253
|
+
await recordFailure(payload.stepKind, PAYLOAD_ERASED_ERROR);
|
|
254
|
+
// skip: erased payload recorded via step.dispatch-failed above
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const dispatchSpec = buildDispatchSpec(payload, read.fields);
|
|
259
|
+
if (!dispatchSpec) {
|
|
260
|
+
await recordFailure(payload.stepKind, INVALID_DISPATCH_PAYLOAD_ERROR);
|
|
261
|
+
// skip: unparseable payload already recorded via step.dispatch-failed above
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
90
264
|
const result =
|
|
91
|
-
|
|
92
|
-
? await performWebhookDispatch(
|
|
265
|
+
dispatchSpec.stepKind === "webhook.send"
|
|
266
|
+
? await performWebhookDispatch(dispatchSpec.spec, {
|
|
93
267
|
tenantId: event.tenantId,
|
|
94
268
|
userId: event.metadata.userId || SYSTEM_USER_ID,
|
|
95
269
|
secrets: ctx.secrets,
|
|
96
270
|
})
|
|
97
|
-
: await performMailDispatch(
|
|
271
|
+
: await performMailDispatch(dispatchSpec.spec);
|
|
98
272
|
if (result.ok) {
|
|
99
|
-
await
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
type: STEP_DISPATCHED_TYPE,
|
|
103
|
-
payload: { stepKind: payload.stepKind, status: result.status },
|
|
273
|
+
await recordOutcome(STEP_DISPATCHED_TYPE, {
|
|
274
|
+
stepKind: payload.stepKind,
|
|
275
|
+
status: result.status,
|
|
104
276
|
});
|
|
105
277
|
} else {
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
278
|
+
if (payload.stepKind === "mail.send") {
|
|
279
|
+
log.warn("mail dispatch failed", {
|
|
280
|
+
aggregateId: event.aggregateId,
|
|
281
|
+
reason: result.error,
|
|
282
|
+
});
|
|
283
|
+
await recordFailure(payload.stepKind, MAIL_DELIVERY_FAILED_ERROR);
|
|
284
|
+
} else {
|
|
285
|
+
await recordFailure(payload.stepKind, result.error);
|
|
286
|
+
}
|
|
112
287
|
}
|
|
113
288
|
},
|
|
114
289
|
},
|
|
@@ -179,7 +179,7 @@ export async function performWebhookDispatch(
|
|
|
179
179
|
try {
|
|
180
180
|
url = new URL(spec.url);
|
|
181
181
|
} catch {
|
|
182
|
-
return { ok: false, error:
|
|
182
|
+
return { ok: false, error: "invalid url" };
|
|
183
183
|
}
|
|
184
184
|
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
185
185
|
return { ok: false, error: `unsupported url scheme "${url.protocol}"` };
|
|
@@ -203,6 +203,10 @@ export async function performWebhookDispatch(
|
|
|
203
203
|
}
|
|
204
204
|
return { ok: true, status: res.status };
|
|
205
205
|
} catch (err) {
|
|
206
|
-
|
|
206
|
+
// err.message can echo the request URL; this text outlives the payload erase.
|
|
207
|
+
return {
|
|
208
|
+
ok: false,
|
|
209
|
+
error: `webhook request failed (${err instanceof Error ? err.name : "unknown error"})`,
|
|
210
|
+
};
|
|
207
211
|
}
|
|
208
212
|
}
|
|
@@ -79,7 +79,7 @@ const retryWorkflow: WorkflowDefinition = defineWorkflow({
|
|
|
79
79
|
do: [
|
|
80
80
|
r.step.compute("gate", (ctx) => {
|
|
81
81
|
const attempt = ctx.workflow?.retryAttempt ?? 1;
|
|
82
|
-
if (attempt === 1) throw new Error("
|
|
82
|
+
if (attempt === 1) throw new Error("send to jane.doe@example.com failed");
|
|
83
83
|
return attempt;
|
|
84
84
|
}),
|
|
85
85
|
],
|
|
@@ -289,6 +289,9 @@ describe("workflow-runner resume loop", () => {
|
|
|
289
289
|
WORKFLOW_RETRY_SCHEDULED_TYPE,
|
|
290
290
|
]);
|
|
291
291
|
expect(rowsAfterSuspend[1]!["payload"]).toMatchObject({ stepIndex: 0, attempt: 1 });
|
|
292
|
+
// The event outlives an erase, so it carries the error class only.
|
|
293
|
+
expect(rowsAfterSuspend[1]!["payload"]["error"]).toBe("workflow step failed (Error)");
|
|
294
|
+
expect(JSON.stringify(rowsAfterSuspend[1]!["payload"])).not.toContain("jane.doe@example.com");
|
|
292
295
|
|
|
293
296
|
await tamperWakeAtToPast(runId, 0);
|
|
294
297
|
await runResumeDueRunsJob();
|
|
@@ -52,7 +52,7 @@ const failingWorkflow: WorkflowDefinition = defineWorkflow({
|
|
|
52
52
|
idempotencyKey: ({ payload }) => (payload as { runKey: string }).runKey,
|
|
53
53
|
steps: stepsPipeline(({ r }) => [
|
|
54
54
|
r.step.compute("boom", () => {
|
|
55
|
-
throw new Error("boom-explicit-failure");
|
|
55
|
+
throw new Error("boom-explicit-failure for jane.doe@example.com");
|
|
56
56
|
}),
|
|
57
57
|
r.step.return({ isSuccess: true, data: undefined }),
|
|
58
58
|
]),
|
|
@@ -186,7 +186,7 @@ describe("workflow-runner event-trigger", () => {
|
|
|
186
186
|
});
|
|
187
187
|
});
|
|
188
188
|
|
|
189
|
-
test("error path: a throwing step writes run-failed with
|
|
189
|
+
test("error path: a throwing step writes run-failed with a generic error text, no run-completed", async () => {
|
|
190
190
|
const runKey = crypto.randomUUID();
|
|
191
191
|
const runId = workflowRunAggregateId(failingWorkflow.name, runKey);
|
|
192
192
|
// The registrar namespaces every MSP as `<feature>:projection:<name>`.
|
|
@@ -223,7 +223,10 @@ describe("workflow-runner event-trigger", () => {
|
|
|
223
223
|
WORKFLOW_RUN_FAILED_TYPE,
|
|
224
224
|
]);
|
|
225
225
|
expect(rows[1]!["payload"]).toMatchObject({ workflowName: failingWorkflow.name, stepIndex: 0 });
|
|
226
|
-
|
|
226
|
+
// The event outlives an erase, so it carries the error class only.
|
|
227
|
+
expect(rows[1]!["payload"]["error"]).toBe("workflow step failed (Error)");
|
|
228
|
+
expect(JSON.stringify(rows[1]!["payload"])).not.toContain("boom-explicit-failure");
|
|
229
|
+
expect(JSON.stringify(rows[1]!["payload"])).not.toContain("jane.doe@example.com");
|
|
227
230
|
|
|
228
231
|
// Second pass has nothing left to redeliver — the same trigger event
|
|
229
232
|
// never spawns a second run-started for this runId. `processed` counts
|
|
@@ -1 +1,8 @@
|
|
|
1
|
-
[
|
|
1
|
+
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.327.0",
|
|
4
|
+
"type": "breaking",
|
|
5
|
+
"title": "Workflow run-failed and retry.scheduled events store a generic error text instead of the raw error",
|
|
6
|
+
"migration": "The `error` field of workflow.run-failed and workflow.retry.scheduled events is now generic, \"workflow step failed (<error class>)\" (or \"(unknown error)\" for non-Error throwables), because the raw text can contain recipients or payload fragments and would survive an erase. The raw error goes to the log (namespace workflow-runner, and the framework logger for retry). Code that parses the `error` text must switch to `reason` (machine-readable, set for definition changes) or to the logs. Already stored events keep their old text. The texts for an unregistered workflow and a definition-fingerprint mismatch are unchanged. describeWorkflowStepError is exported from @cosmicdrift/kumiko-framework/engine."
|
|
7
|
+
}
|
|
8
|
+
]
|
|
@@ -16,14 +16,18 @@ import type {
|
|
|
16
16
|
WriteEvent,
|
|
17
17
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
18
18
|
import {
|
|
19
|
+
describeWorkflowStepError,
|
|
19
20
|
WORKFLOW_AGGREGATE_TYPE,
|
|
20
21
|
WORKFLOW_RUN_FAILED_TYPE,
|
|
21
22
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
23
|
+
import { createFallbackLogger } from "@cosmicdrift/kumiko-framework/logging";
|
|
22
24
|
import { workflowRunAggregateId } from "./aggregate-id";
|
|
23
25
|
import { registerEventWakeup } from "./event-subscriber";
|
|
24
26
|
import { startAndRunWorkflow, type WorkflowRunFailedPayload } from "./runner";
|
|
25
27
|
import { registerWorkflow } from "./workflow-registry";
|
|
26
28
|
|
|
29
|
+
const log = createFallbackLogger("workflow-runner");
|
|
30
|
+
|
|
27
31
|
export function registerEventTrigger(r: FeatureRegistrar, workflow: WorkflowDefinition): void {
|
|
28
32
|
// Populate the workflow-registry unconditionally, before the event-trigger
|
|
29
33
|
// guard below — resume-run (framework#2513 Phase 2) looks workflows up by
|
|
@@ -89,10 +93,16 @@ export function registerEventTrigger(r: FeatureRegistrar, workflow: WorkflowDefi
|
|
|
89
93
|
handlerCtx: ctx as never,
|
|
90
94
|
});
|
|
91
95
|
} catch (error) {
|
|
92
|
-
|
|
96
|
+
log.warn("workflow run failed", {
|
|
97
|
+
runId,
|
|
93
98
|
workflowName: workflow.name,
|
|
94
99
|
stepIndex: 0,
|
|
95
100
|
error: String(error),
|
|
101
|
+
});
|
|
102
|
+
const failedPayload: WorkflowRunFailedPayload = {
|
|
103
|
+
workflowName: workflow.name,
|
|
104
|
+
stepIndex: 0,
|
|
105
|
+
error: describeWorkflowStepError(error),
|
|
96
106
|
};
|
|
97
107
|
await ctx.unsafeAppendEvent({
|
|
98
108
|
aggregateId: runId,
|
|
@@ -31,6 +31,7 @@ import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
|
31
31
|
import {
|
|
32
32
|
buildPipelineSteps,
|
|
33
33
|
computeDefinitionFingerprint,
|
|
34
|
+
describeWorkflowStepError,
|
|
34
35
|
getStep,
|
|
35
36
|
type HandlerContext,
|
|
36
37
|
runStepList,
|
|
@@ -48,6 +49,7 @@ import {
|
|
|
48
49
|
type WriteHandlerDef,
|
|
49
50
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
50
51
|
import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
|
|
52
|
+
import { createFallbackLogger } from "@cosmicdrift/kumiko-framework/logging";
|
|
51
53
|
import * as z from "zod";
|
|
52
54
|
import {
|
|
53
55
|
isResumableSuspension,
|
|
@@ -59,6 +61,8 @@ import {
|
|
|
59
61
|
import { workflowRunPendingTable } from "../tables";
|
|
60
62
|
import { getWorkflow } from "../workflow-registry";
|
|
61
63
|
|
|
64
|
+
const log = createFallbackLogger("workflow-runner");
|
|
65
|
+
|
|
62
66
|
const resumeRunSchema = z.object({
|
|
63
67
|
runId: z.string().min(1),
|
|
64
68
|
stepIndex: z.number().int().nonnegative(),
|
|
@@ -338,10 +342,11 @@ export const resumeRunHandler: WriteHandlerDef = {
|
|
|
338
342
|
});
|
|
339
343
|
return { isSuccess: true, data: { outcome: "completed" as const } };
|
|
340
344
|
} catch (error) {
|
|
345
|
+
log.warn("workflow run failed", { runId, workflowName, stepIndex, error: String(error) });
|
|
341
346
|
const failedPayload: WorkflowRunFailedPayload = {
|
|
342
347
|
workflowName,
|
|
343
348
|
stepIndex,
|
|
344
|
-
error:
|
|
349
|
+
error: describeWorkflowStepError(error),
|
|
345
350
|
};
|
|
346
351
|
await appendRunFailed(ctx, runId, failedPayload);
|
|
347
352
|
return { isSuccess: true, data: { outcome: "failed" as const } };
|
|
@@ -57,10 +57,12 @@ export type WorkflowRunCompletedPayload = {
|
|
|
57
57
|
export type WorkflowRunFailedPayload = {
|
|
58
58
|
readonly workflowName: string;
|
|
59
59
|
readonly stepIndex: number;
|
|
60
|
+
// Generic text (class name only), never the raw error message: the event
|
|
61
|
+
// survives an erase. The raw error goes to the log.
|
|
60
62
|
readonly error: string;
|
|
61
63
|
// Machine-readable failure category — set by resume-run for a Q7
|
|
62
64
|
// fingerprint mismatch ("workflow_definition_changed"); absent for a
|
|
63
|
-
// plain pipeline-step failure (the `error` string is
|
|
65
|
+
// plain pipeline-step failure (the `error` string is generic).
|
|
64
66
|
readonly reason?: string;
|
|
65
67
|
};
|
|
66
68
|
|