@desplega.ai/agent-swarm 1.52.0 → 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.
@@ -0,0 +1,207 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { humanizeToolName, toolCallToProgress } from "../commands/runner";
3
+
4
+ describe("toolCallToProgress", () => {
5
+ // --- Core Claude Code tools ---
6
+
7
+ test("Read tool includes emoji and short path", () => {
8
+ const result = toolCallToProgress("Read", {
9
+ file_path: "/Users/taras/Documents/code/agent-swarm/src/commands/runner.ts",
10
+ });
11
+ expect(result).toBe("šŸ“– Reading commands/runner.ts");
12
+ });
13
+
14
+ test("Edit tool includes emoji and short path", () => {
15
+ const result = toolCallToProgress("Edit", {
16
+ file_path: "/Users/taras/code/src/index.ts",
17
+ });
18
+ expect(result).toBe("āœļø Editing src/index.ts");
19
+ });
20
+
21
+ test("MultiEdit uses same format as Edit", () => {
22
+ const result = toolCallToProgress("MultiEdit", {
23
+ file_path: "/a/b/c/d.ts",
24
+ });
25
+ expect(result).toBe("āœļø Editing c/d.ts");
26
+ });
27
+
28
+ test("Write tool includes emoji and short path", () => {
29
+ const result = toolCallToProgress("Write", {
30
+ file_path: "/Users/taras/code/new-file.ts",
31
+ });
32
+ expect(result).toBe("šŸ“ Writing code/new-file.ts");
33
+ });
34
+
35
+ test("Bash tool uses description when available", () => {
36
+ const result = toolCallToProgress("Bash", {
37
+ description: "Running tests",
38
+ command: "bun test",
39
+ });
40
+ expect(result).toBe("⚔ Running tests");
41
+ });
42
+
43
+ test("Bash tool falls back when no description", () => {
44
+ const result = toolCallToProgress("Bash", { command: "ls -la" });
45
+ expect(result).toBe("⚔ Running shell command");
46
+ });
47
+
48
+ test("Grep tool shows search pattern", () => {
49
+ const result = toolCallToProgress("Grep", { pattern: "TODO" });
50
+ expect(result).toBe('šŸ” Searching for "TODO"');
51
+ });
52
+
53
+ test("Glob tool shows file pattern", () => {
54
+ const result = toolCallToProgress("Glob", { pattern: "**/*.test.ts" });
55
+ expect(result).toBe("šŸ“ Finding files matching **/*.test.ts");
56
+ });
57
+
58
+ test("Agent tool uses description when available", () => {
59
+ const result = toolCallToProgress("Agent", {
60
+ description: "Exploring codebase structure",
61
+ });
62
+ expect(result).toBe("šŸ¤– Exploring codebase structure");
63
+ });
64
+
65
+ test("Agent tool falls back when no description", () => {
66
+ const result = toolCallToProgress("Agent", {});
67
+ expect(result).toBe("šŸ¤– Delegating sub-task");
68
+ });
69
+
70
+ test("Task tool uses description", () => {
71
+ const result = toolCallToProgress("Task", {
72
+ description: "Running lint checks",
73
+ });
74
+ expect(result).toBe("šŸ¤– Running lint checks");
75
+ });
76
+
77
+ test("Skill tool shows skill name", () => {
78
+ const result = toolCallToProgress("Skill", { skill: "commit" });
79
+ expect(result).toBe("āš™ļø Running /commit");
80
+ });
81
+
82
+ // --- Skip list ---
83
+
84
+ test("ToolSearch is skipped (returns null)", () => {
85
+ expect(toolCallToProgress("ToolSearch", {})).toBeNull();
86
+ });
87
+
88
+ test("TodoRead is skipped", () => {
89
+ expect(toolCallToProgress("TodoRead", {})).toBeNull();
90
+ });
91
+
92
+ test("TodoWrite is skipped", () => {
93
+ expect(toolCallToProgress("TodoWrite", {})).toBeNull();
94
+ });
95
+
96
+ // --- Unknown default tools ---
97
+
98
+ test("unknown tool gets fallback with emoji", () => {
99
+ const result = toolCallToProgress("SomeNewTool", {});
100
+ expect(result).toBe("šŸ”§ SomeNewTool");
101
+ });
102
+
103
+ // --- Agent-swarm MCP tools (with pretty labels) ---
104
+
105
+ test("agent-swarm:get-task-details has pretty label", () => {
106
+ const result = toolCallToProgress("mcp__agent-swarm__get-task-details", {});
107
+ expect(result).toBe("šŸ“‹ Reviewing task details");
108
+ });
109
+
110
+ test("agent-swarm:send-task has pretty label", () => {
111
+ const result = toolCallToProgress("mcp__agent-swarm__send-task", {});
112
+ expect(result).toBe("šŸ“¤ Delegating task");
113
+ });
114
+
115
+ test("agent-swarm:post-message has pretty label", () => {
116
+ const result = toolCallToProgress("mcp__agent-swarm__post-message", {});
117
+ expect(result).toBe("šŸ’¬ Sending message");
118
+ });
119
+
120
+ test("agent-swarm:store-progress is skipped (meta/noise)", () => {
121
+ expect(toolCallToProgress("mcp__agent-swarm__store-progress", {})).toBeNull();
122
+ });
123
+
124
+ test("agent-swarm:poll-task has pretty label", () => {
125
+ const result = toolCallToProgress("mcp__agent-swarm__poll-task", {});
126
+ expect(result).toBe("šŸ“” Polling for tasks");
127
+ });
128
+
129
+ test("agent-swarm:request-human-input has pretty label", () => {
130
+ const result = toolCallToProgress("mcp__agent-swarm__request-human-input", {});
131
+ expect(result).toBe("šŸ™‹ Requesting human input");
132
+ });
133
+
134
+ test("agent-swarm:memory-search has pretty label", () => {
135
+ const result = toolCallToProgress("mcp__agent-swarm__memory-search", {});
136
+ expect(result).toBe("🧠 Searching memory");
137
+ });
138
+
139
+ test("agent-swarm:tracker-status has pretty label", () => {
140
+ const result = toolCallToProgress("mcp__agent-swarm__tracker-status", {});
141
+ expect(result).toBe("šŸ“Š Checking tracker status");
142
+ });
143
+
144
+ test("agent-swarm:trigger-workflow has pretty label", () => {
145
+ const result = toolCallToProgress("mcp__agent-swarm__trigger-workflow", {});
146
+ expect(result).toBe("āš™ļø Triggering workflow");
147
+ });
148
+
149
+ test("agent-swarm:slack-post has pretty label", () => {
150
+ const result = toolCallToProgress("mcp__agent-swarm__slack-post", {});
151
+ expect(result).toBe("šŸ’¬ Posting to Slack");
152
+ });
153
+
154
+ // --- Agent-swarm MCP tool NOT in lookup (humanized fallback) ---
155
+
156
+ test("unknown agent-swarm tool gets humanized fallback", () => {
157
+ const result = toolCallToProgress("mcp__agent-swarm__some-new-tool", {});
158
+ expect(result).toBe("šŸ”Œ Some new tool");
159
+ });
160
+
161
+ // --- Other MCP servers ---
162
+
163
+ test("other MCP server tool gets server prefix + humanized name", () => {
164
+ const result = toolCallToProgress("mcp__linear__list-issues", {});
165
+ expect(result).toBe("šŸ”Œ linear: List issues");
166
+ });
167
+
168
+ test("other MCP server with simple tool name", () => {
169
+ const result = toolCallToProgress("mcp__github__search", {});
170
+ expect(result).toBe("šŸ”Œ github: Search");
171
+ });
172
+
173
+ test("MCP tool with double underscores in tool name", () => {
174
+ const result = toolCallToProgress("mcp__context7__query-docs", {});
175
+ expect(result).toBe("šŸ”Œ context7: Query docs");
176
+ });
177
+
178
+ // --- Short path helper (tested implicitly) ---
179
+
180
+ test("Read with short path (<=2 segments) keeps full path", () => {
181
+ const result = toolCallToProgress("Read", { file_path: "file.ts" });
182
+ expect(result).toBe("šŸ“– Reading file.ts");
183
+ });
184
+
185
+ test("Read with missing file_path shows empty", () => {
186
+ const result = toolCallToProgress("Read", {});
187
+ expect(result).toBe("šŸ“– Reading ");
188
+ });
189
+ });
190
+
191
+ describe("humanizeToolName", () => {
192
+ test("converts kebab-case to sentence case", () => {
193
+ expect(humanizeToolName("get-task-details")).toBe("Get task details");
194
+ });
195
+
196
+ test("single word gets capitalized", () => {
197
+ expect(humanizeToolName("search")).toBe("Search");
198
+ });
199
+
200
+ test("multi-word kebab-case", () => {
201
+ expect(humanizeToolName("list-all-workflow-runs")).toBe("List all workflow runs");
202
+ });
203
+
204
+ test("empty string returns empty", () => {
205
+ expect(humanizeToolName("")).toBe("");
206
+ });
207
+ });
@@ -0,0 +1,114 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
+ import * as z from "zod";
4
+ import { createToolRegistrar } from "../tools/utils";
5
+
6
+ describe("createToolRegistrar with no inputSchema", () => {
7
+ test("handler receives requestInfo from meta when no inputSchema is defined", async () => {
8
+ const server = new McpServer({ name: "test-no-input", version: "1.0.0" });
9
+
10
+ let receivedRequestInfo: unknown = null;
11
+ let receivedMeta: unknown = null;
12
+
13
+ createToolRegistrar(server)(
14
+ "test-no-input-tool",
15
+ {
16
+ title: "Test Tool",
17
+ description: "Tool with no inputSchema",
18
+ outputSchema: z.object({ ok: z.boolean() }),
19
+ },
20
+ async (requestInfo, meta) => {
21
+ receivedRequestInfo = requestInfo;
22
+ receivedMeta = meta;
23
+ return {
24
+ content: [{ type: "text" as const, text: "ok" }],
25
+ structuredContent: { ok: true },
26
+ };
27
+ },
28
+ );
29
+
30
+ // Access the internal registered tool handler directly
31
+ // The MCP SDK stores tools in _registeredTools and calls handler(extra) for no-inputSchema
32
+ const registeredTools = (server as Record<string, unknown>)._registeredTools as Record<
33
+ string,
34
+ { handler: (extra: unknown) => Promise<unknown> }
35
+ >;
36
+
37
+ const tool = registeredTools["test-no-input-tool"];
38
+ expect(tool).toBeDefined();
39
+
40
+ // Simulate how the MCP SDK calls the handler for tools without inputSchema:
41
+ // It calls handler(extra) with a single argument (the Meta/extra object)
42
+ const fakeExtra = {
43
+ sessionId: "test-session-123",
44
+ requestInfo: {
45
+ headers: {
46
+ "x-agent-id": "agent-abc",
47
+ "x-source-task-id": "task-xyz",
48
+ },
49
+ },
50
+ };
51
+
52
+ // This is the critical test: the handler should NOT throw
53
+ // Before the fix, this would throw "undefined is not an object (evaluating 'req.requestInfo')"
54
+ const result = await tool.handler(fakeExtra);
55
+
56
+ expect(receivedRequestInfo).toEqual({
57
+ sessionId: "test-session-123",
58
+ agentId: "agent-abc",
59
+ sourceTaskId: "task-xyz",
60
+ });
61
+ expect(receivedMeta).toBe(fakeExtra);
62
+ expect(result).toBeDefined();
63
+ });
64
+
65
+ test("handler with inputSchema still receives args correctly", async () => {
66
+ const server = new McpServer({ name: "test-with-input", version: "1.0.0" });
67
+
68
+ let receivedArgs: unknown = null;
69
+ let receivedRequestInfo: unknown = null;
70
+
71
+ createToolRegistrar(server)(
72
+ "test-with-input-tool",
73
+ {
74
+ title: "Test Tool With Input",
75
+ description: "Tool with inputSchema",
76
+ inputSchema: z.object({ name: z.string() }),
77
+ outputSchema: z.object({ greeting: z.string() }),
78
+ },
79
+ async (args, requestInfo, _meta) => {
80
+ receivedArgs = args;
81
+ receivedRequestInfo = requestInfo;
82
+ return {
83
+ content: [{ type: "text" as const, text: "ok" }],
84
+ structuredContent: { greeting: `Hello ${args.name}` },
85
+ };
86
+ },
87
+ );
88
+
89
+ const registeredTools = (server as Record<string, unknown>)._registeredTools as Record<
90
+ string,
91
+ { handler: (args: unknown, extra: unknown) => Promise<unknown> }
92
+ >;
93
+
94
+ const tool = registeredTools["test-with-input-tool"];
95
+
96
+ // MCP SDK calls handler(args, extra) when inputSchema is defined
97
+ const fakeExtra = {
98
+ sessionId: "test-session-456",
99
+ requestInfo: {
100
+ headers: { "x-agent-id": "agent-def" },
101
+ },
102
+ };
103
+
104
+ const result = await tool.handler({ name: "World" }, fakeExtra);
105
+
106
+ expect(receivedArgs).toEqual({ name: "World" });
107
+ expect(receivedRequestInfo).toEqual({
108
+ sessionId: "test-session-456",
109
+ agentId: "agent-def",
110
+ sourceTaskId: undefined,
111
+ });
112
+ expect(result).toBeDefined();
113
+ });
114
+ });
@@ -28,6 +28,7 @@ async function callUpdateProfile(
28
28
  callerAgentId: string | undefined,
29
29
  args: Record<string, unknown>,
30
30
  ): Promise<{ structuredContent: StructuredContent }> {
31
+ // biome-ignore lint/complexity/noBannedTypes: accessing internal MCP SDK type for test
31
32
  const tools = (server as unknown as { _registeredTools: Record<string, { handler: Function }> })
32
33
  ._registeredTools;
33
34
  const handler = tools["update-profile"].handler;
@@ -1,6 +1,6 @@
1
1
  import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
2
  import * as z from "zod";
3
- import { createApprovalRequest } from "@/be/db";
3
+ import { createApprovalRequest, getAgentCurrentTask } from "@/be/db";
4
4
  import { createToolRegistrar } from "@/tools/utils";
5
5
 
6
6
  const QuestionSchema = z.object({
@@ -73,18 +73,29 @@ export const registerRequestHumanInputTool = (server: McpServer) => {
73
73
  };
74
74
  }
75
75
 
76
+ // Resolve sourceTaskId: prefer header, fall back to agent's current in-progress task.
77
+ // The X-Source-Task-Id header may be missing when the per-session MCP config wasn't
78
+ // created (e.g. .mcp.json not found, session resumed, or lead agent on a non-task trigger).
79
+ let sourceTaskId = requestInfo.sourceTaskId;
80
+ if (!sourceTaskId && requestInfo.agentId) {
81
+ const currentTask = getAgentCurrentTask(requestInfo.agentId);
82
+ if (currentTask) {
83
+ sourceTaskId = currentTask.id;
84
+ }
85
+ }
86
+
76
87
  const id = crypto.randomUUID();
77
88
  const request = createApprovalRequest({
78
89
  id,
79
90
  title,
80
91
  questions,
81
92
  approvers: { policy: "any" },
82
- sourceTaskId: requestInfo.sourceTaskId ?? undefined,
93
+ sourceTaskId: sourceTaskId ?? undefined,
83
94
  timeoutSeconds,
84
95
  });
85
96
 
86
97
  const appUrl = process.env.APP_URL || "http://localhost:5274";
87
- const url = `${appUrl}/requests/${request.id}`;
98
+ const url = `${appUrl}/approval-requests/${request.id}`;
88
99
 
89
100
  return {
90
101
  content: [
@@ -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: "",
@@ -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>,
@@ -146,10 +146,17 @@ export class HumanInTheLoopExecutor extends BaseExecutor<
146
146
  workflowRunId: meta.runId,
147
147
  workflowRunStepId: meta.stepId,
148
148
  timeoutSeconds: config.timeout?.seconds,
149
- notificationChannels: config.notifications, // TODO: Notifications are stored but not dispatched yet (Phase 2)
149
+ notificationChannels: config.notifications,
150
150
  });
151
151
 
152
- // 3. Return async result — engine will pause the workflow
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
153
160
  return {
154
161
  status: "success",
155
162
  async: true,
@@ -157,4 +164,110 @@ export class HumanInTheLoopExecutor extends BaseExecutor<
157
164
  correlationId: requestId,
158
165
  } as unknown as ExecutorResult<HITLOutput>;
159
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`;
160
273
  }