@desplega.ai/agent-swarm 1.91.0 → 1.92.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (114) hide show
  1. package/README.md +3 -2
  2. package/openapi.json +1005 -152
  3. package/package.json +6 -6
  4. package/plugin/skills/pages/SKILL.md +5 -2
  5. package/src/be/db.ts +662 -19
  6. package/src/be/memory/constants.ts +2 -1
  7. package/src/be/memory/providers/openai-embedding.ts +2 -5
  8. package/src/be/memory/providers/sqlite-store.ts +293 -76
  9. package/src/be/memory/types.ts +35 -0
  10. package/src/be/migrations/083_script_workflows.sql +51 -0
  11. package/src/be/migrations/084_script_run_journal_duration.sql +5 -0
  12. package/src/be/migrations/085_script_runs_kind.sql +9 -0
  13. package/src/be/migrations/086_pages_default_authed.sql +64 -0
  14. package/src/be/migrations/087_skill_files.sql +19 -0
  15. package/src/be/modelsdev-cache.json +42310 -38617
  16. package/src/be/scripts/typecheck.ts +49 -0
  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 +310 -6
  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 +506 -0
  23. package/src/be/seed-scripts/catalog/schedule-health.ts +78 -2
  24. package/src/be/seed-scripts/catalog/task-context-gathering.ts +92 -0
  25. package/src/be/seed-scripts/catalog/task-failure-audit.ts +48 -1
  26. package/src/be/seed-scripts/catalog/tool-usage.ts +6 -3
  27. package/src/be/seed-scripts/index.ts +51 -5
  28. package/src/be/seed-skills/index.ts +3 -3
  29. package/src/be/skill-sync.ts +91 -7
  30. package/src/be/swarm-config-guard.ts +17 -0
  31. package/src/commands/runner.ts +49 -4
  32. package/src/heartbeat/templates.ts +20 -16
  33. package/src/http/db-query.ts +20 -5
  34. package/src/http/index.ts +51 -7
  35. package/src/http/mcp-user.ts +23 -0
  36. package/src/http/mcp.ts +58 -0
  37. package/src/http/memory.ts +58 -0
  38. package/src/http/pages.ts +1 -1
  39. package/src/http/script-runs.ts +557 -0
  40. package/src/http/scripts.ts +39 -2
  41. package/src/http/skills.ts +225 -0
  42. package/src/prompts/session-templates.ts +24 -4
  43. package/src/providers/claude-adapter.ts +107 -28
  44. package/src/script-workflows/executor.ts +110 -0
  45. package/src/script-workflows/harness.ts +73 -0
  46. package/src/script-workflows/label-lint.ts +51 -0
  47. package/src/script-workflows/limits.ts +22 -0
  48. package/src/script-workflows/supervisor.ts +139 -0
  49. package/src/script-workflows/workflow-ctx.ts +209 -0
  50. package/src/scripts-runtime/sdk-allowlist.ts +4 -0
  51. package/src/scripts-runtime/swarm-sdk.ts +13 -0
  52. package/src/scripts-runtime/types/stdlib.d.ts +61 -0
  53. package/src/scripts-runtime/types/swarm-sdk.d.ts +61 -0
  54. package/src/server.ts +4 -0
  55. package/src/slack/handlers.ts +11 -4
  56. package/src/slack/message-text.ts +98 -0
  57. package/src/slack/thread-buffer.ts +5 -3
  58. package/src/tests/claude-adapter-binary.test.ts +271 -74
  59. package/src/tests/create-page-tool.test.ts +19 -2
  60. package/src/tests/db-query.test.ts +28 -0
  61. package/src/tests/error-tracker.test.ts +121 -0
  62. package/src/tests/harness-provider-resolution.test.ts +33 -0
  63. package/src/tests/heartbeat-checklist.test.ts +36 -0
  64. package/src/tests/mcp-tools.test.ts +6 -0
  65. package/src/tests/mcp-transport-gc.test.ts +58 -0
  66. package/src/tests/memory-health-endpoint.test.ts +78 -0
  67. package/src/tests/memory-store.test.ts +221 -1
  68. package/src/tests/pages-http.test.ts +20 -2
  69. package/src/tests/pages-storage.test.ts +26 -0
  70. package/src/tests/prompt-template-session.test.ts +34 -5
  71. package/src/tests/script-runs-http.test.ts +278 -0
  72. package/src/tests/script-workflows-label-lint.test.ts +43 -0
  73. package/src/tests/script-workflows-runtime-e2e.test.ts +170 -0
  74. package/src/tests/scripts-mcp-e2e.test.ts +102 -2
  75. package/src/tests/seed-scripts.test.ts +468 -3
  76. package/src/tests/skill-files-http.test.ts +171 -0
  77. package/src/tests/skill-files.test.ts +162 -0
  78. package/src/tests/skill-get-file-tool.test.ts +110 -0
  79. package/src/tests/skill-sync.test.ts +125 -6
  80. package/src/tests/slack-message-text.test.ts +250 -0
  81. package/src/tests/system-default-skills.test.ts +40 -0
  82. package/src/tools/create-page.ts +2 -2
  83. package/src/tools/db-query.ts +16 -6
  84. package/src/tools/script-runs.ts +123 -0
  85. package/src/tools/skills/index.ts +1 -0
  86. package/src/tools/skills/skill-get-file.ts +80 -0
  87. package/src/tools/slack-read.ts +12 -3
  88. package/src/tools/tool-config.ts +6 -2
  89. package/src/types.ts +72 -0
  90. package/src/utils/error-tracker.ts +40 -1
  91. package/src/utils/internal-ai/complete-structured.ts +10 -4
  92. package/src/workflows/executors/raw-llm.ts +76 -59
  93. package/templates/schedules/daily-blocker-digest/content.md +68 -54
  94. package/templates/schedules/daily-compounding-reflection/content.md +4 -4
  95. package/templates/schedules/daily-hn-briefing/content.md +5 -5
  96. package/templates/schedules/daily-workflow-health-audit/content.md +6 -6
  97. package/templates/schedules/gtm-weekly-review/content.md +9 -9
  98. package/templates/schedules/weekly-dependabot-triage/content.md +24 -20
  99. package/templates/skills/agentmail-sending/content.md +6 -7
  100. package/templates/skills/desloppify/content.md +8 -9
  101. package/templates/skills/jira-interaction/content.md +25 -33
  102. package/templates/skills/kapso-whatsapp/content.md +29 -30
  103. package/templates/skills/linear-interaction/content.md +8 -9
  104. package/templates/skills/pages/content.md +205 -55
  105. package/templates/skills/profile-corruption-escalation/content.md +44 -85
  106. package/templates/skills/script-workflows/config.json +14 -0
  107. package/templates/skills/script-workflows/content.md +68 -0
  108. package/templates/skills/sprite-cli/content.md +4 -5
  109. package/templates/skills/swarm-scripts/content.md +2 -3
  110. package/templates/skills/turso-interaction/content.md +14 -17
  111. package/templates/skills/workflow-iterate/content.md +38 -391
  112. package/templates/skills/x-api-interactions/content.md +4 -6
  113. package/templates/skills/scheduled-task-resilience/config.json +0 -14
  114. package/templates/skills/scheduled-task-resilience/content.md +0 -95
