@desplega.ai/agent-swarm 1.51.2 → 1.52.1

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 (55) hide show
  1. package/README.md +131 -0
  2. package/openapi.json +767 -4
  3. package/package.json +3 -1
  4. package/src/be/db.ts +669 -0
  5. package/src/be/migrations/019_skills.sql +65 -0
  6. package/src/be/migrations/020_approval_requests.sql +41 -0
  7. package/src/be/migrations/runner.ts +4 -4
  8. package/src/be/skill-parser.ts +70 -0
  9. package/src/be/skill-sync.ts +106 -0
  10. package/src/commands/runner.ts +299 -52
  11. package/src/http/agents.ts +29 -0
  12. package/src/http/approval-requests.ts +310 -0
  13. package/src/http/config.ts +3 -3
  14. package/src/http/index.ts +26 -2
  15. package/src/http/poll.ts +15 -0
  16. package/src/http/skills.ts +479 -0
  17. package/src/http/tasks.ts +94 -0
  18. package/src/linear/outbound.ts +12 -12
  19. package/src/prompts/base-prompt.ts +8 -0
  20. package/src/providers/claude-adapter.ts +19 -3
  21. package/src/scheduler/scheduler.ts +24 -1
  22. package/src/server.ts +29 -0
  23. package/src/slack/blocks.ts +1 -1
  24. package/src/tests/approval-requests.test.ts +948 -0
  25. package/src/tests/skill-parser.test.ts +178 -0
  26. package/src/tests/skill-sync.test.ts +171 -0
  27. package/src/tests/slack-blocks.test.ts +3 -2
  28. package/src/tests/structured-output.test.ts +1 -0
  29. package/src/tests/tool-annotations.test.ts +2 -1
  30. package/src/tests/tool-call-progress.test.ts +207 -0
  31. package/src/tests/tool-registrar-no-input.test.ts +114 -0
  32. package/src/tests/update-profile-auth.test.ts +1 -0
  33. package/src/tests/workflow-executors.test.ts +4 -2
  34. package/src/tools/request-human-input.ts +117 -0
  35. package/src/tools/skills/index.ts +11 -0
  36. package/src/tools/skills/skill-create.ts +105 -0
  37. package/src/tools/skills/skill-delete.ts +67 -0
  38. package/src/tools/skills/skill-get.ts +75 -0
  39. package/src/tools/skills/skill-install-remote.ts +152 -0
  40. package/src/tools/skills/skill-install.ts +101 -0
  41. package/src/tools/skills/skill-list.ts +77 -0
  42. package/src/tools/skills/skill-publish.ts +123 -0
  43. package/src/tools/skills/skill-search.ts +43 -0
  44. package/src/tools/skills/skill-sync-remote.ts +128 -0
  45. package/src/tools/skills/skill-uninstall.ts +60 -0
  46. package/src/tools/skills/skill-update.ts +128 -0
  47. package/src/tools/store-progress.ts +31 -0
  48. package/src/tools/templates.ts +28 -0
  49. package/src/tools/tool-config.ts +16 -0
  50. package/src/tools/utils.ts +9 -7
  51. package/src/types.ts +54 -0
  52. package/src/workflows/executors/human-in-the-loop.ts +273 -0
  53. package/src/workflows/executors/registry.ts +2 -0
  54. package/src/workflows/recovery.ts +72 -0
  55. package/src/workflows/resume.ts +65 -1
@@ -1,3 +1,4 @@
1
+ import { ensure } from "@desplega.ai/business-use";
1
2
  import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
3
  import * as z from "zod";
