@powerhousedao/switchboard 6.2.3-dev.5 → 6.2.3-dev.51

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (143) hide show
  1. package/.tsbuild/test/attachments/byte-grants.test.d.ts +2 -0
  2. package/.tsbuild/test/attachments/byte-grants.test.d.ts.map +1 -0
  3. package/.tsbuild/test/attachments/byte-routes-read-gate.integration.test.d.ts +2 -0
  4. package/.tsbuild/test/attachments/byte-routes-read-gate.integration.test.d.ts.map +1 -0
  5. package/.tsbuild/test/attachments/reserve-metadata-parity.test.d.ts +2 -0
  6. package/.tsbuild/test/attachments/reserve-metadata-parity.test.d.ts.map +1 -0
  7. package/.tsbuild/test/attachments/url-signer.test.d.ts +2 -0
  8. package/.tsbuild/test/attachments/url-signer.test.d.ts.map +1 -0
  9. package/.tsbuild/test/boot-create-signature-policy.test.d.ts +2 -0
  10. package/.tsbuild/test/boot-create-signature-policy.test.d.ts.map +1 -0
  11. package/.tsbuild/test/boot-mcp-enabled.test.d.ts +2 -0
  12. package/.tsbuild/test/boot-mcp-enabled.test.d.ts.map +1 -0
  13. package/.tsbuild/test/boot-privacy.test.d.ts +2 -0
  14. package/.tsbuild/test/boot-privacy.test.d.ts.map +1 -0
  15. package/.tsbuild/test/boot-unsupported-stored-documents.test.d.ts +2 -0
  16. package/.tsbuild/test/boot-unsupported-stored-documents.test.d.ts.map +1 -0
  17. package/.tsbuild/test/boot-without-renown.test.d.ts +2 -0
  18. package/.tsbuild/test/boot-without-renown.test.d.ts.map +1 -0
  19. package/.tsbuild/test/boot-without-require-signatures.test.d.ts +2 -0
  20. package/.tsbuild/test/boot-without-require-signatures.test.d.ts.map +1 -0
  21. package/.tsbuild/test/boot-worker-trust-policy.test.d.ts +2 -0
  22. package/.tsbuild/test/boot-worker-trust-policy.test.d.ts.map +1 -0
  23. package/.tsbuild/test/boot-workflow-singleton-conflict.test.d.ts +2 -0
  24. package/.tsbuild/test/boot-workflow-singleton-conflict.test.d.ts.map +1 -0
  25. package/.tsbuild/test/boot-workflow-singleton-retry.test.d.ts +2 -0
  26. package/.tsbuild/test/boot-workflow-singleton-retry.test.d.ts.map +1 -0
  27. package/.tsbuild/test/builder-defaults.test.d.ts +2 -0
  28. package/.tsbuild/test/builder-defaults.test.d.ts.map +1 -0
  29. package/.tsbuild/test/create-signature-policy.test.d.ts +2 -0
  30. package/.tsbuild/test/create-signature-policy.test.d.ts.map +1 -0
  31. package/.tsbuild/test/durable-node-fs-pg16.test.d.ts +2 -0
  32. package/.tsbuild/test/durable-node-fs-pg16.test.d.ts.map +1 -0
  33. package/.tsbuild/test/fatal-shutdown.test.d.ts +2 -0
  34. package/.tsbuild/test/fatal-shutdown.test.d.ts.map +1 -0
  35. package/.tsbuild/test/mcp-flag.test.d.ts +2 -0
  36. package/.tsbuild/test/mcp-flag.test.d.ts.map +1 -0
  37. package/.tsbuild/test/observability-signals.test.d.ts +2 -0
  38. package/.tsbuild/test/observability-signals.test.d.ts.map +1 -0
  39. package/.tsbuild/test/pglite-preflight.test.d.ts +2 -0
  40. package/.tsbuild/test/pglite-preflight.test.d.ts.map +1 -0
  41. package/.tsbuild/test/renown-trust-policy.test.d.ts +2 -0
  42. package/.tsbuild/test/renown-trust-policy.test.d.ts.map +1 -0
  43. package/.tsbuild/test/snapshot-writer.d.ts +18 -0
  44. package/.tsbuild/test/snapshot-writer.d.ts.map +1 -0
  45. package/.tsbuild/test/unsupported-stored-documents.test.d.ts +2 -0
  46. package/.tsbuild/test/unsupported-stored-documents.test.d.ts.map +1 -0
  47. package/.tsbuild/test/workflow/harness.d.ts +56 -0
  48. package/.tsbuild/test/workflow/harness.d.ts.map +1 -0
  49. package/.tsbuild/test/workflow/oauth.test.d.ts +2 -0
  50. package/.tsbuild/test/workflow/oauth.test.d.ts.map +1 -0
  51. package/.tsbuild/test/workflow/resolvers.test.d.ts +2 -0
  52. package/.tsbuild/test/workflow/resolvers.test.d.ts.map +1 -0
  53. package/.tsbuild/test/workflow/webhook-dedupe.test.d.ts +2 -0
  54. package/.tsbuild/test/workflow/webhook-dedupe.test.d.ts.map +1 -0
  55. package/.tsbuild/test/workflow/webhook-signed.test.d.ts +2 -0
  56. package/.tsbuild/test/workflow/webhook-signed.test.d.ts.map +1 -0
  57. package/.tsbuild/test/workflow/webhook-trigger-test.test.d.ts +2 -0
  58. package/.tsbuild/test/workflow/webhook-trigger-test.test.d.ts.map +1 -0
  59. package/.tsbuild/test/workflow/webhook-unsigned.test.d.ts +2 -0
  60. package/.tsbuild/test/workflow/webhook-unsigned.test.d.ts.map +1 -0
  61. package/.tsbuild/test/workflow-reactor-access.test.d.ts +2 -0
  62. package/.tsbuild/test/workflow-reactor-access.test.d.ts.map +1 -0
  63. package/.tsbuild/test/workflow-reactor-enforcement.test.d.ts +2 -0
  64. package/.tsbuild/test/workflow-reactor-enforcement.test.d.ts.map +1 -0
  65. package/.tsbuild/test/workflow-read-gate.test.d.ts +2 -0
  66. package/.tsbuild/test/workflow-read-gate.test.d.ts.map +1 -0
  67. package/.tsbuild/test/workflow-runtime.test.d.ts +2 -0
  68. package/.tsbuild/test/workflow-runtime.test.d.ts.map +1 -0
  69. package/.tsbuild/tsconfig.tsbuildinfo +1 -1
  70. package/.tsbuild/tsdown.config.d.ts.map +1 -1
  71. package/.tsbuild/vitest.config.d.ts.map +1 -1
  72. package/Auth.md +94 -0
  73. package/CHANGELOG.md +541 -0
  74. package/README.md +35 -0
  75. package/dist/index.d.mts +2 -1
  76. package/dist/index.mjs +48 -49
  77. package/dist/index.mjs.map +1 -1
  78. package/dist/install-packages.d.mts +2 -1
  79. package/dist/install-packages.mjs +2 -2
  80. package/dist/install-packages.mjs.map +1 -1
  81. package/dist/migrate.d.mts +2 -1
  82. package/dist/migrate.mjs +4 -3
  83. package/dist/migrate.mjs.map +1 -1
  84. package/dist/server-J9fpd42v.mjs +3549 -0
  85. package/dist/server-J9fpd42v.mjs.map +1 -0
  86. package/dist/server.d.mts +155 -30
  87. package/dist/server.d.mts.map +1 -1
  88. package/dist/server.mjs +4 -5
  89. package/dist/{utils-Baw7rThP.mjs → utils-uaBlzyjS.mjs} +15 -9
  90. package/dist/utils-uaBlzyjS.mjs.map +1 -0
  91. package/dist/utils.d.mts +4 -5
  92. package/dist/utils.d.mts.map +1 -1
  93. package/dist/utils.mjs +3 -3
  94. package/package.json +22 -18
  95. package/test/attachment-reference-read-model.test.ts +6 -1
  96. package/test/attachments/auth.test.ts +85 -0
  97. package/test/attachments/byte-grants.test.ts +290 -0
  98. package/test/attachments/byte-routes-read-gate.integration.test.ts +314 -0
  99. package/test/attachments/download-target.test.ts +201 -32
  100. package/test/attachments/index.test.ts +34 -3
  101. package/test/attachments/reserve-metadata-parity.test.ts +96 -0
  102. package/test/attachments/routes-integration.test.ts +60 -4
  103. package/test/attachments/routes.test.ts +166 -23
  104. package/test/attachments/service-config.test.ts +13 -3
  105. package/test/attachments/url-signer.test.ts +105 -0
  106. package/test/boot-create-signature-policy.test.ts +108 -0
  107. package/test/boot-mcp-enabled.test.ts +121 -0
  108. package/test/boot-privacy.test.ts +243 -0
  109. package/test/boot-unsupported-stored-documents.test.ts +213 -0
  110. package/test/boot-without-renown.test.ts +81 -0
  111. package/test/boot-without-require-signatures.test.ts +74 -0
  112. package/test/boot-worker-trust-policy.test.ts +90 -0
  113. package/test/boot-workflow-singleton-conflict.test.ts +107 -0
  114. package/test/boot-workflow-singleton-retry.test.ts +133 -0
  115. package/test/builder-defaults.test.ts +80 -0
  116. package/test/create-signature-policy.test.ts +69 -0
  117. package/test/durable-node-fs-pg16.test.ts +80 -0
  118. package/test/fatal-shutdown.test.ts +165 -0
  119. package/test/mcp-flag.test.ts +56 -0
  120. package/test/observability-signals.test.ts +131 -0
  121. package/test/pglite-dialect.test.ts +217 -2
  122. package/test/pglite-preflight.test.ts +313 -0
  123. package/test/pglite-version.test.ts +9 -1
  124. package/test/renown-trust-policy.test.ts +315 -0
  125. package/test/snapshot-writer.ts +94 -0
  126. package/test/unsupported-stored-documents.test.ts +72 -0
  127. package/test/worker-pool.test.ts +27 -1
  128. package/test/workflow/harness.ts +289 -0
  129. package/test/workflow/oauth.test.ts +214 -0
  130. package/test/workflow/resolvers.test.ts +357 -0
  131. package/test/workflow/webhook-dedupe.test.ts +295 -0
  132. package/test/workflow/webhook-signed.test.ts +509 -0
  133. package/test/workflow/webhook-trigger-test.test.ts +82 -0
  134. package/test/workflow/webhook-unsigned.test.ts +345 -0
  135. package/test/workflow-reactor-access.test.ts +384 -0
  136. package/test/workflow-reactor-enforcement.test.ts +808 -0
  137. package/test/workflow-read-gate.test.ts +213 -0
  138. package/test/workflow-runtime.test.ts +1067 -0
  139. package/tsconfig.json +9 -3
  140. package/tsdown.config.ts +3 -1
  141. package/dist/server-CiKkVE6g.mjs +0 -1555
  142. package/dist/server-CiKkVE6g.mjs.map +0 -1
  143. package/dist/utils-Baw7rThP.mjs.map +0 -1