@@ -8,6 +8,11 @@ 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";
12
+ import compoundInsights from "../be/seed-scripts/catalog/compound-insights";
13
+ import opsCatalogAudit, {
14
+ renderPage as renderOpsCatalogAuditPage,
15
+ } from "../be/seed-scripts/catalog/ops-catalog-audit";
11
16
  import { extractScriptSignature } from "../scripts-runtime/extract-signature";
12
17
  import { validateScriptImports } from "../scripts-runtime/import-allowlist";
13
18
 
@@ -48,8 +53,8 @@ afterAll(async () => {
48
53
  });
49
54
 
50
55
  describe("seed-scripts catalog", () => {
51
- test("manifest holds 14 unique, well-described scripts", () => {
52
- expect(SEED_SCRIPTS.length).toBe(14);
56
+ test("manifest holds 17 unique, well-described scripts", () => {
57
+ expect(SEED_SCRIPTS.length).toBe(18);
53
58
  const names = SEED_SCRIPTS.map((s) => s.name);
54
59
  expect(new Set(names).size).toBe(names.length);
55
60
  for (const s of SEED_SCRIPTS) {
@@ -92,7 +97,9 @@ describe("seed-scripts catalog", () => {
92
97
  test("scriptsSeeder seeds the whole catalog at global scope", async () => {
93
98
  const result = await runSeeder(scriptsSeeder, { quiet: true });
94
99
  expect(result.failed).toEqual([]);
95
- expect(result.created).toBe(SEED_SCRIPTS.length);
100
+ expect(
101
+ result.created + result.updated + result.skippedUnchanged + result.skippedUserModified,
102
+ ).toBe(SEED_SCRIPTS.length);
96
103
 
97
104
  const globals = listScripts({ scope: "global" });
98
105
  for (const s of SEED_SCRIPTS) {
@@ -143,6 +150,464 @@ describe("seed-scripts catalog", () => {
143
150
  const row = getScript({ name: target.name, scope: "global" });
144
151
  expect(row?.source).toBe(userSource);
145
152
  });
153
+
154
+ test("compound-insights decodes numeric-key SQLite blob objects for similarity checks", async () => {
155
+ function encodedVector(values: number[]): Record<string, number> {
156
+ const bytes = new Uint8Array(new Float32Array(values).buffer);
157
+ return Object.fromEntries(Array.from(bytes.entries()).map(([i, byte]) => [String(i), byte]));
158
+ }
159
+
160
+ const queries: string[] = [];
161
+ const ctx = {
162
+ swarm: {
163
+ async db_query({ sql }: { sql: string }) {
164
+ queries.push(sql);
165
+ if (sql.includes("SELECT scope, source, count(*) as cnt")) {
166
+ return {
167
+ columns: ["scope", "source", "cnt", "zeroAccess"],
168
+ rows: [["agent", "session_summary", 2, 0]],
169
+ };
170
+ }
171
+ if (sql.includes("SELECT id, name, source, accessCount, embedding")) {
172
+ return {
173
+ columns: ["id", "name", "source", "accessCount", "embedding"],
174
+ rows: [
175
+ ["a", "first", "session_summary", 3, encodedVector([1, 0, 0, 0])],
176
+ ["b", "second", "task_completion", 2, encodedVector([0.9, 0.1, 0, 0])],
177
+ ],
178
+ };
179
+ }
180
+ if (sql.includes("SELECT source, count(*) as count")) {
181
+ return { columns: ["source", "count"], rows: [] };
182
+ }
183
+ return { columns: [], rows: [] };
184
+ },
185
+ },
186
+ };
187
+
188
+ const result = await compoundInsights(
189
+ {
190
+ days: 7,
191
+ includeToolUsage: false,
192
+ includeScheduleHealth: false,
193
+ includeScriptCandidates: false,
194
+ includeByAgent: false,
195
+ publishPage: false,
196
+ },
197
+ ctx,
198
+ );
199
+
200
+ expect(queries.some((sql) => sql.includes("embedding IS NOT NULL"))).toBe(true);
201
+ expect(result.memoryHealth.pollution.similarityCheck.sampledAutoSnapshots).toBe(2);
202
+ expect(result.memoryHealth.pollution.similarityCheck.strongestAutoSnapshotPair).toMatchObject({
203
+ a: { id: "a", name: "first", source: "session_summary" },
204
+ b: { id: "b", name: "second", source: "task_completion" },
205
+ });
206
+ expect(
207
+ result.memoryHealth.pollution.similarityCheck.strongestAutoSnapshotPair.similarity,
208
+ ).toBeGreaterThan(0.99);
209
+ });
210
+
211
+ test("ops-catalog-audit clusters schedule, workflow, and prompt findings by goal", async () => {
212
+ const queries: string[] = [];
213
+ const result = await opsCatalogAudit(
214
+ { nowIso: "2026-06-04T12:00:00.000Z", publishPage: false },
215
+ {
216
+ swarm: {
217
+ async db_query({ sql }: { sql: string }) {
218
+ queries.push(sql);
219
+ if (sql.includes("FROM scheduled_tasks")) {
220
+ return {
221
+ columns: [
222
+ "id",
223
+ "name",
224
+ "description",
225
+ "cronExpression",
226
+ "intervalMs",
227
+ "taskTemplate",
228
+ "taskType",
229
+ "tags",
230
+ "priority",
231
+ "targetAgentId",
232
+ "enabled",
233
+ "lastRunAt",
234
+ "nextRunAt",
235
+ "createdByAgentId",
236
+ "timezone",
237
+ "consecutiveErrors",
238
+ "scheduleType",
239
+ "targetAgentName",
240
+ "targetAgentRole",
241
+ "targetAgentDescription",
242
+ "targetAgentCapabilities",
243
+ "targetAgentProvider",
244
+ "targetAgentHarnessProvider",
245
+ ],
246
+ rows: [
247
+ [
248
+ "sched-a",
249
+ "repo-ci-audit",
250
+ "",
251
+ "0 * * * *",
252
+ null,
253
+ "Run gh pr checks and bun test in the repo",
254
+ "feature",
255
+ "[]",
256
+ 50,
257
+ null,
258
+ 1,
259
+ "2026-05-01T00:00:00.000Z",
260
+ null,
261
+ null,
262
+ "UTC",
263
+ 0,
264
+ "recurring",
265
+ null,
266
+ null,
267
+ null,
268
+ null,
269
+ null,
270
+ null,
271
+ ],
272
+ [
273
+ "sched-b",
274
+ "memory-gate-597",
275
+ "temporary monitor until 2026-06-01",
276
+ "0 * * * *",
277
+ null,
278
+ "Check memory gate",
279
+ "monitor",
280
+ "[]",
281
+ 50,
282
+ "agent-ops",
283
+ 1,
284
+ "2026-06-04T00:00:00.000Z",
285
+ "2026-06-04T13:00:00.000Z",
286
+ null,
287
+ "UTC",
288
+ 0,
289
+ "recurring",
290
+ "Ops Reviewer",
291
+ "ops",
292
+ "operations reviewer",
293
+ '["ops"]',
294
+ "opencode",
295
+ "opencode",
296
+ ],
297
+ ],
298
+ };
299
+ }
300
+ if (sql.includes("FROM workflows")) {
301
+ return {
302
+ columns: [
303
+ "id",
304
+ "name",
305
+ "description",
306
+ "enabled",
307
+ "definition",
308
+ "triggers",
309
+ "input",
310
+ "triggerSchema",
311
+ "createdAt",
312
+ "lastUpdatedAt",
313
+ ],
314
+ rows: [
315
+ [
316
+ "wf-smoke",
317
+ "gsc-runtime-smoke",
318
+ "temporary smoke fixture",
319
+ 1,
320
+ JSON.stringify({ nodes: [{ id: "a", type: "swarm-script" }] }),
321
+ "[]",
322
+ null,
323
+ null,
324
+ "2026-06-01T00:00:00.000Z",
325
+ "2026-06-01T00:00:00.000Z",
326
+ ],
327
+ [
328
+ "wf-gate",
329
+ "content-litmus-gate",
330
+ "quality gate",
331
+ 1,
332
+ JSON.stringify({ nodes: [{ id: "judge", type: "raw-llm" }] }),
333
+ "[]",
334
+ null,
335
+ null,
336
+ "2026-06-01T00:00:00.000Z",
337
+ "2026-06-01T00:00:00.000Z",
338
+ ],
339
+ ],
340
+ };
341
+ }
342
+ if (sql.includes("FROM prompt_templates")) {
343
+ return {
344
+ columns: [
345
+ "id",
346
+ "eventType",
347
+ "scope",
348
+ "scopeId",
349
+ "state",
350
+ "body",
351
+ "isDefault",
352
+ "version",
353
+ "createdBy",
354
+ "updatedAt",
355
+ ],
356
+ rows: [
357
+ [
358
+ "prompt-a",
359
+ "system.agent.role",
360
+ "global",
361
+ null,
362
+ "enabled",
363
+ "Use https://api.example-swarm.dev and do not browse. You must browse.",
364
+ 1,
365
+ 1,
366
+ "system",
367
+ "2026-06-01T00:00:00.000Z",
368
+ ],
369
+ [
370
+ "prompt-b",
371
+ "legacy.only",
372
+ "global",
373
+ null,
374
+ "enabled",
375
+ "Duplicate body",
376
+ 1,
377
+ 1,
378
+ "system",
379
+ "2026-06-01T00:00:00.000Z",
380
+ ],
381
+ [
382
+ "prompt-c",
383
+ "slack.assistant.greeting",
384
+ "global",
385
+ null,
386
+ "enabled",
387
+ "Duplicate body",
388
+ 1,
389
+ 1,
390
+ "system",
391
+ "2026-06-01T00:00:00.000Z",
392
+ ],
393
+ ],
394
+ };
395
+ }
396
+ if (sql.includes("FROM skills")) {
397
+ return {
398
+ columns: ["name", "count", "locations"],
399
+ rows: [["pages", 2, "global:global, swarm:global"]],
400
+ };
401
+ }
402
+ return { columns: [], rows: [] };
403
+ },
404
+ },
405
+ },
406
+ );
407
+
408
+ const findingIds = (items: Array<{ id: string }>) => items.map((finding) => finding.id);
409
+
410
+ expect(queries.length).toBe(4);
411
+ expect(result.summary.findingsTotal).toBeGreaterThanOrEqual(8);
412
+ expect(findingIds(result.goals.schedules.findings)).toEqual(
413
+ expect.arrayContaining([
414
+ "schedules.duplicate-crons",
415
+ "schedules.dead-or-stale",
416
+ "schedules.temporary-self-lift",
417
+ "schedules.rule-13-15-routing",
418
+ ]),
419
+ );
420
+ expect(findingIds(result.goals.workflows.findings)).toEqual(
421
+ expect.arrayContaining(["workflows.enabled-fixtures", "workflows.structured-output-gaps"]),
422
+ );
423
+ expect(findingIds(result.goals.promptsTemplates.findings)).toEqual(
424
+ expect.arrayContaining([
425
+ "prompts.registry-drift",
426
+ "prompts.redundant-bodies",
427
+ "prompts.stale-urls-hosts",
428
+ "prompts.contradictory-instructions",
429
+ "prompts.system-default-skill-duplicates",
430
+ ]),
431
+ );
432
+ });
433
+
434
+ test("ops-catalog-audit renders a summary-first designed HTML report", () => {
435
+ const html = renderOpsCatalogAuditPage({
436
+ generatedAt: "2026-06-04T12:00:00.000Z",
437
+ summary: {
438
+ schedulesEnabled: 40,
439
+ workflowsTotal: 33,
440
+ workflowsEnabled: 28,
441
+ promptTemplates: 76,
442
+ findingsTotal: 2,
443
+ },
444
+ goals: {
445
+ schedules: {
446
+ goal: "Reduce schedule cost/context waste and prevent misrouted code work.",
447
+ findingCount: 1,
448
+ checks: { duplicateCronGroups: 1, routingRisks: 1 },
449
+ findings: [
450
+ {
451
+ id: "schedules.rule-13-15-routing",
452
+ severity: "critical",
453
+ summary: "1 enabled code-work schedule is not pinned to a code-capable worker.",
454
+ action: "Set targetAgentId to a code-capable worker.",
455
+ samples: [
456
+ { id: "sched-a", name: "repo-ci-audit", reason: "pool-targeted code work" },
457
+ ],
458
+ },
459
+ ],
460
+ },
461
+ workflows: {
462
+ goal: "Separate load-bearing workflows from fixtures and enforce deterministic gate outputs.",
463
+ findingCount: 0,
464
+ checks: { enabledFixtures: 0, structuredOutputGaps: 0 },
465
+ findings: [],
466
+ },
467
+ promptsTemplates: {
468
+ goal: "Keep prompt registry, runtime defaults, host guidance, and skill seed blocks aligned.",
469
+ findingCount: 1,
470
+ checks: { staleUrlPrompts: 1 },
471
+ findings: [
472
+ {
473
+ id: "prompts.stale-urls-hosts",
474
+ severity: "high",
475
+ summary: "1 prompt template contains stale/local/example hosts.",
476
+ action: "Replace hardcoded hosts with runtime env-var guidance.",
477
+ samples: [{ id: "prompt-a", eventType: "system.agent.role", match: "localhost" }],
478
+ },
479
+ ],
480
+ },
481
+ },
482
+ });
483
+
484
+ expect(html).toContain("<main>");
485
+ expect(html).toContain('class="metrics"');
486
+ expect(html).toContain("<strong>40</strong><span>Schedules enabled</span>");
487
+ expect(html).toContain("schedules.rule-13-15-routing");
488
+ expect(html).toContain('class="finding danger"');
489
+ expect(html).toContain("<details>");
490
+ expect(html).toContain("Compressed JSON appendix");
491
+ expect(html).toContain('<div class="sample-table"');
492
+ expect(html).toContain("@media (max-width: 860px)");
493
+ expect(html).not.toContain("<ul>");
494
+ });
495
+
496
+ test("boot-triage returns one read-only post-restart snapshot", async () => {
497
+ const queries: Array<{ sql: string; params?: unknown[] }> = [];
498
+ const result: any = await bootTriage(
499
+ { nowIso: "2026-06-05T10:15:00.000Z", repo: "owner/repo" },
500
+ {
501
+ stdlib: {
502
+ fetch: async () =>
503
+ new Response(
504
+ JSON.stringify([
505
+ {
506
+ number: 669,
507
+ title: "release: v1.92.0",
508
+ html_url: "https://github.com/desplega-ai/agent-swarm/pull/669",
509
+ merged_at: "2026-06-05T10:08:00.000Z",
510
+ },
511
+ ]),
512
+ { status: 200 },
513
+ ),
514
+ },
515
+ swarm: {
516
+ db_query: async (args: { sql: string; params?: unknown[] }) => {
517
+ queries.push(args);
518
+ if (args.sql.includes("t.status = 'failed'")) {
519
+ return {
520
+ columns: [
521
+ "id",
522
+ "task",
523
+ "status",
524
+ "taskType",
525
+ "agentId",
526
+ "agentName",
527
+ "scheduleId",
528
+ "parentTaskId",
529
+ "failureReason",
530
+ "createdAt",
531
+ "lastUpdatedAt",
532
+ ],
533
+ rows: [
534
+ [
535
+ "failed-real",
536
+ "Investigate deploy",
537
+ "failed",
538
+ "feature",
539
+ "agent-1",
540
+ "Worker",
541
+ null,
542
+ null,
543
+ "Typecheck failed",
544
+ "2026-06-05T10:00:00.000Z",
545
+ "2026-06-05T10:02:00.000Z",
546
+ ],
547
+ [
548
+ "failed-benign",
549
+ "Superseded task",
550
+ "failed",
551
+ "task",
552
+ "agent-1",
553
+ "Worker",
554
+ null,
555
+ null,
556
+ "cancelled",
557
+ "2026-06-05T10:00:00.000Z",
558
+ "2026-06-05T10:02:00.000Z",
559
+ ],
560
+ ],
561
+ };
562
+ }
563
+ if (args.sql.includes("t.status = 'in_progress'")) {
564
+ return {
565
+ columns: [
566
+ "id",
567
+ "task",
568
+ "status",
569
+ "taskType",
570
+ "agentId",
571
+ "agentName",
572
+ "scheduleId",
573
+ "parentTaskId",
574
+ "failureReason",
575
+ "createdAt",
576
+ "lastUpdatedAt",
577
+ ],
578
+ rows: [
579
+ [
580
+ "stuck-1",
581
+ "Stuck work",
582
+ "in_progress",
583
+ "feature",
584
+ "agent-offline",
585
+ "Offline",
586
+ null,
587
+ null,
588
+ null,
589
+ "2026-06-05T10:00:00.000Z",
590
+ "2026-06-05T10:01:00.000Z",
591
+ ],
592
+ ],
593
+ };
594
+ }
595
+ return { columns: [], rows: [] };
596
+ },
597
+ },
598
+ },
599
+ );
600
+
601
+ expect(queries.length).toBe(4);
602
+ expect(result.deployRestartDetection.mergedPrsWithinWindow).toHaveLength(1);
603
+ expect(result.recentlyFailedTasks.map((task: any) => task.id)).toEqual(["failed-real"]);
604
+ expect(result.stuckInProgressOnOfflineAgents.map((task: any) => task.id)).toEqual(["stuck-1"]);
605
+ expect(result.summary).toMatchObject({
606
+ mergedPrsWithinWindow: 1,
607
+ recentlyFailedTasks: 1,
608
+ stuckInProgressOnOfflineAgents: 1,
609
+ });
610
+ });
146
611
  });
147
612
 
148
613
  /**
@@ -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
+ });