@desplega.ai/agent-swarm 1.92.2 → 1.94.0

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 (122) hide show
  1. package/README.md +2 -2
  2. package/openapi.json +242 -3
  3. package/package.json +5 -5
  4. package/src/be/db.ts +152 -11
  5. package/src/be/memory/boot-reembed.ts +0 -1
  6. package/src/be/memory/providers/sqlite-store.ts +42 -25
  7. package/src/be/memory/raters/llm-client.ts +12 -5
  8. package/src/be/memory/types.ts +3 -0
  9. package/src/be/migrations/088_script_runs_list_indexes.sql +10 -0
  10. package/src/be/migrations/089_harness_variant.sql +2 -0
  11. package/src/be/migrations/090_model_tiers.sql +2 -0
  12. package/src/be/migrations/091_seed_swarm_operations_metrics.sql +12 -0
  13. package/src/be/migrations/092_metrics_dashboard_combobox_filters.sql +68 -0
  14. package/src/be/migrations/093_slack_message_tracking.sql +6 -0
  15. package/src/be/migrations/runner.ts +52 -0
  16. package/src/be/modelsdev-cache.json +3264 -1166
  17. package/src/be/scripts/boot-reembed.ts +74 -0
  18. package/src/be/scripts/db.ts +19 -3
  19. package/src/be/seed/index.ts +1 -1
  20. package/src/be/seed/registry.ts +2 -2
  21. package/src/be/seed/runner.ts +5 -5
  22. package/src/be/seed/types.ts +6 -1
  23. package/src/be/seed-pricing.ts +2 -0
  24. package/src/be/seed-scripts/catalog/boot-triage.inline.ts +221 -0
  25. package/src/be/seed-scripts/catalog/catalog-report.inline.ts +457 -0
  26. package/src/be/seed-scripts/catalog/compound-insights.inline.ts +863 -0
  27. package/src/be/seed-scripts/catalog/ops-catalog-audit.inline.ts +506 -0
  28. package/src/be/seed-scripts/index.ts +8 -7
  29. package/src/be/skill-sync.ts +28 -179
  30. package/src/commands/runner.ts +197 -10
  31. package/src/http/api-keys.ts +42 -0
  32. package/src/http/index.ts +13 -2
  33. package/src/http/mcp-bridge.ts +1 -1
  34. package/src/http/memory.ts +23 -24
  35. package/src/http/metrics.ts +55 -6
  36. package/src/http/schedules.ts +16 -15
  37. package/src/http/script-runs.ts +7 -1
  38. package/src/http/scripts.ts +147 -1
  39. package/src/http/tasks.ts +17 -6
  40. package/src/model-tiers.ts +140 -0
  41. package/src/providers/claude-adapter.ts +33 -1
  42. package/src/providers/claude-managed-adapter.ts +3 -0
  43. package/src/providers/claude-managed-models.ts +16 -0
  44. package/src/providers/codex-adapter.ts +8 -1
  45. package/src/providers/codex-models.ts +1 -0
  46. package/src/providers/codex-oauth/auth-json.ts +1 -0
  47. package/src/providers/harness-version.ts +7 -0
  48. package/src/providers/opencode-adapter.ts +12 -4
  49. package/src/providers/pi-mono-adapter.ts +90 -8
  50. package/src/providers/types.ts +2 -0
  51. package/src/scheduler/scheduler.ts +22 -34
  52. package/src/scripts-runtime/egress-secrets.ts +83 -0
  53. package/src/scripts-runtime/eval-harness.ts +4 -0
  54. package/src/scripts-runtime/executors/types.ts +7 -0
  55. package/src/scripts-runtime/loader.ts +2 -0
  56. package/src/server-user.ts +8 -2
  57. package/src/slack/channel-join.ts +41 -0
  58. package/src/slack/responses.ts +39 -11
  59. package/src/slack/watcher.ts +121 -8
  60. package/src/tests/additive-buffer.test.ts +0 -1
  61. package/src/tests/agents-list-model-display.test.ts +13 -0
  62. package/src/tests/api-key-tracking.test.ts +113 -0
  63. package/src/tests/approval-requests.test.ts +0 -6
  64. package/src/tests/aws-error-classifier.test.ts +148 -0
  65. package/src/tests/claude-managed-adapter.test.ts +12 -0
  66. package/src/tests/claude-managed-setup.test.ts +0 -4
  67. package/src/tests/codex-pool.test.ts +2 -6
  68. package/src/tests/context-window.test.ts +7 -0
  69. package/src/tests/http-api-integration.test.ts +23 -6
  70. package/src/tests/memory-edges.test.ts +0 -2
  71. package/src/tests/memory-rate-endpoint.test.ts +0 -2
  72. package/src/tests/memory-rater-e2e.test.ts +0 -2
  73. package/src/tests/memory-store.test.ts +19 -1
  74. package/src/tests/memory.test.ts +51 -0
  75. package/src/tests/metrics-http.test.ts +137 -3
  76. package/src/tests/migration-046-budgets.test.ts +33 -0
  77. package/src/tests/migration-runner-regressions.test.ts +69 -0
  78. package/src/tests/model-control.test.ts +162 -46
  79. package/src/tests/opencode-adapter.test.ts +9 -0
  80. package/src/tests/pi-mono-adapter.test.ts +319 -0
  81. package/src/tests/providers/pi-cost.test.ts +9 -0
  82. package/src/tests/reload-config.test.ts +33 -17
  83. package/src/tests/runner-fallback-output.test.ts +50 -0
  84. package/src/tests/runner-skills-refresh.test.ts +216 -46
  85. package/src/tests/script-runs-http.test.ts +7 -1
  86. package/src/tests/scripts-boot-reembed.test.ts +163 -0
  87. package/src/tests/scripts-embeddings.test.ts +90 -0
  88. package/src/tests/scripts-runtime-secret-egress.test.ts +129 -0
  89. package/src/tests/seed-scripts.test.ts +13 -1
  90. package/src/tests/seed.test.ts +26 -1
  91. package/src/tests/session-attach.test.ts +6 -6
  92. package/src/tests/session-costs-model-key-normalize.test.ts +2 -0
  93. package/src/tests/skill-fs-writer.test.ts +250 -0
  94. package/src/tests/slack-attachments-block.test.ts +0 -1
  95. package/src/tests/slack-blocks.test.ts +0 -1
  96. package/src/tests/slack-channel-join.test.ts +80 -0
  97. package/src/tests/slack-identity-resolution.test.ts +0 -1
  98. package/src/tests/slack-watcher.test.ts +66 -0
  99. package/src/tests/structured-output.test.ts +0 -2
  100. package/src/tests/use-dismissible-card.test.ts +0 -4
  101. package/src/tests/workflow-agent-task.test.ts +5 -2
  102. package/src/tests/workflow-validation-port-routing.test.ts +181 -0
  103. package/src/tools/memory-get.ts +11 -0
  104. package/src/tools/memory-search.ts +18 -0
  105. package/src/tools/schedules/create-schedule.ts +71 -70
  106. package/src/tools/schedules/update-schedule.ts +43 -31
  107. package/src/tools/send-task.ts +16 -5
  108. package/src/tools/slack-post.ts +18 -15
  109. package/src/tools/slack-read.ts +9 -11
  110. package/src/tools/slack-reply.ts +18 -15
  111. package/src/tools/slack-start-thread.ts +17 -14
  112. package/src/tools/task-action.ts +11 -3
  113. package/src/types.ts +40 -0
  114. package/src/utils/aws-error-classifier.ts +97 -0
  115. package/src/utils/context-window.ts +5 -0
  116. package/src/utils/credentials.test.ts +68 -0
  117. package/src/utils/credentials.ts +66 -5
  118. package/src/utils/pretty-print.ts +25 -10
  119. package/src/utils/skill-fs-writer.ts +220 -0
  120. package/src/utils/skills-refresh.ts +123 -40
  121. package/src/workflows/engine.ts +3 -2
  122. package/src/workflows/executors/agent-task.ts +3 -1
