@powerhousedao/switchboard 6.2.3-dev.30 → 6.2.3-dev.32

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@powerhousedao/switchboard",
3
3
  "type": "module",
4
- "version": "6.2.3-dev.30",
4
+ "version": "6.2.3-dev.32",
5
5
  "main": "dist/index.mjs",
6
6
  "exports": {
7
7
  ".": {
@@ -41,7 +41,7 @@
41
41
  "@openfeature/core": "1.9.1",
42
42
  "@openfeature/env-var-provider": "0.3.1",
43
43
  "@openfeature/server-sdk": "1.19.0",
44
- "@powerhousedao/config": "6.2.3-dev.30",
44
+ "@powerhousedao/config": "6.2.3-dev.32",
45
45
  "@opentelemetry/api": "^1.9.0",
46
46
  "@opentelemetry/exporter-metrics-otlp-http": "^0.218.0",
47
47
  "@opentelemetry/exporter-trace-otlp-http": "^0.218.0",
@@ -55,22 +55,22 @@
55
55
  "@opentelemetry/sdk-node": "^0.218.0",
56
56
  "@opentelemetry/sdk-trace-base": "^2.7.1",
57
57
  "@opentelemetry/semantic-conventions": "^1.41.1",
58
- "@powerhousedao/opentelemetry-instrumentation-reactor": "6.2.3-dev.30",
59
- "@powerhousedao/reactor": "6.2.3-dev.30",
60
- "@powerhousedao/shared": "6.2.3-dev.30",
61
- "@powerhousedao/vetra": "6.2.3-dev.30",
62
- "@powerhousedao/reactor-api": "6.2.3-dev.30",
63
- "@powerhousedao/reactor-attachments": "6.2.3-dev.30",
64
- "@powerhousedao/reactor-drive": "6.2.3-dev.30",
65
- "@powerhousedao/reactor-group": "6.2.3-dev.30",
66
- "@powerhousedao/reactor-workflow": "6.2.3-dev.30",
67
- "@powerhousedao/workflow": "6.2.3-dev.30",
68
- "@powerhousedao/pglite-fs": "6.2.3-dev.30",
58
+ "@powerhousedao/opentelemetry-instrumentation-reactor": "6.2.3-dev.32",
59
+ "@powerhousedao/reactor": "6.2.3-dev.32",
60
+ "@powerhousedao/shared": "6.2.3-dev.32",
61
+ "@powerhousedao/vetra": "6.2.3-dev.32",
62
+ "@powerhousedao/reactor-api": "6.2.3-dev.32",
63
+ "@powerhousedao/reactor-attachments": "6.2.3-dev.32",
64
+ "@powerhousedao/reactor-drive": "6.2.3-dev.32",
65
+ "@powerhousedao/reactor-group": "6.2.3-dev.32",
66
+ "@powerhousedao/reactor-workflow": "6.2.3-dev.32",
67
+ "@powerhousedao/workflow": "6.2.3-dev.32",
68
+ "@powerhousedao/pglite-fs": "6.2.3-dev.32",
69
69
  "@pyroscope/nodejs": "^0.4.5",
70
- "@renown/sdk": "6.2.3-dev.30",
70
+ "@renown/sdk": "6.2.3-dev.32",
71
71
  "@sentry/node": "^10.53.1",
72
72
  "@sentry/opentelemetry": "^10.53.1",
73
- "document-model": "6.2.3-dev.30",
73
+ "document-model": "6.2.3-dev.32",
74
74
  "dotenv": "^16.4.7",
75
75
  "express": "^4.22.1",
76
76
  "graphql": "^16",
@@ -18,6 +18,10 @@ import {
18
18
  import type { OperationWithContext } from "document-model";
19
19
  import type { Kysely } from "kysely";
20
20
  import { vi } from "vitest";
21
+ import {
22
+ CORE_PIECE_NAME,
23
+ CORE_PIECE_VERSION,
24
+ } from "@powerhousedao/reactor-workflow";
21
25
 
22
26
  export const WORKFLOW_TYPE = "powerhouse/workflow";
23
27
  export const PACKAGE_NAME = "@powerhousedao/workflow";
@@ -33,6 +37,19 @@ export interface FiredRun {
33
37
  kind: string;
34
38
  }
35
39
 
40
+ export interface TriggerFields {
41
+ pieceName: string;
42
+ pieceVersion: string;
43
+ triggerName: string;
44
+ }
45
+
46
+ // The core piece's webhook trigger, pinned to the installed core piece.
47
+ export const WEBHOOK_TRIGGER: TriggerFields = {
48
+ pieceName: CORE_PIECE_NAME,
49
+ pieceVersion: CORE_PIECE_VERSION,
50
+ triggerName: "webhook",
51
+ };
52
+
36
53
  export interface WebhookHost {
37
54
  /** Origin the endpoint is served from, e.g. `http://127.0.0.1:53124`. */
38
55
  readonly url: string;
@@ -42,10 +59,14 @@ export interface WebhookHost {
42
59
  /** Publishes an ENABLED workflow with `config`; returns its endpoint. */
43
60
  arm(
44
61
  config: Record<string, unknown>,
45
- options?: { blockType?: string; workflowId?: string },
62
+ options?: { trigger?: TriggerFields; workflowId?: string },
46
63
  ): Promise<{ token: string; url: string }>;
47
64
  /** Publishes the same workflow as DISABLED, keeping its endpoint row. */
48
65
  disarm(workflowId?: string): Promise<void>;
66
+ /** Deletes the workflow document, as the reactor reports it. */
67
+ remove(workflowId?: string): Promise<void>;
68
+ /** Whether the reactor's webhook store still holds this token. */
69
+ hasToken(token: string): Promise<boolean>;
49
70
  /** The policy the service hands the reactor for one endpoint. */
50
71
  policyFor(workflowId?: string): Promise<unknown>;
51
72
  deliver(
@@ -91,7 +112,11 @@ export function workflowOperation(
91
112
  // `publicUrl` is known only after `listen`: a family registered against the
92
113
  // wrong origin advertises URLs no caller can reach.
93
114
  export async function startWebhookHost(
94
- options: { fire?: (run: FiredRun) => unknown } = {},
115
+ options: {
116
+ fire?: (run: FiredRun) => unknown;
117
+ // Reads and writes of the workflow documents; none by default.
118
+ reactorClient?: unknown;
119
+ } = {},
95
120
  ): Promise<WebhookHost> {
96
121
  // The public factory, not the adapter class: the class is internal, so
97
122
  // constructing it would test a path no consumer can take.
@@ -101,7 +126,8 @@ export async function startWebhookHost(
101
126
  const { port } = server.address() as { port: number };
102
127
  const url = `http://127.0.0.1:${port}`;
103
128
 
104
- const webhooks = new WebhookService({ store: new MemoryWebhookStore() });
129
+ const webhookStore = new MemoryWebhookStore();
130
+ const webhooks = new WebhookService({ store: webhookStore });
105
131
  const routes = new HttpRouteService({
106
132
  httpAdapter: adapter,
107
133
  webhooks,
@@ -120,7 +146,7 @@ export async function startWebhookHost(
120
146
  relationalDb: createRelationalDb(
121
147
  db as unknown as Kysely<unknown>,
122
148
  ) as IRelationalDb,
123
- reactorClient: {
149
+ reactorClient: options.reactorClient ?? {
124
150
  get: () => Promise.reject(new Error("not used")),
125
151
  find: () => Promise.resolve({ results: [] }),
126
152
  },
@@ -154,7 +180,7 @@ export async function startWebhookHost(
154
180
  const publish = async (
155
181
  config: Record<string, unknown>,
156
182
  status: string,
157
- blockType: string,
183
+ trigger: TriggerFields,
158
184
  workflowId: string,
159
185
  ) => {
160
186
  await service.onOperations([
@@ -163,7 +189,7 @@ export async function startWebhookHost(
163
189
  name: "Integration",
164
190
  status,
165
191
  version: ordinal + 1,
166
- trigger: { id: "t1", blockType, config },
192
+ trigger: { id: "t1", ...trigger, config },
167
193
  steps: [],
168
194
  edges: [],
169
195
  variables: [],
@@ -179,10 +205,11 @@ export async function startWebhookHost(
179
205
  service,
180
206
  async arm(config, opts = {}) {
181
207
  const workflowId = opts.workflowId ?? DEFAULT_WORKFLOW;
208
+ // The runtime requires a scheme; an unsigned suite names "none".
182
209
  await publish(
183
- config,
210
+ { scheme: "none", ...config },
184
211
  "ENABLED",
185
- opts.blockType ?? "core#webhook",
212
+ opts.trigger ?? WEBHOOK_TRIGGER,
186
213
  workflowId,
187
214
  );
188
215
  const endpoint = await service.webhookEndpoint(workflowId, CALLER);
@@ -194,7 +221,32 @@ export async function startWebhookHost(
194
221
  };
195
222
  },
196
223
  async disarm(workflowId = DEFAULT_WORKFLOW) {
197
- await publish({}, "DISABLED", "core#webhook", workflowId);
224
+ await publish({}, "DISABLED", WEBHOOK_TRIGGER, workflowId);
225
+ },
226
+ async remove(workflowId = DEFAULT_WORKFLOW) {
227
+ ordinal += 1;
228
+ await service.onOperations([
229
+ {
230
+ operation: {
231
+ index: ordinal,
232
+ timestampUtcMs: `${ordinal}`,
233
+ action: {
234
+ type: "DELETE_DOCUMENT",
235
+ input: { documentId: workflowId },
236
+ },
237
+ },
238
+ context: {
239
+ documentId: workflowId,
240
+ documentType: WORKFLOW_TYPE,
241
+ scope: "document",
242
+ branch: "main",
243
+ ordinal,
244
+ },
245
+ } as unknown as OperationWithContext,
246
+ ]);
247
+ },
248
+ async hasToken(token) {
249
+ return (await webhookStore.find(token)) !== undefined;
198
250
  },
199
251
  policyFor(workflowId = DEFAULT_WORKFLOW) {
200
252
  return service.webhookPolicy(workflowId);
@@ -0,0 +1,214 @@
1
+ // The OAuth2 callback over a real socket through the reactor's route service,
2
+ // and what startOAuth accepts as a redirect and a return URL.
3
+ import {
4
+ createHttpAdapter,
5
+ HttpRouteService,
6
+ type Context,
7
+ type IAuthorizationService,
8
+ } from "@powerhousedao/reactor-api";
9
+ import type { WorkflowRuntimeService } from "@powerhousedao/reactor-workflow";
10
+ import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
11
+ import {
12
+ callbackUrlOf,
13
+ registerOAuthCallback,
14
+ } from "../../src/workflow/oauth-callback.js";
15
+ import { getResolvers } from "../../src/workflow/resolvers.js";
16
+
17
+ const PACKAGE_NAME = "@powerhousedao/workflow";
18
+
19
+ type Resolver = (
20
+ parent: unknown,
21
+ args: unknown,
22
+ ctx: Context,
23
+ ) => Promise<unknown>;
24
+
25
+ describe("the OAuth2 callback route", () => {
26
+ let url: string;
27
+ let callbackUrl: string;
28
+ const completeOAuth = vi.fn();
29
+ let close: () => Promise<void>;
30
+
31
+ beforeAll(async () => {
32
+ const { adapter } = await createHttpAdapter("express");
33
+ adapter.setupMiddleware({});
34
+ const server = await adapter.listen(0, undefined, "127.0.0.1");
35
+ const { port } = server.address() as { port: number };
36
+ url = `http://127.0.0.1:${port}`;
37
+ const routes = new HttpRouteService({
38
+ httpAdapter: adapter,
39
+ publicUrl: url,
40
+ });
41
+ const scope = routes.scopeFor(PACKAGE_NAME);
42
+ registerOAuthCallback(scope, { completeOAuth } as never);
43
+ callbackUrl = callbackUrlOf(scope);
44
+ close = () => new Promise((done) => server.close(() => done()));
45
+ });
46
+
47
+ afterAll(() => close());
48
+
49
+ it("lives under the workflow package's namespace", () => {
50
+ expect(callbackUrl).toBe(
51
+ `${url}/api/@powerhousedao/workflow/oauth/callback`,
52
+ );
53
+ });
54
+
55
+ it("hands the provider's answer to the runtime and says it is done", async () => {
56
+ completeOAuth.mockResolvedValueOnce({
57
+ ok: true,
58
+ detail: null,
59
+ returnUrl: null,
60
+ });
61
+ const response = await fetch(`${callbackUrl}?state=s1&code=c1`);
62
+ expect(response.status).toBe(200);
63
+ expect(response.headers.get("cache-control")).toBe("no-store");
64
+ expect(await response.text()).toContain("<h1>Connected</h1>");
65
+ expect(completeOAuth).toHaveBeenLastCalledWith({
66
+ state: "s1",
67
+ code: "c1",
68
+ error: undefined,
69
+ errorDescription: undefined,
70
+ });
71
+ });
72
+
73
+ it("shows a failure without letting it write markup", async () => {
74
+ completeOAuth.mockResolvedValueOnce({
75
+ ok: false,
76
+ detail: "<script>alert(1)</script>",
77
+ returnUrl: null,
78
+ });
79
+ const response = await fetch(`${callbackUrl}?state=s2&error=access_denied`);
80
+ expect(response.status).toBe(400);
81
+ const html = await response.text();
82
+ expect(html).toContain("&lt;script&gt;alert(1)&lt;/script&gt;");
83
+ expect(html).not.toContain("window.close");
84
+ });
85
+
86
+ it("sends a full-page sign-in back where it started", async () => {
87
+ completeOAuth.mockResolvedValueOnce({
88
+ ok: true,
89
+ detail: null,
90
+ returnUrl: "https://connect.example/d/drive?tab=x",
91
+ });
92
+ const response = await fetch(`${callbackUrl}?state=s3&code=c3`, {
93
+ redirect: "manual",
94
+ });
95
+ expect(response.status).toBe(303);
96
+ expect(response.headers.get("location")).toBe(
97
+ "https://connect.example/d/drive?tab=x&ph_oauth=s3",
98
+ );
99
+ });
100
+
101
+ it("refuses a request with no state", async () => {
102
+ completeOAuth.mockClear();
103
+ const response = await fetch(`${callbackUrl}?code=c4`);
104
+ expect(response.status).toBe(400);
105
+ expect(completeOAuth).not.toHaveBeenCalled();
106
+ });
107
+ });
108
+
109
+ describe("startOAuth", () => {
110
+ const ORIGIN = "https://connect.example";
111
+ const ctx = (origin?: string) =>
112
+ ({
113
+ headers: origin ? { origin } : {},
114
+ db: {},
115
+ user: { address: "0xadmin" },
116
+ }) as unknown as Context;
117
+
118
+ function build(callbackUrl: string | undefined, isAdmin = true) {
119
+ const runtime = {
120
+ startOAuth: vi.fn(() => Promise.resolve({})),
121
+ };
122
+ const resolvers = getResolvers(
123
+ runtime as unknown as WorkflowRuntimeService,
124
+ { isSupremeAdmin: () => isAdmin } as unknown as IAuthorizationService,
125
+ callbackUrl ? { callbackUrl } : undefined,
126
+ ) as Record<string, Record<string, Resolver>>;
127
+ return {
128
+ runtime,
129
+ start: resolvers.WorkflowRuntimeMutations.startOAuth,
130
+ redirectUri: resolvers.WorkflowRuntimeQueries.oauthRedirectUri,
131
+ };
132
+ }
133
+
134
+ const PATH = "/api/@powerhousedao/workflow/oauth/callback";
135
+
136
+ it("is an administrator's, like minting a secret", () => {
137
+ const { start } = build(`https://sb.example${PATH}`, false);
138
+ expect(() => start({}, { connectionId: "c1" }, ctx())).toThrow(
139
+ "Admin access required",
140
+ );
141
+ });
142
+
143
+ it("uses the host's own redirect when it knows its origin", async () => {
144
+ const { runtime, start } = build(`https://sb.example${PATH}`);
145
+ await start(
146
+ {},
147
+ { connectionId: "c1", redirectUri: `https://evil.example${PATH}` },
148
+ ctx(),
149
+ );
150
+ expect(runtime.startOAuth).toHaveBeenCalledWith("c1", expect.anything(), {
151
+ redirectUri: `https://sb.example${PATH}`,
152
+ });
153
+ });
154
+
155
+ it("takes the caller's redirect only when it names this host's path", async () => {
156
+ const { runtime, start, redirectUri } = build(PATH);
157
+ expect(redirectUri({}, {}, ctx())).toBe(PATH);
158
+ expect(() => start({}, { connectionId: "c1" }, ctx())).toThrow(
159
+ "pass redirectUri",
160
+ );
161
+ expect(() =>
162
+ start(
163
+ {},
164
+ { connectionId: "c1", redirectUri: "http://localhost:4001/elsewhere" },
165
+ ctx(),
166
+ ),
167
+ ).toThrow(`redirectUri must be this host's ${PATH}`);
168
+ await start(
169
+ {},
170
+ { connectionId: "c1", redirectUri: `http://localhost:4001${PATH}` },
171
+ ctx(),
172
+ );
173
+ expect(runtime.startOAuth).toHaveBeenLastCalledWith(
174
+ "c1",
175
+ expect.anything(),
176
+ { redirectUri: `http://localhost:4001${PATH}` },
177
+ );
178
+ });
179
+
180
+ it("returns a full-page sign-in only to the page that asked", async () => {
181
+ const { runtime, start } = build(`https://sb.example${PATH}`);
182
+ expect(() =>
183
+ start(
184
+ {},
185
+ { connectionId: "c1", returnUrl: "https://evil.example/" },
186
+ ctx(ORIGIN),
187
+ ),
188
+ ).toThrow("returnUrl must be on the requesting page's origin");
189
+ expect(() =>
190
+ start({}, { connectionId: "c1", returnUrl: `${ORIGIN}/d` }, ctx()),
191
+ ).toThrow("returnUrl must be on the requesting page's origin");
192
+ await start(
193
+ {},
194
+ { connectionId: "c1", returnUrl: `${ORIGIN}/d?x=1` },
195
+ ctx(ORIGIN),
196
+ );
197
+ expect(runtime.startOAuth).toHaveBeenLastCalledWith(
198
+ "c1",
199
+ expect.anything(),
200
+ {
201
+ redirectUri: `https://sb.example${PATH}`,
202
+ returnUrl: `${ORIGIN}/d?x=1`,
203
+ },
204
+ );
205
+ });
206
+
207
+ it("has no redirect to offer on a host without HTTP routes", () => {
208
+ const { redirectUri, start } = build(undefined);
209
+ expect(redirectUri({}, {}, ctx())).toBeNull();
210
+ expect(() => start({}, { connectionId: "c1" }, ctx())).toThrow(
211
+ "This host serves no OAuth2 callback",
212
+ );
213
+ });
214
+ });
@@ -4,7 +4,12 @@ import type {
4
4
  Context,
5
5
  IAuthorizationService,
6
6
  } from "@powerhousedao/reactor-api";
7
- import type { WorkflowRuntimeService } from "@powerhousedao/reactor-workflow";
7
+ import {
8
+ WORKFLOW_SYNCING_MESSAGE,
9
+ WorkflowSyncingError,
10
+ type WorkflowRuntimeService,
11
+ } from "@powerhousedao/reactor-workflow";
12
+ import { parse, type FieldNode, type OperationDefinitionNode } from "graphql";
8
13
  import { describe, expect, it, vi } from "vitest";
9
14
  import { getResolvers } from "../../src/workflow/resolvers.js";
10
15
 
@@ -18,16 +23,33 @@ type Resolver = (
18
23
  parent: unknown,
19
24
  args: unknown,
20
25
  ctx: Context,
26
+ info?: unknown,
21
27
  ) => Promise<unknown>;
22
28
 
29
+ // The resolve info for the first runtime field of a real query document.
30
+ function runsInfo(query: string) {
31
+ const operation = parse(query).definitions[0] as OperationDefinitionNode;
32
+ const root = operation.selectionSet.selections[0] as FieldNode;
33
+ return { fieldNodes: [root.selectionSet!.selections[0] as FieldNode] };
34
+ }
35
+
36
+ const emptyPage = (): Promise<unknown> =>
37
+ Promise.resolve({ records: [], hasNextPage: false, cursor: null });
38
+
23
39
  function fakeRuntime() {
24
40
  return {
25
41
  webhookEndpoint: vi.fn(() => Promise.resolve(null)),
26
42
  triggerStates: vi.fn(() => Promise.resolve([])),
27
43
  runs: vi.fn(() => Promise.resolve([])),
44
+ runsPage: vi.fn(emptyPage),
28
45
  run: vi.fn(() => Promise.resolve(null)),
29
46
  fire: vi.fn(() => Promise.resolve({})),
30
47
  rerun: vi.fn(() => Promise.resolve({})),
48
+ testStep: vi.fn(() => Promise.resolve({})),
49
+ stepOutputTree: vi.fn(() => Promise.resolve({})),
50
+ blockResolutions: vi.fn(() => Promise.resolve([])),
51
+ testTrigger: vi.fn(() => Promise.resolve(null)),
52
+ cancelTriggerTestFor: vi.fn(() => Promise.resolve(true)),
31
53
  secrets: vi.fn(() =>
32
54
  Promise.resolve({
33
55
  create: vi.fn(() => Promise.resolve({ ref: "secret://v1:00" })),
@@ -64,10 +86,22 @@ describe("the workflow resolvers and the caller", () => {
64
86
  await queries.run({}, { id: "run-1" }, CTX);
65
87
  await mutations.fire({}, { workflowId: "wf-1", payload: { a: 1 } }, CTX);
66
88
  await mutations.rerun({}, { runId: "run-1" }, CTX);
89
+ await mutations.testStep({}, { workflowId: "wf-1", stepId: "s1" }, CTX);
90
+ await queries.stepOutputTree({}, { workflowId: "wf-1", stepId: "s1" }, CTX);
91
+ await queries.blockResolutions({}, { workflowId: "wf-1" }, CTX);
92
+ await mutations.testTrigger(
93
+ {},
94
+ { workflowId: "wf-1", payload: { a: 1 }, timeoutSeconds: 30 },
95
+ CTX,
96
+ );
97
+ await mutations.cancelTriggerTest({}, { workflowId: "wf-1" }, CTX);
67
98
 
68
- expect(runtime.webhookEndpoint).toHaveBeenCalledWith("wf-1", CTX);
99
+ expect(runtime.webhookEndpoint).toHaveBeenCalledWith("wf-1", CTX, {});
69
100
  expect(runtime.triggerStates).toHaveBeenCalledWith(CTX);
70
- expect(runtime.runs).toHaveBeenCalledWith({ driveId: "drive-1" }, CTX);
101
+ expect(runtime.runs).toHaveBeenCalledWith(
102
+ { driveId: "drive-1", withStepData: true },
103
+ CTX,
104
+ );
71
105
  expect(runtime.run).toHaveBeenCalledWith("run-1", CTX);
72
106
  expect(runtime.fire).toHaveBeenCalledWith(
73
107
  "wf-1",
@@ -77,6 +111,36 @@ describe("the workflow resolvers and the caller", () => {
77
111
  CTX,
78
112
  );
79
113
  expect(runtime.rerun).toHaveBeenCalledWith("run-1", CTX);
114
+ expect(runtime.testStep).toHaveBeenCalledWith("wf-1", "s1", CTX, {});
115
+ expect(runtime.stepOutputTree).toHaveBeenCalledWith("wf-1", "s1", CTX);
116
+ expect(runtime.blockResolutions).toHaveBeenCalledWith("wf-1", CTX);
117
+ expect(runtime.testTrigger).toHaveBeenCalledWith("wf-1", CTX, {
118
+ payload: { a: 1 },
119
+ timeoutMs: 30_000,
120
+ });
121
+ expect(runtime.cancelTriggerTestFor).toHaveBeenCalledWith("wf-1", CTX);
122
+ });
123
+
124
+ it("passes the caller's drive, and tags a still-syncing workflow", async () => {
125
+ const { runtime, queries, mutations } = build(true);
126
+ const drive = { driveId: "drive-1" };
127
+
128
+ await queries.webhookEndpoint({}, { workflowId: "wf-1", ...drive }, CTX);
129
+ await mutations.testTrigger({}, { workflowId: "wf-1", ...drive }, CTX);
130
+ runtime.testStep.mockRejectedValueOnce(new WorkflowSyncingError());
131
+ const syncing = mutations.testStep(
132
+ {},
133
+ { workflowId: "wf-1", stepId: "s1", ...drive },
134
+ CTX,
135
+ );
136
+
137
+ expect(runtime.webhookEndpoint).toHaveBeenCalledWith("wf-1", CTX, drive);
138
+ expect(runtime.testTrigger).toHaveBeenCalledWith("wf-1", CTX, drive);
139
+ await expect(syncing).rejects.toMatchObject({
140
+ message: WORKFLOW_SYNCING_MESSAGE,
141
+ extensions: { code: "WORKFLOW_SYNCING", retryable: true },
142
+ });
143
+ expect(runtime.testStep).toHaveBeenCalledWith("wf-1", "s1", CTX, drive);
80
144
  });
81
145
 
82
146
  it("refuses secret writes to a caller who does not administer the reactor", async () => {
@@ -102,4 +166,131 @@ describe("the workflow resolvers and the caller", () => {
102
166
 
103
167
  expect(runtime.secrets).toHaveBeenCalledTimes(1);
104
168
  });
169
+
170
+ it("serves the piece version a run's steps ran", async () => {
171
+ const { runtime, queries } = build(true);
172
+ runtime.run.mockResolvedValueOnce({
173
+ row: { id: "run-1", warnings: 1, trigger_payload: null },
174
+ steps: [
175
+ {
176
+ step_id: "s1",
177
+ input: null,
178
+ output: null,
179
+ piece_version: "2.1.0",
180
+ piece_source: "registry",
181
+ version_match: "fallback",
182
+ version_note:
183
+ "Pinned 3.0.0 is not available; runs 2.1.0 from registry",
184
+ },
185
+ ],
186
+ } as never);
187
+
188
+ const run = (await queries.run({}, { id: "run-1" }, CTX)) as {
189
+ warnings: number;
190
+ steps: Record<string, unknown>[];
191
+ };
192
+
193
+ expect(run.warnings).toBe(1);
194
+ expect(run.steps[0]).toMatchObject({
195
+ pieceVersion: "2.1.0",
196
+ pieceSource: "registry",
197
+ versionMatch: "fallback",
198
+ versionNote: "Pinned 3.0.0 is not available; runs 2.1.0 from registry",
199
+ });
200
+ });
201
+ });
202
+
203
+ describe("the runs listing and step data", () => {
204
+ it("reads step blobs only when the query selects them", async () => {
205
+ const { runtime, queries } = build(false);
206
+ const withData = (query: string) =>
207
+ queries.runs({}, {}, CTX, runsInfo(query)).then(
208
+ () =>
209
+ (runtime.runs.mock.calls.at(-1) as unknown[])[0] as {
210
+ withStepData: boolean;
211
+ },
212
+ );
213
+
214
+ expect(
215
+ (await withData("{ workflowRuntime { runs { id status } } }"))
216
+ .withStepData,
217
+ ).toBe(false);
218
+ expect(
219
+ (
220
+ await withData(
221
+ "{ workflowRuntime { runs { id steps { stepId status } } } }",
222
+ )
223
+ ).withStepData,
224
+ ).toBe(false);
225
+ expect(
226
+ (
227
+ await withData(
228
+ "{ workflowRuntime { runs { id steps { stepId output } } } }",
229
+ )
230
+ ).withStepData,
231
+ ).toBe(true);
232
+ // A fragment can select anything, so it reads as asking for the blobs.
233
+ expect(
234
+ (
235
+ await withData(
236
+ "{ workflowRuntime { runs { id steps { ...S } } } } fragment S on WorkflowStepRunRecord { input }",
237
+ )
238
+ ).withStepData,
239
+ ).toBe(true);
240
+ });
241
+ });
242
+
243
+ describe("the runs page", () => {
244
+ it("passes paging through and serves the page", async () => {
245
+ const { runtime, queries } = build(false);
246
+ runtime.runsPage.mockResolvedValueOnce({
247
+ records: [],
248
+ hasNextPage: true,
249
+ cursor: "next",
250
+ });
251
+
252
+ const page = await queries.runsPage(
253
+ {},
254
+ {
255
+ driveId: "drive-1",
256
+ excludeTriggerKinds: ["test"],
257
+ paging: { limit: 10, cursor: "prev" },
258
+ },
259
+ CTX,
260
+ runsInfo(
261
+ "{ workflowRuntime { runsPage { items { id steps { status } } cursor } } }",
262
+ ),
263
+ );
264
+
265
+ expect(runtime.runsPage).toHaveBeenCalledWith(
266
+ {
267
+ driveId: "drive-1",
268
+ excludeTriggerKinds: ["test"],
269
+ limit: 10,
270
+ cursor: "prev",
271
+ withStepData: false,
272
+ },
273
+ CTX,
274
+ );
275
+ expect(page).toEqual({
276
+ items: [],
277
+ hasNextPage: true,
278
+ hasPreviousPage: true,
279
+ cursor: "next",
280
+ });
281
+ });
282
+
283
+ it("reports a cursor it did not issue as bad input", async () => {
284
+ const { runtime, queries } = build(false);
285
+ const invalid = new Error("Invalid runs cursor");
286
+ invalid.name = "InvalidRunCursorError";
287
+ runtime.runsPage.mockRejectedValueOnce(invalid);
288
+
289
+ const error = (await queries
290
+ .runsPage({}, { paging: { cursor: "junk" } }, CTX)
291
+ .catch((thrown: unknown) => thrown)) as {
292
+ extensions?: { code?: string };
293
+ };
294
+ expect(error.extensions?.code).toBe("BAD_USER_INPUT");
295
+ });
105
296
  });
@@ -24,7 +24,7 @@ interface PolicyShape {
24
24
  challengeField?: unknown;
25
25
  }
26
26
 
27
- describe("core#webhook redelivery", () => {
27
+ describe("the core webhook trigger redelivery", () => {
28
28
  it("dedupes on a bare field name found in the query string", async () => {
29
29
  host = await startWebhookHost();
30
30
  const { token } = await host.arm({
@@ -207,7 +207,7 @@ describe("core#webhook redelivery", () => {
207
207
  });
208
208
  });
209
209
 
210
- describe("core#webhook endpoint verification", () => {
210
+ describe("the core webhook trigger endpoint verification", () => {
211
211
  it("echoes a bare challenge field from the query string without running", async () => {
212
212
  host = await startWebhookHost();
213
213
  const { token } = await host.arm({
@@ -262,7 +262,7 @@ describe("core#webhook endpoint verification", () => {
262
262
  });
263
263
  });
264
264
 
265
- describe("core#webhook field policy", () => {
265
+ describe("the core webhook trigger field policy", () => {
266
266
  it("hands the reactor a source-tagged field for a prefixed config", async () => {
267
267
  host = await startWebhookHost();
268
268
  await host.arm({
@@ -1,4 +1,4 @@
1
- // The signature-verifying half of core#webhook, over a real socket: config,
1
+ // The signature-verifying half of the core webhook trigger, over a real socket: config,
2
2
  // resolved policy and the reactor's verifier have to agree on the wire format.
3
3
  import { createHmac } from "node:crypto";
4
4
  import { afterEach, describe, expect, it } from "vitest";
@@ -42,7 +42,7 @@ async function expectNoRun(current: WebhookHost): Promise<void> {
42
42
  expect(current.fired).toHaveLength(0);
43
43
  }
44
44
 
45
- describe("core#webhook signature verification", () => {
45
+ describe("the core webhook trigger signature verification", () => {
46
46
  describe("the three layouts", () => {
47
47
  it("accepts a bare digest under hmac and starts a run", async () => {
48
48
  host = await startWebhookHost();