@desplega.ai/agent-swarm 1.74.0 → 1.74.2

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 (43) hide show
  1. package/openapi.json +199 -1
  2. package/package.json +1 -1
  3. package/src/be/db.ts +278 -0
  4. package/src/be/migrations/049_wait_states.sql +30 -0
  5. package/src/be/migrations/050_wait_states_scope.sql +19 -0
  6. package/src/http/index.ts +2 -0
  7. package/src/http/trackers/jira.ts +84 -27
  8. package/src/http/trackers/linear.ts +67 -11
  9. package/src/http/utils.ts +15 -0
  10. package/src/http/workflow-events.ts +107 -0
  11. package/src/http/workflows.ts +55 -6
  12. package/src/jira/sync.ts +20 -7
  13. package/src/linear/gate.ts +122 -0
  14. package/src/linear/sync.ts +128 -0
  15. package/src/oauth/keepalive.ts +34 -13
  16. package/src/tests/ensure-token.test.ts +33 -0
  17. package/src/tests/linear-webhook.test.ts +383 -0
  18. package/src/tests/workflow-executors.test.ts +4 -2
  19. package/src/tests/workflow-mcp-trigger-schema.test.ts +617 -0
  20. package/src/tests/workflow-patch.test.ts +14 -14
  21. package/src/tests/workflow-wait-builtin-events.test.ts +279 -0
  22. package/src/tests/workflow-wait-event.test.ts +384 -0
  23. package/src/tests/workflow-wait-filter.test.ts +200 -0
  24. package/src/tests/workflow-wait-http.test.ts +177 -0
  25. package/src/tests/workflow-wait-recovery.test.ts +178 -0
  26. package/src/tests/workflow-wait-state-queries.test.ts +419 -0
  27. package/src/tests/workflow-wait-time.test.ts +255 -0
  28. package/src/tools/tracker/tracker-status.ts +7 -1
  29. package/src/tools/workflows/create-workflow.ts +16 -2
  30. package/src/tools/workflows/patch-workflow.ts +26 -6
  31. package/src/tools/workflows/trigger-workflow.ts +26 -1
  32. package/src/tools/workflows/update-workflow.ts +28 -2
  33. package/src/types.ts +48 -3
  34. package/src/workflows/definition.ts +2 -5
  35. package/src/workflows/executors/index.ts +1 -0
  36. package/src/workflows/executors/registry.ts +2 -0
  37. package/src/workflows/executors/wait.ts +170 -0
  38. package/src/workflows/index.ts +18 -2
  39. package/src/workflows/json-schema-validator.ts +8 -1
  40. package/src/workflows/recovery.ts +55 -1
  41. package/src/workflows/resume.ts +272 -0
  42. package/src/workflows/wait-filter.ts +311 -0
  43. package/src/workflows/wait-poller.ts +63 -0
