@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
@@ -8,6 +8,7 @@ import { setScriptEmbeddingProviderForTests } from "../be/scripts/embeddings";
8
8
  import { typecheckScript } from "../be/scripts/typecheck";
9
9
  import { runSeeder } from "../be/seed";
10
10
  import { SEED_SCRIPTS, scriptsSeeder } from "../be/seed-scripts";
11
+ import bootTriage from "../be/seed-scripts/catalog/boot-triage";
11
12
  import compoundInsights from "../be/seed-scripts/catalog/compound-insights";
12
13
  import opsCatalogAudit, {
13
14
  renderPage as renderOpsCatalogAuditPage,
@@ -52,8 +53,8 @@ afterAll(async () => {
52
53
  });
53
54
 
54
55
  describe("seed-scripts catalog", () => {
55
- test("manifest holds 16 unique, well-described scripts", () => {
56
- expect(SEED_SCRIPTS.length).toBe(16);
56
+ test("manifest holds 17 unique, well-described scripts", () => {
57
+ expect(SEED_SCRIPTS.length).toBe(18);
57
58
  const names = SEED_SCRIPTS.map((s) => s.name);
58
59
  expect(new Set(names).size).toBe(names.length);
59
60
  for (const s of SEED_SCRIPTS) {
@@ -96,7 +97,9 @@ describe("seed-scripts catalog", () => {
96
97
  test("scriptsSeeder seeds the whole catalog at global scope", async () => {
97
98
  const result = await runSeeder(scriptsSeeder, { quiet: true });
98
99
  expect(result.failed).toEqual([]);
99
- expect(result.created).toBe(SEED_SCRIPTS.length);
100
+ expect(
101
+ result.created + result.updated + result.skippedUnchanged + result.skippedUserModified,
102
+ ).toBe(SEED_SCRIPTS.length);
100
103
 
101
104
  const globals = listScripts({ scope: "global" });
102
105
  for (const s of SEED_SCRIPTS) {
@@ -189,6 +192,7 @@ describe("seed-scripts catalog", () => {
189
192
  includeScheduleHealth: false,
190
193
  includeScriptCandidates: false,
191
194
  includeByAgent: false,
195
+ publishPage: false,
192
196
  },
193
197
  ctx,
194
198
  );
@@ -204,6 +208,211 @@ describe("seed-scripts catalog", () => {
204
208
  ).toBeGreaterThan(0.99);
205
209
  });
206
210
 
211
+ test("compound-insights reports script usage and cost honesty rails", async () => {
212
+ const queries: string[] = [];
213
+ const ctx = {
214
+ swarm: {
215
+ async db_query({ sql }: { sql: string }) {
216
+ queries.push(sql);
217
+ if (sql.includes("FROM script_runs sr")) {
218
+ return {
219
+ columns: ["scriptName", "kind", "status", "startedAt", "finishedAt", "durationMs"],
220
+ rows: [
221
+ [
222
+ "compound-insights",
223
+ "inline",
224
+ "completed",
225
+ "2026-06-08T00:00:00.000Z",
226
+ "2026-06-08T00:00:01.000Z",
227
+ 1000,
228
+ ],
229
+ [
230
+ "daily-dashboard",
231
+ "workflow",
232
+ "failed",
233
+ "2026-06-08T01:00:00.000Z",
234
+ "2026-06-08T01:00:03.000Z",
235
+ 3000,
236
+ ],
237
+ ],
238
+ };
239
+ }
240
+ if (sql.includes("FROM scripts") && sql.includes("GROUP BY scope, isScratch")) {
241
+ return {
242
+ columns: ["scope", "isScratch", "count"],
243
+ rows: [
244
+ ["global", 0, 2],
245
+ ["agent", 1, 1],
246
+ ],
247
+ };
248
+ }
249
+ if (sql.includes("FROM script_versions sv")) {
250
+ return {
251
+ columns: ["scope", "count"],
252
+ rows: [["global", 3]],
253
+ };
254
+ }
255
+ if (sql.includes("FROM session_logs") && sql.includes("%script-run%")) {
256
+ return {
257
+ columns: ["tool", "calls"],
258
+ rows: [["mcp__agent_swarm__script-run", 5]],
259
+ };
260
+ }
261
+ if (sql.includes("FROM session_costs sc")) {
262
+ return {
263
+ columns: [
264
+ "taskId",
265
+ "agentId",
266
+ "agentName",
267
+ "provider",
268
+ "totalCostUsd",
269
+ "inputTokens",
270
+ "outputTokens",
271
+ "cacheReadTokens",
272
+ "cacheWriteTokens",
273
+ "reasoningOutputTokens",
274
+ "thinkingTokens",
275
+ "numTurns",
276
+ "model",
277
+ "costSource",
278
+ ],
279
+ rows: [
280
+ [
281
+ "task-a",
282
+ "agent-a",
283
+ "Picateclas",
284
+ "codex",
285
+ 0.3,
286
+ 100,
287
+ 20,
288
+ 10,
289
+ null,
290
+ 3,
291
+ 4,
292
+ null,
293
+ "gpt-5.5",
294
+ "harness",
295
+ ],
296
+ [
297
+ "task-b",
298
+ "agent-a",
299
+ "Picateclas",
300
+ "codex",
301
+ 0.5,
302
+ 200,
303
+ 40,
304
+ 20,
305
+ 2,
306
+ 0,
307
+ 0,
308
+ 2,
309
+ "gpt-5.5",
310
+ "pricing-table",
311
+ ],
312
+ [
313
+ "task-c",
314
+ "agent-b",
315
+ "Worker",
316
+ "claude",
317
+ 9.9,
318
+ 300,
319
+ 60,
320
+ 30,
321
+ 3,
322
+ 0,
323
+ 0,
324
+ 3,
325
+ "unknown",
326
+ "unpriced",
327
+ ],
328
+ [
329
+ null,
330
+ "agent-a",
331
+ "Picateclas",
332
+ "codex",
333
+ 0.2,
334
+ 50,
335
+ 10,
336
+ 5,
337
+ null,
338
+ 1,
339
+ 1,
340
+ null,
341
+ "gpt-5.5",
342
+ "harness",
343
+ ],
344
+ ],
345
+ };
346
+ }
347
+ return { columns: [], rows: [] };
348
+ },
349
+ },
350
+ };
351
+
352
+ const result = await compoundInsights(
353
+ {
354
+ days: 7,
355
+ includeToolUsage: false,
356
+ includeScheduleHealth: false,
357
+ includeMemoryHealth: false,
358
+ includeScriptCandidates: false,
359
+ includeByAgent: false,
360
+ publishPage: false,
361
+ },
362
+ ctx,
363
+ );
364
+
365
+ expect(queries.some((sql) => sql.includes("FROM script_runs sr"))).toBe(true);
366
+ expect(queries.some((sql) => sql.includes("FROM session_costs sc"))).toBe(true);
367
+ expect(result.scriptUsage.runs).toMatchObject({
368
+ total: 2,
369
+ inline: 1,
370
+ workflow: 1,
371
+ completed: 1,
372
+ failed: 1,
373
+ successRate: 50,
374
+ durationP50Ms: 1000,
375
+ durationP95Ms: 3000,
376
+ });
377
+ expect(result.scriptUsage.creations).toMatchObject({
378
+ totalNonScratch: 2,
379
+ scratch: 1,
380
+ byScope: { global: 2 },
381
+ });
382
+ expect(result.scriptUsage.edits).toMatchObject({
383
+ total: 3,
384
+ byScope: { global: 3 },
385
+ });
386
+ expect(result.scriptUsage.mcpToolCalls).toEqual([
387
+ { tool: "mcp__agent_swarm__script-run", calls: 5 },
388
+ ]);
389
+ expect(result.costAndTokens).toMatchObject({
390
+ rows: 4,
391
+ taskCountForHeadlineAvg: 2,
392
+ avgCostPerTaskUsd: 0.4,
393
+ totalSpendUsd: 10.9,
394
+ trustedSpendUsd: 1,
395
+ trustedRows: 3,
396
+ trustedRowPercent: 75,
397
+ unpricedRows: 1,
398
+ unpricedSpendUsd: 9.9,
399
+ nonTaskSessionRows: 1,
400
+ nonTaskSessionSpendUsd: 0.2,
401
+ unknownCounts: {
402
+ cacheWriteTokens: 2,
403
+ numTurns: 2,
404
+ },
405
+ });
406
+ expect(result.costAndTokens.tokenTotals).toMatchObject({
407
+ inputTokens: 650,
408
+ outputTokens: 130,
409
+ cacheReadTokens: 65,
410
+ cacheWriteTokens: 5,
411
+ reasoningOutputTokens: 4,
412
+ thinkingTokens: 5,
413
+ });
414
+ });
415
+
207
416
  test("ops-catalog-audit clusters schedule, workflow, and prompt findings by goal", async () => {
208
417
  const queries: string[] = [];
209
418
  const result = await opsCatalogAudit(
@@ -488,6 +697,122 @@ describe("seed-scripts catalog", () => {
488
697
  expect(html).toContain("@media (max-width: 860px)");
489
698
  expect(html).not.toContain("<ul>");
490
699
  });
700
+
701
+ test("boot-triage returns one read-only post-restart snapshot", async () => {
702
+ const queries: Array<{ sql: string; params?: unknown[] }> = [];
703
+ const result: any = await bootTriage(
704
+ { nowIso: "2026-06-05T10:15:00.000Z", repo: "owner/repo" },
705
+ {
706
+ stdlib: {
707
+ fetch: async () =>
708
+ new Response(
709
+ JSON.stringify([
710
+ {
711
+ number: 669,
712
+ title: "release: v1.92.0",
713
+ html_url: "https://github.com/desplega-ai/agent-swarm/pull/669",
714
+ merged_at: "2026-06-05T10:08:00.000Z",
715
+ },
716
+ ]),
717
+ { status: 200 },
718
+ ),
719
+ },
720
+ swarm: {
721
+ db_query: async (args: { sql: string; params?: unknown[] }) => {
722
+ queries.push(args);
723
+ if (args.sql.includes("t.status = 'failed'")) {
724
+ return {
725
+ columns: [
726
+ "id",
727
+ "task",
728
+ "status",
729
+ "taskType",
730
+ "agentId",
731
+ "agentName",
732
+ "scheduleId",
733
+ "parentTaskId",
734
+ "failureReason",
735
+ "createdAt",
736
+ "lastUpdatedAt",
737
+ ],
738
+ rows: [
739
+ [
740
+ "failed-real",
741
+ "Investigate deploy",
742
+ "failed",
743
+ "feature",
744
+ "agent-1",
745
+ "Worker",
746
+ null,
747
+ null,
748
+ "Typecheck failed",
749
+ "2026-06-05T10:00:00.000Z",
750
+ "2026-06-05T10:02:00.000Z",
751
+ ],
752
+ [
753
+ "failed-benign",
754
+ "Superseded task",
755
+ "failed",
756
+ "task",
757
+ "agent-1",
758
+ "Worker",
759
+ null,
760
+ null,
761
+ "cancelled",
762
+ "2026-06-05T10:00:00.000Z",
763
+ "2026-06-05T10:02:00.000Z",
764
+ ],
765
+ ],
766
+ };
767
+ }
768
+ if (args.sql.includes("t.status = 'in_progress'")) {
769
+ return {
770
+ columns: [
771
+ "id",
772
+ "task",
773
+ "status",
774
+ "taskType",
775
+ "agentId",
776
+ "agentName",
777
+ "scheduleId",
778
+ "parentTaskId",
779
+ "failureReason",
780
+ "createdAt",
781
+ "lastUpdatedAt",
782
+ ],
783
+ rows: [
784
+ [
785
+ "stuck-1",
786
+ "Stuck work",
787
+ "in_progress",
788
+ "feature",
789
+ "agent-offline",
790
+ "Offline",
791
+ null,
792
+ null,
793
+ null,
794
+ "2026-06-05T10:00:00.000Z",
795
+ "2026-06-05T10:01:00.000Z",
796
+ ],
797
+ ],
798
+ };
799
+ }
800
+ return { columns: [], rows: [] };
801
+ },
802
+ },
803
+ },
804
+ );
805
+
806
+ expect(queries.length).toBe(4);
807
+ expect(result.deployRestartDetection.mergedPrsWithinWindow).toHaveLength(1);
808
+ expect(result.recentlyFailedTasks.map((task: any) => task.id)).toEqual(["failed-real"]);
809
+ expect(result.stuckInProgressOnOfflineAgents.map((task: any) => task.id)).toEqual(["stuck-1"]);
810
+ expect(result.summary).toMatchObject({
811
+ mergedPrsWithinWindow: 1,
812
+ recentlyFailedTasks: 1,
813
+ stuckInProgressOnOfflineAgents: 1,
814
+ });
815
+ });
491
816
  });
492
817
 
493
818
  /**
@@ -0,0 +1,171 @@
1
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
2
+ import { unlink } from "node:fs/promises";
3
+ import type { IncomingMessage, ServerResponse } from "node:http";
4
+ import { Readable } from "node:stream";
5
+ import { closeDb, createSkill, getDb, initDb } from "../be/db";
6
+ import { handleSkills } from "../http/skills";
7
+ import { getPathSegments, parseQueryParams } from "../http/utils";
8
+
9
+ const TEST_DB_PATH = `./test-skill-files-http-${process.pid}.sqlite`;
10
+
11
+ async function removeDbFiles(path: string): Promise<void> {
12
+ for (const suffix of ["", "-wal", "-shm"]) {
13
+ await unlink(path + suffix).catch(() => {});
14
+ }
15
+ }
16
+
17
+ type TestResponse = {
18
+ status: number;
19
+ text: string;
20
+ json: () => Promise<unknown>;
21
+ };
22
+
23
+ async function dispatch(path: string, init: RequestInit = {}): Promise<TestResponse> {
24
+ const req = Readable.from(init.body ? [Buffer.from(String(init.body))] : []) as IncomingMessage;
25
+ req.method = init.method ?? "GET";
26
+ req.url = path;
27
+ req.headers = { "content-type": "application/json" };
28
+
29
+ let status = 200;
30
+ let text = "";
31
+ const res = {
32
+ headersSent: false,
33
+ writableEnded: false,
34
+ setHeader() {},
35
+ writeHead(code: number) {
36
+ status = code;
37
+ this.headersSent = true;
38
+ return this;
39
+ },
40
+ end(chunk?: unknown) {
41
+ if (chunk !== undefined) text += String(chunk);
42
+ this.writableEnded = true;
43
+ return this;
44
+ },
45
+ } as unknown as ServerResponse;
46
+
47
+ const pathSegments = getPathSegments(req.url || "");
48
+ const queryParams = parseQueryParams(req.url || "");
49
+ if (!(await handleSkills(req, res, pathSegments, queryParams, undefined))) {
50
+ res.writeHead(404);
51
+ res.end("Not Found");
52
+ }
53
+
54
+ return {
55
+ status,
56
+ text,
57
+ json: async () => JSON.parse(text),
58
+ };
59
+ }
60
+
61
+ describe("/api/skills/:id/files", () => {
62
+ let skillId: string;
63
+
64
+ beforeAll(async () => {
65
+ await removeDbFiles(TEST_DB_PATH);
66
+ initDb(TEST_DB_PATH);
67
+ });
68
+
69
+ beforeEach(() => {
70
+ getDb().run("DELETE FROM skill_files");
71
+ getDb().run("DELETE FROM skills");
72
+ const skill = createSkill({
73
+ name: `http-file-skill-${crypto.randomUUID()}`,
74
+ description: "HTTP file skill",
75
+ content: "---\nname: http-file-skill\ndescription: HTTP file skill\n---\n\nBody.",
76
+ type: "personal",
77
+ scope: "agent",
78
+ isComplex: true,
79
+ });
80
+ skillId = skill.id;
81
+ });
82
+
83
+ afterAll(async () => {
84
+ closeDb();
85
+ await removeDbFiles(TEST_DB_PATH);
86
+ });
87
+
88
+ test("POST bulk upserts files and GET manifest omits content", async () => {
89
+ const post = await dispatch(`/api/skills/${skillId}/files`, {
90
+ method: "POST",
91
+ body: JSON.stringify({
92
+ files: [
93
+ { path: "references/guide.md", content: "# Guide", mimeType: "text/markdown" },
94
+ { path: "scripts/setup.sh", content: "echo ok", mimeType: "text/x-shellscript" },
95
+ ],
96
+ }),
97
+ });
98
+ expect(post.status).toBe(200);
99
+ expect((await post.json()) as { total: number }).toMatchObject({ total: 2 });
100
+
101
+ const list = await dispatch(`/api/skills/${skillId}/files`);
102
+ const body = (await list.json()) as { files: Array<Record<string, unknown>>; total: number };
103
+ expect(body.total).toBe(2);
104
+ expect(body.files[0]).not.toHaveProperty("content");
105
+ });
106
+
107
+ test("GET, PUT, and DELETE a nested file path", async () => {
108
+ const put = await dispatch(`/api/skills/${skillId}/files/references/deep/guide.md`, {
109
+ method: "PUT",
110
+ body: JSON.stringify({ content: "deep guide", mimeType: "text/markdown" }),
111
+ });
112
+ expect(put.status).toBe(200);
113
+
114
+ const get = await dispatch(`/api/skills/${skillId}/files/references/deep/guide.md`);
115
+ const got = (await get.json()) as { file: { path: string; content: string } };
116
+ expect(got.file).toMatchObject({
117
+ path: "references/deep/guide.md",
118
+ content: "deep guide",
119
+ });
120
+
121
+ const del = await dispatch(`/api/skills/${skillId}/files/references/deep/guide.md`, {
122
+ method: "DELETE",
123
+ });
124
+ expect(del.status).toBe(200);
125
+
126
+ const missing = await dispatch(`/api/skills/${skillId}/files/references/deep/guide.md`);
127
+ expect(missing.status).toBe(404);
128
+ });
129
+
130
+ test("rejects invalid paths and unknown skills", async () => {
131
+ const traversal = await dispatch(`/api/skills/${skillId}/files/references/../secret.md`, {
132
+ method: "PUT",
133
+ body: JSON.stringify({ content: "nope" }),
134
+ });
135
+ expect(traversal.status).toBe(400);
136
+
137
+ const missingSkill = await dispatch(`/api/skills/no-such-skill/files/references/a.md`);
138
+ expect(missingSkill.status).toBe(404);
139
+ });
140
+
141
+ test("rejects file mutations for system-managed skills", async () => {
142
+ const systemSkill = createSkill({
143
+ name: `system-file-skill-${crypto.randomUUID()}`,
144
+ description: "System file skill",
145
+ content: "---\nname: system-file-skill\ndescription: System file skill\n---\n\nBody.",
146
+ type: "remote",
147
+ scope: "global",
148
+ isComplex: true,
149
+ systemDefault: true,
150
+ });
151
+
152
+ const post = await dispatch(`/api/skills/${systemSkill.id}/files`, {
153
+ method: "POST",
154
+ body: JSON.stringify({
155
+ files: [{ path: "references/guide.md", content: "# Guide" }],
156
+ }),
157
+ });
158
+ expect(post.status).toBe(403);
159
+
160
+ const put = await dispatch(`/api/skills/${systemSkill.id}/files/references/guide.md`, {
161
+ method: "PUT",
162
+ body: JSON.stringify({ content: "# Guide" }),
163
+ });
164
+ expect(put.status).toBe(403);
165
+
166
+ const del = await dispatch(`/api/skills/${systemSkill.id}/files/references/guide.md`, {
167
+ method: "DELETE",
168
+ });
169
+ expect(del.status).toBe(403);
170
+ });
171
+ });
@@ -0,0 +1,162 @@
1
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
2
+ import { unlink } from "node:fs/promises";
3
+ import {
4
+ closeDb,
5
+ createSkill,
6
+ deleteSkill,
7
+ deleteSkillFile,
8
+ getDb,
9
+ getSkillFile,
10
+ initDb,
11
+ listSkillFileManifest,
12
+ normalizeSkillFilePath,
13
+ upsertSkillFile,
14
+ upsertSkillFiles,
15
+ } from "../be/db";
16
+
17
+ const TEST_DB_PATH = `./test-skill-files-${process.pid}.sqlite`;
18
+
19
+ async function removeDbFiles(path: string): Promise<void> {
20
+ for (const suffix of ["", "-wal", "-shm"]) {
21
+ await unlink(path + suffix).catch(() => {});
22
+ }
23
+ }
24
+
25
+ describe("skill_files storage", () => {
26
+ let skillId: string;
27
+
28
+ beforeAll(async () => {
29
+ await removeDbFiles(TEST_DB_PATH);
30
+ initDb(TEST_DB_PATH);
31
+ });
32
+
33
+ beforeEach(() => {
34
+ getDb().run("DELETE FROM skill_files");
35
+ getDb().run("DELETE FROM skills");
36
+ const skill = createSkill({
37
+ name: `file-skill-${crypto.randomUUID()}`,
38
+ description: "Skill with bundled files",
39
+ content: "---\nname: file-skill\ndescription: Skill with bundled files\n---\n\nBody.",
40
+ type: "personal",
41
+ scope: "agent",
42
+ isComplex: true,
43
+ });
44
+ skillId = skill.id;
45
+ });
46
+
47
+ afterAll(async () => {
48
+ closeDb();
49
+ await removeDbFiles(TEST_DB_PATH);
50
+ });
51
+
52
+ test("migration creates skill_files on a fresh DB", () => {
53
+ const table = getDb()
54
+ .prepare<{ name: string }, []>(
55
+ "SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'skill_files'",
56
+ )
57
+ .get();
58
+ expect(table?.name).toBe("skill_files");
59
+
60
+ const migration = getDb()
61
+ .prepare<{ version: number; name: string }, []>(
62
+ "SELECT version, name FROM _migrations WHERE version = 87",
63
+ )
64
+ .get();
65
+ expect(migration?.name).toBe("087_skill_files");
66
+ });
67
+
68
+ test("re-opening an existing DB keeps skill_files available", () => {
69
+ closeDb();
70
+ initDb(TEST_DB_PATH);
71
+
72
+ const columns = getDb().prepare<{ name: string }, []>("PRAGMA table_info(skill_files)").all();
73
+ expect(columns.map((column) => column.name)).toEqual([
74
+ "id",
75
+ "skillId",
76
+ "path",
77
+ "content",
78
+ "mimeType",
79
+ "isBinary",
80
+ "size",
81
+ "createdAt",
82
+ "lastUpdatedAt",
83
+ "created_by",
84
+ "updated_by",
85
+ ]);
86
+ });
87
+
88
+ test("upserts, lists manifest without content, fetches, and deletes files", () => {
89
+ const beforeVersion = getDb()
90
+ .prepare<{ version: number }, [string]>("SELECT version FROM skills WHERE id = ?")
91
+ .get(skillId)!.version;
92
+
93
+ const file = upsertSkillFile(skillId, {
94
+ path: "references/guide.md",
95
+ content: "# Guide",
96
+ mimeType: "text/markdown",
97
+ });
98
+ expect(file.path).toBe("references/guide.md");
99
+ expect(file.size).toBe(Buffer.byteLength("# Guide"));
100
+
101
+ const manifest = listSkillFileManifest(skillId);
102
+ expect(manifest).toHaveLength(1);
103
+ expect(manifest[0]).not.toHaveProperty("content");
104
+
105
+ expect(getSkillFile(skillId, "references/guide.md")?.content).toBe("# Guide");
106
+
107
+ const afterVersion = getDb()
108
+ .prepare<{ version: number }, [string]>("SELECT version FROM skills WHERE id = ?")
109
+ .get(skillId)!.version;
110
+ expect(afterVersion).toBeGreaterThan(beforeVersion);
111
+
112
+ expect(deleteSkillFile(skillId, "references/guide.md")).toBe(true);
113
+ expect(getSkillFile(skillId, "references/guide.md")).toBeNull();
114
+ });
115
+
116
+ test("bulk upsert enforces path normalization and stores binary placeholders", () => {
117
+ const files = upsertSkillFiles(skillId, [
118
+ {
119
+ path: "references//nested.md",
120
+ content: "nested",
121
+ },
122
+ {
123
+ path: "assets/logo.png",
124
+ content: "",
125
+ mimeType: "image/png",
126
+ isBinary: true,
127
+ size: 1234,
128
+ },
129
+ ]);
130
+
131
+ expect(files.map((file) => file.path)).toEqual(["references/nested.md", "assets/logo.png"]);
132
+ const binary = getSkillFile(skillId, "assets/logo.png");
133
+ expect(binary?.isBinary).toBe(true);
134
+ expect(binary?.content).toBe("[binary file - not synced]");
135
+ });
136
+
137
+ test("rejects traversal and SKILL.md rows", () => {
138
+ expect(() => normalizeSkillFilePath("../secret.md")).toThrow("traversal");
139
+ expect(() =>
140
+ upsertSkillFile(skillId, {
141
+ path: "SKILL.md",
142
+ content: "nope",
143
+ }),
144
+ ).toThrow("SKILL.md");
145
+ });
146
+
147
+ test("deleting a skill cascades bundled files", () => {
148
+ const skill = createSkill({
149
+ name: "cascade-file-skill",
150
+ description: "Cascade test",
151
+ content: "---\nname: cascade-file-skill\ndescription: Cascade test\n---\n\nBody.",
152
+ type: "personal",
153
+ scope: "agent",
154
+ isComplex: true,
155
+ });
156
+ upsertSkillFile(skill.id, { path: "references/a.md", content: "a" });
157
+
158
+ expect(listSkillFileManifest(skill.id)).toHaveLength(1);
159
+ expect(deleteSkill(skill.id)).toBe(true);
160
+ expect(listSkillFileManifest(skill.id)).toHaveLength(0);
161
+ });
162
+ });