@desplega.ai/agent-swarm 1.92.0 → 1.92.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/README.md +1 -1
- package/openapi.json +276 -3
- package/package.json +6 -6
- package/plugin/skills/pages/SKILL.md +5 -2
- package/src/be/db.ts +416 -20
- package/src/be/memory/boot-reembed.ts +85 -0
- package/src/be/memory/constants.ts +44 -2
- package/src/be/memory/providers/openai-embedding.ts +15 -5
- package/src/be/memory/providers/sqlite-store.ts +325 -76
- package/src/be/memory/reranker.ts +35 -17
- package/src/be/memory/types.ts +43 -0
- package/src/be/migrations/084_script_run_journal_duration.sql +5 -0
- package/src/be/migrations/085_script_runs_kind.sql +9 -0
- package/src/be/migrations/086_pages_default_authed.sql +64 -0
- package/src/be/migrations/087_skill_files.sql +19 -0
- package/src/be/modelsdev-cache.json +5622 -2543
- package/src/be/seed-scripts/catalog/boot-triage.ts +221 -0
- package/src/be/seed-scripts/catalog/catalog-report.ts +457 -0
- package/src/be/seed-scripts/catalog/compound-insights.ts +465 -0
- package/src/be/seed-scripts/catalog/gh-pr-snapshot.ts +1 -1
- package/src/be/seed-scripts/catalog/memory-eval.ts +1059 -0
- package/src/be/seed-scripts/catalog/ops-catalog-audit.ts +34 -439
- package/src/be/seed-scripts/catalog/schedule-health.ts +78 -2
- package/src/be/seed-scripts/catalog/task-failure-audit.ts +48 -1
- package/src/be/seed-scripts/index.ts +32 -4
- package/src/be/seed-skills/index.ts +0 -7
- package/src/be/skill-sync.ts +91 -7
- package/src/commands/runner.ts +6 -2
- package/src/heartbeat/templates.ts +20 -16
- package/src/http/index.ts +50 -7
- package/src/http/mcp-user.ts +23 -0
- package/src/http/mcp.ts +58 -0
- package/src/http/memory.ts +62 -0
- package/src/http/pages.ts +1 -1
- package/src/http/script-runs.ts +2 -0
- package/src/http/scripts.ts +39 -2
- package/src/http/skills.ts +225 -0
- package/src/providers/claude-adapter.ts +56 -24
- package/src/script-workflows/workflow-ctx.ts +7 -3
- package/src/scripts-runtime/sdk-allowlist.ts +1 -0
- package/src/scripts-runtime/swarm-sdk.ts +13 -0
- package/src/scripts-runtime/types/stdlib.d.ts +1 -0
- package/src/scripts-runtime/types/swarm-sdk.d.ts +1 -0
- package/src/server.ts +2 -0
- package/src/tasks/worker-follow-up.ts +12 -0
- package/src/tests/claude-adapter-binary.test.ts +135 -81
- package/src/tests/create-page-tool.test.ts +19 -2
- package/src/tests/heartbeat-checklist.test.ts +36 -0
- package/src/tests/mcp-transport-gc.test.ts +58 -0
- package/src/tests/memory-e2e.test.ts +6 -6
- package/src/tests/memory-health-endpoint.test.ts +78 -0
- package/src/tests/memory-rater-e2e.test.ts +4 -5
- package/src/tests/memory-reranker.test.ts +135 -124
- package/src/tests/memory-store.test.ts +221 -1
- package/src/tests/memory.test.ts +13 -12
- package/src/tests/pages-http.test.ts +20 -2
- package/src/tests/pages-storage.test.ts +26 -0
- package/src/tests/scripts-mcp-e2e.test.ts +53 -0
- package/src/tests/seed-scripts.test.ts +328 -3
- package/src/tests/skill-files-http.test.ts +171 -0
- package/src/tests/skill-files.test.ts +162 -0
- package/src/tests/skill-get-file-tool.test.ts +110 -0
- package/src/tests/skill-sync.test.ts +125 -6
- package/src/tests/task-cascade-fail.test.ts +304 -0
- package/src/tools/create-page.ts +2 -2
- package/src/tools/skills/index.ts +1 -0
- package/src/tools/skills/skill-get-file.ts +80 -0
- package/src/tools/tool-config.ts +2 -1
- package/src/types.ts +20 -0
- package/src/utils/internal-ai/complete-structured.ts +2 -2
- package/templates/schedules/daily-blocker-digest/content.md +68 -54
- package/templates/schedules/daily-compounding-reflection/content.md +4 -4
- package/templates/schedules/daily-hn-briefing/content.md +5 -5
- package/templates/schedules/daily-workflow-health-audit/content.md +6 -6
- package/templates/schedules/gtm-weekly-review/content.md +9 -9
- package/templates/schedules/weekly-dependabot-triage/content.md +24 -20
- package/templates/skills/agentmail-sending/content.md +6 -7
- package/templates/skills/desloppify/content.md +8 -9
- package/templates/skills/jira-interaction/content.md +25 -33
- package/templates/skills/kapso-whatsapp/content.md +29 -30
- package/templates/skills/linear-interaction/content.md +8 -9
- package/templates/skills/profile-corruption-escalation/content.md +44 -85
- package/templates/skills/sprite-cli/content.md +4 -5
- package/templates/skills/turso-interaction/content.md +14 -17
- package/templates/skills/workflow-iterate/content.md +38 -391
- package/templates/skills/x-api-interactions/content.md +4 -6
- package/templates/workflows/llm-safe-release-context/config.json +13 -0
- package/templates/workflows/llm-safe-release-context/content.md +69 -0
- package/templates/skills/scheduled-task-resilience/config.json +0 -14
- package/templates/skills/scheduled-task-resilience/content.md +0 -95
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
2
|
+
import { unlink } from "node:fs/promises";
|
|
3
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
4
|
+
import { closeDb, createSkill, initDb, upsertSkillFile } from "../be/db";
|
|
5
|
+
import { registerSkillGetFileTool } from "../tools/skills/skill-get-file";
|
|
6
|
+
|
|
7
|
+
const TEST_DB_PATH = `./test-skill-get-file-tool-${process.pid}.sqlite`;
|
|
8
|
+
const CALLER_AGENT_ID = "bbbb0000-0000-4000-8000-000000000020";
|
|
9
|
+
|
|
10
|
+
type StructuredContent = {
|
|
11
|
+
yourAgentId?: string;
|
|
12
|
+
success: boolean;
|
|
13
|
+
message: string;
|
|
14
|
+
file?: { skillId: string; path: string; content: string };
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
async function removeDbFiles(path: string): Promise<void> {
|
|
18
|
+
for (const suffix of ["", "-wal", "-shm"]) {
|
|
19
|
+
await unlink(path + suffix).catch(() => {});
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async function callSkillGetFile(
|
|
24
|
+
server: McpServer,
|
|
25
|
+
args: Record<string, unknown>,
|
|
26
|
+
): Promise<{
|
|
27
|
+
structuredContent: StructuredContent;
|
|
28
|
+
content: Array<{ type: string; text: string }>;
|
|
29
|
+
}> {
|
|
30
|
+
// biome-ignore lint/complexity/noBannedTypes: accessing internal MCP SDK type for test
|
|
31
|
+
const tools = (server as unknown as { _registeredTools: Record<string, { handler: Function }> })
|
|
32
|
+
._registeredTools;
|
|
33
|
+
const handler = tools["skill-get-file"].handler;
|
|
34
|
+
|
|
35
|
+
const result = await handler(args, {
|
|
36
|
+
sessionId: "test-session",
|
|
37
|
+
requestInfo: {
|
|
38
|
+
headers: {
|
|
39
|
+
"x-agent-id": CALLER_AGENT_ID,
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
});
|
|
43
|
+
return result as {
|
|
44
|
+
structuredContent: StructuredContent;
|
|
45
|
+
content: Array<{ type: string; text: string }>;
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
describe("skill-get-file tool", () => {
|
|
50
|
+
let server: McpServer;
|
|
51
|
+
let skillId: string;
|
|
52
|
+
|
|
53
|
+
beforeAll(async () => {
|
|
54
|
+
await removeDbFiles(TEST_DB_PATH);
|
|
55
|
+
initDb(TEST_DB_PATH);
|
|
56
|
+
|
|
57
|
+
server = new McpServer({ name: "skill-get-file-test", version: "1.0.0" });
|
|
58
|
+
registerSkillGetFileTool(server);
|
|
59
|
+
|
|
60
|
+
const skill = createSkill({
|
|
61
|
+
name: "tool-file-skill",
|
|
62
|
+
description: "Tool file skill",
|
|
63
|
+
content: "---\nname: tool-file-skill\ndescription: Tool file skill\n---\n\nBody.",
|
|
64
|
+
type: "personal",
|
|
65
|
+
scope: "agent",
|
|
66
|
+
isComplex: true,
|
|
67
|
+
});
|
|
68
|
+
skillId = skill.id;
|
|
69
|
+
upsertSkillFile(skill.id, {
|
|
70
|
+
path: "references/guide.md",
|
|
71
|
+
content: "# Guide",
|
|
72
|
+
mimeType: "text/markdown",
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
afterAll(async () => {
|
|
77
|
+
closeDb();
|
|
78
|
+
await removeDbFiles(TEST_DB_PATH);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("fetches a bundled skill file by skillId and path", async () => {
|
|
82
|
+
const result = await callSkillGetFile(server, {
|
|
83
|
+
skillId,
|
|
84
|
+
path: "references/guide.md",
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
expect(result.structuredContent).toMatchObject({
|
|
88
|
+
yourAgentId: CALLER_AGENT_ID,
|
|
89
|
+
success: true,
|
|
90
|
+
file: {
|
|
91
|
+
skillId,
|
|
92
|
+
path: "references/guide.md",
|
|
93
|
+
content: "# Guide",
|
|
94
|
+
},
|
|
95
|
+
});
|
|
96
|
+
expect(result.content[0].text).toContain("# Guide");
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("returns structured failure for missing file", async () => {
|
|
100
|
+
const result = await callSkillGetFile(server, {
|
|
101
|
+
skillId,
|
|
102
|
+
path: "references/missing.md",
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
expect(result.structuredContent).toMatchObject({
|
|
106
|
+
success: false,
|
|
107
|
+
message: "Skill file not found.",
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
});
|
|
@@ -2,8 +2,8 @@ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
|
2
2
|
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { unlink } from "node:fs/promises";
|
|
4
4
|
import { tmpdir } from "node:os";
|
|
5
|
-
import { join } from "node:path";
|
|
6
|
-
import { closeDb, createAgent, createSkill, initDb, installSkill } from "../be/db";
|
|
5
|
+
import { dirname, join } from "node:path";
|
|
6
|
+
import { closeDb, createAgent, createSkill, initDb, installSkill, upsertSkillFile } from "../be/db";
|
|
7
7
|
import { syncSkillsToFilesystem } from "../be/skill-sync";
|
|
8
8
|
|
|
9
9
|
const SWARM_MARKER = ".swarm-managed";
|
|
@@ -38,7 +38,7 @@ describe("syncSkillsToFilesystem", () => {
|
|
|
38
38
|
});
|
|
39
39
|
installSkill(agentId, skill.id);
|
|
40
40
|
|
|
41
|
-
// Create a complex skill (should be skipped)
|
|
41
|
+
// Create a legacy complex skill with no stored files (should be skipped)
|
|
42
42
|
const complexSkill = createSkill({
|
|
43
43
|
name: "complex-skill",
|
|
44
44
|
description: "A complex skill",
|
|
@@ -49,6 +49,28 @@ describe("syncSkillsToFilesystem", () => {
|
|
|
49
49
|
});
|
|
50
50
|
installSkill(agentId, complexSkill.id);
|
|
51
51
|
|
|
52
|
+
const dbBackedComplexSkill = createSkill({
|
|
53
|
+
name: "complex-db-skill",
|
|
54
|
+
description: "A DB-backed complex skill",
|
|
55
|
+
content: "---\nname: complex-db-skill\ndescription: A DB-backed complex skill\n---\n\nBody.",
|
|
56
|
+
type: "remote",
|
|
57
|
+
scope: "global",
|
|
58
|
+
isComplex: true,
|
|
59
|
+
});
|
|
60
|
+
installSkill(agentId, dbBackedComplexSkill.id);
|
|
61
|
+
upsertSkillFile(dbBackedComplexSkill.id, {
|
|
62
|
+
path: "references/guide.md",
|
|
63
|
+
content: "# Guide\n\nBundled reference.",
|
|
64
|
+
mimeType: "text/markdown",
|
|
65
|
+
});
|
|
66
|
+
upsertSkillFile(dbBackedComplexSkill.id, {
|
|
67
|
+
path: "assets/logo.png",
|
|
68
|
+
content: "[binary file - not synced]",
|
|
69
|
+
mimeType: "image/png",
|
|
70
|
+
isBinary: true,
|
|
71
|
+
size: 2048,
|
|
72
|
+
});
|
|
73
|
+
|
|
52
74
|
mkdirSync(FAKE_HOME, { recursive: true });
|
|
53
75
|
});
|
|
54
76
|
|
|
@@ -108,7 +130,7 @@ describe("syncSkillsToFilesystem", () => {
|
|
|
108
130
|
const result = syncSkillsToFilesystem(agentId, "all", FAKE_HOME);
|
|
109
131
|
|
|
110
132
|
expect(result.errors).toHaveLength(0);
|
|
111
|
-
expect(result.synced).toBe(
|
|
133
|
+
expect(result.synced).toBe(6); // 2 DB-backed skills × 3 dirs
|
|
112
134
|
|
|
113
135
|
const claudeFile = join(FAKE_HOME, ".claude", "skills", "test-skill", "SKILL.md");
|
|
114
136
|
const piFile = join(FAKE_HOME, ".pi", "agent", "skills", "test-skill", "SKILL.md");
|
|
@@ -118,13 +140,110 @@ describe("syncSkillsToFilesystem", () => {
|
|
|
118
140
|
expect(existsSync(codexFile)).toBe(true);
|
|
119
141
|
});
|
|
120
142
|
|
|
121
|
-
test("
|
|
143
|
+
test("syncs DB-backed complex skill files and skips binary placeholders", () => {
|
|
144
|
+
rmSync(join(FAKE_HOME, ".claude"), { recursive: true, force: true });
|
|
145
|
+
|
|
146
|
+
const result = syncSkillsToFilesystem(agentId, "claude", FAKE_HOME);
|
|
147
|
+
|
|
148
|
+
expect(result.errors).toHaveLength(0);
|
|
149
|
+
|
|
150
|
+
const skillFile = join(FAKE_HOME, ".claude", "skills", "complex-db-skill", "SKILL.md");
|
|
151
|
+
const bundledFile = join(
|
|
152
|
+
FAKE_HOME,
|
|
153
|
+
".claude",
|
|
154
|
+
"skills",
|
|
155
|
+
"complex-db-skill",
|
|
156
|
+
"references",
|
|
157
|
+
"guide.md",
|
|
158
|
+
);
|
|
159
|
+
const binaryFile = join(
|
|
160
|
+
FAKE_HOME,
|
|
161
|
+
".claude",
|
|
162
|
+
"skills",
|
|
163
|
+
"complex-db-skill",
|
|
164
|
+
"assets",
|
|
165
|
+
"logo.png",
|
|
166
|
+
);
|
|
167
|
+
expect(existsSync(skillFile)).toBe(true);
|
|
168
|
+
expect(readFileSync(bundledFile, "utf-8")).toContain("Bundled reference.");
|
|
169
|
+
expect(existsSync(binaryFile)).toBe(false);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test("removes stale bundled files from swarm-managed skill directories", () => {
|
|
173
|
+
rmSync(join(FAKE_HOME, ".claude"), { recursive: true, force: true });
|
|
174
|
+
|
|
175
|
+
const result = syncSkillsToFilesystem(agentId, "claude", FAKE_HOME);
|
|
176
|
+
expect(result.errors).toHaveLength(0);
|
|
177
|
+
|
|
178
|
+
const skillDir = join(FAKE_HOME, ".claude", "skills", "complex-db-skill");
|
|
179
|
+
const staleFile = join(skillDir, "references", "old-guide.md");
|
|
180
|
+
const currentFile = join(skillDir, "references", "guide.md");
|
|
181
|
+
const staleBinary = join(skillDir, "assets", "logo.png");
|
|
182
|
+
mkdirSync(dirname(staleFile), { recursive: true });
|
|
183
|
+
mkdirSync(dirname(staleBinary), { recursive: true });
|
|
184
|
+
writeFileSync(staleFile, "stale");
|
|
185
|
+
writeFileSync(staleBinary, "previous binary payload");
|
|
186
|
+
|
|
187
|
+
const nextResult = syncSkillsToFilesystem(agentId, "claude", FAKE_HOME);
|
|
188
|
+
|
|
189
|
+
expect(nextResult.errors).toHaveLength(0);
|
|
190
|
+
expect(nextResult.removed).toBeGreaterThanOrEqual(2);
|
|
191
|
+
expect(existsSync(staleFile)).toBe(false);
|
|
192
|
+
expect(existsSync(staleBinary)).toBe(false);
|
|
193
|
+
expect(readFileSync(currentFile, "utf-8")).toContain("Bundled reference.");
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
test("skips legacy complex skills without stored files", () => {
|
|
122
197
|
const _result = syncSkillsToFilesystem(agentId, "claude", FAKE_HOME);
|
|
123
198
|
|
|
124
199
|
const complexDir = join(FAKE_HOME, ".claude", "skills", "complex-skill");
|
|
125
200
|
expect(existsSync(complexDir)).toBe(false);
|
|
126
201
|
});
|
|
127
202
|
|
|
203
|
+
test("continues syncing bundled files after one file write fails", () => {
|
|
204
|
+
const failSkill = createSkill({
|
|
205
|
+
name: "complex-fail-safe",
|
|
206
|
+
description: "Complex skill with one blocked file",
|
|
207
|
+
content:
|
|
208
|
+
"---\nname: complex-fail-safe\ndescription: Complex skill with one blocked file\n---\n\nBody.",
|
|
209
|
+
type: "remote",
|
|
210
|
+
scope: "global",
|
|
211
|
+
isComplex: true,
|
|
212
|
+
});
|
|
213
|
+
installSkill(agentId, failSkill.id);
|
|
214
|
+
upsertSkillFile(failSkill.id, {
|
|
215
|
+
path: "references/blocked.md",
|
|
216
|
+
content: "blocked",
|
|
217
|
+
});
|
|
218
|
+
upsertSkillFile(failSkill.id, {
|
|
219
|
+
path: "references/ok.md",
|
|
220
|
+
content: "ok",
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
rmSync(join(FAKE_HOME, ".claude"), { recursive: true, force: true });
|
|
224
|
+
const blockedTarget = join(
|
|
225
|
+
FAKE_HOME,
|
|
226
|
+
".claude",
|
|
227
|
+
"skills",
|
|
228
|
+
"complex-fail-safe",
|
|
229
|
+
"references",
|
|
230
|
+
"blocked.md",
|
|
231
|
+
);
|
|
232
|
+
mkdirSync(blockedTarget, { recursive: true });
|
|
233
|
+
|
|
234
|
+
const result = syncSkillsToFilesystem(agentId, "claude", FAKE_HOME);
|
|
235
|
+
|
|
236
|
+
expect(result.errors.some((error) => error.includes("references/blocked.md"))).toBe(true);
|
|
237
|
+
expect(
|
|
238
|
+
readFileSync(
|
|
239
|
+
join(FAKE_HOME, ".claude", "skills", "complex-fail-safe", "references", "ok.md"),
|
|
240
|
+
"utf-8",
|
|
241
|
+
),
|
|
242
|
+
).toBe("ok");
|
|
243
|
+
|
|
244
|
+
rmSync(join(FAKE_HOME, ".claude"), { recursive: true, force: true });
|
|
245
|
+
});
|
|
246
|
+
|
|
128
247
|
test("removes stale swarm-managed skill directories", () => {
|
|
129
248
|
// Mark this stale dir as swarm-managed (mirrors what an earlier sync would have done)
|
|
130
249
|
const staleDir = join(FAKE_HOME, ".claude", "skills", "old-removed-skill");
|
|
@@ -185,7 +304,7 @@ describe("syncSkillsToFilesystem", () => {
|
|
|
185
304
|
const result = syncSkillsToFilesystem(agentId, "all", FAKE_HOME);
|
|
186
305
|
|
|
187
306
|
expect(result.errors).toHaveLength(0);
|
|
188
|
-
expect(result.synced).
|
|
307
|
+
expect(result.synced).toBeGreaterThanOrEqual(6);
|
|
189
308
|
|
|
190
309
|
const claudeFile = join(FAKE_HOME, ".claude", "skills", "test-skill", "SKILL.md");
|
|
191
310
|
const piFile = join(FAKE_HOME, ".pi", "agent", "skills", "test-skill", "SKILL.md");
|
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
2
|
+
import { unlinkSync } from "node:fs";
|
|
3
|
+
import {
|
|
4
|
+
cancelTask,
|
|
5
|
+
cascadeFailDependents,
|
|
6
|
+
closeDb,
|
|
7
|
+
completeTask,
|
|
8
|
+
createAgent,
|
|
9
|
+
createTaskExtended,
|
|
10
|
+
failTask,
|
|
11
|
+
getDb,
|
|
12
|
+
getDependentTasks,
|
|
13
|
+
getTaskById,
|
|
14
|
+
initDb,
|
|
15
|
+
startTask,
|
|
16
|
+
supersedeTask,
|
|
17
|
+
} from "../be/db";
|
|
18
|
+
|
|
19
|
+
const TEST_DB_PATH = "./test-task-cascade-fail.sqlite";
|
|
20
|
+
|
|
21
|
+
beforeAll(() => {
|
|
22
|
+
initDb(TEST_DB_PATH);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
afterAll(() => {
|
|
26
|
+
closeDb();
|
|
27
|
+
try {
|
|
28
|
+
unlinkSync(TEST_DB_PATH);
|
|
29
|
+
unlinkSync(`${TEST_DB_PATH}-wal`);
|
|
30
|
+
unlinkSync(`${TEST_DB_PATH}-shm`);
|
|
31
|
+
} catch {
|
|
32
|
+
// ignore
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
describe("getDependentTasks", () => {
|
|
37
|
+
test("finds tasks that depend on a given parent", () => {
|
|
38
|
+
const agent = createAgent({
|
|
39
|
+
name: "dep-lookup-worker-1",
|
|
40
|
+
isLead: false,
|
|
41
|
+
status: "idle",
|
|
42
|
+
capabilities: [],
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
const parent = createTaskExtended("Parent task", { agentId: agent.id });
|
|
46
|
+
const child = createTaskExtended("Child task", {
|
|
47
|
+
agentId: agent.id,
|
|
48
|
+
dependsOn: [parent.id],
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
const deps = getDependentTasks(parent.id, { includeTerminal: true });
|
|
52
|
+
expect(deps.length).toBeGreaterThanOrEqual(1);
|
|
53
|
+
expect(deps.some((d) => d.id === child.id)).toBe(true);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("filters out terminal tasks by default", () => {
|
|
57
|
+
const agent = createAgent({
|
|
58
|
+
name: "dep-lookup-worker-2",
|
|
59
|
+
isLead: false,
|
|
60
|
+
status: "idle",
|
|
61
|
+
capabilities: [],
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
const parent = createTaskExtended("Parent task 2", { agentId: agent.id });
|
|
65
|
+
const child1 = createTaskExtended("Child completed", {
|
|
66
|
+
agentId: agent.id,
|
|
67
|
+
dependsOn: [parent.id],
|
|
68
|
+
});
|
|
69
|
+
const child2 = createTaskExtended("Child pending", {
|
|
70
|
+
agentId: agent.id,
|
|
71
|
+
dependsOn: [parent.id],
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
startTask(child1.id, agent.id);
|
|
75
|
+
completeTask(child1.id, "done");
|
|
76
|
+
|
|
77
|
+
const nonTerminalDeps = getDependentTasks(parent.id);
|
|
78
|
+
expect(nonTerminalDeps.some((d) => d.id === child1.id)).toBe(false);
|
|
79
|
+
expect(nonTerminalDeps.some((d) => d.id === child2.id)).toBe(true);
|
|
80
|
+
|
|
81
|
+
const allDeps = getDependentTasks(parent.id, { includeTerminal: true });
|
|
82
|
+
expect(allDeps.some((d) => d.id === child1.id)).toBe(true);
|
|
83
|
+
expect(allDeps.some((d) => d.id === child2.id)).toBe(true);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("returns empty array when no dependents exist", () => {
|
|
87
|
+
const agent = createAgent({
|
|
88
|
+
name: "dep-lookup-worker-3",
|
|
89
|
+
isLead: false,
|
|
90
|
+
status: "idle",
|
|
91
|
+
capabilities: [],
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
const task = createTaskExtended("Lonely task", { agentId: agent.id });
|
|
95
|
+
const deps = getDependentTasks(task.id);
|
|
96
|
+
expect(deps).toEqual([]);
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
describe("cascadeFailDependents", () => {
|
|
101
|
+
test("single-level cascade: failing parent fails its dependent", () => {
|
|
102
|
+
const agent = createAgent({
|
|
103
|
+
name: "cascade-worker-1",
|
|
104
|
+
isLead: false,
|
|
105
|
+
status: "idle",
|
|
106
|
+
capabilities: [],
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
const parent = createTaskExtended("Parent A", { agentId: agent.id });
|
|
110
|
+
const child = createTaskExtended("Child of A", {
|
|
111
|
+
agentId: agent.id,
|
|
112
|
+
dependsOn: [parent.id],
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
startTask(parent.id, agent.id);
|
|
116
|
+
failTask(parent.id, "parent failed");
|
|
117
|
+
|
|
118
|
+
const childAfter = getTaskById(child.id);
|
|
119
|
+
expect(childAfter!.status).toBe("failed");
|
|
120
|
+
expect(childAfter!.failureReason).toContain("Blocked dependency");
|
|
121
|
+
expect(childAfter!.failureReason).toContain("was failed");
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test("multi-level recursive cascade: A→B→C all fail", () => {
|
|
125
|
+
const agent = createAgent({
|
|
126
|
+
name: "cascade-worker-2",
|
|
127
|
+
isLead: false,
|
|
128
|
+
status: "idle",
|
|
129
|
+
capabilities: [],
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
const taskA = createTaskExtended("Task A (root)", { agentId: agent.id });
|
|
133
|
+
const taskB = createTaskExtended("Task B (depends on A)", {
|
|
134
|
+
agentId: agent.id,
|
|
135
|
+
dependsOn: [taskA.id],
|
|
136
|
+
});
|
|
137
|
+
const taskC = createTaskExtended("Task C (depends on B)", {
|
|
138
|
+
agentId: agent.id,
|
|
139
|
+
dependsOn: [taskB.id],
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
startTask(taskA.id, agent.id);
|
|
143
|
+
failTask(taskA.id, "root failure");
|
|
144
|
+
|
|
145
|
+
const bAfter = getTaskById(taskB.id);
|
|
146
|
+
expect(bAfter!.status).toBe("failed");
|
|
147
|
+
expect(bAfter!.failureReason).toContain("Blocked dependency");
|
|
148
|
+
|
|
149
|
+
const cAfter = getTaskById(taskC.id);
|
|
150
|
+
expect(cAfter!.status).toBe("failed");
|
|
151
|
+
expect(cAfter!.failureReason).toContain("Blocked dependency");
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test("cycle-safety: A↔B does not infinite-loop", () => {
|
|
155
|
+
const agent = createAgent({
|
|
156
|
+
name: "cascade-worker-3",
|
|
157
|
+
isLead: false,
|
|
158
|
+
status: "idle",
|
|
159
|
+
capabilities: [],
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
// Create tasks with a dependency cycle: A depends on B, B depends on A
|
|
163
|
+
const taskA = createTaskExtended("Cycle A", { agentId: agent.id });
|
|
164
|
+
const taskB = createTaskExtended("Cycle B", {
|
|
165
|
+
agentId: agent.id,
|
|
166
|
+
dependsOn: [taskA.id],
|
|
167
|
+
});
|
|
168
|
+
// Manually update taskA to depend on taskB (creating a cycle)
|
|
169
|
+
getDb().run("UPDATE agent_tasks SET dependsOn = ? WHERE id = ?", [
|
|
170
|
+
JSON.stringify([taskB.id]),
|
|
171
|
+
taskA.id,
|
|
172
|
+
]);
|
|
173
|
+
|
|
174
|
+
// This should not infinite-loop — the visited set protects us
|
|
175
|
+
startTask(taskA.id, agent.id);
|
|
176
|
+
const results = cascadeFailDependents(taskA.id, "failed");
|
|
177
|
+
|
|
178
|
+
// taskB should be cascade-failed
|
|
179
|
+
const bAfter = getTaskById(taskB.id);
|
|
180
|
+
expect(bAfter!.status).toBe("failed");
|
|
181
|
+
|
|
182
|
+
// results should include taskB but NOT loop infinitely
|
|
183
|
+
expect(results.length).toBeGreaterThanOrEqual(1);
|
|
184
|
+
expect(results.some((r) => r.taskId === taskB.id)).toBe(true);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test("already-completed dependent is left untouched", () => {
|
|
188
|
+
const agent = createAgent({
|
|
189
|
+
name: "cascade-worker-4",
|
|
190
|
+
isLead: false,
|
|
191
|
+
status: "idle",
|
|
192
|
+
capabilities: [],
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
const parent = createTaskExtended("Parent D", { agentId: agent.id });
|
|
196
|
+
const child = createTaskExtended("Child D (completed)", {
|
|
197
|
+
agentId: agent.id,
|
|
198
|
+
dependsOn: [parent.id],
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
startTask(child.id, agent.id);
|
|
202
|
+
completeTask(child.id, "finished before parent failed");
|
|
203
|
+
|
|
204
|
+
startTask(parent.id, agent.id);
|
|
205
|
+
failTask(parent.id, "parent failed late");
|
|
206
|
+
|
|
207
|
+
const childAfter = getTaskById(child.id);
|
|
208
|
+
expect(childAfter!.status).toBe("completed");
|
|
209
|
+
expect(childAfter!.output).toBe("finished before parent failed");
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
test("cancelTask cascades to dependents", () => {
|
|
213
|
+
const agent = createAgent({
|
|
214
|
+
name: "cascade-worker-5",
|
|
215
|
+
isLead: false,
|
|
216
|
+
status: "idle",
|
|
217
|
+
capabilities: [],
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
const parent = createTaskExtended("Parent cancel", { agentId: agent.id });
|
|
221
|
+
const child = createTaskExtended("Child of cancelled", {
|
|
222
|
+
agentId: agent.id,
|
|
223
|
+
dependsOn: [parent.id],
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
cancelTask(parent.id, "no longer needed");
|
|
227
|
+
|
|
228
|
+
const childAfter = getTaskById(child.id);
|
|
229
|
+
expect(childAfter!.status).toBe("failed");
|
|
230
|
+
expect(childAfter!.failureReason).toContain("was cancelled");
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
test("supersedeTask cascades to dependents", () => {
|
|
234
|
+
const agent = createAgent({
|
|
235
|
+
name: "cascade-worker-6",
|
|
236
|
+
isLead: false,
|
|
237
|
+
status: "idle",
|
|
238
|
+
capabilities: [],
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
const parent = createTaskExtended("Parent supersede", { agentId: agent.id });
|
|
242
|
+
const child = createTaskExtended("Child of superseded", {
|
|
243
|
+
agentId: agent.id,
|
|
244
|
+
dependsOn: [parent.id],
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
startTask(parent.id, agent.id);
|
|
248
|
+
supersedeTask(parent.id, { reason: "context limit", resumeTaskId: null });
|
|
249
|
+
|
|
250
|
+
const childAfter = getTaskById(child.id);
|
|
251
|
+
expect(childAfter!.status).toBe("failed");
|
|
252
|
+
expect(childAfter!.failureReason).toContain("was superseded");
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
test("wide fan-out: multiple dependents all cascade-failed", () => {
|
|
256
|
+
const agent = createAgent({
|
|
257
|
+
name: "cascade-worker-7",
|
|
258
|
+
isLead: false,
|
|
259
|
+
status: "idle",
|
|
260
|
+
capabilities: [],
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
const parent = createTaskExtended("Parent wide", { agentId: agent.id });
|
|
264
|
+
const children = Array.from({ length: 5 }, (_, i) =>
|
|
265
|
+
createTaskExtended(`Child ${i}`, {
|
|
266
|
+
agentId: agent.id,
|
|
267
|
+
dependsOn: [parent.id],
|
|
268
|
+
}),
|
|
269
|
+
);
|
|
270
|
+
|
|
271
|
+
startTask(parent.id, agent.id);
|
|
272
|
+
failTask(parent.id, "parent gone");
|
|
273
|
+
|
|
274
|
+
for (const child of children) {
|
|
275
|
+
const after = getTaskById(child.id);
|
|
276
|
+
expect(after!.status).toBe("failed");
|
|
277
|
+
expect(after!.failureReason).toContain("Blocked dependency");
|
|
278
|
+
}
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
test("diamond dependency: C depends on both A and B, only A fails", () => {
|
|
282
|
+
const agent = createAgent({
|
|
283
|
+
name: "cascade-worker-8",
|
|
284
|
+
isLead: false,
|
|
285
|
+
status: "idle",
|
|
286
|
+
capabilities: [],
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
const taskA = createTaskExtended("Diamond A", { agentId: agent.id });
|
|
290
|
+
const taskB = createTaskExtended("Diamond B", { agentId: agent.id });
|
|
291
|
+
const taskC = createTaskExtended("Diamond C (depends on A and B)", {
|
|
292
|
+
agentId: agent.id,
|
|
293
|
+
dependsOn: [taskA.id, taskB.id],
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
startTask(taskA.id, agent.id);
|
|
297
|
+
failTask(taskA.id, "A failed");
|
|
298
|
+
|
|
299
|
+
// C should be cascade-failed because one of its dependencies failed
|
|
300
|
+
const cAfter = getTaskById(taskC.id);
|
|
301
|
+
expect(cAfter!.status).toBe("failed");
|
|
302
|
+
expect(cAfter!.failureReason).toContain("Blocked dependency");
|
|
303
|
+
});
|
|
304
|
+
});
|
package/src/tools/create-page.ts
CHANGED
|
@@ -82,8 +82,8 @@ export const registerCreatePageTool = (server: McpServer) => {
|
|
|
82
82
|
contentType: PageContentTypeSchema.describe(
|
|
83
83
|
"'text/html' renders directly at /p/:id; 'application/json' is rendered by the SPA.",
|
|
84
84
|
),
|
|
85
|
-
authMode: PageAuthModeSchema.default("
|
|
86
|
-
"'
|
|
85
|
+
authMode: PageAuthModeSchema.default("authed").describe(
|
|
86
|
+
"'authed' — requires page-session cookie (default); 'public' — no gate and must be explicit; 'password' — requires key.",
|
|
87
87
|
),
|
|
88
88
|
password: z
|
|
89
89
|
.string()
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
export { registerSkillCreateTool } from "./skill-create";
|
|
2
2
|
export { registerSkillDeleteTool } from "./skill-delete";
|
|
3
3
|
export { registerSkillGetTool } from "./skill-get";
|
|
4
|
+
export { registerSkillGetFileTool } from "./skill-get-file";
|
|
4
5
|
export { registerSkillInstallTool } from "./skill-install";
|
|
5
6
|
export { registerSkillInstallRemoteTool } from "./skill-install-remote";
|
|
6
7
|
export { registerSkillListTool } from "./skill-list";
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import * as z from "zod";
|
|
3
|
+
import { getSkillById, getSkillFile } from "@/be/db";
|
|
4
|
+
import { createToolRegistrar } from "@/tools/utils";
|
|
5
|
+
|
|
6
|
+
export const registerSkillGetFileTool = (server: McpServer) => {
|
|
7
|
+
createToolRegistrar(server)(
|
|
8
|
+
"skill-get-file",
|
|
9
|
+
{
|
|
10
|
+
title: "Get Skill File",
|
|
11
|
+
annotations: { destructiveHint: false },
|
|
12
|
+
description:
|
|
13
|
+
"Fetch a bundled reference file from a complex skill by skillId and relative path. Use this when the file is not available on disk.",
|
|
14
|
+
inputSchema: z.object({
|
|
15
|
+
skillId: z.string().describe("Skill ID"),
|
|
16
|
+
path: z.string().describe("Relative path, e.g. references/animations.md"),
|
|
17
|
+
}),
|
|
18
|
+
outputSchema: z.object({
|
|
19
|
+
yourAgentId: z.string().uuid().optional(),
|
|
20
|
+
success: z.boolean(),
|
|
21
|
+
message: z.string(),
|
|
22
|
+
file: z.any().optional(),
|
|
23
|
+
}),
|
|
24
|
+
},
|
|
25
|
+
async (args, requestInfo, _meta) => {
|
|
26
|
+
const skill = getSkillById(args.skillId);
|
|
27
|
+
if (!skill) {
|
|
28
|
+
return {
|
|
29
|
+
content: [{ type: "text", text: "Skill not found." }],
|
|
30
|
+
structuredContent: {
|
|
31
|
+
yourAgentId: requestInfo.agentId,
|
|
32
|
+
success: false,
|
|
33
|
+
message: "Skill not found.",
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
let file = null;
|
|
39
|
+
try {
|
|
40
|
+
file = getSkillFile(args.skillId, args.path);
|
|
41
|
+
} catch (err) {
|
|
42
|
+
const message = err instanceof Error ? err.message : "Invalid file path.";
|
|
43
|
+
return {
|
|
44
|
+
content: [{ type: "text", text: message }],
|
|
45
|
+
structuredContent: {
|
|
46
|
+
yourAgentId: requestInfo.agentId,
|
|
47
|
+
success: false,
|
|
48
|
+
message,
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (!file) {
|
|
54
|
+
return {
|
|
55
|
+
content: [{ type: "text", text: "Skill file not found." }],
|
|
56
|
+
structuredContent: {
|
|
57
|
+
yourAgentId: requestInfo.agentId,
|
|
58
|
+
success: false,
|
|
59
|
+
message: "Skill file not found.",
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return {
|
|
65
|
+
content: [
|
|
66
|
+
{
|
|
67
|
+
type: "text",
|
|
68
|
+
text: `Skill file "${skill.name}/${file.path}" (${file.mimeType}):\n\n${file.content}`,
|
|
69
|
+
},
|
|
70
|
+
],
|
|
71
|
+
structuredContent: {
|
|
72
|
+
yourAgentId: requestInfo.agentId,
|
|
73
|
+
success: true,
|
|
74
|
+
message: `Found skill file "${file.path}".`,
|
|
75
|
+
file,
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
},
|
|
79
|
+
);
|
|
80
|
+
};
|