@@ -0,0 +1,1067 @@
1
+ import { PGlite } from "@electric-sql/pglite";
2
+ import { mkdtemp, rm } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import {
6
+ ReactorBuilder,
7
+ ReactorClientBuilder,
8
+ type AttachmentRef,
9
+ type Database,
10
+ type InProcessReactorClientModule,
11
+ type IReadModelCoordinator,
12
+ } from "@powerhousedao/reactor";
13
+ import {
14
+ BaseSubgraph,
15
+ type CanonicalDocumentId,
16
+ type GraphQLManager,
17
+ type PackagePieceEntry,
18
+ } from "@powerhousedao/reactor-api";
19
+ import {
20
+ PieceRegistry,
21
+ type WorkflowRuntimeService,
22
+ } from "@powerhousedao/reactor-workflow";
23
+ import {
24
+ createRelationalDb,
25
+ type IRelationalDb,
26
+ type IWebhookScope,
27
+ type WebhookSpec,
28
+ } from "@powerhousedao/shared/processors";
29
+ import type { ILogger } from "document-model";
30
+ import { Kysely } from "kysely";
31
+ import { PGliteDialect } from "kysely-pglite-dialect";
32
+ import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
33
+ import { initFeatureFlags } from "../src/feature-flags.js";
34
+ import { startSwitchboard } from "../src/server.mjs";
35
+ import {
36
+ PH_WORKFLOWS_ENABLED,
37
+ bindPackagePieces,
38
+ attachmentReadPolicy,
39
+ composeWorkflowRuntime,
40
+ assertWorkflowPackageLoadable,
41
+ hostPrincipalOf,
42
+ isWorkflowSingletonConflict,
43
+ retryWorkflowSingleton,
44
+ workflowSingletonLossHandler,
45
+ reactorAccessOf,
46
+ resolveWorkflowsEnabled,
47
+ type BooleanFlagSource,
48
+ type ComposedWorkflowRuntime,
49
+ } from "../src/workflow-runtime.mjs";
50
+
51
+ function stubLogger(): ILogger & {
52
+ info: ReturnType<typeof vi.fn>;
53
+ error: ReturnType<typeof vi.fn>;
54
+ } {
55
+ const logger = {
56
+ verbose: vi.fn(),
57
+ debug: vi.fn(),
58
+ info: vi.fn(),
59
+ warn: vi.fn(),
60
+ error: vi.fn(),
61
+ child: vi.fn(),
62
+ };
63
+ logger.child.mockReturnValue(logger);
64
+ return logger as unknown as ILogger & {
65
+ info: ReturnType<typeof vi.fn>;
66
+ error: ReturnType<typeof vi.fn>;
67
+ };
68
+ }
69
+
70
+ describe("resolveWorkflowsEnabled", () => {
71
+ let featureFlags: BooleanFlagSource;
72
+
73
+ beforeAll(async () => {
74
+ featureFlags = await initFeatureFlags();
75
+ });
76
+
77
+ afterEach(() => {
78
+ delete process.env[PH_WORKFLOWS_ENABLED];
79
+ });
80
+
81
+ it("defaults to off", async () => {
82
+ await expect(resolveWorkflowsEnabled({ featureFlags })).resolves.toBe(
83
+ false,
84
+ );
85
+ });
86
+
87
+ it("lets the host's option win over the env var", async () => {
88
+ process.env[PH_WORKFLOWS_ENABLED] = "false";
89
+
90
+ await expect(
91
+ resolveWorkflowsEnabled({ featureFlags, override: true }),
92
+ ).resolves.toBe(true);
93
+
94
+ process.env[PH_WORKFLOWS_ENABLED] = "true";
95
+
96
+ await expect(
97
+ resolveWorkflowsEnabled({ featureFlags, override: false }),
98
+ ).resolves.toBe(false);
99
+ });
100
+
101
+ it("reads the env var in every form reactor-api accepted", async () => {
102
+ for (const [raw, expected] of [
103
+ ["true", true],
104
+ ["1", true],
105
+ ["false", false],
106
+ ["0", false],
107
+ ] as const) {
108
+ process.env[PH_WORKFLOWS_ENABLED] = raw;
109
+ await expect(
110
+ resolveWorkflowsEnabled({ featureFlags, configEnabled: !expected }),
111
+ ).resolves.toBe(expected);
112
+ }
113
+ });
114
+
115
+ it("falls back to the config file, then off", async () => {
116
+ await expect(
117
+ resolveWorkflowsEnabled({ featureFlags, configEnabled: true }),
118
+ ).resolves.toBe(true);
119
+ await expect(
120
+ resolveWorkflowsEnabled({ featureFlags, configEnabled: false }),
121
+ ).resolves.toBe(false);
122
+ });
123
+ });
124
+
125
+ describe("assertWorkflowPackageLoadable", () => {
126
+ it("passes when the package resolves", async () => {
127
+ await expect(
128
+ assertWorkflowPackageLoadable(() => Promise.resolve({})),
129
+ ).resolves.toBeUndefined();
130
+ });
131
+
132
+ it("names the package a host would have to install when the load fails", async () => {
133
+ const cause = new Error("Cannot find module");
134
+ const failing = assertWorkflowPackageLoadable(() => Promise.reject(cause));
135
+ await expect(failing).rejects.toThrow("@powerhousedao/workflow");
136
+ await expect(failing).rejects.toMatchObject({ cause });
137
+ });
138
+ });
139
+
140
+ // The endpoint family as the host's webhook service holds it, in memory.
141
+ function memoryWebhooks() {
142
+ const families: WebhookSpec[] = [];
143
+ const scope: IWebhookScope = {
144
+ hasPublicOrigin: true,
145
+ register: (spec) => {
146
+ families.push(spec);
147
+ return Promise.resolve({
148
+ endpointFor: (key) =>
149
+ Promise.resolve({
150
+ token: key,
151
+ url: `https://hooks.test/${key}`,
152
+ createdAt: new Date().toISOString(),
153
+ }),
154
+ revoke: () => Promise.resolve(),
155
+ list: () => Promise.resolve([]),
156
+ });
157
+ },
158
+ };
159
+ return { families, scope };
160
+ }
161
+
162
+ describe("composeWorkflowRuntime", () => {
163
+ let databases: Kysely<unknown>[] = [];
164
+ let module: InProcessReactorClientModule | undefined;
165
+ let composed: ComposedWorkflowRuntime | undefined;
166
+
167
+ afterEach(async () => {
168
+ await composed?.stop();
169
+ await module?.reactor.kill().completed;
170
+ for (const database of databases) await database.destroy();
171
+ composed = undefined;
172
+ module = undefined;
173
+ databases = [];
174
+ });
175
+
176
+ function pglite(): Kysely<unknown> {
177
+ const database = new Kysely<unknown>({
178
+ dialect: new PGliteDialect(new PGlite()),
179
+ });
180
+ databases.push(database);
181
+ return database;
182
+ }
183
+
184
+ async function buildReactorModule(
185
+ coordinator?: IReadModelCoordinator,
186
+ ): Promise<InProcessReactorClientModule> {
187
+ const builder = new ReactorBuilder().withKysely(
188
+ pglite() as unknown as Kysely<Database>,
189
+ );
190
+ if (coordinator) builder.withReadModelCoordinator(coordinator);
191
+ module = await new ReactorClientBuilder()
192
+ .withReactorBuilder(builder)
193
+ .buildModule();
194
+ return module;
195
+ }
196
+
197
+ // The real engine, loaded as production loads it.
198
+ async function compose(
199
+ clientModule: InProcessReactorClientModule,
200
+ overrides: Partial<Parameters<typeof composeWorkflowRuntime>[0]> = {},
201
+ ): Promise<ComposedWorkflowRuntime> {
202
+ composed = await composeWorkflowRuntime({
203
+ reactorClient: clientModule.client,
204
+ clientModule,
205
+ relationalDb: createRelationalDb(pglite()) as IRelationalDb,
206
+ attachments: {} as never,
207
+ authorizationService: {} as never,
208
+ logger: stubLogger(),
209
+ ...overrides,
210
+ });
211
+ return composed;
212
+ }
213
+
214
+ it("lets a step read a ref only through a document its user may read", async () => {
215
+ const ref = `attachment://v1:${"a".repeat(64)}`;
216
+ const asked: unknown[] = [];
217
+ const policy = attachmentReadPolicy(
218
+ {
219
+ canReadAttachment: (request) => {
220
+ asked.push(request);
221
+ return Promise.resolve(
222
+ request.documentId === "doc-b" && request.userAddress === "0xabc"
223
+ ? {
224
+ kind: "allowed",
225
+ documentId: "doc-b" as CanonicalDocumentId,
226
+ ref: request.attachmentRef as AttachmentRef,
227
+ }
228
+ : { kind: "denied" },
229
+ );
230
+ },
231
+ admitCaller: () => Promise.resolve({ kind: "admitted" }),
232
+ },
233
+ 1,
234
+ );
235
+ const runUser = {
236
+ address: "0xabc",
237
+ subject: { address: "0xabc", key: "did:key:z1" },
238
+ };
239
+ expect(
240
+ await policy({
241
+ workflowId: "wf-1",
242
+ ref,
243
+ documentIds: ["doc-a", "doc-b"],
244
+ runUser,
245
+ }),
246
+ ).toBe(true);
247
+ expect(asked[1]).toEqual({
248
+ documentId: "doc-b",
249
+ attachmentRef: ref,
250
+ userAddress: "0xabc",
251
+ appKey: "did:key:z1",
252
+ });
253
+ // No document to read it through, another user, or not a ref: refused.
254
+ expect(
255
+ await policy({ workflowId: "wf-1", ref, documentIds: [], runUser }),
256
+ ).toBe(false);
257
+ expect(
258
+ await policy({
259
+ workflowId: "wf-1",
260
+ ref,
261
+ documentIds: ["doc-b"],
262
+ runUser: null,
263
+ }),
264
+ ).toBe(false);
265
+ expect(
266
+ await policy({
267
+ workflowId: "wf-1",
268
+ ref: "not-a-ref",
269
+ documentIds: ["doc-b"],
270
+ runUser,
271
+ }),
272
+ ).toBe(false);
273
+ expect(
274
+ await attachmentReadPolicy(undefined)({
275
+ workflowId: "wf-1",
276
+ ref,
277
+ documentIds: ["doc-b"],
278
+ runUser,
279
+ }),
280
+ ).toBe(false);
281
+ });
282
+
283
+ it("waits for the reference index to catch up with the triggering operation", async () => {
284
+ const ref = `attachment://v1:${"b".repeat(64)}`;
285
+ let calls = 0;
286
+ const policy = attachmentReadPolicy(
287
+ {
288
+ canReadAttachment: (request) =>
289
+ Promise.resolve(
290
+ ++calls < 3
291
+ ? { kind: "denied" }
292
+ : {
293
+ kind: "allowed",
294
+ documentId: request.documentId as CanonicalDocumentId,
295
+ ref: request.attachmentRef as AttachmentRef,
296
+ },
297
+ ),
298
+ admitCaller: () => Promise.resolve({ kind: "admitted" }),
299
+ },
300
+ 1,
301
+ );
302
+ expect(
303
+ await policy({ workflowId: "wf-1", ref, documentIds: ["doc-a"] }),
304
+ ).toBe(true);
305
+ expect(calls).toBe(3);
306
+ });
307
+
308
+ it("serves a subgraph the GraphQL manager can construct", async () => {
309
+ const { subgraph } = await compose(await buildReactorModule());
310
+
311
+ expect(subgraph.prototype).toBeInstanceOf(BaseSubgraph);
312
+ });
313
+
314
+ it("registers the trigger read model on the reactor's coordinator", async () => {
315
+ const clientModule = await buildReactorModule();
316
+
317
+ const workflows = await compose(clientModule);
318
+
319
+ expect(workflows.triggers).toEqual({ status: "available" });
320
+ expect(
321
+ clientModule.reactorModule!.readModelCoordinator.readModels.filter(
322
+ ({ name }) => name === "workflow-triggers",
323
+ ),
324
+ ).toHaveLength(1);
325
+ });
326
+
327
+ it("reports the intake unavailable on a coordinator without live registration", async () => {
328
+ const customCoordinator: IReadModelCoordinator = {
329
+ readModels: [],
330
+ start: vi.fn(),
331
+ stop: vi.fn(),
332
+ drain: vi.fn().mockResolvedValue(undefined),
333
+ getChainDepth: vi.fn().mockReturnValue(0),
334
+ };
335
+
336
+ const workflows = await compose(
337
+ await buildReactorModule(customCoordinator),
338
+ );
339
+
340
+ expect(workflows.triggers).toEqual({
341
+ status: "unavailable",
342
+ reason: "live-read-model-registration-unsupported",
343
+ });
344
+ expect(customCoordinator.readModels).toEqual([]);
345
+ });
346
+
347
+ it("reports the intake unavailable without an in-process reactor", async () => {
348
+ const clientModule = await buildReactorModule();
349
+
350
+ const workflows = await compose(clientModule, {
351
+ clientModule: {} as InProcessReactorClientModule,
352
+ });
353
+
354
+ expect(workflows.triggers).toEqual({
355
+ status: "unavailable",
356
+ reason: "in-process-reactor-module-unavailable",
357
+ });
358
+ });
359
+
360
+ it("arms the webhooks and the supervisor on start, and stops them once", async () => {
361
+ const webhooks = memoryWebhooks();
362
+ const clientModule = await buildReactorModule();
363
+ // Intervals only, from here on: the singleton lease's heartbeat and the
364
+ // run-retention sweep from compose, then the supervisor's tick.
365
+ vi.useFakeTimers({ toFake: ["setInterval", "clearInterval"] });
366
+ try {
367
+ const workflows = await compose(clientModule, {
368
+ webhooks: webhooks.scope,
369
+ });
370
+ expect(vi.getTimerCount()).toBe(2);
371
+
372
+ await workflows.start();
373
+
374
+ expect(webhooks.families.map(({ name }) => name)).toEqual(["trigger"]);
375
+ // The real runtime answers for it: an unknown workflow is not armed.
376
+ await expect(
377
+ webhooks.families[0]!.policyFor?.("wf-unknown"),
378
+ ).resolves.toBeUndefined();
379
+ expect(vi.getTimerCount()).toBe(3);
380
+
381
+ await workflows.stop();
382
+ await workflows.stop();
383
+
384
+ expect(vi.getTimerCount()).toBe(0);
385
+ } finally {
386
+ vi.useRealTimers();
387
+ }
388
+ });
389
+
390
+ it("names the package a host would have to install when the load fails", async () => {
391
+ const cause = new Error("Cannot find module");
392
+ const failing = compose(await buildReactorModule(), {
393
+ load: () => Promise.reject(cause),
394
+ });
395
+
396
+ await expect(failing).rejects.toThrow("@powerhousedao/reactor-workflow");
397
+ await expect(failing).rejects.toMatchObject({ cause });
398
+ });
399
+
400
+ // Placement (plan agreed decision 3): the claim is taken before the runtime
401
+ // exists and released on the way out. Distinct storage ids give the two
402
+ // composes distinct owner names, as two hosts would have.
403
+ function composeSecond(
404
+ clientModule: InProcessReactorClientModule,
405
+ relationalDb: IRelationalDb,
406
+ ): Promise<ComposedWorkflowRuntime> {
407
+ return composeWorkflowRuntime({
408
+ reactorClient: clientModule.client,
409
+ clientModule: {} as InProcessReactorClientModule,
410
+ relationalDb,
411
+ attachments: {} as never,
412
+ authorizationService: {} as never,
413
+ logger: stubLogger(),
414
+ storageId: "/srv/slot-b",
415
+ });
416
+ }
417
+
418
+ it("claims the workflow singleton and refuses a second owner until it is released", async () => {
419
+ const clientModule = await buildReactorModule();
420
+ const relationalDb = createRelationalDb(pglite()) as IRelationalDb;
421
+ const first = await compose(clientModule, {
422
+ relationalDb,
423
+ storageId: "/srv/slot-a",
424
+ });
425
+ expect(first.singletonOwner).toBeDefined();
426
+
427
+ const refused = await composeSecond(clientModule, relationalDb).catch(
428
+ (error: unknown) => error,
429
+ );
430
+ expect(isWorkflowSingletonConflict(refused)).toBe(true);
431
+
432
+ await first.stop();
433
+ const second = await composeSecond(clientModule, relationalDb);
434
+ try {
435
+ expect(second.singletonOwner).toBeDefined();
436
+ expect(second.singletonOwner).not.toBe(first.singletonOwner);
437
+ } finally {
438
+ await second.stop();
439
+ }
440
+ });
441
+
442
+ it("hands the claim back when composing fails after it", async () => {
443
+ const clientModule = await buildReactorModule();
444
+ const relationalDb = createRelationalDb(pglite()) as IRelationalDb;
445
+ const engine = await import("@powerhousedao/reactor-workflow");
446
+
447
+ await expect(
448
+ compose(clientModule, {
449
+ relationalDb,
450
+ storageId: "/srv/slot-a",
451
+ load: () =>
452
+ Promise.resolve({
453
+ ...engine,
454
+ createWorkflowRuntime: () => {
455
+ throw new Error("the runtime could not be built");
456
+ },
457
+ }),
458
+ }),
459
+ ).rejects.toThrow("the runtime could not be built");
460
+
461
+ const next = await composeSecond(clientModule, relationalDb);
462
+ try {
463
+ expect(next.singletonOwner).toBeDefined();
464
+ } finally {
465
+ await next.stop();
466
+ }
467
+ });
468
+
469
+ // A rolling deploy under one stable owner name: the next pod's claim wins,
470
+ // and this one must stop running workflows rather than become a second
471
+ // writer. There is no re-claim; it stays down until restarted.
472
+ it("shuts the runtime down when a newer claim takes the singleton", async () => {
473
+ const clientModule = await buildReactorModule();
474
+ const relationalDb = createRelationalDb(pglite()) as IRelationalDb;
475
+ const engine = await import("@powerhousedao/reactor-workflow");
476
+ const lost = vi.fn();
477
+ let newer:
478
+ | Awaited<ReturnType<typeof engine.acquireWorkflowSingletonLease>>
479
+ | undefined;
480
+ // The lease measures its silence on performance.now().
481
+ vi.useFakeTimers({
482
+ toFake: ["setInterval", "clearInterval", "performance"],
483
+ });
484
+ try {
485
+ const workflows = await compose(clientModule, {
486
+ relationalDb,
487
+ storageId: "/srv/slot-a",
488
+ onSingletonLost: lost,
489
+ });
490
+ await workflows.start();
491
+ expect(workflows.triggers).toEqual({ status: "available" });
492
+
493
+ // The older holder went quiet, so its own slot may take over.
494
+ const leaseDb = await relationalDb.createNamespace<{
495
+ workflow_singleton: { heartbeat_at: Date };
496
+ }>("workflow_runtime");
497
+ await leaseDb
498
+ .updateTable("workflow_singleton")
499
+ .set({ heartbeat_at: new Date(Date.now() - 3_600_000) })
500
+ .execute();
501
+ newer = await engine.acquireWorkflowSingletonLease({
502
+ relationalDb,
503
+ storageId: "/srv/slot-a",
504
+ logger: stubLogger(),
505
+ });
506
+ expect(newer.owner).toBe(workflows.singletonOwner);
507
+
508
+ await vi.advanceTimersByTimeAsync(engine.SINGLETON_HEARTBEAT_MS);
509
+ await vi.waitFor(() =>
510
+ expect(lost).toHaveBeenCalledWith({
511
+ reason: "taken",
512
+ heldBy: newer!.owner,
513
+ }),
514
+ );
515
+ expect(lost).toHaveBeenCalledTimes(1);
516
+ expect(workflows.triggers).toEqual({
517
+ status: "unavailable",
518
+ reason: "workflow-singleton-lost",
519
+ });
520
+ // Only the newer claim's heartbeat is left running.
521
+ expect(vi.getTimerCount()).toBe(1);
522
+
523
+ await workflows.start();
524
+ expect(vi.getTimerCount()).toBe(1);
525
+
526
+ await workflows.stop();
527
+ expect(await newer.heartbeat()).toBe(true);
528
+ } finally {
529
+ await newer?.release();
530
+ vi.useRealTimers();
531
+ }
532
+ });
533
+
534
+ it("shuts the runtime down before handing the claim back when composing fails", async () => {
535
+ const clientModule = await buildReactorModule();
536
+ const relationalDb = createRelationalDb(pglite()) as IRelationalDb;
537
+ const engine = await import("@powerhousedao/reactor-workflow");
538
+ const shutdowns: ReturnType<typeof vi.fn>[] = [];
539
+
540
+ await expect(
541
+ compose(clientModule, {
542
+ relationalDb,
543
+ storageId: "/srv/slot-a",
544
+ load: () =>
545
+ Promise.resolve({
546
+ ...engine,
547
+ createWorkflowRuntime: (host) => {
548
+ const runtime = engine.createWorkflowRuntime(host);
549
+ shutdowns.push(vi.spyOn(runtime, "shutdown"));
550
+ return runtime;
551
+ },
552
+ WorkflowTriggersReadModel: class {
553
+ constructor() {
554
+ throw new Error("the read model could not be built");
555
+ }
556
+ } as never,
557
+ }),
558
+ }),
559
+ ).rejects.toThrow("the read model could not be built");
560
+
561
+ expect(shutdowns).toHaveLength(1);
562
+ expect(shutdowns[0]).toHaveBeenCalledOnce();
563
+ const next = await composeSecond(clientModule, relationalDb);
564
+ await next.stop();
565
+ });
566
+
567
+ const triggerModels = (clientModule: InProcessReactorClientModule) =>
568
+ clientModule.reactorModule!.readModelCoordinator.readModels.filter(
569
+ ({ name }) => name === "workflow-triggers",
570
+ );
571
+
572
+ // The retry path composes again in the same process, against the same
573
+ // coordinator: a registration the failed attempt left behind blocks it.
574
+ it("composes again after a compose that failed past registering the trigger read model", async () => {
575
+ const clientModule = await buildReactorModule();
576
+ const relationalDb = createRelationalDb(pglite()) as IRelationalDb;
577
+
578
+ await expect(
579
+ compose(clientModule, {
580
+ relationalDb,
581
+ http: {
582
+ baseUrl: "https://host.test/workflow",
583
+ get: () => {
584
+ throw new Error("the callback route could not be mounted");
585
+ },
586
+ } as never,
587
+ }),
588
+ ).rejects.toThrow("the callback route could not be mounted");
589
+ expect(triggerModels(clientModule)).toHaveLength(0);
590
+
591
+ const again = await compose(clientModule, { relationalDb });
592
+ expect(again.triggers).toEqual({ status: "available" });
593
+ expect(triggerModels(clientModule)).toHaveLength(1);
594
+ });
595
+
596
+ it("registers no trigger read model when the lease is lost while it initialises", async () => {
597
+ const clientModule = await buildReactorModule();
598
+ const relationalDb = createRelationalDb(pglite()) as IRelationalDb;
599
+ const engine = await import("@powerhousedao/reactor-workflow");
600
+ let onLost:
601
+ | ((loss: { reason: "unrenewable"; silentMs: number }) => void)
602
+ | undefined;
603
+ class LosingReadModel extends engine.WorkflowTriggersReadModel {
604
+ override async init(): Promise<void> {
605
+ onLost?.({ reason: "unrenewable", silentMs: 30_000 });
606
+ await super.init();
607
+ }
608
+ }
609
+
610
+ await expect(
611
+ compose(clientModule, {
612
+ relationalDb,
613
+ load: () =>
614
+ Promise.resolve({
615
+ ...engine,
616
+ WorkflowTriggersReadModel: LosingReadModel,
617
+ acquireWorkflowSingletonLease: (options) => {
618
+ onLost = options.onLost as typeof onLost;
619
+ return engine.acquireWorkflowSingletonLease(options);
620
+ },
621
+ }),
622
+ }),
623
+ ).rejects.toMatchObject({ name: "WorkflowSingletonConflictError" });
624
+ expect(triggerModels(clientModule)).toHaveLength(0);
625
+
626
+ const again = await compose(clientModule, { relationalDb });
627
+ expect(triggerModels(clientModule)).toHaveLength(1);
628
+ await again.stop();
629
+ expect(triggerModels(clientModule)).toHaveLength(0);
630
+ });
631
+
632
+ // Embedded PGlite: no other process can open the journal, so the lease must
633
+ // not turn workflows off over a stalled renewal.
634
+ it("lets the lease fence itself only over a journal others can open", async () => {
635
+ const engine = await import("@powerhousedao/reactor-workflow");
636
+ const selfFence: (boolean | undefined)[] = [];
637
+ const load = () =>
638
+ Promise.resolve({
639
+ ...engine,
640
+ acquireWorkflowSingletonLease: (
641
+ options: Parameters<typeof engine.acquireWorkflowSingletonLease>[0],
642
+ ) => {
643
+ selfFence.push(options.selfFence);
644
+ return engine.acquireWorkflowSingletonLease(options);
645
+ },
646
+ });
647
+
648
+ for (const exclusiveJournal of [true, false]) {
649
+ const composed = await compose(await buildReactorModule(), {
650
+ relationalDb: createRelationalDb(pglite()) as IRelationalDb,
651
+ exclusiveJournal,
652
+ load,
653
+ });
654
+ await composed.stop();
655
+ }
656
+
657
+ expect(selfFence).toEqual([false, true]);
658
+ // Two reactors and two PGlite stores: past 5s on a Windows runner
659
+ }, 30_000);
660
+
661
+ it("opens no journal when the lease is lost before the runtime exists", async () => {
662
+ const clientModule = await buildReactorModule();
663
+ const relationalDb = createRelationalDb(pglite()) as IRelationalDb;
664
+ const engine = await import("@powerhousedao/reactor-workflow");
665
+ const createWorkflowRuntime = vi.fn(engine.createWorkflowRuntime);
666
+
667
+ const composing = compose(clientModule, {
668
+ relationalDb,
669
+ load: () =>
670
+ Promise.resolve({
671
+ ...engine,
672
+ createWorkflowRuntime,
673
+ acquireWorkflowSingletonLease: async (options) => {
674
+ const lease = await engine.acquireWorkflowSingletonLease(options);
675
+ options.onLost?.({ reason: "taken", heldBy: "thief" });
676
+ return lease;
677
+ },
678
+ }),
679
+ });
680
+
681
+ await expect(composing).rejects.toMatchObject({
682
+ name: "WorkflowSingletonConflictError",
683
+ owner: "thief",
684
+ });
685
+ expect(createWorkflowRuntime).not.toHaveBeenCalled();
686
+ });
687
+
688
+ // A rolling deploy whose slots have different owner names: the new pod is
689
+ // refused while the old one holds the lease, and must pick workflows up
690
+ // once the old pod releases it rather than leave nobody running them.
691
+ it("composes a refused host once the holder releases the singleton", async () => {
692
+ const clientModule = await buildReactorModule();
693
+ const relationalDb = createRelationalDb(pglite()) as IRelationalDb;
694
+ const holder = await compose(clientModule, {
695
+ relationalDb,
696
+ storageId: "/srv/slot-a",
697
+ });
698
+ const onComposed = vi.fn(() => Promise.resolve());
699
+ const retry = retryWorkflowSingleton({
700
+ compose: () => composeSecond(clientModule, relationalDb),
701
+ onComposed,
702
+ logger: stubLogger(),
703
+ intervalMs: 20,
704
+ });
705
+ try {
706
+ await new Promise((resolve) => setTimeout(resolve, 150));
707
+ expect(onComposed).not.toHaveBeenCalled();
708
+
709
+ await holder.stop();
710
+
711
+ await vi.waitFor(() => expect(onComposed).toHaveBeenCalledOnce());
712
+ const [taken] = onComposed.mock.calls[0] as unknown as [
713
+ ComposedWorkflowRuntime,
714
+ ];
715
+ expect(taken.singletonOwner).not.toBe(holder.singletonOwner);
716
+ await taken.stop();
717
+ } finally {
718
+ await retry.stop();
719
+ }
720
+ });
721
+
722
+ // Each claim builds its own runtime, so park state seeded under an earlier
723
+ // claim never outlives the gap in which another holder wrote.
724
+ it("reads the parks another holder wrote when it claims again", async () => {
725
+ const clientModule = await buildReactorModule();
726
+ const relationalDb = createRelationalDb(pglite()) as IRelationalDb;
727
+ const engine = await import("@powerhousedao/reactor-workflow");
728
+ const runtimes: WorkflowRuntimeService[] = [];
729
+ const claim = (storageId: string) =>
730
+ composeWorkflowRuntime({
731
+ reactorClient: clientModule.client,
732
+ clientModule: {} as InProcessReactorClientModule,
733
+ relationalDb,
734
+ attachments: {} as never,
735
+ authorizationService: {} as never,
736
+ logger: stubLogger(),
737
+ storageId,
738
+ load: () =>
739
+ Promise.resolve({
740
+ ...engine,
741
+ createWorkflowRuntime: (host) => {
742
+ const runtime = engine.createWorkflowRuntime(host);
743
+ runtimes.push(runtime);
744
+ return runtime;
745
+ },
746
+ }),
747
+ });
748
+ const parkOf = (runtime: WorkflowRuntimeService) =>
749
+ (
750
+ runtime as unknown as {
751
+ parks: { get(id: string): Promise<unknown> };
752
+ }
753
+ ).parks.get("wf-gap");
754
+
755
+ const first = await claim("/srv/slot-a");
756
+ expect(await parkOf(runtimes[0]!)).toBeUndefined();
757
+ await first.stop();
758
+
759
+ const other = await claim("/srv/slot-b");
760
+ await (await runtimes[1]!.store())!.parkWorkflow(
761
+ "wf-gap",
762
+ 1,
763
+ "parked by the other holder",
764
+ );
765
+ await other.stop();
766
+
767
+ const again = await claim("/srv/slot-a");
768
+ try {
769
+ expect(runtimes[2]).not.toBe(runtimes[0]);
770
+ expect(await parkOf(runtimes[2]!)).toMatchObject({
771
+ reason: "parked by the other holder",
772
+ });
773
+ } finally {
774
+ await again.stop();
775
+ }
776
+ });
777
+
778
+ it("stops retrying the claim when the host stops", async () => {
779
+ const clientModule = await buildReactorModule();
780
+ const relationalDb = createRelationalDb(pglite()) as IRelationalDb;
781
+ const holder = await compose(clientModule, {
782
+ relationalDb,
783
+ storageId: "/srv/slot-a",
784
+ });
785
+ const onComposed = vi.fn(() => Promise.resolve());
786
+ const retry = retryWorkflowSingleton({
787
+ compose: () => composeSecond(clientModule, relationalDb),
788
+ onComposed,
789
+ logger: stubLogger(),
790
+ intervalMs: 20,
791
+ });
792
+ await new Promise((resolve) => setTimeout(resolve, 60));
793
+
794
+ await retry.stop();
795
+ await holder.stop();
796
+ await new Promise((resolve) => setTimeout(resolve, 150));
797
+
798
+ expect(onComposed).not.toHaveBeenCalled();
799
+ });
800
+
801
+ it("composes without a claim only when the host opts out", async () => {
802
+ const clientModule = await buildReactorModule();
803
+ const relationalDb = createRelationalDb(pglite()) as IRelationalDb;
804
+
805
+ const unclaimed = await compose(clientModule, {
806
+ relationalDb,
807
+ singletonLease: false,
808
+ });
809
+ expect(unclaimed.singletonOwner).toBeUndefined();
810
+
811
+ // Nothing was claimed, so a host that does claim takes it at once.
812
+ const claimed = await composeSecond(clientModule, relationalDb);
813
+ try {
814
+ expect(claimed.singletonOwner).toBeDefined();
815
+ } finally {
816
+ await claimed.stop();
817
+ }
818
+ });
819
+ });
820
+
821
+ describe("the host's answer to losing the workflow singleton", () => {
822
+ it("stays without workflows, naming the holder, when another claim took it", () => {
823
+ const logger = stubLogger();
824
+ const fatal = vi.fn(() => true);
825
+
826
+ workflowSingletonLossHandler(
827
+ logger,
828
+ fatal,
829
+ )({
830
+ reason: "taken",
831
+ heldBy: "switchboard-1",
832
+ });
833
+
834
+ expect(fatal).not.toHaveBeenCalled();
835
+ const logged = vi
836
+ .mocked(logger.error)
837
+ .mock.calls.map(([line]) => String(line));
838
+ expect(logged.join("\n")).toContain('Another process ("switchboard-1")');
839
+ });
840
+
841
+ // Nobody else runs workflows after a database blip: going down lets the
842
+ // supervisor restart the process, which re-claims at boot.
843
+ it("goes through the fatal shutdown when no takeover was seen", () => {
844
+ const logger = stubLogger();
845
+ const fatal = vi.fn(() => true);
846
+
847
+ workflowSingletonLossHandler(
848
+ logger,
849
+ fatal,
850
+ )({
851
+ reason: "unrenewable",
852
+ silentMs: 30_000,
853
+ });
854
+
855
+ expect(fatal).toHaveBeenCalledOnce();
856
+ const [, error] = fatal.mock.calls[0] as unknown as [string, Error];
857
+ expect(error.message).toContain("no other process was seen");
858
+ expect(error.message).not.toContain("Another process");
859
+ });
860
+
861
+ it("says a restart is needed when no fatal shutdown is installed", () => {
862
+ const logger = stubLogger();
863
+
864
+ workflowSingletonLossHandler(
865
+ logger,
866
+ () => false,
867
+ )({
868
+ reason: "unrenewable",
869
+ silentMs: 30_000,
870
+ });
871
+
872
+ const logged = vi
873
+ .mocked(logger.error)
874
+ .mock.calls.map(([line]) => String(line));
875
+ expect(logged.join("\n")).toContain("until it is restarted");
876
+ expect(logged.join("\n")).not.toContain("Another process");
877
+ });
878
+ });
879
+
880
+ describe("the host's reactor access", () => {
881
+ const signer = { app: { key: "did:key:zHost" }, user: { address: "0xhost" } };
882
+ const moduleWith = (flags?: Record<string, boolean>) =>
883
+ ({
884
+ signer,
885
+ reactorModule: flags ? { featureFlags: flags } : undefined,
886
+ }) as unknown as InProcessReactorClientModule;
887
+
888
+ it("grants the host by key under auth conditions, else by address", () => {
889
+ const byKey = reactorAccessOf(
890
+ moduleWith({ authEnforcement: true, authConditions: true }),
891
+ );
892
+ expect(byKey).toEqual({
893
+ authEnforcement: true,
894
+ authConditions: true,
895
+ identity: { address: "0xhost", key: "did:key:zHost" },
896
+ });
897
+ expect(hostPrincipalOf(byKey)).toEqual({
898
+ match: { eq: [{ attr: "subject.key" }, { lit: "did:key:zHost" }] },
899
+ });
900
+
901
+ const byAddress = reactorAccessOf(
902
+ moduleWith({ authEnforcement: false, authConditions: false }),
903
+ );
904
+ expect(byAddress.authEnforcement).toBe(false);
905
+ expect(hostPrincipalOf(byAddress)).toEqual({ address: "0xhost" });
906
+ });
907
+
908
+ it("reads unknown flags as enforced and a keyless signer as no identity", () => {
909
+ const access = reactorAccessOf({
910
+ signer: {},
911
+ reactorModule: undefined,
912
+ } as unknown as InProcessReactorClientModule);
913
+ expect(access).toEqual({
914
+ authEnforcement: true,
915
+ authConditions: false,
916
+ identity: null,
917
+ });
918
+ expect(hostPrincipalOf(access)).toBeUndefined();
919
+ });
920
+ });
921
+
922
+ // The GraphQL manager rides on the boot result without being on its public
923
+ // type, and the subgraph registers late: poll rather than race it.
924
+ async function pollWorkflowSubgraph(
925
+ switchboard: Awaited<ReturnType<typeof startSwitchboard>>,
926
+ ): Promise<{ name: string } | undefined> {
927
+ const { graphqlManager } = (
928
+ switchboard as unknown as {
929
+ api: { graphqlManager: GraphQLManager };
930
+ }
931
+ ).api;
932
+ for (let attempt = 0; attempt < 100; attempt++) {
933
+ const subgraph = graphqlManager.getSubgraphByName("workflow-runtime");
934
+ if (subgraph) return subgraph;
935
+ await new Promise((resolve) => setTimeout(resolve, 50));
936
+ }
937
+ return undefined;
938
+ }
939
+
940
+ describe("booting Switchboard with workflows on", () => {
941
+ it("arms the intake and registers the workflow document models", async () => {
942
+ const tempRoot = await mkdtemp(join(tmpdir(), "switchboard-workflows-"));
943
+ const previousReactorDb = process.env.PH_REACTOR_DATABASE_URL;
944
+ process.env.PH_REACTOR_DATABASE_URL = join(tempRoot, "reactor-storage");
945
+ let switchboard: Awaited<ReturnType<typeof startSwitchboard>> | undefined;
946
+
947
+ try {
948
+ switchboard = await startSwitchboard({
949
+ workflows: { enabled: true },
950
+ dbPath: join(tempRoot, "read-model"),
951
+ port: 0,
952
+ mcp: false,
953
+ disableLocalPackages: true,
954
+ identity: { keypairPath: join(tempRoot, "identity.json") },
955
+ logger: stubLogger(),
956
+ });
957
+
958
+ expect(switchboard.workflowTriggers).toEqual({ status: "available" });
959
+ const { results } = await switchboard.reactor.getDocumentModelModules();
960
+ expect(
961
+ results.map(({ documentModel }) => documentModel.global.id),
962
+ ).toContain("powerhouse/workflow");
963
+ // With no worker pool or projection worker, workflows alone resolve them.
964
+ expect(
965
+ switchboard.modelManifest().map((entry) => entry.documentType),
966
+ ).toEqual(
967
+ expect.arrayContaining([
968
+ "powerhouse/workflow",
969
+ "powerhouse/connection",
970
+ ]),
971
+ );
972
+ // The subgraph is registered late, so the schema it joins arrives after
973
+ // the boot resolves; poll rather than race it.
974
+ await expect(pollWorkflowSubgraph(switchboard)).resolves.toMatchObject({
975
+ name: "workflow-runtime",
976
+ });
977
+ // The boot type does not publish `api`; the facts sink lives on it.
978
+ const { api } = switchboard as unknown as {
979
+ api: { inspection?: { inspector: { info(): Promise<unknown> } } };
980
+ };
981
+ await expect(api.inspection?.inspector.info()).resolves.toMatchObject({
982
+ workflows: true,
983
+ });
984
+
985
+ await switchboard.shutdown();
986
+ switchboard = undefined;
987
+ } finally {
988
+ await switchboard?.shutdown();
989
+ if (previousReactorDb === undefined) {
990
+ delete process.env.PH_REACTOR_DATABASE_URL;
991
+ } else {
992
+ process.env.PH_REACTOR_DATABASE_URL = previousReactorDb;
993
+ }
994
+ await rm(tempRoot, { recursive: true, force: true });
995
+ }
996
+ }, 60_000);
997
+ });
998
+
999
+ describe("bindPackagePieces", () => {
1000
+ // The manager's shape, no more of it than the binding uses.
1001
+ function stubSource(initial: Map<string, PackagePieceEntry[]>) {
1002
+ const handlers: ((pieces: Map<string, PackagePieceEntry[]>) => void)[] = [];
1003
+ return {
1004
+ getPieces: () => initial,
1005
+ onPiecesChange(
1006
+ handler: (pieces: Map<string, PackagePieceEntry[]>) => void,
1007
+ ) {
1008
+ handlers.push(handler);
1009
+ },
1010
+ emit(pieces: Map<string, PackagePieceEntry[]>) {
1011
+ for (const handler of handlers) handler(pieces);
1012
+ },
1013
+ };
1014
+ }
1015
+
1016
+ const entry = (name: string, version: string): PackagePieceEntry => ({
1017
+ name,
1018
+ version,
1019
+ bundleDir: `/pkg/dist/node/pieces/${name}`,
1020
+ });
1021
+
1022
+ it("fills the registry from what the manager already loaded", () => {
1023
+ const registry = new PieceRegistry();
1024
+ const source = stubSource(
1025
+ new Map([
1026
+ ["@acme/pack", [entry("@acme/piece-a", "1.0.0")]],
1027
+ ["/srv/project", [entry("@acme/piece-b", "2.0.0")]],
1028
+ ]),
1029
+ );
1030
+
1031
+ bindPackagePieces(registry, source);
1032
+
1033
+ expect(registry.versions()).toEqual({
1034
+ "@acme/piece-a": "1.0.0",
1035
+ "@acme/piece-b": "2.0.0",
1036
+ });
1037
+ });
1038
+
1039
+ it("refills on every change, so a rebuilt package needs no restart", () => {
1040
+ const registry = new PieceRegistry();
1041
+ const source = stubSource(new Map());
1042
+
1043
+ bindPackagePieces(registry, source);
1044
+ expect(registry.entries()).toEqual([]);
1045
+
1046
+ source.emit(new Map([["/srv/project", [entry("@acme/piece-b", "2.0.0")]]]));
1047
+ expect(registry.versions()).toEqual({ "@acme/piece-b": "2.0.0" });
1048
+
1049
+ // And a package that stops shipping one loses it.
1050
+ source.emit(new Map());
1051
+ expect(registry.entries()).toEqual([]);
1052
+ });
1053
+
1054
+ it("gives a piece two packages ship to the first by package name, whatever the load order", () => {
1055
+ const registry = new PieceRegistry();
1056
+ const source = stubSource(
1057
+ new Map([
1058
+ ["@zeta/pack", [entry("@acme/piece-a", "9.0.0")]],
1059
+ ["@alpha/pack", [entry("@acme/piece-a", "1.0.0")]],
1060
+ ]),
1061
+ );
1062
+
1063
+ bindPackagePieces(registry, source);
1064
+
1065
+ expect(registry.versions()).toEqual({ "@acme/piece-a": "1.0.0" });
1066
+ });
1067
+ });