@@ -0,0 +1,419 @@
1
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
+ import { unlink } from "node:fs/promises";
3
+ import {
4
+ closeDb,
5
+ createWaitState,
6
+ createWorkflow,
7
+ createWorkflowRun,
8
+ createWorkflowRunStep,
9
+ getDueWaitStates,
10
+ getPendingWaitsByEvent,
11
+ getStuckWaitRuns,
12
+ getWaitStateById,
13
+ getWaitStateByStepId,
14
+ initDb,
15
+ resolveWaitState,
16
+ updateWorkflowRun,
17
+ updateWorkflowRunStep,
18
+ } from "../be/db";
19
+ import type { WorkflowDefinition } from "../types";
20
+
21
+ const TEST_DB_PATH = "./test-workflow-wait-state-queries.sqlite";
22
+
23
+ beforeAll(() => {
24
+ initDb(TEST_DB_PATH);
25
+ });
26
+
27
+ afterAll(async () => {
28
+ closeDb();
29
+ for (const suffix of ["", "-wal", "-shm"]) {
30
+ await unlink(`${TEST_DB_PATH}${suffix}`).catch(() => {});
31
+ }
32
+ });
33
+
34
+ const minimalWorkflowDef: WorkflowDefinition = {
35
+ nodes: [{ id: "n1", type: "notify", config: { message: "hi" } }],
36
+ };
37
+
38
+ function makeWorkflow(name: string) {
39
+ return createWorkflow({
40
+ name,
41
+ definition: minimalWorkflowDef,
42
+ });
43
+ }
44
+
45
+ function timeIso(offsetMs: number): string {
46
+ return new Date(Date.now() + offsetMs).toISOString();
47
+ }
48
+
49
+ describe("createWaitState + getWaitStateById", () => {
50
+ test("inserts a time-mode row and round-trips", () => {
51
+ const id = crypto.randomUUID();
52
+ const runId = crypto.randomUUID();
53
+ const stepId = crypto.randomUUID();
54
+ const wakeUpAt = timeIso(60_000);
55
+
56
+ const row = createWaitState({
57
+ id,
58
+ workflowRunId: runId,
59
+ workflowRunStepId: stepId,
60
+ mode: "time",
61
+ wakeUpAt,
62
+ });
63
+
64
+ expect(row.id).toBe(id);
65
+ expect(row.mode).toBe("time");
66
+ expect(row.status).toBe("pending");
67
+ expect(row.wakeUpAt).toBe(wakeUpAt);
68
+ expect(row.eventName).toBeNull();
69
+ expect(row.expiresAt).toBeNull();
70
+
71
+ const fetched = getWaitStateById(id);
72
+ expect(fetched).not.toBeNull();
73
+ expect(fetched?.id).toBe(id);
74
+ expect(fetched?.workflowRunId).toBe(runId);
75
+ });
76
+
77
+ test("inserts an event-mode row with object filter", () => {
78
+ const id = crypto.randomUUID();
79
+ const stepId = crypto.randomUUID();
80
+ const filter = { number: 42, "pr.merged": true };
81
+
82
+ const row = createWaitState({
83
+ id,
84
+ workflowRunId: crypto.randomUUID(),
85
+ workflowRunStepId: stepId,
86
+ mode: "event",
87
+ eventName: "github.pull_request.merged",
88
+ eventFilter: filter,
89
+ expiresAt: timeIso(3_600_000),
90
+ });
91
+
92
+ expect(row.mode).toBe("event");
93
+ expect(row.eventName).toBe("github.pull_request.merged");
94
+ expect(row.eventFilter).toEqual(filter);
95
+ });
96
+
97
+ test("inserts an event-mode row with string filter (arrow-fn body)", () => {
98
+ const id = crypto.randomUUID();
99
+ const stepId = crypto.randomUUID();
100
+ const filter = "(p) => p.number > 100";
101
+
102
+ const row = createWaitState({
103
+ id,
104
+ workflowRunId: crypto.randomUUID(),
105
+ workflowRunStepId: stepId,
106
+ mode: "event",
107
+ eventName: "github.pull_request.merged",
108
+ eventFilter: filter,
109
+ });
110
+
111
+ expect(row.eventFilter).toBe(filter);
112
+ });
113
+ });
114
+
115
+ describe("getWaitStateByStepId", () => {
116
+ test("idempotency: a second create on the same stepId is detectable", () => {
117
+ const stepId = crypto.randomUUID();
118
+ const runId = crypto.randomUUID();
119
+ createWaitState({
120
+ id: crypto.randomUUID(),
121
+ workflowRunId: runId,
122
+ workflowRunStepId: stepId,
123
+ mode: "time",
124
+ wakeUpAt: timeIso(10_000),
125
+ });
126
+
127
+ const found = getWaitStateByStepId(stepId);
128
+ expect(found).not.toBeNull();
129
+ expect(found?.workflowRunStepId).toBe(stepId);
130
+ });
131
+
132
+ test("returns null for unknown stepId", () => {
133
+ expect(getWaitStateByStepId(crypto.randomUUID())).toBeNull();
134
+ });
135
+ });
136
+
137
+ describe("getDueWaitStates", () => {
138
+ test("returns time-mode waits with wakeUpAt in the past", () => {
139
+ const overdueId = crypto.randomUUID();
140
+ const futureId = crypto.randomUUID();
141
+ createWaitState({
142
+ id: overdueId,
143
+ workflowRunId: crypto.randomUUID(),
144
+ workflowRunStepId: crypto.randomUUID(),
145
+ mode: "time",
146
+ wakeUpAt: timeIso(-60_000), // 1 min ago
147
+ });
148
+ createWaitState({
149
+ id: futureId,
150
+ workflowRunId: crypto.randomUUID(),
151
+ workflowRunStepId: crypto.randomUUID(),
152
+ mode: "time",
153
+ wakeUpAt: timeIso(60_000), // 1 min from now
154
+ });
155
+
156
+ const due = getDueWaitStates();
157
+ const dueIds = new Set(due.map((r) => r.id));
158
+ expect(dueIds.has(overdueId)).toBe(true);
159
+ expect(dueIds.has(futureId)).toBe(false);
160
+ });
161
+
162
+ test("returns event-mode waits with expiresAt in the past", () => {
163
+ const overdueId = crypto.randomUUID();
164
+ createWaitState({
165
+ id: overdueId,
166
+ workflowRunId: crypto.randomUUID(),
167
+ workflowRunStepId: crypto.randomUUID(),
168
+ mode: "event",
169
+ eventName: "demo.signal",
170
+ expiresAt: timeIso(-30_000),
171
+ });
172
+
173
+ const due = getDueWaitStates();
174
+ expect(due.find((r) => r.id === overdueId)).toBeDefined();
175
+ });
176
+
177
+ test("excludes already-fired or already-timeout rows", () => {
178
+ const id = crypto.randomUUID();
179
+ createWaitState({
180
+ id,
181
+ workflowRunId: crypto.randomUUID(),
182
+ workflowRunStepId: crypto.randomUUID(),
183
+ mode: "time",
184
+ wakeUpAt: timeIso(-1_000),
185
+ });
186
+ const result = resolveWaitState(id, { status: "fired" });
187
+ expect(result.updated).toBe(true);
188
+
189
+ const due = getDueWaitStates();
190
+ expect(due.find((r) => r.id === id)).toBeUndefined();
191
+ });
192
+
193
+ test("excludes event waits with no expiresAt (open-ended)", () => {
194
+ const id = crypto.randomUUID();
195
+ createWaitState({
196
+ id,
197
+ workflowRunId: crypto.randomUUID(),
198
+ workflowRunStepId: crypto.randomUUID(),
199
+ mode: "event",
200
+ eventName: "open.signal",
201
+ // expiresAt: null — open-ended
202
+ });
203
+ const due = getDueWaitStates();
204
+ expect(due.find((r) => r.id === id)).toBeUndefined();
205
+ });
206
+ });
207
+
208
+ describe("getPendingWaitsByEvent", () => {
209
+ test("returns only matching pending event-mode waits", () => {
210
+ const matchId = crypto.randomUUID();
211
+ const otherEventId = crypto.randomUUID();
212
+ const timeId = crypto.randomUUID();
213
+
214
+ createWaitState({
215
+ id: matchId,
216
+ workflowRunId: crypto.randomUUID(),
217
+ workflowRunStepId: crypto.randomUUID(),
218
+ mode: "event",
219
+ eventName: "release.cut",
220
+ });
221
+ createWaitState({
222
+ id: otherEventId,
223
+ workflowRunId: crypto.randomUUID(),
224
+ workflowRunStepId: crypto.randomUUID(),
225
+ mode: "event",
226
+ eventName: "different.signal",
227
+ });
228
+ createWaitState({
229
+ id: timeId,
230
+ workflowRunId: crypto.randomUUID(),
231
+ workflowRunStepId: crypto.randomUUID(),
232
+ mode: "time",
233
+ wakeUpAt: timeIso(60_000),
234
+ });
235
+
236
+ const pending = getPendingWaitsByEvent("release.cut");
237
+ const ids = new Set(pending.map((r) => r.id));
238
+ expect(ids.has(matchId)).toBe(true);
239
+ expect(ids.has(otherEventId)).toBe(false);
240
+ expect(ids.has(timeId)).toBe(false);
241
+ });
242
+
243
+ test("runId narrows to run-scoped waits", () => {
244
+ const runA = crypto.randomUUID();
245
+ const runB = crypto.randomUUID();
246
+ const aId = crypto.randomUUID();
247
+ const bId = crypto.randomUUID();
248
+ createWaitState({
249
+ id: aId,
250
+ workflowRunId: runA,
251
+ workflowRunStepId: crypto.randomUUID(),
252
+ mode: "event",
253
+ eventName: "scoped.evt",
254
+ });
255
+ createWaitState({
256
+ id: bId,
257
+ workflowRunId: runB,
258
+ workflowRunStepId: crypto.randomUUID(),
259
+ mode: "event",
260
+ eventName: "scoped.evt",
261
+ });
262
+
263
+ const onlyA = getPendingWaitsByEvent("scoped.evt", runA);
264
+ expect(onlyA.map((r) => r.id)).toEqual([aId]);
265
+
266
+ const both = getPendingWaitsByEvent("scoped.evt");
267
+ const ids = new Set(both.map((r) => r.id));
268
+ expect(ids.has(aId)).toBe(true);
269
+ expect(ids.has(bId)).toBe(true);
270
+ });
271
+ });
272
+
273
+ describe("resolveWaitState — race-safety", () => {
274
+ test("first caller wins, second sees updated=false", () => {
275
+ const id = crypto.randomUUID();
276
+ createWaitState({
277
+ id,
278
+ workflowRunId: crypto.randomUUID(),
279
+ workflowRunStepId: crypto.randomUUID(),
280
+ mode: "time",
281
+ wakeUpAt: timeIso(-1_000),
282
+ });
283
+
284
+ const a = resolveWaitState(id, { status: "fired", firedPayload: { winner: "A" } });
285
+ const b = resolveWaitState(id, { status: "fired", firedPayload: { winner: "B" } });
286
+
287
+ expect(a.updated).toBe(true);
288
+ expect(a.row?.status).toBe("fired");
289
+ expect(a.row?.firedPayload).toEqual({ winner: "A" });
290
+
291
+ expect(b.updated).toBe(false);
292
+ expect(b.row).toBeNull();
293
+
294
+ // Verify final stored state reflects A's payload
295
+ const final = getWaitStateById(id);
296
+ expect(final?.firedPayload).toEqual({ winner: "A" });
297
+ expect(final?.resolvedAt).not.toBeNull();
298
+ });
299
+
300
+ test("transitions to timeout status without payload", () => {
301
+ const id = crypto.randomUUID();
302
+ createWaitState({
303
+ id,
304
+ workflowRunId: crypto.randomUUID(),
305
+ workflowRunStepId: crypto.randomUUID(),
306
+ mode: "event",
307
+ eventName: "evt",
308
+ expiresAt: timeIso(-1_000),
309
+ });
310
+
311
+ const r = resolveWaitState(id, { status: "timeout" });
312
+ expect(r.updated).toBe(true);
313
+ expect(r.row?.status).toBe("timeout");
314
+ expect(r.row?.firedPayload).toBeNull();
315
+ });
316
+
317
+ test("returns updated=false for unknown id", () => {
318
+ const r = resolveWaitState(crypto.randomUUID(), { status: "fired" });
319
+ expect(r.updated).toBe(false);
320
+ });
321
+ });
322
+
323
+ describe("getStuckWaitRuns", () => {
324
+ // The query JOINs workflow_runs (waiting) → workflow_run_steps (waiting,
325
+ // nodeType='wait') → wait_states. We build that triple via the public
326
+ // helpers so the test mirrors what `recoverWaitStates` will see in production.
327
+ test("returns waits whose run+step are 'waiting' and wait_state is overdue (case b)", () => {
328
+ const wf = makeWorkflow("stuck-wait-overdue");
329
+ const run = createWorkflowRun({
330
+ id: crypto.randomUUID(),
331
+ workflowId: wf.id,
332
+ });
333
+ updateWorkflowRun(run.id, { status: "waiting" });
334
+
335
+ const step = createWorkflowRunStep({
336
+ id: crypto.randomUUID(),
337
+ runId: run.id,
338
+ nodeId: "w1",
339
+ nodeType: "wait",
340
+ });
341
+ updateWorkflowRunStep(step.id, { status: "waiting" });
342
+
343
+ const overdueId = crypto.randomUUID();
344
+ createWaitState({
345
+ id: overdueId,
346
+ workflowRunId: run.id,
347
+ workflowRunStepId: step.id,
348
+ mode: "time",
349
+ wakeUpAt: timeIso(-60_000),
350
+ });
351
+
352
+ const stuck = getStuckWaitRuns();
353
+ const stuckIds = new Set(stuck.map((r) => r.waitId));
354
+ expect(stuckIds.has(overdueId)).toBe(true);
355
+ const found = stuck.find((r) => r.waitId === overdueId);
356
+ expect(found?.runId).toBe(run.id);
357
+ expect(found?.stepId).toBe(step.id);
358
+ expect(found?.waitMode).toBe("time");
359
+ expect(found?.waitStatus).toBe("pending");
360
+ });
361
+
362
+ test("returns waits whose status is non-pending while the step is still waiting (case a)", () => {
363
+ const wf = makeWorkflow("stuck-wait-fired-while-down");
364
+ const run = createWorkflowRun({
365
+ id: crypto.randomUUID(),
366
+ workflowId: wf.id,
367
+ });
368
+ updateWorkflowRun(run.id, { status: "waiting" });
369
+
370
+ const step = createWorkflowRunStep({
371
+ id: crypto.randomUUID(),
372
+ runId: run.id,
373
+ nodeId: "w2",
374
+ nodeType: "wait",
375
+ });
376
+ updateWorkflowRunStep(step.id, { status: "waiting" });
377
+
378
+ const firedId = crypto.randomUUID();
379
+ createWaitState({
380
+ id: firedId,
381
+ workflowRunId: run.id,
382
+ workflowRunStepId: step.id,
383
+ mode: "event",
384
+ eventName: "x",
385
+ });
386
+ resolveWaitState(firedId, { status: "fired", firedPayload: { hello: "world" } });
387
+
388
+ const stuck = getStuckWaitRuns();
389
+ const found = stuck.find((r) => r.waitId === firedId);
390
+ expect(found).toBeDefined();
391
+ expect(found?.waitStatus).toBe("fired");
392
+ });
393
+
394
+ test("excludes runs that aren't 'waiting' or steps that aren't 'wait' nodeType", () => {
395
+ const wf = makeWorkflow("stuck-wait-excludes");
396
+ // Run NOT in waiting state — should be excluded.
397
+ const run = createWorkflowRun({ id: crypto.randomUUID(), workflowId: wf.id });
398
+ // Leave run.status default (running)
399
+ const step = createWorkflowRunStep({
400
+ id: crypto.randomUUID(),
401
+ runId: run.id,
402
+ nodeId: "w3",
403
+ nodeType: "wait",
404
+ });
405
+ updateWorkflowRunStep(step.id, { status: "waiting" });
406
+
407
+ const id = crypto.randomUUID();
408
+ createWaitState({
409
+ id,
410
+ workflowRunId: run.id,
411
+ workflowRunStepId: step.id,
412
+ mode: "time",
413
+ wakeUpAt: timeIso(-60_000),
414
+ });
415
+
416
+ const stuck = getStuckWaitRuns();
417
+ expect(stuck.find((r) => r.waitId === id)).toBeUndefined();
418
+ });
419
+ });
@@ -0,0 +1,255 @@
1
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
+ import { unlink } from "node:fs/promises";
3
+ import * as db from "../be/db";
4
+ import {
5
+ closeDb,
6
+ createWorkflow,
7
+ deleteWorkflow,
8
+ getDueWaitStates,
9
+ getWaitStateByStepId,
10
+ getWorkflowRun,
11
+ getWorkflowRunStepsByRunId,
12
+ initDb,
13
+ } from "../be/db";
14
+ import type { Workflow, WorkflowDefinition } from "../types";
15
+ import { startWorkflowExecution } from "../workflows/engine";
16
+ import { InProcessEventBus } from "../workflows/event-bus";
17
+ import type { ExecutorDependencies } from "../workflows/executors/base";
18
+ import { createExecutorRegistry } from "../workflows/executors/registry";
19
+ import { resumeWaitState } from "../workflows/resume";
20
+
21
+ const TEST_DB_PATH = "./test-workflow-wait-time.sqlite";
22
+
23
+ const eventBus = new InProcessEventBus();
24
+ const deps: ExecutorDependencies = {
25
+ db: db as typeof import("../be/db"),
26
+ eventBus,
27
+ interpolate: (t: string) => t,
28
+ };
29
+
30
+ const createdWorkflowIds: string[] = [];
31
+
32
+ function makeWorkflow(name: string, def: WorkflowDefinition): Workflow {
33
+ const wf = createWorkflow({ name: `${name}-${Date.now()}-${Math.random()}`, definition: def });
34
+ createdWorkflowIds.push(wf.id);
35
+ return wf;
36
+ }
37
+
38
+ beforeAll(async () => {
39
+ try {
40
+ await unlink(TEST_DB_PATH);
41
+ } catch {
42
+ // ignore
43
+ }
44
+ initDb(TEST_DB_PATH);
45
+ });
46
+
47
+ afterAll(async () => {
48
+ for (const id of createdWorkflowIds) {
49
+ try {
50
+ deleteWorkflow(id);
51
+ } catch {
52
+ // already deleted
53
+ }
54
+ }
55
+ closeDb();
56
+ for (const suffix of ["", "-wal", "-shm"]) {
57
+ await unlink(`${TEST_DB_PATH}${suffix}`).catch(() => {});
58
+ }
59
+ });
60
+
61
+ describe("WaitExecutor — time mode end-to-end", () => {
62
+ test("workflow with a time-wait node pauses, then resumes via the poll path", async () => {
63
+ const registry = createExecutorRegistry(deps);
64
+
65
+ // Tiny duration so the wait_state is overdue immediately. We don't rely
66
+ // on the 5s wait poller in the test — instead we drive the resume path
67
+ // directly via the same code path the poller would call.
68
+ const def: WorkflowDefinition = {
69
+ nodes: [
70
+ {
71
+ id: "w1",
72
+ type: "wait",
73
+ config: { mode: "time", durationMs: 50 },
74
+ next: { default: "done" },
75
+ },
76
+ {
77
+ id: "done",
78
+ type: "notify",
79
+ config: { channel: "swarm", template: "wait finished" },
80
+ },
81
+ ],
82
+ };
83
+ const wf = makeWorkflow("wait-time-end-to-end", def);
84
+ const runId = await startWorkflowExecution(wf, {}, registry);
85
+
86
+ // Wait should be paused — run + step both 'waiting'.
87
+ const run = getWorkflowRun(runId);
88
+ expect(run?.status).toBe("waiting");
89
+ const steps = getWorkflowRunStepsByRunId(runId);
90
+ const w1Step = steps.find((s) => s.nodeId === "w1");
91
+ expect(w1Step?.status).toBe("waiting");
92
+ expect(w1Step?.nodeType).toBe("wait"); // recovery query uses this
93
+
94
+ // The wait_state row exists and is pending.
95
+ const waitState = getWaitStateByStepId(w1Step!.id);
96
+ expect(waitState).not.toBeNull();
97
+ expect(waitState?.status).toBe("pending");
98
+ expect(waitState?.mode).toBe("time");
99
+
100
+ // Wait long enough that wakeUpAt is in the past, then drive the same
101
+ // resume path the poller would use.
102
+ await new Promise((r) => setTimeout(r, 80));
103
+
104
+ const due = getDueWaitStates();
105
+ expect(due.find((d) => d.id === waitState!.id)).toBeDefined();
106
+
107
+ await resumeWaitState(waitState!.id, "fired", undefined, registry);
108
+
109
+ // After resume: wait_state fired, step completed via 'default' port,
110
+ // notify ran, run completed.
111
+ const afterWait = getWaitStateByStepId(w1Step!.id);
112
+ expect(afterWait?.status).toBe("fired");
113
+ expect(afterWait?.resolvedAt).not.toBeNull();
114
+
115
+ const afterRun = getWorkflowRun(runId);
116
+ expect(afterRun?.status).toBe("completed");
117
+
118
+ const afterSteps = getWorkflowRunStepsByRunId(runId);
119
+ const w1After = afterSteps.find((s) => s.nodeId === "w1");
120
+ expect(w1After?.status).toBe("completed");
121
+ expect(w1After?.nextPort).toBe("default");
122
+
123
+ const doneStep = afterSteps.find((s) => s.nodeId === "done");
124
+ expect(doneStep?.status).toBe("completed");
125
+ });
126
+
127
+ test("idempotency: double-resume is a no-op (race-safe)", async () => {
128
+ const registry = createExecutorRegistry(deps);
129
+
130
+ const def: WorkflowDefinition = {
131
+ nodes: [
132
+ {
133
+ id: "w1",
134
+ type: "wait",
135
+ config: { mode: "time", durationMs: 30 },
136
+ next: { default: "done" },
137
+ },
138
+ { id: "done", type: "notify", config: { channel: "swarm", template: "ok" } },
139
+ ],
140
+ };
141
+ const wf = makeWorkflow("wait-time-idempotent", def);
142
+ const runId = await startWorkflowExecution(wf, {}, registry);
143
+
144
+ const steps = getWorkflowRunStepsByRunId(runId);
145
+ const w1Step = steps.find((s) => s.nodeId === "w1");
146
+ const waitState = getWaitStateByStepId(w1Step!.id);
147
+
148
+ await new Promise((r) => setTimeout(r, 50));
149
+
150
+ // First resume — should advance the run.
151
+ await resumeWaitState(waitState!.id, "fired", undefined, registry);
152
+ let run = getWorkflowRun(runId);
153
+ expect(run?.status).toBe("completed");
154
+
155
+ // Second resume — must NOT throw, must NOT undo state.
156
+ await resumeWaitState(waitState!.id, "fired", undefined, registry);
157
+ run = getWorkflowRun(runId);
158
+ expect(run?.status).toBe("completed");
159
+ });
160
+
161
+ test("re-execute on existing pending wait_state returns async marker (idempotent execute)", async () => {
162
+ const registry = createExecutorRegistry(deps);
163
+ const waitExecutor = registry.get("wait");
164
+
165
+ // Simulate running execute twice with the same stepId without resolving
166
+ // the wait. The second call should detect the existing pending row and
167
+ // return the async marker rather than inserting a duplicate.
168
+ const meta = {
169
+ runId: crypto.randomUUID(),
170
+ stepId: crypto.randomUUID(),
171
+ nodeId: "w1",
172
+ workflowId: crypto.randomUUID(),
173
+ dryRun: false,
174
+ };
175
+ const config = { mode: "time", durationMs: 60_000 };
176
+
177
+ const r1 = await waitExecutor.run({ config, context: {}, meta });
178
+ expect(r1.status).toBe("success");
179
+ expect((r1 as unknown as { async?: boolean }).async).toBe(true);
180
+
181
+ const stateAfter1 = getWaitStateByStepId(meta.stepId);
182
+ expect(stateAfter1).not.toBeNull();
183
+
184
+ const r2 = await waitExecutor.run({ config, context: {}, meta });
185
+ expect(r2.status).toBe("success");
186
+ expect((r2 as unknown as { async?: boolean }).async).toBe(true);
187
+
188
+ // Still exactly one wait_state row — second execute didn't insert.
189
+ const stateAfter2 = getWaitStateByStepId(meta.stepId);
190
+ expect(stateAfter2?.id).toBe(stateAfter1!.id);
191
+ });
192
+
193
+ test("config validation rejects mode='time' with durationMs <= 0", async () => {
194
+ const registry = createExecutorRegistry(deps);
195
+ const waitExecutor = registry.get("wait");
196
+ const meta = {
197
+ runId: crypto.randomUUID(),
198
+ stepId: crypto.randomUUID(),
199
+ nodeId: "w1",
200
+ workflowId: crypto.randomUUID(),
201
+ dryRun: false,
202
+ };
203
+
204
+ const result = await waitExecutor.run({
205
+ config: { mode: "time", durationMs: 0 },
206
+ context: {},
207
+ meta,
208
+ });
209
+ expect(result.status).toBe("failed");
210
+ });
211
+
212
+ test("config validation rejects unknown mode", async () => {
213
+ const registry = createExecutorRegistry(deps);
214
+ const waitExecutor = registry.get("wait");
215
+ const meta = {
216
+ runId: crypto.randomUUID(),
217
+ stepId: crypto.randomUUID(),
218
+ nodeId: "w1",
219
+ workflowId: crypto.randomUUID(),
220
+ dryRun: false,
221
+ };
222
+
223
+ const result = await waitExecutor.run({
224
+ config: { mode: "bogus", durationMs: 100 },
225
+ context: {},
226
+ meta,
227
+ });
228
+ expect(result.status).toBe("failed");
229
+ });
230
+
231
+ test("event-mode execute now wired (Phase 3) — returns async marker, persists wait_state", async () => {
232
+ const registry = createExecutorRegistry(deps);
233
+ const waitExecutor = registry.get("wait");
234
+ const meta = {
235
+ runId: crypto.randomUUID(),
236
+ stepId: crypto.randomUUID(),
237
+ nodeId: "w1",
238
+ workflowId: crypto.randomUUID(),
239
+ dryRun: false,
240
+ };
241
+
242
+ const result = await waitExecutor.run({
243
+ config: { mode: "event", eventName: "demo.signal" },
244
+ context: {},
245
+ meta,
246
+ });
247
+ expect(result.status).toBe("success");
248
+ expect((result as unknown as { async?: boolean }).async).toBe(true);
249
+
250
+ const persisted = getWaitStateByStepId(meta.stepId);
251
+ expect(persisted?.mode).toBe("event");
252
+ expect(persisted?.eventName).toBe("demo.signal");
253
+ expect(persisted?.eventScope).toBe("run");
254
+ });
255
+ });
@@ -1,6 +1,7 @@
1
1
  import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