@@ -1,34 +1,68 @@
1
1
  /**
2
2
  * Coverage for the worker-side `refreshSkillsIfChanged()` helper. The helper
3
3
  * is exercised against a Bun.serve() stub that mimics the signature + list
4
- * + sync-filesystem endpoints. Cases lock down its contract: cheap probe on
5
- * no-change, full refresh on hash drift, inactive/disabled filtering,
6
- * transient 5xx swallowed.
4
+ * endpoints. Cases lock down its contract: cheap probe on no-change, full
5
+ * refresh on hash drift, inactive/disabled filtering, transient 5xx swallowed,
6
+ * local FS write (not POST to /api/skills/sync-filesystem), and hash-caching
7
+ * only on successful local write.
7
8
  */
8
9
  import { afterAll, beforeAll, describe, expect, test } from "bun:test";
10
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
11
+ import { tmpdir } from "node:os";
12
+ import { join } from "node:path";
9
13
  import { refreshSkillsIfChanged, type SkillsRefreshContext } from "../utils/skills-refresh";
10
14
 
11
- // ── Bun.serve() stub backing fake signature/list/sync endpoints ──────────────
15
+ // ── Bun.serve() stub backing fake signature/list endpoints ───────────────────
16
+
17
+ type SkillStub = {
18
+ id: string;
19
+ name: string;
20
+ description: string;
21
+ content: string | null;
22
+ isComplex: boolean;
23
+ isActive: boolean;
24
+ isEnabled: boolean;
25
+ };
12
26
 
