@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
@@ -24,7 +24,12 @@ export const registerCreateWorkflowTool = (server: McpServer) => {
24
24
  "Without 'inputs', only 'trigger' and workflow-level 'input' are available for interpolation.\n" +
25
25
  "- STRUCTURED OUTPUT: For agent-task nodes, put outputSchema inside 'config' to validate the agent's raw JSON output. " +
26
26
  "Node-level outputSchema validates the executor's return ({taskId, taskOutput}), which is different.\n" +
27
- "- Agent-task config: { template, outputSchema?, agentId?, tags?, priority?, dir?, vcsRepo?, model? }.",
27
+ "- Agent-task config: { template, outputSchema?, agentId?, tags?, priority?, dir?, vcsRepo?, model? }.\n" +
28
+ "- TRIGGER SCHEMA: Optional 'triggerSchema' is a JSON-Schema object that validates incoming trigger payloads. " +
29
+ "Supported keywords: type, required, properties, enum, const, items (recursive into arrays). " +
30
+ "Other JSON-Schema keywords (oneOf/anyOf/$ref/pattern/format/additionalProperties) are silently ignored.\n" +
31
+ "- WAIT NODE: type 'wait' pauses a workflow for a duration or until a named workflowEventBus event arrives. " +
32
+ "See runbooks/workflows.md#wait-nodes for config shapes, ordering caveats, and built-in event names.",
28
33
  inputSchema: z.object({
29
34
  name: z.string().describe("Unique name for the workflow"),
30
35
  description: z.string().optional().describe("Description of what this workflow does"),
@@ -57,6 +62,14 @@ export const registerCreateWorkflowTool = (server: McpServer) => {
57
62
  .min(1)
58
63
  .optional()
59
64
  .describe("Default VCS repo for all agent-task nodes (e.g. org/repo)"),
65
+ triggerSchema: z
66
+ .record(z.string(), z.unknown())
67
+ .optional()
68
+ .describe(
69
+ "Optional JSON-Schema object that validates incoming trigger payloads. " +
70
+ "Supported keywords: type, required, properties, enum, const, items. " +
71
+ "Other JSON-Schema keywords are silently ignored.",
72
+ ),
60
73
  }),
61
74
  outputSchema: z.object({
62
75
  yourAgentId: z.string().optional(),
@@ -66,7 +79,7 @@ export const registerCreateWorkflowTool = (server: McpServer) => {
66
79
  }),
67
80
  },
68
81
  async (
69
- { name, description, definition, triggers, cooldown, input, dir, vcsRepo },
82
+ { name, description, definition, triggers, cooldown, input, dir, vcsRepo, triggerSchema },
70
83
  requestInfo,
71
84
  ) => {
72
85
  if (!requestInfo.agentId) {
@@ -102,6 +115,7 @@ export const registerCreateWorkflowTool = (server: McpServer) => {
102
115
  input,
103
116
  dir,
104
117
  vcsRepo,
118
+ triggerSchema,
105
119
  createdByAgentId: requestInfo.agentId,
106
120
  });
107
121
  return {
@@ -2,7 +2,7 @@ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
2
  import { z } from "zod";
3
3
  import { getWorkflow, updateWorkflow } from "@/be/db";
4
4
  import { createToolRegistrar } from "@/tools/utils";
5
- import type { WorkflowDefinitionPatch } from "@/types";
5
+ import type { WorkflowPatch } from "@/types";
6
6
  import { WorkflowNodePatchSchema } from "@/types";
7
7
  import { applyDefinitionPatch, validateDefinition } from "@/workflows/definition";
8
8
  import { snapshotWorkflow } from "@/workflows/version";
@@ -14,8 +14,13 @@ export const registerPatchWorkflowTool = (server: McpServer) => {
14
14
  title: "Patch Workflow Definition",
15
15
  annotations: { destructiveHint: false },
16
16
  description:
17
- "Partially update a workflow definition by creating, updating, or deleting individual nodes. " +
18
- "Operations are applied in order: delete → create → update. " +
17
+ "Partially update a workflow by creating, updating, or deleting individual nodes, " +
18
+ "and/or by setting/clearing the trigger payload schema. " +
19
+ "DAG operations are applied in order: delete → create → update. " +
20
+ "`triggerSchema` is independent of DAG ops: pass an object to set/replace, " +
21
+ "pass null to clear, or omit to leave unchanged. " +
22
+ "Validator subset for `triggerSchema`: type, required, properties, enum, const, items. " +
23
+ "Other JSON-Schema keywords are silently ignored. " +
19
24
  "Creates a version snapshot before applying changes.",
20
25
  inputSchema: z.object({
21
26
  id: z.string().uuid().describe("Workflow ID to patch"),
@@ -48,6 +53,15 @@ export const registerPatchWorkflowTool = (server: McpServer) => {
48
53
  .enum(["fail", "continue"])
49
54
  .optional()
50
55
  .describe("Update onNodeFailure behavior"),
56
+ triggerSchema: z
57
+ .record(z.string(), z.unknown())
58
+ .optional()
59
+ .nullable()
60
+ .describe(
61
+ "Optional JSON-Schema describing the expected trigger payload. " +
62
+ "Pass an object to set/replace; pass null to clear; omit to leave unchanged. " +
63
+ "Validator subset: type, required, properties, enum, const, items.",
64
+ ),
51
65
  }),
52
66
  outputSchema: z.object({
53
67
  success: z.boolean(),
@@ -59,7 +73,7 @@ export const registerPatchWorkflowTool = (server: McpServer) => {
59
73
  nodesDeleted: z.number().optional(),
60
74
  }),
61
75
  },
62
- async ({ id, update, delete: del, create, onNodeFailure }, requestInfo) => {
76
+ async ({ id, update, delete: del, create, onNodeFailure, triggerSchema }, requestInfo) => {
63
77
  try {
64
78
  const existing = getWorkflow(id);
65
79
  if (!existing) {
@@ -72,7 +86,7 @@ export const registerPatchWorkflowTool = (server: McpServer) => {
72
86
  const patchResult = applyDefinitionPatch(existing.definition, {
73
87
  update,
74
88
  delete: del,
75
- create: create as WorkflowDefinitionPatch["create"],
89
+ create: create as WorkflowPatch["create"],
76
90
  onNodeFailure,
77
91
  });
78
92
  if (patchResult.errors.length > 0) {
@@ -94,7 +108,13 @@ export const registerPatchWorkflowTool = (server: McpServer) => {
94
108
 
95
109
  const version = snapshotWorkflow(id, requestInfo.agentId);
96
110
 
97
- const workflow = updateWorkflow(id, { definition: patchResult.definition });
111
+ const updateArgs: Parameters<typeof updateWorkflow>[1] = {
112
+ definition: patchResult.definition,
113
+ };
114
+ if (triggerSchema !== undefined) {
115
+ updateArgs.triggerSchema = triggerSchema;
116
+ }
117
+ const workflow = updateWorkflow(id, updateArgs);
98
118
  if (!workflow) {
99
119
  return {
100
120
  content: [{ type: "text" as const, text: `Workflow not found: ${id}` }],
@@ -3,6 +3,7 @@ import { z } from "zod";
3
3
  import { getWorkflow, getWorkflowRun } from "@/be/db";
4
4
  import { createToolRegistrar } from "@/tools/utils";
5
5
  import { getExecutorRegistry, startWorkflowExecution } from "@/workflows";
6
+ import { TriggerSchemaError } from "@/workflows/engine";
6
7
 
7
8
  export const registerTriggerWorkflowTool = (server: McpServer) => {
8
9
  createToolRegistrar(server)(
@@ -11,7 +12,8 @@ export const registerTriggerWorkflowTool = (server: McpServer) => {
11
12
  title: "Trigger Workflow",
12
13
  annotations: { destructiveHint: false },
13
14
  description:
14
- "Manually trigger a workflow execution, optionally passing trigger data as context. Respects cooldown configuration.",
15
+ "Manually trigger a workflow execution, optionally passing trigger data as context. Respects cooldown configuration. " +
16
+ "If the workflow has a triggerSchema, the payload is validated first; on failure, the response includes structured validationErrors plus the workflow's triggerSchema for self-correction.",
15
17
  inputSchema: z.object({
16
18
  id: z.string().uuid().describe("Workflow ID to trigger"),
17
19
  triggerData: z
@@ -24,6 +26,8 @@ export const registerTriggerWorkflowTool = (server: McpServer) => {
24
26
  message: z.string(),
25
27
  runId: z.string().optional(),
26
28
  skipped: z.boolean().optional(),
29
+ validationErrors: z.array(z.string()).optional(),
30
+ triggerSchema: z.record(z.string(), z.unknown()).optional(),
27
31
  }),
28
32
  },
29
33
  async ({ id, triggerData }) => {
@@ -86,6 +90,27 @@ export const registerTriggerWorkflowTool = (server: McpServer) => {
86
90
  },
87
91
  };
88
92
  } catch (err) {
93
+ if (err instanceof TriggerSchemaError) {
94
+ // Re-fetch workflow so we can echo its triggerSchema for self-correction.
95
+ // (Workflow existence was already proven above; this is best-effort.)
96
+ const workflow = getWorkflow(id);
97
+ const bulleted = err.validationErrors.map((e) => `- ${e}`).join("\n");
98
+ const schemaBlock = workflow?.triggerSchema
99
+ ? `\n\nExpected triggerSchema:\n\`\`\`json\n${JSON.stringify(workflow.triggerSchema, null, 2)}\n\`\`\``
100
+ : "";
101
+ const text =
102
+ `Trigger payload did not match the workflow's triggerSchema:\n${bulleted}` +
103
+ schemaBlock;
104
+ return {
105
+ content: [{ type: "text" as const, text }],
106
+ structuredContent: {
107
+ success: false,
108
+ message: `Trigger payload did not match the workflow's triggerSchema (${err.validationErrors.length} error${err.validationErrors.length === 1 ? "" : "s"}).`,
109
+ validationErrors: err.validationErrors,
110
+ triggerSchema: workflow?.triggerSchema,
111
+ },
112
+ };
113
+ }
89
114
  return {
90
115
  content: [{ type: "text" as const, text: `Failed: ${err}` }],
91
116
  structuredContent: { success: false, message: String(err) },
@@ -18,7 +18,11 @@ export const registerUpdateWorkflowTool = (server: McpServer) => {
18
18
  title: "Update Workflow",
19
19
  annotations: { destructiveHint: false },
20
20
  description:
21
- "Update an existing workflow's name, description, definition, triggers, cooldown, input, or enabled state. Creates a version snapshot before applying changes.",
21
+ "Update an existing workflow's name, description, definition, triggers, cooldown, input, triggerSchema, or enabled state. " +
22
+ "Creates a version snapshot before applying changes. " +
23
+ "TRIGGER SCHEMA: pass 'triggerSchema' as a JSON-Schema object to set/replace, or 'null' to clear. " +
24
+ "Supported JSON-Schema keywords: type, required, properties, enum, const, items (recursive into arrays). " +
25
+ "Other JSON-Schema keywords (oneOf/anyOf/$ref/pattern/format/additionalProperties) are silently ignored.",
22
26
  inputSchema: z.object({
23
27
  id: z.string().uuid().describe("Workflow ID to update"),
24
28
  name: z.string().optional().describe("New name for the workflow"),
@@ -47,6 +51,15 @@ export const registerUpdateWorkflowTool = (server: McpServer) => {
47
51
  .nullable()
48
52
  .describe("Default VCS repo for all agent-task nodes (null to remove)"),
49
53
  enabled: z.boolean().optional().describe("Enable or disable the workflow"),
54
+ triggerSchema: z
55
+ .record(z.string(), z.unknown())
56
+ .optional()
57
+ .nullable()
58
+ .describe(
59
+ "New trigger payload JSON-Schema (null to clear). " +
60
+ "Supported keywords: type, required, properties, enum, const, items. " +
61
+ "Other JSON-Schema keywords are silently ignored.",
62
+ ),
50
63
  }),
51
64
  outputSchema: z.object({
52
65
  success: z.boolean(),
@@ -56,7 +69,19 @@ export const registerUpdateWorkflowTool = (server: McpServer) => {
56
69
  }),
57
70
  },
58
71
  async (
59
- { id, name, description, definition, triggers, cooldown, input, dir, vcsRepo, enabled },
72
+ {
73
+ id,
74
+ name,
75
+ description,
76
+ definition,
77
+ triggers,
78
+ cooldown,
79
+ input,
80
+ dir,
81
+ vcsRepo,
82
+ enabled,
83
+ triggerSchema,
84
+ },
60
85
  requestInfo,
61
86
  ) => {
62
87
  try {
@@ -101,6 +126,7 @@ export const registerUpdateWorkflowTool = (server: McpServer) => {
101
126
  dir: dir === null ? null : dir,
102
127
  vcsRepo: vcsRepo === null ? null : vcsRepo,
103
128
  enabled,
129
+ triggerSchema: triggerSchema === null ? null : triggerSchema,
104
130
  });
105
131
  if (!workflow) {
106
132
  return {
package/src/types.ts CHANGED
@@ -811,8 +811,8 @@ export type WorkflowDefinition = z.infer<typeof WorkflowDefinitionSchema>;
811
811
  export const WorkflowNodePatchSchema = WorkflowNodeSchema.partial().omit({ id: true });
812
812
  export type WorkflowNodePatch = z.infer<typeof WorkflowNodePatchSchema>;
813
813
 
814
- /** Bulk workflow definition patch */
815
- export const WorkflowDefinitionPatchSchema = z.object({
814
+ /** Bulk workflow patch — DAG operations plus optional metadata fields like triggerSchema */
815
+ export const WorkflowPatchSchema = z.object({
816
816
  update: z
817
817
  .array(
818
818
  z.object({
@@ -828,8 +828,18 @@ export const WorkflowDefinitionPatchSchema = z.object({
828
828
  .enum(["fail", "continue"])
829
829
  .optional()
830
830
  .describe("Update the definition-level onNodeFailure behavior"),
831
+ triggerSchema: z
832
+ .record(z.string(), z.unknown())
833
+ .optional()
834
+ .nullable()
835
+ .describe(
836
+ "Optional JSON-Schema describing the expected trigger payload shape. " +
837
+ "Pass an object to set/replace; pass null to clear; omit to leave unchanged. " +
838
+ "Validator subset: type, required, properties, enum, const, items. " +
839
+ "Other JSON-Schema keywords are silently ignored.",
840
+ ),
831
841
  });
832
- export type WorkflowDefinitionPatch = z.infer<typeof WorkflowDefinitionPatchSchema>;
842
+ export type WorkflowPatch = z.infer<typeof WorkflowPatchSchema>;
833
843
 
834
844
  /** Result of applying a patch — collects all errors instead of throwing on the first */
835
845
  export interface PatchResult {
@@ -1004,6 +1014,41 @@ export const WorkflowRunStepSchema = z.object({
1004
1014
  });
1005
1015
  export type WorkflowRunStep = z.infer<typeof WorkflowRunStepSchema>;
1006
1016
 
1017
+ // --- Wait State (workflow `wait` node side table) ---
1018
+
1019
+ export const WaitModeSchema = z.enum(["time", "event"]);
1020
+ export type WaitMode = z.infer<typeof WaitModeSchema>;
1021
+
1022
+ export const WaitStateStatusSchema = z.enum(["pending", "fired", "timeout"]);
1023
+ export type WaitStateStatus = z.infer<typeof WaitStateStatusSchema>;
1024
+
1025
+ /**
1026
+ * Row shape for `wait_states` table — keep in sync with
1027
+ * `src/be/migrations/049_wait_states.sql`.
1028
+ *
1029
+ * - `mode='time'`: `wakeUpAt` is set; `eventName`/`eventFilter`/`expiresAt` are null.
1030
+ * - `mode='event'`: `eventName` is set; `eventFilter` is optional (flat
1031
+ * key/dot-path object OR arrow-fn body string); `expiresAt` is set when the
1032
+ * wait carries a timeout.
1033
+ */
1034
+ export const WaitStateRowSchema = z.object({
1035
+ id: z.string(),
1036
+ workflowRunId: z.string(),
1037
+ workflowRunStepId: z.string(),
1038
+ mode: WaitModeSchema,
1039
+ wakeUpAt: z.string().nullable(),
1040
+ eventName: z.string().nullable(),
1041
+ eventFilter: z.union([z.record(z.string(), z.unknown()), z.string()]).nullable(),
1042
+ expiresAt: z.string().nullable(),
1043
+ status: WaitStateStatusSchema,
1044
+ firedPayload: z.unknown().nullable(),
1045
+ resolvedAt: z.string().nullable(),
1046
+ createdAt: z.string(),
1047
+ updatedAt: z.string(),
1048
+ eventScope: z.enum(["run", "global"]),
1049
+ });
1050
+ export type WaitStateRow = z.infer<typeof WaitStateRowSchema>;
1051
+
1007
1052
  // ============================================================================
1008
1053
  // Prompt Template Types
1009
1054
  // ============================================================================
@@ -1,9 +1,9 @@
1
1
  import type {
2
2
  PatchResult,
3
3
  WorkflowDefinition,
4
- WorkflowDefinitionPatch,
5
4
  WorkflowEdge,
6
5
  WorkflowNode,
6
+ WorkflowPatch,
7
7
  } from "../types";
8
8
  import type { ExecutorRegistry } from "./executors/registry";
9
9
 
@@ -249,10 +249,7 @@ export function validateDefinition(
249
249
  * even if earlier ones have errors. Validation of the resulting definition
250
250
  * (next refs, entry nodes, etc.) is the caller's responsibility.
251
251
  */
252
- export function applyDefinitionPatch(
253
- def: WorkflowDefinition,
254
- patch: WorkflowDefinitionPatch,
255
- ): PatchResult {
252
+ export function applyDefinitionPatch(def: WorkflowDefinition, patch: WorkflowPatch): PatchResult {
256
253
  const errors: string[] = [];
257
254
  let nodes = [...def.nodes];
258
255
 
@@ -14,3 +14,4 @@ export { createExecutorRegistry, ExecutorRegistry } from "./registry";
14
14
  export { ScriptExecutor } from "./script";
15
15
  export { ValidateExecutor } from "./validate";
16
16
  export { VcsExecutor } from "./vcs";
17
+ export { WaitExecutor } from "./wait";
@@ -9,6 +9,7 @@ import { RawLlmExecutor } from "./raw-llm";
9
9
  import { ScriptExecutor } from "./script";
10
10
  import { ValidateExecutor } from "./validate";
11
11
  import { VcsExecutor } from "./vcs";
12
+ import { WaitExecutor } from "./wait";
12
13
 
13
14
  export interface ExecutorTypeInfo {
14
15
  type: string;
@@ -73,6 +74,7 @@ export function createExecutorRegistry(deps: ExecutorDependencies): ExecutorRegi
73
74
  // Async executors (Phase 4)
74
75
  registry.register(new AgentTaskExecutor(deps));
75
76
  registry.register(new HumanInTheLoopExecutor(deps));
77
+ registry.register(new WaitExecutor(deps));
76
78
 
77
79
  return registry;
78
80
  }
@@ -0,0 +1,170 @@
1
+ import { z } from "zod";
2
+ import type { ExecutorMeta } from "../../types";
3
+ import { subscribeWaitToBus } from "../resume";
4
+ import { compileStringFilter } from "../wait-filter";
5
+ import type { ExecutorResult } from "./base";
6
+ import { BaseExecutor } from "./base";
7
+
8
+ // ─── Config / Output Schemas ────────────────────────────────
9
+
10
+ /**
11
+ * `wait` node config — discriminated union on `mode`.
12
+ *
13
+ * - `mode: "time"`: pause for `durationMs` (1ms..1y; effective resolution ~5s
14
+ * from the wait-poller cadence).
15
+ * - `mode: "event"`: pause until a `workflowEventBus` event named `eventName`
16
+ * arrives whose payload satisfies `filter`. The string-form filter is
17
+ * capped at 2KB at the Zod boundary as defense-in-depth against pathologically
18
+ * large filter sources.
19
+ */
20
+ const WaitConfigSchema = z.discriminatedUnion("mode", [
21
+ z.object({
22
+ mode: z.literal("time"),
23
+ durationMs: z
24
+ .number()
25
+ .int()
26
+ .min(1)
27
+ .max(31_536_000_000) // 1 year ceiling
28
+ .describe("Wait duration in milliseconds (effective resolution ~5s)"),
29
+ }),
30
+ z.object({
31
+ mode: z.literal("event"),
32
+ eventName: z.string().min(1),
33
+ filter: z.union([z.record(z.string(), z.unknown()), z.string().max(2048)]).optional(),
34
+ scope: z.enum(["run", "global"]).default("run"),
35
+ timeoutMs: z
36
+ .number()
37
+ .int()
38
+ .min(1)
39
+ .max(31_536_000_000)
40
+ .optional()
41
+ .describe(
42
+ "Timeout in milliseconds (effective resolution ~5s) — when reached, routes via 'timeout' port",
43
+ ),
44
+ }),
45
+ ]);
46
+
47
+ const WaitOutputSchema = z.object({
48
+ waitId: z.string().uuid(),
49
+ mode: z.enum(["time", "event"]),
50
+ firedAt: z.string().nullable(),
51
+ payload: z.unknown().optional(),
52
+ });
53
+
54
+ type WaitOutput = z.infer<typeof WaitOutputSchema>;
55
+ type WaitConfig = z.infer<typeof WaitConfigSchema>;
56
+
57
+ // ─── Executor ───────────────────────────────────────────────
58
+
59
+ export class WaitExecutor extends BaseExecutor<typeof WaitConfigSchema, typeof WaitOutputSchema> {
60
+ readonly type = "wait";
61
+ readonly mode = "async" as const;
62
+ readonly configSchema = WaitConfigSchema;
63
+ readonly outputSchema = WaitOutputSchema;
64
+
65
+ protected async execute(
66
+ config: WaitConfig,
67
+ _context: Readonly<Record<string, unknown>>,
68
+ meta: ExecutorMeta,
69
+ ): Promise<ExecutorResult<WaitOutput>> {
70
+ const { db } = this.deps;
71
+
72
+ // 1. Idempotency check — if a wait_state already exists for this step,
73
+ // either return the resolved port (resolution happened during retry/recovery)
74
+ // or return the async marker to keep waiting.
75
+ const existing = db.getWaitStateByStepId(meta.stepId);
76
+ if (existing) {
77
+ if (existing.status !== "pending") {
78
+ const nextPort = computeNextPort(existing.mode, existing.status);
79
+ return {
80
+ status: "success",
81
+ output: {
82
+ waitId: existing.id,
83
+ mode: existing.mode,
84
+ firedAt: existing.resolvedAt,
85
+ payload: existing.firedPayload ?? undefined,
86
+ },
87
+ nextPort,
88
+ };
89
+ }
90
+ // Still pending — return async marker
91
+ return {
92
+ status: "success",
93
+ async: true,
94
+ waitFor: "wait.fired",
95
+ correlationId: existing.id,
96
+ } as unknown as ExecutorResult<WaitOutput>;
97
+ }
98
+
99
+ // 2. Mode-specific creation.
100
+ if (config.mode === "time") {
101
+ const waitId = crypto.randomUUID();
102
+ const wakeUpAt = new Date(Date.now() + config.durationMs).toISOString();
103
+ db.createWaitState({
104
+ id: waitId,
105
+ workflowRunId: meta.runId,
106
+ workflowRunStepId: meta.stepId,
107
+ mode: "time",
108
+ wakeUpAt,
109
+ });
110
+
111
+ return {
112
+ status: "success",
113
+ async: true,
114
+ waitFor: "wait.fired",
115
+ correlationId: waitId,
116
+ } as unknown as ExecutorResult<WaitOutput>;
117
+ }
118
+
119
+ // Event mode: validate the filter at executor-init time (so a bad workflow
120
+ // surfaces here, not at first event), insert the wait_state row, and
121
+ // subscribe to the bus so signals route to this wait.
122
+ if (typeof config.filter === "string") {
123
+ // Throws on parse error — caught by BaseExecutor.run wrapper and surfaced
124
+ // as a `failed` ExecutorResult.
125
+ compileStringFilter(config.filter);
126
+ }
127
+
128
+ const waitId = crypto.randomUUID();
129
+ const expiresAt = config.timeoutMs
130
+ ? new Date(Date.now() + config.timeoutMs).toISOString()
131
+ : null;
132
+
133
+ db.createWaitState({
134
+ id: waitId,
135
+ workflowRunId: meta.runId,
136
+ workflowRunStepId: meta.stepId,
137
+ mode: "event",
138
+ eventName: config.eventName,
139
+ eventFilter: config.filter ?? null,
140
+ expiresAt,
141
+ scope: config.scope,
142
+ });
143
+
144
+ // Register the bus listener for this event name (idempotent).
145
+ subscribeWaitToBus(waitId, config.eventName);
146
+
147
+ return {
148
+ status: "success",
149
+ async: true,
150
+ waitFor: "wait.fired",
151
+ correlationId: waitId,
152
+ } as unknown as ExecutorResult<WaitOutput>;
153
+ }
154
+ }
155
+
156
+ /**
157
+ * Map (mode, status) to the `next` port name.
158
+ *
159
+ * - time + fired → "default" (single happy port)
160
+ * - event + fired → "event"
161
+ * - event + timeout → "timeout"
162
+ *
163
+ * Time mode never produces "timeout" in practice — wait_states for time-mode
164
+ * waits never set `expiresAt` so the poller will never resolve them as
165
+ * timeout. We default to "default" anyway as a safe fallback.
166
+ */
167
+ export function computeNextPort(mode: "time" | "event", status: "fired" | "timeout"): string {
168
+ if (mode === "time") return "default";
169
+ return status === "timeout" ? "timeout" : "event";
170
+ }
@@ -2,21 +2,30 @@ export { findEntryNodes, getSuccessors } from "./definition";
2
2
  export { startWorkflowExecution } from "./engine";
3
3
  export { workflowEventBus } from "./event-bus";
4
4
  export { recoverIncompleteRuns } from "./recovery";
5
- export { cancelWorkflowRun, retryFailedRun, setupWorkflowResumeListener } from "./resume";
5
+ export {
6
+ cancelWorkflowRun,
7
+ initWaitBusSubscriptions,
8
+ resumeWaitState,
9
+ retryFailedRun,
10
+ setupWorkflowResumeListener,
11
+ subscribeWaitToBus,
12
+ } from "./resume";
6
13
  export { startRetryPoller, stopRetryPoller } from "./retry-poller";
7
14
  export { interpolate } from "./template";
8
15
  export { instantiateTemplate, validateTemplateVariables } from "./templates";
9
16
  export { handleScheduleTrigger, handleWebhookTrigger } from "./triggers";
10
17
  export { snapshotWorkflow } from "./version";
18
+ export { startWaitPoller, stopWaitPoller } from "./wait-poller";
11
19
 
12
20
  import * as db from "../be/db";
13
21
  import { workflowEventBus } from "./event-bus";
14
22
  import type { ExecutorRegistry } from "./executors/registry";
15
23
  import { createExecutorRegistry } from "./executors/registry";
16
24
  import { recoverIncompleteRuns } from "./recovery";
17
- import { setupWorkflowResumeListener } from "./resume";
25
+ import { initWaitBusSubscriptions, setupWorkflowResumeListener } from "./resume";
18
26
  import { startRetryPoller } from "./retry-poller";
19
27
  import { interpolate } from "./template";
28
+ import { startWaitPoller } from "./wait-poller";
20
29
 
21
30
  // ─── Module-level singleton ────────────────────────────────
22
31
 
@@ -58,4 +67,11 @@ export function initWorkflows(): void {
58
67
 
59
68
  // 4. Start retry poller for failed steps with nextRetryAt
60
69
  startRetryPoller(_registry);
70
+
71
+ // 5. Start wait poller for time-mode waits + event-mode timeouts
72
+ startWaitPoller(_registry);
73
+
74
+ // 6. Initialize wait-bus subscriptions for event-mode waits (Phase 3).
75
+ // Re-attaches one bus listener per distinct pending eventName from the DB.
76
+ initWaitBusSubscriptions(_registry);
61
77
  }
@@ -1,10 +1,17 @@
1
1
  /**
2
2
  * Minimal hand-rolled JSON Schema validator.
3
3
  *
4
- * Supports the subset needed for workflow I/O schemas:
4
+ * Supports the subset needed for workflow I/O schemas and `triggerSchema`:
5
5
  * - `type`: "object", "string", "number", "boolean", "array"
6
6
  * - `required`: array of required property names
7
7
  * - `properties`: map of property name → schema (recursive)
8
+ * - `enum`: array of allowed primitive values (strict equality)
9
+ * - `const`: a single allowed value (strict equality)
10
+ * - `items`: schema applied to every element of an array (recursive)
11
+ *
12
+ * Other JSON-Schema keywords (`oneOf`, `anyOf`, `$ref`, `pattern`, `format`,
13
+ * `additionalProperties`, etc.) are silently ignored. Document any new
14
+ * authoring surface for `triggerSchema` accordingly.
8
15
  *
9
16
  * Returns an array of validation error strings (empty = valid).
10
17
  */
@@ -2,6 +2,7 @@ import {
2
2
  getCompletedStepNodeIds,
3
3
  getDb,
4
4
  getStuckApprovalRuns,
5
+ getStuckWaitRuns,
5
6
  getStuckWorkflowRuns,
6
7
  getWorkflow,
7
8
  getWorkflowRun,
@@ -13,7 +14,7 @@ import { checkpointStep } from "./checkpoint";
13
14
  import { getSuccessors } from "./definition";
14
15
  import { findReadyNodes, walkGraph } from "./engine";
15
16
  import type { ExecutorRegistry } from "./executors/registry";
16
- import { finalizeOrWait } from "./resume";
17
+ import { finalizeOrWait, resumeWaitState } from "./resume";
17
18
 
18
19
  /**
19
20
  * Recover incomplete workflow runs on server startup.
@@ -36,6 +37,9 @@ export async function recoverIncompleteRuns(registry: ExecutorRegistry): Promise
36
37
  // --- Case 3: Waiting runs whose approval requests may have resolved ---
37
38
  recovered += await recoverApprovalWaitingRuns(registry);
38
39
 
40
+ // --- Case 4: Waiting runs whose wait_states are overdue or already resolved ---
41
+ recovered += await recoverWaitStates(registry);
42
+
39
43
  if (recovered > 0) {
40
44
  console.log(`[workflows] Recovered ${recovered} incomplete run(s) on startup`);
41
45
  }
@@ -200,6 +204,56 @@ async function recoverApprovalWaitingRuns(registry: ExecutorRegistry): Promise<n
200
204
  return recovered;
201
205
  }
202
206
 
207
+ /**
208
+ * Recover waiting runs whose `wait_states` rows are either already resolved
209
+ * (case a — signal arrived / timeout fired while the API was down and the
210
+ * in-memory bus event was lost) or pending-but-overdue (case b — `wakeUpAt`
211
+ * or `expiresAt` already past; the wait poller would catch these on its first
212
+ * tick, but explicit recovery avoids the up-to-5s startup latency window).
213
+ *
214
+ * Mirrors `recoverApprovalWaitingRuns`. Time-mode overdue rows resume as
215
+ * `fired`. Event-mode overdue-but-pending rows resume as `timeout`. Already-
216
+ * resolved rows resume with their stored status (and stored `firedPayload`
217
+ * for fired event waits).
218
+ */
219
+ async function recoverWaitStates(registry: ExecutorRegistry): Promise<number> {
220
+ const stuckRuns = getStuckWaitRuns();
221
+ let recovered = 0;
222
+
223
+ for (const stuck of stuckRuns) {
224
+ try {
225
+ // Decide what status to (re)apply.
226
+ let resumeStatus: "fired" | "timeout";
227
+ let payload: unknown;
228
+
229
+ if (stuck.waitStatus === "fired") {
230
+ resumeStatus = "fired";
231
+ payload = stuck.firedPayload != null ? safeJsonParse(stuck.firedPayload) : undefined;
232
+ } else if (stuck.waitStatus === "timeout") {
233
+ resumeStatus = "timeout";
234
+ } else {
235
+ // pending + overdue
236
+ resumeStatus = stuck.waitMode === "time" ? "fired" : "timeout";
237
+ }
238
+
239
+ await resumeWaitState(stuck.waitId, resumeStatus, payload, registry);
240
+ recovered++;
241
+ } catch (err) {
242
+ console.error(`[workflows] Failed to recover wait-state ${stuck.waitId}:`, err);
243
+ }
244
+ }
245
+
246
+ return recovered;
247
+ }
248
+
249
+ function safeJsonParse(s: string): unknown {
250
+ try {
251
+ return JSON.parse(s);
252
+ } catch {
253
+ return s;
254
+ }
255
+ }
256
+
203
257
  /**
204
258
  * Get run IDs by status. Simple query since there's no dedicated function for this.
205
259
  */