2
  import * as z from "zod";
3
3
  import { getOAuthApp, getOAuthTokens } from "@/be/db-queries/oauth";
4
+ import { ensureToken } from "@/oauth/ensure-token";
4
5
  import { createToolRegistrar } from "@/tools/utils";
5
6
 
6
7
  export const registerTrackerStatusTool = (server: McpServer) => {
@@ -9,7 +10,7 @@ export const registerTrackerStatusTool = (server: McpServer) => {
9
10
  {
10
11
  title: "Tracker Status",
11
12
  description:
12
- "Show all connected trackers and their OAuth status (token expiry, workspace info).",
13
+ "Show all connected trackers and their OAuth status (token expiry, workspace info). Proactively refreshes near-expiry tokens before reporting, so the returned `tokenExpiresAt` reflects the row that subsequent API calls (and direct DB reads) will see.",
13
14
  annotations: { readOnlyHint: true },
14
15
 
15
16
  outputSchema: z.object({
@@ -27,6 +28,11 @@ export const registerTrackerStatusTool = (server: McpServer) => {
27
28
  },
28
29
  async (_requestInfo, _meta) => {
29
30
  const providers = ["linear", "jira"] as const;
31
+ // Refresh near-expiry tokens before reading so agents that subsequently
32
+ // read oauth_tokens directly (e.g. via the read-only db-query MCP) see a
33
+ // not-yet-expired access token. ensureToken is no-op when no refresh
34
+ // token is stored and swallows refresh failures internally.
35
+ await Promise.all(providers.map((provider) => ensureToken(provider)));
30
36
  const trackers = providers.map((provider) => {
31
37
  const app = getOAuthApp(provider);
32
38
  const tokens = getOAuthTokens(provider);