@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.
- package/README.md +131 -0
- package/openapi.json +767 -4
- package/package.json +3 -1
- package/src/be/db.ts +669 -0
- package/src/be/migrations/019_skills.sql +65 -0
- package/src/be/migrations/020_approval_requests.sql +41 -0
- package/src/be/migrations/runner.ts +4 -4
- package/src/be/skill-parser.ts +70 -0
- package/src/be/skill-sync.ts +106 -0
- package/src/commands/runner.ts +299 -52
- package/src/http/agents.ts +29 -0
- package/src/http/approval-requests.ts +310 -0
- package/src/http/config.ts +3 -3
- package/src/http/index.ts +26 -2
- package/src/http/poll.ts +15 -0
- package/src/http/skills.ts +479 -0
- package/src/http/tasks.ts +94 -0
- package/src/linear/outbound.ts +12 -12
- package/src/prompts/base-prompt.ts +8 -0
- package/src/providers/claude-adapter.ts +19 -3
- package/src/scheduler/scheduler.ts +24 -1
- package/src/server.ts +29 -0
- package/src/slack/blocks.ts +1 -1
- package/src/tests/approval-requests.test.ts +948 -0
- package/src/tests/skill-parser.test.ts +178 -0
- package/src/tests/skill-sync.test.ts +171 -0
- package/src/tests/slack-blocks.test.ts +3 -2
- package/src/tests/structured-output.test.ts +1 -0
- package/src/tests/tool-annotations.test.ts +2 -1
- package/src/tests/tool-call-progress.test.ts +207 -0
- package/src/tests/tool-registrar-no-input.test.ts +114 -0
- package/src/tests/update-profile-auth.test.ts +1 -0
- package/src/tests/workflow-executors.test.ts +4 -2
- package/src/tools/request-human-input.ts +117 -0
- package/src/tools/skills/index.ts +11 -0
- package/src/tools/skills/skill-create.ts +105 -0
- package/src/tools/skills/skill-delete.ts +67 -0
- package/src/tools/skills/skill-get.ts +75 -0
- package/src/tools/skills/skill-install-remote.ts +152 -0
- package/src/tools/skills/skill-install.ts +101 -0
- package/src/tools/skills/skill-list.ts +77 -0
- package/src/tools/skills/skill-publish.ts +123 -0
- package/src/tools/skills/skill-search.ts +43 -0
- package/src/tools/skills/skill-sync-remote.ts +128 -0
- package/src/tools/skills/skill-uninstall.ts +60 -0
- package/src/tools/skills/skill-update.ts +128 -0
- package/src/tools/store-progress.ts +31 -0
- package/src/tools/templates.ts +28 -0
- package/src/tools/tool-config.ts +16 -0
- package/src/tools/utils.ts +9 -7
- package/src/types.ts +54 -0
- package/src/workflows/executors/human-in-the-loop.ts +273 -0
- package/src/workflows/executors/registry.ts +2 -0
- package/src/workflows/recovery.ts +72 -0
- 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);
|
package/src/tools/templates.ts
CHANGED
|
@@ -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: "",
|
package/src/tools/tool-config.ts
CHANGED
|
@@ -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",
|
package/src/tools/utils.ts
CHANGED
|
@@ -101,17 +101,19 @@ export const createToolRegistrar = (server: McpServer) => {
|
|
|
101
101
|
config: ToolConfig<InputArgs, OutputArgs>,
|
|
102
102
|
cb: ToolCallbackWithInfo<InputArgs>,
|
|
103
103
|
) => {
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
-
|
|
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
|
*/
|
package/src/workflows/resume.ts
CHANGED
|
@@ -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
|
+
}
|