13
27
  type StubState = {
14
28
  signatureHash: string;
15
29
  signatureStatus: number;
16
- syncStatus: number;
30
+ listStatus: number;
17
31
  skillsBody: {
18
- skills: { name: string; description: string; isActive: boolean; isEnabled: boolean }[];
32
+ skills: SkillStub[];
19
33
  signature: string;
20
34
  };
21
35
  calls: { signature: number; list: number; sync: number };
36
+ skillFilesStatus: number;
22
37
  };
23
38
 
39
+ const FAKE_HOME = join(tmpdir(), `runner-refresh-test-${process.pid}`);
40
+
24
41
  const state: StubState = {
25
42
  signatureHash: "hash-v1",
26
43
  signatureStatus: 200,
27
- syncStatus: 200,
44
+ listStatus: 200,
45
+ skillFilesStatus: 200,
28
46
  skillsBody: {
29
47
  skills: [
30
- { name: "alpha", description: "first skill", isActive: true, isEnabled: true },
31
- { name: "beta", description: "second skill", isActive: true, isEnabled: true },
48
+ {
49
+ id: "skill-alpha",
50
+ name: "alpha",
51
+ description: "first skill",
52
+ content: "---\nname: alpha\n---\n\nAlpha body.",
53
+ isComplex: false,
54
+ isActive: true,
55
+ isEnabled: true,
56
+ },
57
+ {
58
+ id: "skill-beta",
59
+ name: "beta",
60
+ description: "second skill",
61
+ content: "---\nname: beta\n---\n\nBeta body.",
62
+ isComplex: false,
63
+ isActive: true,
64
+ isEnabled: true,
65
+ },
32
66
  ],
33
67
  signature: "hash-v1",
34
68
  },
@@ -40,6 +74,8 @@ let baseUrl = "";
40
74
 
41
75
  describe("refreshSkillsIfChanged", () => {
42
76
  beforeAll(() => {
77
+ mkdirSync(FAKE_HOME, { recursive: true });
78
+
43
79
  server = Bun.serve({
44
80
  port: 0,
45
81
  fetch(req) {
@@ -57,18 +93,23 @@ describe("refreshSkillsIfChanged", () => {
57
93
  }
58
94
  if (url.pathname.match(/\/api\/agents\/[^/]+\/skills$/)) {
59
95
  state.calls.list++;
96
+ if (state.listStatus !== 200) {
97
+ return new Response("err", { status: state.listStatus });
98
+ }
60
99
  return Response.json({
61
100
  skills: state.skillsBody.skills,
62
101
  total: state.skillsBody.skills.length,
63
102
  signature: state.skillsBody.signature,
64
103
  });
65
104
  }
105
+ // Track that the old sync-filesystem endpoint is NOT called
66
106
  if (url.pathname === "/api/skills/sync-filesystem") {
67
107
  state.calls.sync++;
68
- if (state.syncStatus !== 200) {
69
- return new Response("sync err", { status: state.syncStatus });
70
- }
71
- return Response.json({ synced: 2, removed: 0, errors: [] });
108
+ return Response.json({ synced: 0, removed: 0, errors: [] });
109
+ }
110
+ // Complex skill: file manifest
111
+ if (url.pathname.match(/\/api\/skills\/[^/]+\/files$/) && state.skillFilesStatus === 200) {
112
+ return Response.json({ files: [], total: 0 });
72
113
  }
73
114
  return new Response("not found", { status: 404 });
74
115
  },
@@ -78,6 +119,7 @@ describe("refreshSkillsIfChanged", () => {
78
119
 
79
120
  afterAll(() => {
80
121
  server?.stop(true);
122
+ rmSync(FAKE_HOME, { recursive: true, force: true });
81
123
  });
82
124
 
83
125
  function makeCtx(): SkillsRefreshContext {
@@ -90,13 +132,22 @@ describe("refreshSkillsIfChanged", () => {
90
132
  };
91
133
  }
92
134
 
93
- test("first call populates summary and updates the cached hash", async () => {
135
+ // Thin helper to pass homeOverride through to refreshSkillsIfChanged
136
+ async function refreshWithHome(
137
+ ctx: SkillsRefreshContext,
138
+ lastHashRef: { current: string | null },
139
+ home: string,
140
+ ) {
141
+ return refreshSkillsIfChanged(ctx, lastHashRef, home);
142
+ }
143
+
144
+ test("first call writes SKILL.md to local HOME and updates cached hash", async () => {
94
145
  state.calls = { signature: 0, list: 0, sync: 0 };
95
146
  state.signatureHash = "hash-v1";
96
147
  state.skillsBody.signature = "hash-v1";
97
148
 
98
149
  const lastHash = { current: null as string | null };
99
- const result = await refreshSkillsIfChanged(makeCtx(), lastHash);
150
+ const result = await refreshWithHome(makeCtx(), lastHash, FAKE_HOME);
100
151
 
101
152
  expect(result.changed).toBe(true);
102
153
  expect(result.summary).toEqual([
@@ -104,14 +155,24 @@ describe("refreshSkillsIfChanged", () => {
104
155
  { name: "beta", description: "second skill" },
105
156
  ]);
106
157
  expect(lastHash.current).toBe("hash-v1");
107
- expect(state.calls).toEqual({ signature: 1, list: 1, sync: 1 });
158
+
159
+ // SKILL.md files must be written on the local worker disk
160
+ const alphaFile = join(FAKE_HOME, ".claude", "skills", "alpha", "SKILL.md");
161
+ const betaFile = join(FAKE_HOME, ".claude", "skills", "beta", "SKILL.md");
162
+ expect(existsSync(alphaFile)).toBe(true);
163
+ expect(readFileSync(alphaFile, "utf-8")).toContain("Alpha body.");
164
+ expect(existsSync(betaFile)).toBe(true);
165
+
166
+ // Must NOT have called /api/skills/sync-filesystem
167
+ expect(state.calls.sync).toBe(0);
168
+ expect(state.calls).toEqual({ signature: 1, list: 1, sync: 0 });
108
169
  });
109
170
 
110
- test("subsequent call with unchanged hash skips list + sync", async () => {
171
+ test("subsequent call with unchanged hash skips list + write", async () => {
111
172
  state.calls = { signature: 0, list: 0, sync: 0 };
112
173
  const lastHash = { current: "hash-v1" };
113
174
 
114
- const result = await refreshSkillsIfChanged(makeCtx(), lastHash);
175
+ const result = await refreshWithHome(makeCtx(), lastHash, FAKE_HOME);
115
176
 
116
177
  expect(result.changed).toBe(false);
117
178
  expect(result.summary).toBeUndefined();
@@ -119,82 +180,191 @@ describe("refreshSkillsIfChanged", () => {
119
180
  expect(state.calls).toEqual({ signature: 1, list: 0, sync: 0 });
120
181
  });
121
182
 
122
- test("hash drift refetches list and updates cached hash to the list's snapshot", async () => {
183
+ test("hash drift refetches list, writes new skill, updates cached hash", async () => {
123
184
  state.calls = { signature: 0, list: 0, sync: 0 };
124
185
  state.signatureHash = "hash-v2";
125
186
  state.skillsBody.signature = "hash-v2";
126
187
  state.skillsBody.skills = [
127
- { name: "alpha", description: "first skill", isActive: true, isEnabled: true },
128
- { name: "beta", description: "second skill", isActive: true, isEnabled: true },
129
- { name: "gamma", description: "third skill", isActive: true, isEnabled: true },
188
+ {
189
+ id: "skill-alpha",
190
+ name: "alpha",
191
+ description: "first skill",
192
+ content: "---\nname: alpha\n---\n\nAlpha updated.",
193
+ isComplex: false,
194
+ isActive: true,
195
+ isEnabled: true,
196
+ },
197
+ {
198
+ id: "skill-beta",
199
+ name: "beta",
200
+ description: "second skill",
201
+ content: "---\nname: beta\n---\n\nBeta body.",
202
+ isComplex: false,
203
+ isActive: true,
204
+ isEnabled: true,
205
+ },
206
+ {
207
+ id: "skill-gamma",
208
+ name: "gamma",
209
+ description: "third skill",
210
+ content: "---\nname: gamma\n---\n\nGamma body.",
211
+ isComplex: false,
212
+ isActive: true,
213
+ isEnabled: true,
214
+ },
130
215
  ];
131
216
 
132
217
  const lastHash = { current: "hash-v1" };
133
- const result = await refreshSkillsIfChanged(makeCtx(), lastHash);
218
+ const result = await refreshWithHome(makeCtx(), lastHash, FAKE_HOME);
134
219
 
135
220
  expect(result.changed).toBe(true);
136
221
  expect(result.summary).toHaveLength(3);
137
222
  expect(lastHash.current).toBe("hash-v2");
138
- expect(state.calls).toEqual({ signature: 1, list: 1, sync: 1 });
223
+
224
+ const gammaFile = join(FAKE_HOME, ".claude", "skills", "gamma", "SKILL.md");
225
+ expect(existsSync(gammaFile)).toBe(true);
226
+ expect(readFileSync(gammaFile, "utf-8")).toContain("Gamma body.");
227
+ expect(state.calls.sync).toBe(0); // never POSTed
139
228
  });
140
229
 
141
- test("filters out inactive or disabled skills from the summary", async () => {
230
+ test("filters out inactive or disabled skills from summary and does not write them", async () => {
142
231
  state.calls = { signature: 0, list: 0, sync: 0 };
143
232
  state.signatureHash = "hash-v3";
144
233
  state.skillsBody.signature = "hash-v3";
145
234
  state.skillsBody.skills = [
146
- { name: "active", description: "kept", isActive: true, isEnabled: true },
147
- { name: "disabled", description: "dropped", isActive: true, isEnabled: false },
148
- { name: "inactive", description: "dropped", isActive: false, isEnabled: true },
235
+ {
236
+ id: "skill-active",
237
+ name: "active-skill",
238
+ description: "kept",
239
+ content: "# Active",
240
+ isComplex: false,
241
+ isActive: true,
242
+ isEnabled: true,
243
+ },
244
+ {
245
+ id: "skill-disabled",
246
+ name: "disabled-skill",
247
+ description: "dropped",
248
+ content: "# Disabled",
249
+ isComplex: false,
250
+ isActive: true,
251
+ isEnabled: false,
252
+ },
253
+ {
254
+ id: "skill-inactive",
255
+ name: "inactive-skill",
256
+ description: "dropped",
257
+ content: "# Inactive",
258
+ isComplex: false,
259
+ isActive: false,
260
+ isEnabled: true,
261
+ },
149
262
  ];
150
263
 
151
264
  const lastHash = { current: "hash-v2" };
152
- const result = await refreshSkillsIfChanged(makeCtx(), lastHash);
265
+ const result = await refreshWithHome(makeCtx(), lastHash, FAKE_HOME);
153
266
 
154
267
  expect(result.changed).toBe(true);
155
- expect(result.summary).toEqual([{ name: "active", description: "kept" }]);
268
+ expect(result.summary).toEqual([{ name: "active-skill", description: "kept" }]);
269
+
270
+ const disabledFile = join(FAKE_HOME, ".claude", "skills", "disabled-skill", "SKILL.md");
271
+ const inactiveFile = join(FAKE_HOME, ".claude", "skills", "inactive-skill", "SKILL.md");
272
+ expect(existsSync(disabledFile)).toBe(false);
273
+ expect(existsSync(inactiveFile)).toBe(false);
156
274
  });
157
275
 
158
- test("transient 5xx on signature endpoint returns changed:false without touching list/sync", async () => {
276
+ test("transient 5xx on signature endpoint returns changed:false without touching list/write", async () => {
159
277
  state.calls = { signature: 0, list: 0, sync: 0 };
160
278
  state.signatureStatus = 503;
161
279
 
162
280
  const lastHash = { current: "hash-v3" };
163
- const result = await refreshSkillsIfChanged(makeCtx(), lastHash);
281
+ const result = await refreshWithHome(makeCtx(), lastHash, FAKE_HOME);
164
282
 
165
283
  expect(result.changed).toBe(false);
166
284
  expect(lastHash.current).toBe("hash-v3");
167
285
  expect(state.calls).toEqual({ signature: 1, list: 0, sync: 0 });
168
286
 
169
- state.signatureStatus = 200; // restore for any later tests
287
+ state.signatureStatus = 200; // restore
170
288
  });
171
289
 
172
- test("sync-filesystem failure leaves cached hash unchanged so the next poll retries", async () => {
173
- // Server-side state: a new hash + skill set, sync endpoint failing.
290
+ test("local write failure leaves cached hash unchanged so the next poll retries", async () => {
291
+ // Use a read-only HOME path to force a write failure
292
+ const readOnlyHome = join(FAKE_HOME, "readonly-home");
293
+ mkdirSync(readOnlyHome, { recursive: true });
294
+
174
295
  state.signatureHash = "hash-v4";
175
296
  state.skillsBody.signature = "hash-v4";
176
297
  state.skillsBody.skills = [
177
- { name: "alpha", description: "first", isActive: true, isEnabled: true },
298
+ {
299
+ id: "skill-alpha",
300
+ name: "alpha",
301
+ description: "first",
302
+ content: "# Alpha",
303
+ isComplex: false,
304
+ isActive: true,
305
+ isEnabled: true,
306
+ },
178
307
  ];
179
- state.syncStatus = 503;
180
308
  state.calls = { signature: 0, list: 0, sync: 0 };
181
309
 
310
+ // Make the claude skills dir a FILE (not a dir) so mkdir fails on write
311
+ const blockerPath = join(readOnlyHome, ".claude");
312
+ mkdirSync(join(readOnlyHome), { recursive: true });
313
+ // Write a file at .claude to block mkdirSync from creating it as a dir
314
+ writeFileSync(blockerPath, "blocker");
315
+
182
316
  const lastHash = { current: "hash-prev" };
183
- const first = await refreshSkillsIfChanged(makeCtx(), lastHash);
317
+ const first = await refreshWithHome(makeCtx(), lastHash, readOnlyHome);
184
318
 
185
- // Summary still returns (the list call succeeded), but the cached
186
- // hash must NOT advance — otherwise the next signature probe would
187
- // short-circuit and the FS would stay stale forever.
319
+ // Summary still returns (the list call succeeded), but cached hash must
320
+ // NOT advance — FS write failed
188
321
  expect(first.changed).toBe(true);
189
322
  expect(first.summary).toEqual([{ name: "alpha", description: "first" }]);
190
323
  expect(lastHash.current).toBe("hash-prev");
191
- expect(state.calls).toEqual({ signature: 1, list: 1, sync: 1 });
324
+ expect(state.calls.sync).toBe(0); // still never calls sync-filesystem
192
325
 
193
- // Sync recovers next poll retries because cached hash still differs.
194
- state.syncStatus = 200;
195
- const second = await refreshSkillsIfChanged(makeCtx(), lastHash);
326
+ // Clean up the blocker
327
+ rmSync(readOnlyHome, { recursive: true, force: true });
328
+
329
+ // Normal write in a clean home recovers — next poll retries because hash differs
330
+ state.calls = { signature: 0, list: 0, sync: 0 };
331
+ const cleanHome = join(FAKE_HOME, "clean-home");
332
+ mkdirSync(cleanHome, { recursive: true });
333
+ const second = await refreshWithHome(makeCtx(), lastHash, cleanHome);
196
334
  expect(second.changed).toBe(true);
197
335
  expect(lastHash.current).toBe("hash-v4");
198
- expect(state.calls).toEqual({ signature: 2, list: 2, sync: 2 });
336
+ expect(state.calls.sync).toBe(0);
337
+ rmSync(cleanHome, { recursive: true, force: true });
338
+ });
339
+
340
+ test("list fetch failure after signature drift leaves pre-existing skills on disk and does not advance hash", async () => {
341
+ // Arrange: fresh isolated home with a pre-existing swarm-managed skill
342
+ const isolatedHome = join(FAKE_HOME, "list-fail-home");
343
+ const skillDir = join(isolatedHome, ".claude", "skills", "pre-existing");
344
+ mkdirSync(skillDir, { recursive: true });
345
+ writeFileSync(join(skillDir, ".swarm-managed"), "");
346
+ writeFileSync(join(skillDir, "SKILL.md"), "# Pre-existing skill");
347
+
348
+ // Simulate signature drift so the list endpoint is called
349
+ state.signatureHash = "hash-new";
350
+ state.listStatus = 503; // List fetch fails
351
+ state.calls = { signature: 0, list: 0, sync: 0 };
352
+
353
+ const lastHash = { current: "hash-old" };
354
+ const result = await refreshWithHome(makeCtx(), lastHash, isolatedHome);
355
+
356
+ // Must bail out without touching disk
357
+ expect(result.changed).toBe(false);
358
+ // Cached hash must NOT advance — disk is still in "old" state
359
+ expect(lastHash.current).toBe("hash-old");
360
+ // List endpoint was called (signature differed) but the failure must bail early
361
+ expect(state.calls.list).toBe(1);
362
+ // Pre-existing managed skill file must survive
363
+ expect(existsSync(join(skillDir, "SKILL.md"))).toBe(true);
364
+ expect(readFileSync(join(skillDir, "SKILL.md"), "utf-8")).toBe("# Pre-existing skill");
365
+
366
+ // Restore
367
+ state.listStatus = 200;
368
+ rmSync(isolatedHome, { recursive: true, force: true });
199
369
  });
200
370
  });
@@ -141,9 +141,15 @@ describe("/api/script-runs HTTP", () => {
141
141
 
142
142
  const listed = await dispatch("/api/script-runs", { agentId });
143
143
  expect(listed.status).toBe(200);
144
- const listBody = (await listed.json()) as { runs: Array<{ id: string }>; total: number };
144
+ const listBody = (await listed.json()) as {
145
+ runs: Array<{ id: string; source?: string; args?: unknown; output?: unknown }>;
146
+ total: number;
147
+ };
145
148
  expect(listBody.total).toBe(1);
146
149
  expect(listBody.runs[0]?.id).toBe(body.id);
150
+ expect(listBody.runs[0]?.source).toBeUndefined();
151
+ expect(listBody.runs[0]?.args).toBeUndefined();
152
+ expect(listBody.runs[0]?.output).toBeUndefined();
147
153
  });
148
154
 
149
155
  test("returns the existing run for an idempotency key", async () => {
@@ -0,0 +1,163 @@
1
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
2
+ import { unlink } from "node:fs/promises";
3
+ import { closeDb, getDb, initDb } from "../be/db";
4
+ import type { EmbeddingProvider } from "../be/memory/types";
5
+ import { runBootReembedScripts } from "../be/scripts/boot-reembed";
6
+ import { upsertScriptByName } from "../be/scripts/db";
7
+ import { setScriptEmbeddingProviderForTests } from "../be/scripts/embeddings";
8
+
9
+ const TEST_DB_PATH = "./test-scripts-boot-reembed.sqlite";
10
+
11
+ const signatureJson = JSON.stringify({
12
+ argsType: "{ value: string }",
13
+ resultType: "Promise<{ ok: boolean }>",
14
+ description: "",
15
+ });
16
+
17
+ async function clearDb() {
18
+ for (const suffix of ["", "-wal", "-shm"]) {
19
+ try {
20
+ await unlink(TEST_DB_PATH + suffix);
21
+ } catch {}
22
+ }
23
+ }
24
+
25
+ function source(label: string) {
26
+ return `export default async () => ({ label: ${JSON.stringify(label)} });`;
27
+ }
28
+
29
+ class FakeEmbeddingProvider implements EmbeddingProvider {
30
+ readonly name = "test/fake-boot-reembed";
31
+ readonly dimensions = 5;
32
+ readonly calls: string[] = [];
33
+
34
+ async embed(text: string): Promise<Float32Array | null> {
35
+ this.calls.push(text);
36
+ return new Float32Array([0.1, 0.2, 0.3, 0.4, 0.5]);
37
+ }
38
+
39
+ async embedBatch(texts: string[]): Promise<(Float32Array | null)[]> {
40
+ return Promise.all(texts.map((text) => this.embed(text)));
41
+ }
42
+
43
+ reset(): void {
44
+ this.calls.length = 0;
45
+ }
46
+ }
47
+
48
+ let provider: FakeEmbeddingProvider;
49
+
50
+ function embeddingCount(scriptId: string): number {
51
+ return (
52
+ getDb()
53
+ .prepare<{ count: number }, [string]>(
54
+ "SELECT COUNT(*) as count FROM script_embeddings WHERE scriptId = ?",
55
+ )
56
+ .get(scriptId)?.count ?? 0
57
+ );
58
+ }
59
+
60
+ function totalEmbeddingCount(): number {
61
+ return (
62
+ getDb().prepare<{ count: number }, []>("SELECT COUNT(*) as count FROM script_embeddings").get()
63
+ ?.count ?? 0
64
+ );
65
+ }
66
+
67
+ beforeAll(async () => {
68
+ await clearDb();
69
+ initDb(TEST_DB_PATH);
70
+ });
71
+
72
+ afterAll(async () => {
73
+ setScriptEmbeddingProviderForTests(null);
74
+ closeDb();
75
+ await clearDb();
76
+ });
77
+
78
+ beforeEach(() => {
79
+ getDb().run("DELETE FROM scripts");
80
+ getDb().run("DELETE FROM script_embeddings");
81
+ provider = new FakeEmbeddingProvider();
82
+ setScriptEmbeddingProviderForTests(provider);
83
+ });
84
+
85
+ describe("boot-reembed-scripts", () => {
86
+ test("backfills scripts that were seeded with embeddingMode: skip", async () => {
87
+ const result = await upsertScriptByName({
88
+ name: "skipped-embed",
89
+ scope: "global",
90
+ source: source("skipped"),
91
+ description: "A script seeded without embedding",
92
+ intent: "Test backfill",
93
+ signatureJson,
94
+ embeddingMode: "skip",
95
+ });
96
+ expect(embeddingCount(result.script.id)).toBe(0);
97
+
98
+ provider.reset();
99
+ await runBootReembedScripts();
100
+ expect(embeddingCount(result.script.id)).toBe(1);
101
+ expect(provider.calls).toHaveLength(1);
102
+ });
103
+
104
+ test("no-ops when all scripts already have embeddings", async () => {
105
+ await upsertScriptByName({
106
+ name: "already-embedded",
107
+ scope: "global",
108
+ source: source("embedded"),
109
+ description: "Already has embedding",
110
+ intent: "No-op test",
111
+ signatureJson,
112
+ });
113
+ expect(totalEmbeddingCount()).toBe(1);
114
+
115
+ provider.reset();
116
+ await runBootReembedScripts();
117
+ expect(provider.calls).toHaveLength(0);
118
+ });
119
+
120
+ test("skips scratch scripts during backfill", async () => {
121
+ await upsertScriptByName({
122
+ name: "scratch-no-backfill",
123
+ scope: "agent",
124
+ scopeId: "agent-1",
125
+ source: source("scratch"),
126
+ description: "Scratch script",
127
+ intent: "Should not be backfilled",
128
+ signatureJson,
129
+ isScratch: true,
130
+ });
131
+
132
+ provider.reset();
133
+ await runBootReembedScripts();
134
+ expect(provider.calls).toHaveLength(0);
135
+ });
136
+
137
+ test("backfills only scripts missing embeddings, not those that already have them", async () => {
138
+ const withEmbed = await upsertScriptByName({
139
+ name: "has-embed",
140
+ scope: "global",
141
+ source: source("has"),
142
+ description: "Has embedding",
143
+ intent: "Already embedded",
144
+ signatureJson,
145
+ });
146
+ const withoutEmbed = await upsertScriptByName({
147
+ name: "missing-embed",
148
+ scope: "global",
149
+ source: source("missing"),
150
+ description: "Missing embedding",
151
+ intent: "Needs backfill",
152
+ signatureJson,
153
+ embeddingMode: "skip",
154
+ });
155
+ expect(embeddingCount(withEmbed.script.id)).toBe(1);
156
+ expect(embeddingCount(withoutEmbed.script.id)).toBe(0);
157
+
158
+ provider.reset();
159
+ await runBootReembedScripts();
160
+ expect(provider.calls).toHaveLength(1);
161
+ expect(embeddingCount(withoutEmbed.script.id)).toBe(1);
162
+ });
163
+ });
@@ -268,6 +268,96 @@ describe("script embeddings", () => {
268
268
  expect(topOneHits).toBeGreaterThanOrEqual(4);
269
269
  });
270
270
 
271
+ test("embeddingMode: skip prevents embedding on new script", async () => {
272
+ provider.reset();
273
+ const result = await upsertScriptByName({
274
+ name: "skip-new",
275
+ scope: "agent",
276
+ scopeId: "agent-1",
277
+ source: source("skip-new"),
278
+ description: "Should not embed",
279
+ intent: "Skip mode test",
280
+ signatureJson,
281
+ agentId: "agent-1",
282
+ embeddingMode: "skip",
283
+ });
284
+ expect(result.isNew).toBe(true);
285
+ expect(embeddingCount(result.script.id)).toBe(0);
286
+ expect(provider.calls).toHaveLength(0);
287
+ });
288
+
289
+ test("embeddingMode: skip prevents embedding on source change", async () => {
290
+ const first = await upsertScriptByName({
291
+ name: "skip-update",
292
+ scope: "agent",
293
+ scopeId: "agent-1",
294
+ source: source("v1"),
295
+ description: "Will update",
296
+ intent: "Skip mode update test",
297
+ signatureJson,
298
+ agentId: "agent-1",
299
+ });
300
+ expect(embeddingCount(first.script.id)).toBe(1);
301
+
302
+ provider.reset();
303
+ const second = await upsertScriptByName({
304
+ name: "skip-update",
305
+ scope: "agent",
306
+ scopeId: "agent-1",
307
+ source: source("v2"),
308
+ description: "Updated source",
309
+ intent: "Skip mode update test",
310
+ signatureJson,
311
+ agentId: "agent-1",
312
+ embeddingMode: "skip",
313
+ });
314
+ expect(second.contentDeduped).toBe(false);
315
+ expect(provider.calls).toHaveLength(0);
316
+ });
317
+
318
+ test("embeddingMode: skip prevents embedding on metadata change", async () => {
319
+ await upsertScriptByName({
320
+ name: "skip-meta",
321
+ scope: "agent",
322
+ scopeId: "agent-1",
323
+ source: source("skip-meta"),
324
+ description: "Original description",
325
+ intent: "Original intent",
326
+ signatureJson,
327
+ agentId: "agent-1",
328
+ });
329
+
330
+ provider.reset();
331
+ await upsertScriptByName({
332
+ name: "skip-meta",
333
+ scope: "agent",
334
+ scopeId: "agent-1",
335
+ source: source("skip-meta"),
336
+ description: "Changed description",
337
+ intent: "Changed intent",
338
+ signatureJson,
339
+ agentId: "agent-1",
340
+ embeddingMode: "skip",
341
+ });
342
+ expect(provider.calls).toHaveLength(0);
343
+ });
344
+
345
+ test("embeddingMode defaults to sync (embeds normally)", async () => {
346
+ provider.reset();
347
+ const result = await upsertScriptByName({
348
+ name: "default-sync",
349
+ scope: "agent",
350
+ scopeId: "agent-1",
351
+ source: source("default-sync"),
352
+ description: "Should embed by default",
353
+ intent: "Default mode test",
354
+ signatureJson,
355
+ agentId: "agent-1",
356
+ });
357
+ expect(embeddingCount(result.script.id)).toBe(1);
358
+ expect(provider.calls).toHaveLength(1);
359
+ });
360
+
271
361
  test("reembedAllScripts updates every explicit script", async () => {
272
362
  await upsertFixture({ name: "linear-one", description: "Linear issue parser" });
273
363
  await upsertFixture({ name: "slack-one", description: "Slack message digest" });