3
4
  import {
@@ -161,6 +162,21 @@ export const registerStoreProgressTool = (server: McpServer) => {
161
162
  const result = completeTask(taskId, output);
162
163
  if (result) {
163
164
  updatedTask = result;
165
+
166
+ ensure({
167
+ id: "completed",
168
+ flow: "task",
169
+ runId: taskId,
170
+ depIds: ["started"],
171
+ data: {
172
+ taskId,
173
+ agentId: existingTask.agentId,
174
+ previousStatus: existingTask.status,
175
+ hasOutput: !!output,
176
+ },
177
+ validator: (data) => data.previousStatus === "in_progress",
178
+ });
179
+
164
180
  if (existingTask.agentId) {
165
181
  // Derive status from capacity instead of always setting idle
166
182
  updateAgentStatusFromCapacity(existingTask.agentId);
@@ -170,6 +186,21 @@ export const registerStoreProgressTool = (server: McpServer) => {
170
186
  const result = failTask(taskId, failureReason ?? "Unknown failure");
171
187
  if (result) {
172
188
  updatedTask = result;
189
+
190
+ ensure({
191
+ id: "failed",
192
+ flow: "task",
193
+ runId: taskId,
194
+ depIds: ["started"],
195
+ data: {
196
+ taskId,
197
+ agentId: existingTask.agentId,
198
+ previousStatus: existingTask.status,
199
+ failureReason: failureReason ?? "Unknown failure",
200
+ },
201
+ validator: (data) => data.previousStatus === "in_progress",
202
+ });
203
+
173
204
  if (existingTask.agentId) {
174
205
  // Derive status from capacity instead of always setting idle
175
206
  updateAgentStatusFromCapacity(existingTask.agentId);
@@ -32,6 +32,34 @@ Use \`get-task-details\` with taskId "{{task_id}}" for full details.`,
32
32
  category: "task_lifecycle",
33
33
  });
34
34
 
35
+ // ============================================================================
36
+ // HITL follow-up (created when a standalone approval request is resolved)
37
+ // ============================================================================
38
+
39
+ registerTemplate({
40
+ eventType: "hitl.follow_up",
41
+ header: "",
42
+ defaultBody: `Human responded to your approval request ({{request_id}}).
43
+
44
+ Title: {{title}}
45
+ Status: {{status}}
46
+
47
+ Questions and responses:
48
+ {{responses}}
49
+
50
+ Continue your work based on the human's input.`,
51
+ variables: [
52
+ { name: "request_id", description: "The approval request ID" },
53
+ { name: "title", description: "Title of the approval request" },
54
+ { name: "status", description: "Resolution status: approved or rejected" },
55
+ {
56
+ name: "responses",
57
+ description: "Formatted questions and human responses",
58
+ },
59
+ ],
60
+ category: "task_lifecycle",
61
+ });
62
+
35
63
  registerTemplate({
36
64
  eventType: "task.worker.failed",
37
65
  header: "",
@@ -115,6 +115,22 @@ export const DEFERRED_TOOLS = new Set([
115
115
  // Debug (1)
116
116
  "db-query",
117
117
 
118
+ // Approval Requests (1)
119
+ "request-human-input",
120
+
121
+ // Skills (11)
122
+ "skill-create",
123
+ "skill-update",
124
+ "skill-delete",
125
+ "skill-get",
126
+ "skill-list",
127
+ "skill-search",
128
+ "skill-install",
129
+ "skill-uninstall",
130
+ "skill-install-remote",
131
+ "skill-sync-remote",
132
+ "skill-publish",
133
+
118
134
  // Other (3)
119
135
  "cancel-task",
120
136
  "inject-learning",
@@ -101,17 +101,19 @@ export const createToolRegistrar = (server: McpServer) => {
101
101
  config: ToolConfig<InputArgs, OutputArgs>,
102
102
  cb: ToolCallbackWithInfo<InputArgs>,
103
103
  ) => {
104
- return server.registerTool(name, config, ((args: InferInput<InputArgs>, meta: Meta) => {
105
- const requestInfo = getRequestInfo(meta);
106
-
107
- // Handle zero-argument case
108
- if (config.inputSchema === undefined) {
104
+ // When inputSchema is undefined, the MCP SDK calls handler(extra) with a single arg.
105
+ // When inputSchema is defined, it calls handler(args, extra) with two args.
106
+ if (config.inputSchema === undefined) {
107
+ return server.registerTool(name, config, ((meta: Meta) => {
108
+ const requestInfo = getRequestInfo(meta);
109
109
  return (
110
110
  cb as (requestInfo: RequestInfo, meta: Meta) => CallToolResult | Promise<CallToolResult>
111
111
  )(requestInfo, meta);
112
- }
112
+ }) as Parameters<typeof server.registerTool>[2]);
113
+ }
113
114
 
114
- // Handle with-arguments case
115
+ return server.registerTool(name, config, ((args: InferInput<InputArgs>, meta: Meta) => {
116
+ const requestInfo = getRequestInfo(meta);
115
117
  return (
116
118
  cb as (
117
119
  args: InferInput<InputArgs>,
package/src/types.ts CHANGED
@@ -869,3 +869,57 @@ export const PromptTemplateHistorySchema = z.object({
869
869
  changeReason: z.string().nullable(),
870
870
  });
871
871
  export type PromptTemplateHistory = z.infer<typeof PromptTemplateHistorySchema>;
872
+
873
+ // ============================================================================
874
+ // Skill Types
875
+ // ============================================================================
876
+
877
+ export const SkillTypeSchema = z.enum(["remote", "personal"]);
878
+ export type SkillType = z.infer<typeof SkillTypeSchema>;
879
+
880
+ export const SkillScopeSchema = z.enum(["global", "swarm", "agent"]);
881
+ export type SkillScope = z.infer<typeof SkillScopeSchema>;
882
+
883
+ export const SkillSchema = z.object({
884
+ id: z.string(),
885
+ name: z.string(),
886
+ description: z.string(),
887
+ content: z.string(),
888
+ type: SkillTypeSchema,
889
+ scope: SkillScopeSchema,
890
+ ownerAgentId: z.string().nullable(),
891
+ sourceUrl: z.string().nullable(),
892
+ sourceRepo: z.string().nullable(),
893
+ sourcePath: z.string().nullable(),
894
+ sourceBranch: z.string(),
895
+ sourceHash: z.string().nullable(),
896
+ isComplex: z.boolean(),
897
+ allowedTools: z.string().nullable(),
898
+ model: z.string().nullable(),
899
+ effort: z.string().nullable(),
900
+ context: z.string().nullable(),
901
+ agent: z.string().nullable(),
902
+ disableModelInvocation: z.boolean(),
903
+ userInvocable: z.boolean(),
904
+ version: z.number(),
905
+ isEnabled: z.boolean(),
906
+ createdAt: z.string(),
907
+ lastUpdatedAt: z.string(),
908
+ lastFetchedAt: z.string().nullable(),
909
+ });
910
+ export type Skill = z.infer<typeof SkillSchema>;
911
+
912
+ export const AgentSkillSchema = z.object({
913
+ id: z.string(),
914
+ agentId: z.string(),
915
+ skillId: z.string(),
916
+ isActive: z.boolean(),
917
+ installedAt: z.string(),
918
+ });
919
+ export type AgentSkill = z.infer<typeof AgentSkillSchema>;
920
+
921
+ export const SkillWithInstallInfoSchema = SkillSchema.extend({
922
+ isActive: z.boolean(),
923
+ installedAt: z.string(),
924
+ });
925
+ export type SkillWithInstallInfo = z.infer<typeof SkillWithInstallInfoSchema>;
@@ -0,0 +1,273 @@
1
+ import { z } from "zod";
2
+ import type { ExecutorMeta } from "../../types";
3
+ import type { ExecutorResult } from "./base";
4
+ import { BaseExecutor } from "./base";
5
+
6
+ // ─── Config / Output Schemas ────────────────────────────────
7
+
8
+ const SelectOptionSchema = z.object({
9
+ value: z.string(),
10
+ label: z.string(),
11
+ description: z.string().optional(),
12
+ });
13
+
14
+ const QuestionSchema = z.discriminatedUnion("type", [
15
+ z.object({
16
+ id: z.string(),
17
+ type: z.literal("approval"),
18
+ label: z.string(),
19
+ required: z.boolean().default(true),
20
+ description: z.string().optional(),
21
+ }),
22
+ z.object({
23
+ id: z.string(),
24
+ type: z.literal("text"),
25
+ label: z.string(),
26
+ required: z.boolean().default(true),
27
+ description: z.string().optional(),
28
+ placeholder: z.string().optional(),
29
+ multiline: z.boolean().optional(),
30
+ }),
31
+ z.object({
32
+ id: z.string(),
33
+ type: z.literal("single-select"),
34
+ label: z.string(),
35
+ required: z.boolean().default(true),
36
+ description: z.string().optional(),
37
+ options: z.array(SelectOptionSchema),
38
+ }),
39
+ z.object({
40
+ id: z.string(),
41
+ type: z.literal("multi-select"),
42
+ label: z.string(),
43
+ required: z.boolean().default(true),
44
+ description: z.string().optional(),
45
+ options: z.array(SelectOptionSchema),
46
+ minSelections: z.number().int().min(0).optional(),
47
+ maxSelections: z.number().int().min(1).optional(),
48
+ }),
49
+ z.object({
50
+ id: z.string(),
51
+ type: z.literal("boolean"),
52
+ label: z.string(),
53
+ required: z.boolean().default(true),
54
+ description: z.string().optional(),
55
+ defaultValue: z.boolean().optional(),
56
+ }),
57
+ ]);
58
+
59
+ const ApproverConfigSchema = z.object({
60
+ users: z.array(z.string()).optional(),
61
+ roles: z.array(z.string()).optional(),
62
+ policy: z.union([z.literal("any"), z.literal("all"), z.object({ min: z.number().int().min(1) })]),
63
+ });
64
+
65
+ const NotificationConfigSchema = z.object({
66
+ channel: z.enum(["slack", "email"]),
67
+ target: z.string(),
68
+ });
69
+
70
+ const HITLConfigSchema = z.object({
71
+ title: z.string(),
72
+ questions: z.array(QuestionSchema).min(1),
73
+ approvers: ApproverConfigSchema,
74
+ timeout: z
75
+ .object({
76
+ seconds: z.number().int().min(1),
77
+ action: z.literal("reject"),
78
+ })
79
+ .optional(),
80
+ notifications: z.array(NotificationConfigSchema).optional(),
81
+ });
82
+
83
+ const HITLOutputSchema = z.object({
84
+ requestId: z.string().uuid(),
85
+ status: z.string(),
86
+ responses: z.record(z.string(), z.unknown()).nullable(),
87
+ });
88
+
89
+ type HITLOutput = z.infer<typeof HITLOutputSchema>;
90
+
91
+ // ─── Executor ───────────────────────────────────────────────
92
+
93
+ export class HumanInTheLoopExecutor extends BaseExecutor<
94
+ typeof HITLConfigSchema,
95
+ typeof HITLOutputSchema
96
+ > {
97
+ readonly type = "human-in-the-loop";
98
+ readonly mode = "async" as const;
99
+ readonly configSchema = HITLConfigSchema;
100
+ readonly outputSchema = HITLOutputSchema;
101
+
102
+ protected async execute(
103
+ config: z.infer<typeof HITLConfigSchema>,
104
+ _context: Readonly<Record<string, unknown>>,
105
+ meta: ExecutorMeta,
106
+ ): Promise<ExecutorResult<HITLOutput>> {
107
+ const { db } = this.deps;
108
+
109
+ // 1. Idempotency: check if an approval request was already created for this step
110
+ const existing = db.getApprovalRequestByStepId(meta.stepId);
111
+ if (existing) {
112
+ if (existing.status !== "pending") {
113
+ // Already resolved — return result
114
+ const nextPort =
115
+ existing.status === "timeout"
116
+ ? "timeout"
117
+ : existing.status === "rejected"
118
+ ? "rejected"
119
+ : "approved";
120
+ return {
121
+ status: "success",
122
+ output: {
123
+ requestId: existing.id,
124
+ status: existing.status,
125
+ responses: existing.responses as Record<string, unknown> | null,
126
+ },
127
+ nextPort,
128
+ };
129
+ }
130
+ // Still pending — return async marker
131
+ return {
132
+ status: "success",
133
+ async: true,
134
+ waitFor: "approval.resolved",
135
+ correlationId: existing.id,
136
+ } as unknown as ExecutorResult<HITLOutput>;
137
+ }
138
+
139
+ // 2. Create the approval request
140
+ const requestId = crypto.randomUUID();
141
+ db.createApprovalRequest({
142
+ id: requestId,
143
+ title: config.title,
144
+ questions: config.questions,
145
+ approvers: config.approvers,
146
+ workflowRunId: meta.runId,
147
+ workflowRunStepId: meta.stepId,
148
+ timeoutSeconds: config.timeout?.seconds,
149
+ notificationChannels: config.notifications,
150
+ });
151
+
152
+ // 3. Dispatch notifications (fire-and-forget — failures must not block the workflow)
153
+ if (config.notifications?.length) {
154
+ this.dispatchNotifications(requestId, config, db).catch((err) => {
155
+ console.error("[HITL] Unexpected error dispatching notifications:", err);
156
+ });
157
+ }
158
+
159
+ // 4. Return async result — engine will pause the workflow
160
+ return {
161
+ status: "success",
162
+ async: true,
163
+ waitFor: "approval.resolved",
164
+ correlationId: requestId,
165
+ } as unknown as ExecutorResult<HITLOutput>;
166
+ }
167
+
168
+ /** Dispatch notifications for each configured channel. Updates DB with messageTs on success. */
169
+ private async dispatchNotifications(
170
+ requestId: string,
171
+ config: z.infer<typeof HITLConfigSchema>,
172
+ db: typeof import("../../be/db"),
173
+ ): Promise<void> {
174
+ if (!config.notifications?.length) return;
175
+
176
+ const approvalUrl = `https://app.agent-swarm.dev/approval-requests/${requestId}`;
177
+ const updatedChannels = [...config.notifications] as Array<
178
+ z.infer<typeof NotificationConfigSchema> & { messageTs?: string }
179
+ >;
180
+ let updated = false;
181
+
182
+ for (let i = 0; i < config.notifications.length; i++) {
183
+ const notification = config.notifications[i]!;
184
+
185
+ if (notification.channel === "email") {
186
+ console.warn(
187
+ `[HITL] Email notifications not yet supported (target: ${notification.target})`,
188
+ );
189
+ continue;
190
+ }
191
+
192
+ if (notification.channel === "slack") {
193
+ try {
194
+ const { getSlackApp } = await import("../../slack/app");
195
+ const slackApp = getSlackApp();
196
+ if (!slackApp) {
197
+ console.warn("[HITL] Slack not initialized — cannot send notification");
198
+ continue;
199
+ }
200
+
201
+ const questionsSummary = config.questions.map((q) => `• ${q.label}`).join("\n");
202
+
203
+ const timeoutText = config.timeout
204
+ ? `\n⏱ _Timeout: ${formatTimeout(config.timeout.seconds)} — auto-rejects if not responded_`
205
+ : "";
206
+
207
+ const blocks = [
208
+ {
209
+ type: "section",
210
+ text: {
211
+ type: "mrkdwn",
212
+ text: `🔔 *Approval Required: ${config.title}*`,
213
+ },
214
+ },
215
+ {
216
+ type: "section",
217
+ text: {
218
+ type: "mrkdwn",
219
+ text: `*Questions:*\n${questionsSummary}${timeoutText}`,
220
+ },
221
+ },
222
+ {
223
+ type: "actions",
224
+ elements: [
225
+ {
226
+ type: "button",
227
+ text: { type: "plain_text", text: "Review & Respond" },
228
+ url: approvalUrl,
229
+ style: "primary",
230
+ },
231
+ ],
232
+ },
233
+ ];
234
+
235
+ const result = await slackApp.client.chat.postMessage({
236
+ channel: notification.target,
237
+ text: `Approval Required: ${config.title} — ${approvalUrl}`,
238
+ // biome-ignore lint/suspicious/noExplicitAny: Block Kit objects
239
+ blocks: blocks as any,
240
+ });
241
+
242
+ if (result.ts) {
243
+ updatedChannels[i] = { ...notification, messageTs: result.ts };
244
+ updated = true;
245
+ }
246
+
247
+ console.log(
248
+ `[HITL] Slack notification sent to ${notification.target} for request ${requestId}`,
249
+ );
250
+ } catch (err) {
251
+ console.error(`[HITL] Failed to send Slack notification to ${notification.target}:`, err);
252
+ }
253
+ }
254
+ }
255
+
256
+ // Persist messageTs values back to DB
257
+ if (updated) {
258
+ try {
259
+ db.updateApprovalRequestNotifications(requestId, updatedChannels);
260
+ } catch (err) {
261
+ console.error("[HITL] Failed to update notification channels in DB:", err);
262
+ }
263
+ }
264
+ }
265
+ }
266
+
267
+ function formatTimeout(seconds: number): string {
268
+ if (seconds < 60) return `${seconds}s`;
269
+ if (seconds < 3600) return `${Math.round(seconds / 60)}m`;
270
+ const hours = Math.floor(seconds / 3600);
271
+ const mins = Math.round((seconds % 3600) / 60);
272
+ return mins > 0 ? `${hours}h ${mins}m` : `${hours}h`;
273
+ }
@@ -2,6 +2,7 @@ import { z } from "zod";
2
2
  import { AgentTaskExecutor } from "./agent-task";
3
3
  import type { BaseExecutor, ExecutorDependencies } from "./base";
4
4
  import { CodeMatchExecutor } from "./code-match";
5
+ import { HumanInTheLoopExecutor } from "./human-in-the-loop";
5
6
  import { NotifyExecutor } from "./notify";
6
7
  import { PropertyMatchExecutor } from "./property-match";
7
8
  import { RawLlmExecutor } from "./raw-llm";
@@ -71,6 +72,7 @@ export function createExecutorRegistry(deps: ExecutorDependencies): ExecutorRegi
71
72
 
72
73
  // Async executors (Phase 4)
73
74
  registry.register(new AgentTaskExecutor(deps));
75
+ registry.register(new HumanInTheLoopExecutor(deps));
74
76
 
75
77
  return registry;
76
78
  }
@@ -1,9 +1,11 @@
1
1
  import {
2
2
  getCompletedStepNodeIds,
3
3
  getDb,
4
+ getStuckApprovalRuns,
4
5
  getStuckWorkflowRuns,
5
6
  getWorkflow,
6
7
  getWorkflowRun,
8
+ resolveApprovalRequest,
7
9
  updateWorkflowRun,
8
10
  updateWorkflowRunStep,
9
11
  } from "../be/db";
@@ -11,6 +13,7 @@ import { checkpointStep } from "./checkpoint";
11
13
  import { getSuccessors } from "./definition";
12
14
  import { findReadyNodes, walkGraph } from "./engine";
13
15
  import type { ExecutorRegistry } from "./executors/registry";
16
+ import { finalizeOrWait } from "./resume";
14
17
 
15
18
  /**
16
19
  * Recover incomplete workflow runs on server startup.
@@ -30,6 +33,9 @@ export async function recoverIncompleteRuns(registry: ExecutorRegistry): Promise
30
33
  // --- Case 2: Waiting runs whose tasks may have finished ---
31
34
  recovered += await recoverWaitingRuns(registry);
32
35
 
36
+ // --- Case 3: Waiting runs whose approval requests may have resolved ---
37
+ recovered += await recoverApprovalWaitingRuns(registry);
38
+
33
39
  if (recovered > 0) {
34
40
  console.log(`[workflows] Recovered ${recovered} incomplete run(s) on startup`);
35
41
  }
@@ -128,6 +134,72 @@ async function recoverWaitingRuns(registry: ExecutorRegistry): Promise<number> {
128
134
  return recovered;
129
135
  }
130
136
 
137
+ /**
138
+ * Recover waiting runs whose linked approval requests have resolved or expired
139
+ * while the server was down.
140
+ */
141
+ async function recoverApprovalWaitingRuns(registry: ExecutorRegistry): Promise<number> {
142
+ const stuckRuns = getStuckApprovalRuns();
143
+ let recovered = 0;
144
+
145
+ for (const stuck of stuckRuns) {
146
+ try {
147
+ const run = getWorkflowRun(stuck.runId);
148
+ const workflow = getWorkflow(stuck.workflowId);
149
+ if (!run || !workflow) continue;
150
+
151
+ let approvalStatus = stuck.approvalStatus;
152
+ let responses: unknown = stuck.approvalResponses ? JSON.parse(stuck.approvalResponses) : null;
153
+
154
+ // If still pending but expired, auto-reject
155
+ if (approvalStatus === "pending" && stuck.expiresAt) {
156
+ resolveApprovalRequest(stuck.approvalId, {
157
+ status: "timeout",
158
+ });
159
+ approvalStatus = "timeout";
160
+ responses = null;
161
+ }
162
+
163
+ const nextPort =
164
+ approvalStatus === "timeout"
165
+ ? "timeout"
166
+ : approvalStatus === "rejected"
167
+ ? "rejected"
168
+ : "approved";
169
+
170
+ const ctx = (run.context ?? {}) as Record<string, unknown>;
171
+ const stepOutput = {
172
+ requestId: stuck.approvalId,
173
+ status: approvalStatus,
174
+ responses,
175
+ };
176
+
177
+ checkpointStep(
178
+ stuck.runId,
179
+ stuck.stepId,
180
+ stuck.nodeId,
181
+ { output: stepOutput, nextPort },
182
+ ctx,
183
+ );
184
+ updateWorkflowRun(stuck.runId, { status: "running" });
185
+
186
+ const completedNodeIds = new Set(getCompletedStepNodeIds(stuck.runId));
187
+ const readyNodes = findReadyNodes(workflow.definition, completedNodeIds);
188
+
189
+ if (readyNodes.length > 0) {
190
+ await walkGraph(workflow.definition, stuck.runId, ctx, readyNodes, registry, workflow.id);
191
+ } else {
192
+ finalizeOrWait(stuck.runId);
193
+ }
194
+ recovered++;
195
+ } catch (err) {
196
+ console.error(`[workflows] Failed to recover approval-waiting run ${stuck.runId}:`, err);
197
+ }
198
+ }
199
+
200
+ return recovered;
201
+ }
202
+
131
203
  /**
132
204
  * Get run IDs by status. Simple query since there's no dedicated function for this.
133
205
  */
@@ -21,6 +21,14 @@ interface TaskEvent {
21
21
  failureReason?: string;
22
22
  }
23
23
 
24
+ interface ApprovalEvent {
25
+ requestId: string;
26
+ status: "approved" | "rejected" | "timeout";
27
+ responses: Record<string, unknown> | null;
28
+ workflowRunId?: string;
29
+ workflowRunStepId?: string;
30
+ }
31
+
24
32
  /**
25
33
  * Wire up event bus listeners for workflow resume on task lifecycle events.
26
34
  */
@@ -57,6 +65,16 @@ export function setupWorkflowResumeListener(
57
65
  console.error("[workflows] Handle task cancellation error:", err);
58
66
  }
59
67
  });
68
+
69
+ eventBus.on("approval.resolved", async (data: unknown) => {
70
+ const event = data as ApprovalEvent;
71
+ if (!event.workflowRunId || !event.workflowRunStepId) return;
72
+ try {
73
+ await resumeFromApprovalResolution(event, registry);
74
+ } catch (err) {
75
+ console.error("[workflows] Resume from approval resolution failed:", err);
76
+ }
77
+ });
60
78
  }
61
79
 
62
80
  /**
@@ -119,7 +137,7 @@ async function resumeFromTaskCompletion(
119
137
  * If no nodes are ready and no steps are still waiting, finalize the run.
120
138
  * Otherwise set it back to waiting for the next task completion.
121
139
  */
122
- function finalizeOrWait(runId: string): void {
140
+ export function finalizeOrWait(runId: string): void {
123
141
  const steps = getWorkflowRunStepsByRunId(runId);
124
142
  const hasWaiting = steps.some((s) => s.status === "waiting");
125
143
  if (hasWaiting) {
@@ -227,3 +245,49 @@ export async function retryFailedRun(runId: string, registry: ExecutorRegistry):
227
245
  : [failedNode, ...readyNodes];
228
246
  await walkGraph(workflow.definition, runId, ctx, nodesToRun, registry, workflow.id);
229
247
  }
248
+
249
+ /**
250
+ * Resume a workflow after a linked approval request is resolved.
251
+ *
252
+ * 1. Verify run and step are in "waiting" state
253
+ * 2. Checkpoint step completion with approval response data
254
+ * 3. Route to the appropriate port (approved/rejected/timeout)
255
+ * 4. Continue the graph walk
256
+ */
257
+ async function resumeFromApprovalResolution(
258
+ event: ApprovalEvent,
259
+ registry: ExecutorRegistry,
260
+ ): Promise<void> {
261
+ const run = getWorkflowRun(event.workflowRunId!);
262
+ if (!run || (run.status !== "waiting" && run.status !== "running")) return;
263
+
264
+ const step = getWorkflowRunStep(event.workflowRunStepId!);
265
+ if (!step || step.status !== "waiting") return;
266
+
267
+ const workflow = getWorkflow(run.workflowId);
268
+ if (!workflow) return;
269
+
270
+ const ctx = (run.context ?? {}) as Record<string, unknown>;
271
+
272
+ // Determine output port based on approval status
273
+ const nextPort =
274
+ event.status === "timeout" ? "timeout" : event.status === "rejected" ? "rejected" : "approved";
275
+
276
+ const stepOutput = {
277
+ requestId: event.requestId,
278
+ status: event.status,
279
+ responses: event.responses,
280
+ };
281
+
282
+ checkpointStep(run.id, step.id, step.nodeId, { output: stepOutput, nextPort }, ctx);
283
+ updateWorkflowRun(run.id, { status: "running" });
284
+
285
+ const completedNodeIds = new Set(getCompletedStepNodeIds(run.id));
286
+ const readyNodes = findReadyNodes(workflow.definition, completedNodeIds);
287
+
288
+ if (readyNodes.length > 0) {
289
+ await walkGraph(workflow.definition, run.id, ctx, readyNodes, registry, workflow.id);
290
+ } else {
291
+ finalizeOrWait(run.id);
292
+ }
293
+ }