@desplega.ai/agent-swarm 1.83.0 → 1.83.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/openapi.json +39 -3
- package/package.json +6 -6
- package/src/be/db.ts +2 -2
- package/src/be/schedules/validate.ts +21 -0
- package/src/be/skill-sync.ts +65 -15
- package/src/commands/runner.ts +41 -54
- package/src/http/schedules.ts +34 -9
- package/src/http/skills.ts +27 -2
- package/src/tests/http-api-integration.test.ts +36 -0
- package/src/tests/runner-skills-refresh.test.ts +200 -0
- package/src/tests/schedule-validation-helper.test.ts +51 -0
- package/src/tests/skill-sync.test.ts +73 -9
- package/src/tests/skills-signature.test.ts +141 -0
- package/src/tests/update-schedule-mcp-tool.test.ts +161 -0
- package/src/tools/schedules/update-schedule.ts +48 -8
- package/src/tools/slack-upload-file.ts +17 -5
- package/src/utils/skills-refresh.ts +123 -0
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
getScheduledTaskByName,
|
|
8
8
|
updateScheduledTask,
|
|
9
9
|
} from "@/be/db";
|
|
10
|
+
import { mergeScheduleTiming, validateRecurringTiming } from "@/be/schedules/validate";
|
|
10
11
|
import { calculateNextRun } from "@/scheduler";
|
|
11
12
|
import { createToolRegistrar } from "@/tools/utils";
|
|
12
13
|
|
|
@@ -23,8 +24,18 @@ export const registerUpdateScheduleTool = (server: McpServer) => {
|
|
|
23
24
|
name: z.string().optional().describe("Schedule name to update (alternative to ID)"),
|
|
24
25
|
newName: z.string().min(1).max(100).optional().describe("New name for the schedule"),
|
|
25
26
|
taskTemplate: z.string().min(1).optional().describe("New task template"),
|
|
26
|
-
cronExpression: z
|
|
27
|
-
|
|
27
|
+
cronExpression: z
|
|
28
|
+
.string()
|
|
29
|
+
.nullable()
|
|
30
|
+
.optional()
|
|
31
|
+
.describe("New cron expression (null to clear)"),
|
|
32
|
+
intervalMs: z
|
|
33
|
+
.number()
|
|
34
|
+
.int()
|
|
35
|
+
.positive()
|
|
36
|
+
.nullable()
|
|
37
|
+
.optional()
|
|
38
|
+
.describe("New interval in milliseconds (null to clear)"),
|
|
28
39
|
description: z.string().optional().describe("New description"),
|
|
29
40
|
taskType: z.string().max(50).optional().describe("New task type"),
|
|
30
41
|
tags: z.array(z.string()).optional().describe("New tags"),
|
|
@@ -215,6 +226,32 @@ export const registerUpdateScheduleTool = (server: McpServer) => {
|
|
|
215
226
|
updateData.nextRunAt = undefined;
|
|
216
227
|
}
|
|
217
228
|
} else {
|
|
229
|
+
// Validate merged timing before recalc — runs BEFORE the enabled===false
|
|
230
|
+
// skip-recalc branch so disabling cannot bypass the invariant.
|
|
231
|
+
const timing = mergeScheduleTiming(
|
|
232
|
+
{
|
|
233
|
+
cronExpression: schedule.cronExpression ?? null,
|
|
234
|
+
intervalMs: schedule.intervalMs ?? null,
|
|
235
|
+
},
|
|
236
|
+
{ cronExpression, intervalMs },
|
|
237
|
+
);
|
|
238
|
+
const timingError = validateRecurringTiming(timing);
|
|
239
|
+
if (timingError) {
|
|
240
|
+
return {
|
|
241
|
+
content: [
|
|
242
|
+
{
|
|
243
|
+
type: "text",
|
|
244
|
+
text: "At least one of intervalMs or cronExpression must be set for recurring schedules.",
|
|
245
|
+
},
|
|
246
|
+
],
|
|
247
|
+
structuredContent: {
|
|
248
|
+
success: false,
|
|
249
|
+
message:
|
|
250
|
+
"At least one of intervalMs or cronExpression must be set for recurring schedules.",
|
|
251
|
+
},
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
|
|
218
255
|
const needsNextRunRecalc =
|
|
219
256
|
cronExpression !== undefined ||
|
|
220
257
|
intervalMs !== undefined ||
|
|
@@ -222,12 +259,15 @@ export const registerUpdateScheduleTool = (server: McpServer) => {
|
|
|
222
259
|
(enabled === true && !schedule.enabled);
|
|
223
260
|
|
|
224
261
|
if (needsNextRunRecalc && enabled !== false) {
|
|
225
|
-
const
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
262
|
+
const mergedTimezone = timezone !== undefined ? timezone : schedule.timezone;
|
|
263
|
+
updateData.nextRunAt = calculateNextRun(
|
|
264
|
+
{
|
|
265
|
+
cronExpression: timing.mergedCron,
|
|
266
|
+
intervalMs: timing.mergedInterval,
|
|
267
|
+
timezone: mergedTimezone,
|
|
268
|
+
} as Parameters<typeof calculateNextRun>[0],
|
|
269
|
+
new Date(),
|
|
270
|
+
);
|
|
231
271
|
} else if (enabled === false) {
|
|
232
272
|
updateData.nextRunAt = undefined;
|
|
233
273
|
}
|
|
@@ -13,6 +13,13 @@ const WORKSPACE_DIR = "/workspace";
|
|
|
13
13
|
|
|
14
14
|
/**
|
|
15
15
|
* Resolves a file path to an absolute path and checks if it exists.
|
|
16
|
+
*
|
|
17
|
+
* NOTE: existence is checked on the API server's filesystem (where this tool
|
|
18
|
+
* runs), NOT the caller's. Worker/lead containers have their own `/tmp` that
|
|
19
|
+
* the API server cannot see — the only volume mounted on both is
|
|
20
|
+
* `/workspace/shared/`. Files outside `/workspace/shared/` must be passed
|
|
21
|
+
* inline via the `content` (base64) param instead of `filePath`.
|
|
22
|
+
*
|
|
16
23
|
* Handles several common patterns:
|
|
17
24
|
* 1. Relative paths (e.g., "shared/file.png") -> resolved from /workspace
|
|
18
25
|
* 2. Absolute paths under /workspace (e.g., "/workspace/shared/file.png") -> used as-is
|
|
@@ -110,14 +117,17 @@ export const registerSlackUploadFileTool = (server: McpServer) => {
|
|
|
110
117
|
.optional()
|
|
111
118
|
.describe(
|
|
112
119
|
"Path to the file to upload. Either filePath OR content must be provided. " +
|
|
113
|
-
"
|
|
114
|
-
"
|
|
120
|
+
"IMPORTANT: the file is read on the API server's filesystem (where this tool runs), NOT on the caller's. " +
|
|
121
|
+
"Worker/lead containers do NOT share /tmp or /workspace/personal/ with the API server — the only shared volume is /workspace/shared/. " +
|
|
122
|
+
"Use /workspace/shared/<agent-id>/file.png (or a relative path like 'shared/<agent-id>/file.png'). " +
|
|
123
|
+
"For files that only live on the caller (e.g. /tmp), pass them inline via `content` (base64) instead.",
|
|
115
124
|
),
|
|
116
125
|
content: z
|
|
117
126
|
.string()
|
|
118
127
|
.optional()
|
|
119
128
|
.describe(
|
|
120
|
-
"Base64-encoded file content. Use this when the file
|
|
129
|
+
"Base64-encoded file content. Use this when the file lives on the caller's filesystem and isn't reachable by the API server " +
|
|
130
|
+
"(e.g. anything under /tmp on a worker/lead container). Either filePath OR content must be provided.",
|
|
121
131
|
),
|
|
122
132
|
filename: z
|
|
123
133
|
.string()
|
|
@@ -287,9 +297,11 @@ export const registerSlackUploadFileTool = (server: McpServer) => {
|
|
|
287
297
|
`${pathResult.error}\n` +
|
|
288
298
|
`Provided path: ${filePath}\n` +
|
|
289
299
|
`Tried locations:\n${triedPathsList}\n\n` +
|
|
300
|
+
`Note: this tool reads the file on the API server's filesystem, NOT the caller's. ` +
|
|
301
|
+
`Worker/lead containers do not share /tmp or /workspace/personal/ with the API server — only /workspace/shared/.\n\n` +
|
|
290
302
|
`Tips:\n` +
|
|
291
|
-
`-
|
|
292
|
-
`- Or
|
|
303
|
+
`- Put the file in /workspace/shared/<agent-id>/ (e.g., '/workspace/shared/<agent-id>/my-file.png') and pass that path.\n` +
|
|
304
|
+
`- Or pass the file inline via the \`content\` arg as base64 (with \`filename\`) so no shared-mount round-trip is needed.`;
|
|
293
305
|
return {
|
|
294
306
|
content: [{ type: "text", text: errorMsg }],
|
|
295
307
|
structuredContent: { success: false, message: errorMsg },
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Worker-side per-task skill refresh.
|
|
3
|
+
*
|
|
4
|
+
* Polls the cheap signature endpoint; on a hash mismatch, refetches the
|
|
5
|
+
* full skill list and re-runs filesystem sync (claude/pi/codex dirs). The
|
|
6
|
+
* worker stores the signature returned in the list response so the cached
|
|
7
|
+
* hash always corresponds exactly to the snapshot it acted on — avoids a
|
|
8
|
+
* stale-hash race between the signature and list endpoints.
|
|
9
|
+
*
|
|
10
|
+
* Transient errors are swallowed (returned as `changed: false`) so a flaky
|
|
11
|
+
* API can't churn the system prompt.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export type SkillsRefreshContext = {
|
|
15
|
+
apiUrl: string;
|
|
16
|
+
swarmUrl: string;
|
|
17
|
+
apiKey: string;
|
|
18
|
+
agentId: string;
|
|
19
|
+
role: string;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export type SkillsRefreshResult = {
|
|
23
|
+
changed: boolean;
|
|
24
|
+
summary?: { name: string; description: string }[];
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export async function refreshSkillsIfChanged(
|
|
28
|
+
ctx: SkillsRefreshContext,
|
|
29
|
+
lastHashRef: { current: string | null },
|
|
30
|
+
): Promise<SkillsRefreshResult> {
|
|
31
|
+
const { apiUrl, swarmUrl, apiKey, agentId, role } = ctx;
|
|
32
|
+
const authHeaders: Record<string, string> = { "X-Agent-ID": agentId };
|
|
33
|
+
if (apiKey) authHeaders.Authorization = `Bearer ${apiKey}`;
|
|
34
|
+
|
|
35
|
+
// Step 1: cheap signature probe
|
|
36
|
+
try {
|
|
37
|
+
const sigResp = await fetch(`${apiUrl}/api/agents/${agentId}/skills/signature`, {
|
|
38
|
+
headers: authHeaders,
|
|
39
|
+
});
|
|
40
|
+
if (sigResp.ok) {
|
|
41
|
+
const sig = (await sigResp.json()) as { hash: string };
|
|
42
|
+
if (lastHashRef.current !== null && sig.hash === lastHashRef.current) {
|
|
43
|
+
return { changed: false };
|
|
44
|
+
}
|
|
45
|
+
} else if (sigResp.status >= 500) {
|
|
46
|
+
// Transient — don't churn the prompt on a flaky API
|
|
47
|
+
return { changed: false };
|
|
48
|
+
}
|
|
49
|
+
// 4xx falls through (e.g. fresh worker hitting a legacy server without
|
|
50
|
+
// the signature endpoint yet) — let the list call drive the result.
|
|
51
|
+
} catch {
|
|
52
|
+
return { changed: false };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Step 2: full fetch + sync (only reached when hash differs or first call)
|
|
56
|
+
let summary: { name: string; description: string }[] | undefined;
|
|
57
|
+
let newHash: string | null = null;
|
|
58
|
+
try {
|
|
59
|
+
const skillsResp = await fetch(`${apiUrl}/api/agents/${agentId}/skills`, {
|
|
60
|
+
headers: authHeaders,
|
|
61
|
+
});
|
|
62
|
+
if (skillsResp.ok) {
|
|
63
|
+
const skillsData = (await skillsResp.json()) as {
|
|
64
|
+
skills: { name: string; description: string; isActive: boolean; isEnabled: boolean }[];
|
|
65
|
+
signature?: string;
|
|
66
|
+
};
|
|
67
|
+
summary = skillsData.skills
|
|
68
|
+
.filter((s) => s.isActive && s.isEnabled)
|
|
69
|
+
.map((s) => ({ name: s.name, description: s.description }));
|
|
70
|
+
if (typeof skillsData.signature === "string") {
|
|
71
|
+
newHash = skillsData.signature;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
} catch {
|
|
75
|
+
// Non-fatal — skills are optional
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Step 3: filesystem sync (claude/pi/codex dirs)
|
|
79
|
+
let syncOk = false;
|
|
80
|
+
try {
|
|
81
|
+
const syncHeaders: Record<string, string> = {
|
|
82
|
+
"Content-Type": "application/json",
|
|
83
|
+
"X-Agent-ID": agentId,
|
|
84
|
+
};
|
|
85
|
+
if (apiKey) syncHeaders.Authorization = `Bearer ${apiKey}`;
|
|
86
|
+
const syncRes = await fetch(`${swarmUrl}/api/skills/sync-filesystem`, {
|
|
87
|
+
method: "POST",
|
|
88
|
+
headers: syncHeaders,
|
|
89
|
+
});
|
|
90
|
+
if (syncRes.ok) {
|
|
91
|
+
const syncResult = (await syncRes.json()) as {
|
|
92
|
+
synced: number;
|
|
93
|
+
removed: number;
|
|
94
|
+
errors: string[];
|
|
95
|
+
};
|
|
96
|
+
console.log(
|
|
97
|
+
`[${role}] Skills synced: ${syncResult.synced} written, ${syncResult.removed} removed`,
|
|
98
|
+
);
|
|
99
|
+
if (syncResult.errors.length > 0) {
|
|
100
|
+
console.warn(`[${role}] Skill sync errors: ${syncResult.errors.join(", ")}`);
|
|
101
|
+
}
|
|
102
|
+
syncOk = true;
|
|
103
|
+
} else {
|
|
104
|
+
console.warn(`[${role}] Skill sync failed: HTTP ${syncRes.status}`);
|
|
105
|
+
}
|
|
106
|
+
} catch (err) {
|
|
107
|
+
console.warn(`[${role}] Skill sync failed: ${(err as Error).message}`);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (summary === undefined && newHash === null) {
|
|
111
|
+
return { changed: false };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Only cache the new hash once the FS sync has actually succeeded —
|
|
115
|
+
// otherwise a transient sync failure would leave the cached hash matching
|
|
116
|
+
// the current signature, causing later polls to short-circuit and the
|
|
117
|
+
// disk state to stay stale until an unrelated skill mutation. The next
|
|
118
|
+
// poll re-enters this code path (lastHashRef unchanged) and retries.
|
|
119
|
+
if (syncOk && newHash !== null) {
|
|
120
|
+
lastHashRef.current = newHash;
|
|
121
|
+
}
|
|
122
|
+
return { changed: true, summary };
|
|
123
|
+
}
|