@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.
- package/openapi.json +199 -1
- package/package.json +1 -1
- package/src/be/db.ts +278 -0
- package/src/be/migrations/049_wait_states.sql +30 -0
- package/src/be/migrations/050_wait_states_scope.sql +19 -0
- package/src/http/index.ts +2 -0
- package/src/http/trackers/jira.ts +84 -27
- package/src/http/trackers/linear.ts +67 -11
- package/src/http/utils.ts +15 -0
- package/src/http/workflow-events.ts +107 -0
- package/src/http/workflows.ts +55 -6
- package/src/jira/sync.ts +20 -7
- package/src/linear/gate.ts +122 -0
- package/src/linear/sync.ts +128 -0
- package/src/oauth/keepalive.ts +34 -13
- package/src/tests/ensure-token.test.ts +33 -0
- package/src/tests/linear-webhook.test.ts +383 -0
- package/src/tests/workflow-executors.test.ts +4 -2
- package/src/tests/workflow-mcp-trigger-schema.test.ts +617 -0
- package/src/tests/workflow-patch.test.ts +14 -14
- package/src/tests/workflow-wait-builtin-events.test.ts +279 -0
- package/src/tests/workflow-wait-event.test.ts +384 -0
- package/src/tests/workflow-wait-filter.test.ts +200 -0
- package/src/tests/workflow-wait-http.test.ts +177 -0
- package/src/tests/workflow-wait-recovery.test.ts +178 -0
- package/src/tests/workflow-wait-state-queries.test.ts +419 -0
- package/src/tests/workflow-wait-time.test.ts +255 -0
- package/src/tools/tracker/tracker-status.ts +7 -1
- package/src/tools/workflows/create-workflow.ts +16 -2
- package/src/tools/workflows/patch-workflow.ts +26 -6
- package/src/tools/workflows/trigger-workflow.ts +26 -1
- package/src/tools/workflows/update-workflow.ts +28 -2
- package/src/types.ts +48 -3
- package/src/workflows/definition.ts +2 -5
- package/src/workflows/executors/index.ts +1 -0
- package/src/workflows/executors/registry.ts +2 -0
- package/src/workflows/executors/wait.ts +170 -0
- package/src/workflows/index.ts +18 -2
- package/src/workflows/json-schema-validator.ts +8 -1
- package/src/workflows/recovery.ts +55 -1
- package/src/workflows/resume.ts +272 -0
- package/src/workflows/wait-filter.ts +311 -0
- 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 {
|
|
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
|
|
18
|
-
"
|
|
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
|
|
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
|
|
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.
|
|
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
|
-
{
|
|
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
|
|
815
|
-
export const
|
|
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
|
|
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
|
|
|
@@ -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
|
+
}
|
package/src/workflows/index.ts
CHANGED
|
@@ -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 {
|
|
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
|
*/
|