@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
|
@@ -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
|
|
56
|
-
expect(SEED_SCRIPTS.length).toBe(
|
|
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(
|
|
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
|
+
});
|