@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.
Files changed (90) hide show
  1. package/README.md +1 -1
  2. package/openapi.json +276 -3
  3. package/package.json +6 -6
  4. package/plugin/skills/pages/SKILL.md +5 -2
  5. package/src/be/db.ts +416 -20
  6. package/src/be/memory/boot-reembed.ts +85 -0
  7. package/src/be/memory/constants.ts +44 -2
  8. package/src/be/memory/providers/openai-embedding.ts +15 -5
  9. package/src/be/memory/providers/sqlite-store.ts +325 -76
  10. package/src/be/memory/reranker.ts +35 -17
  11. package/src/be/memory/types.ts +43 -0
  12. package/src/be/migrations/084_script_run_journal_duration.sql +5 -0
  13. package/src/be/migrations/085_script_runs_kind.sql +9 -0
  14. package/src/be/migrations/086_pages_default_authed.sql +64 -0
  15. package/src/be/migrations/087_skill_files.sql +19 -0
  16. package/src/be/modelsdev-cache.json +5622 -2543
  17. package/src/be/seed-scripts/catalog/boot-triage.ts +221 -0
  18. package/src/be/seed-scripts/catalog/catalog-report.ts +457 -0
  19. package/src/be/seed-scripts/catalog/compound-insights.ts +465 -0
  20. package/src/be/seed-scripts/catalog/gh-pr-snapshot.ts +1 -1
  21. package/src/be/seed-scripts/catalog/memory-eval.ts +1059 -0
  22. package/src/be/seed-scripts/catalog/ops-catalog-audit.ts +34 -439
  23. package/src/be/seed-scripts/catalog/schedule-health.ts +78 -2
  24. package/src/be/seed-scripts/catalog/task-failure-audit.ts +48 -1
  25. package/src/be/seed-scripts/index.ts +32 -4
  26. package/src/be/seed-skills/index.ts +0 -7
  27. package/src/be/skill-sync.ts +91 -7
  28. package/src/commands/runner.ts +6 -2
  29. package/src/heartbeat/templates.ts +20 -16
  30. package/src/http/index.ts +50 -7
  31. package/src/http/mcp-user.ts +23 -0
  32. package/src/http/mcp.ts +58 -0
  33. package/src/http/memory.ts +62 -0
  34. package/src/http/pages.ts +1 -1
  35. package/src/http/script-runs.ts +2 -0
  36. package/src/http/scripts.ts +39 -2
  37. package/src/http/skills.ts +225 -0
  38. package/src/providers/claude-adapter.ts +56 -24
  39. package/src/script-workflows/workflow-ctx.ts +7 -3
  40. package/src/scripts-runtime/sdk-allowlist.ts +1 -0
  41. package/src/scripts-runtime/swarm-sdk.ts +13 -0
  42. package/src/scripts-runtime/types/stdlib.d.ts +1 -0
  43. package/src/scripts-runtime/types/swarm-sdk.d.ts +1 -0
  44. package/src/server.ts +2 -0
  45. package/src/tasks/worker-follow-up.ts +12 -0
  46. package/src/tests/claude-adapter-binary.test.ts +135 -81
  47. package/src/tests/create-page-tool.test.ts +19 -2
  48. package/src/tests/heartbeat-checklist.test.ts +36 -0
  49. package/src/tests/mcp-transport-gc.test.ts +58 -0
  50. package/src/tests/memory-e2e.test.ts +6 -6
  51. package/src/tests/memory-health-endpoint.test.ts +78 -0
  52. package/src/tests/memory-rater-e2e.test.ts +4 -5
  53. package/src/tests/memory-reranker.test.ts +135 -124
  54. package/src/tests/memory-store.test.ts +221 -1
  55. package/src/tests/memory.test.ts +13 -12
  56. package/src/tests/pages-http.test.ts +20 -2
  57. package/src/tests/pages-storage.test.ts +26 -0
  58. package/src/tests/scripts-mcp-e2e.test.ts +53 -0
  59. package/src/tests/seed-scripts.test.ts +328 -3
  60. package/src/tests/skill-files-http.test.ts +171 -0
  61. package/src/tests/skill-files.test.ts +162 -0
  62. package/src/tests/skill-get-file-tool.test.ts +110 -0
  63. package/src/tests/skill-sync.test.ts +125 -6
  64. package/src/tests/task-cascade-fail.test.ts +304 -0
  65. package/src/tools/create-page.ts +2 -2
  66. package/src/tools/skills/index.ts +1 -0
  67. package/src/tools/skills/skill-get-file.ts +80 -0
  68. package/src/tools/tool-config.ts +2 -1
  69. package/src/types.ts +20 -0
  70. package/src/utils/internal-ai/complete-structured.ts +2 -2
  71. package/templates/schedules/daily-blocker-digest/content.md +68 -54
  72. package/templates/schedules/daily-compounding-reflection/content.md +4 -4
  73. package/templates/schedules/daily-hn-briefing/content.md +5 -5
  74. package/templates/schedules/daily-workflow-health-audit/content.md +6 -6
  75. package/templates/schedules/gtm-weekly-review/content.md +9 -9
  76. package/templates/schedules/weekly-dependabot-triage/content.md +24 -20
  77. package/templates/skills/agentmail-sending/content.md +6 -7
  78. package/templates/skills/desloppify/content.md +8 -9
  79. package/templates/skills/jira-interaction/content.md +25 -33
  80. package/templates/skills/kapso-whatsapp/content.md +29 -30
  81. package/templates/skills/linear-interaction/content.md +8 -9
  82. package/templates/skills/profile-corruption-escalation/content.md +44 -85
  83. package/templates/skills/sprite-cli/content.md +4 -5
  84. package/templates/skills/turso-interaction/content.md +14 -17
  85. package/templates/skills/workflow-iterate/content.md +38 -391
  86. package/templates/skills/x-api-interactions/content.md +4 -6
  87. package/templates/workflows/llm-safe-release-context/config.json +13 -0
  88. package/templates/workflows/llm-safe-release-context/content.md +69 -0
  89. package/templates/skills/scheduled-task-resilience/config.json +0 -14
  90. 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(3); // 1 skill × 3 dirs
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("skips complex skills", () => {
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).toBe(3);
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
+ });
@@ -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("public").describe(
86
- "'public' — no gate; 'authed' — requires page-session cookie; 'password' — requires key.",
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
+ };