@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.
- package/README.md +2 -2
- package/openapi.json +242 -3
- package/package.json +5 -5
- package/src/be/db.ts +152 -11
- package/src/be/memory/boot-reembed.ts +0 -1
- package/src/be/memory/providers/sqlite-store.ts +42 -25
- package/src/be/memory/raters/llm-client.ts +12 -5
- package/src/be/memory/types.ts +3 -0
- package/src/be/migrations/088_script_runs_list_indexes.sql +10 -0
- package/src/be/migrations/089_harness_variant.sql +2 -0
- package/src/be/migrations/090_model_tiers.sql +2 -0
- package/src/be/migrations/091_seed_swarm_operations_metrics.sql +12 -0
- package/src/be/migrations/092_metrics_dashboard_combobox_filters.sql +68 -0
- package/src/be/migrations/093_slack_message_tracking.sql +6 -0
- package/src/be/migrations/runner.ts +52 -0
- package/src/be/modelsdev-cache.json +3264 -1166
- package/src/be/scripts/boot-reembed.ts +74 -0
- package/src/be/scripts/db.ts +19 -3
- package/src/be/seed/index.ts +1 -1
- package/src/be/seed/registry.ts +2 -2
- package/src/be/seed/runner.ts +5 -5
- package/src/be/seed/types.ts +6 -1
- package/src/be/seed-pricing.ts +2 -0
- package/src/be/seed-scripts/catalog/boot-triage.inline.ts +221 -0
- package/src/be/seed-scripts/catalog/catalog-report.inline.ts +457 -0
- package/src/be/seed-scripts/catalog/compound-insights.inline.ts +863 -0
- package/src/be/seed-scripts/catalog/ops-catalog-audit.inline.ts +506 -0
- package/src/be/seed-scripts/index.ts +8 -7
- package/src/be/skill-sync.ts +28 -179
- package/src/commands/runner.ts +197 -10
- package/src/http/api-keys.ts +42 -0
- package/src/http/index.ts +13 -2
- package/src/http/mcp-bridge.ts +1 -1
- package/src/http/memory.ts +23 -24
- package/src/http/metrics.ts +55 -6
- package/src/http/schedules.ts +16 -15
- package/src/http/script-runs.ts +7 -1
- package/src/http/scripts.ts +147 -1
- package/src/http/tasks.ts +17 -6
- package/src/model-tiers.ts +140 -0
- package/src/providers/claude-adapter.ts +33 -1
- package/src/providers/claude-managed-adapter.ts +3 -0
- package/src/providers/claude-managed-models.ts +16 -0
- package/src/providers/codex-adapter.ts +8 -1
- package/src/providers/codex-models.ts +1 -0
- package/src/providers/codex-oauth/auth-json.ts +1 -0
- package/src/providers/harness-version.ts +7 -0
- package/src/providers/opencode-adapter.ts +12 -4
- package/src/providers/pi-mono-adapter.ts +90 -8
- package/src/providers/types.ts +2 -0
- package/src/scheduler/scheduler.ts +22 -34
- package/src/scripts-runtime/egress-secrets.ts +83 -0
- package/src/scripts-runtime/eval-harness.ts +4 -0
- package/src/scripts-runtime/executors/types.ts +7 -0
- package/src/scripts-runtime/loader.ts +2 -0
- package/src/server-user.ts +8 -2
- package/src/slack/channel-join.ts +41 -0
- package/src/slack/responses.ts +39 -11
- package/src/slack/watcher.ts +121 -8
- package/src/tests/additive-buffer.test.ts +0 -1
- package/src/tests/agents-list-model-display.test.ts +13 -0
- package/src/tests/api-key-tracking.test.ts +113 -0
- package/src/tests/approval-requests.test.ts +0 -6
- package/src/tests/aws-error-classifier.test.ts +148 -0
- package/src/tests/claude-managed-adapter.test.ts +12 -0
- package/src/tests/claude-managed-setup.test.ts +0 -4
- package/src/tests/codex-pool.test.ts +2 -6
- package/src/tests/context-window.test.ts +7 -0
- package/src/tests/http-api-integration.test.ts +23 -6
- package/src/tests/memory-edges.test.ts +0 -2
- package/src/tests/memory-rate-endpoint.test.ts +0 -2
- package/src/tests/memory-rater-e2e.test.ts +0 -2
- package/src/tests/memory-store.test.ts +19 -1
- package/src/tests/memory.test.ts +51 -0
- package/src/tests/metrics-http.test.ts +137 -3
- package/src/tests/migration-046-budgets.test.ts +33 -0
- package/src/tests/migration-runner-regressions.test.ts +69 -0
- package/src/tests/model-control.test.ts +162 -46
- package/src/tests/opencode-adapter.test.ts +9 -0
- package/src/tests/pi-mono-adapter.test.ts +319 -0
- package/src/tests/providers/pi-cost.test.ts +9 -0
- package/src/tests/reload-config.test.ts +33 -17
- package/src/tests/runner-fallback-output.test.ts +50 -0
- package/src/tests/runner-skills-refresh.test.ts +216 -46
- package/src/tests/script-runs-http.test.ts +7 -1
- package/src/tests/scripts-boot-reembed.test.ts +163 -0
- package/src/tests/scripts-embeddings.test.ts +90 -0
- package/src/tests/scripts-runtime-secret-egress.test.ts +129 -0
- package/src/tests/seed-scripts.test.ts +13 -1
- package/src/tests/seed.test.ts +26 -1
- package/src/tests/session-attach.test.ts +6 -6
- package/src/tests/session-costs-model-key-normalize.test.ts +2 -0
- package/src/tests/skill-fs-writer.test.ts +250 -0
- package/src/tests/slack-attachments-block.test.ts +0 -1
- package/src/tests/slack-blocks.test.ts +0 -1
- package/src/tests/slack-channel-join.test.ts +80 -0
- package/src/tests/slack-identity-resolution.test.ts +0 -1
- package/src/tests/slack-watcher.test.ts +66 -0
- package/src/tests/structured-output.test.ts +0 -2
- package/src/tests/use-dismissible-card.test.ts +0 -4
- package/src/tests/workflow-agent-task.test.ts +5 -2
- package/src/tests/workflow-validation-port-routing.test.ts +181 -0
- package/src/tools/memory-get.ts +11 -0
- package/src/tools/memory-search.ts +18 -0
- package/src/tools/schedules/create-schedule.ts +71 -70
- package/src/tools/schedules/update-schedule.ts +43 -31
- package/src/tools/send-task.ts +16 -5
- package/src/tools/slack-post.ts +18 -15
- package/src/tools/slack-read.ts +9 -11
- package/src/tools/slack-reply.ts +18 -15
- package/src/tools/slack-start-thread.ts +17 -14
- package/src/tools/task-action.ts +11 -3
- package/src/types.ts +40 -0
- package/src/utils/aws-error-classifier.ts +97 -0
- package/src/utils/context-window.ts +5 -0
- package/src/utils/credentials.test.ts +68 -0
- package/src/utils/credentials.ts +66 -5
- package/src/utils/pretty-print.ts +25 -10
- package/src/utils/skill-fs-writer.ts +220 -0
- package/src/utils/skills-refresh.ts +123 -40
- package/src/workflows/engine.ts +3 -2
- 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
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
|
|
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
|
-
|
|
30
|
+
listStatus: number;
|
|
17
31
|
skillsBody: {
|
|
18
|
-
skills:
|
|
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
|
-
|
|
44
|
+
listStatus: 200,
|
|
45
|
+
skillFilesStatus: 200,
|
|
28
46
|
skillsBody: {
|
|
29
47
|
skills: [
|
|
30
|
-
{
|
|
31
|
-
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 +
|
|
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
|
|
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
|
|
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
|
-
{
|
|
128
|
-
|
|
129
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
{
|
|
147
|
-
|
|
148
|
-
|
|
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
|
|
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/
|
|
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
|
|
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
|
|
287
|
+
state.signatureStatus = 200; // restore
|
|
170
288
|
});
|
|
171
289
|
|
|
172
|
-
test("
|
|
173
|
-
//
|
|
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
|
-
{
|
|
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
|
|
317
|
+
const first = await refreshWithHome(makeCtx(), lastHash, readOnlyHome);
|
|
184
318
|
|
|
185
|
-
// Summary still returns (the list call succeeded), but
|
|
186
|
-
//
|
|
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).
|
|
324
|
+
expect(state.calls.sync).toBe(0); // still never calls sync-filesystem
|
|
192
325
|
|
|
193
|
-
//
|
|
194
|
-
|
|
195
|
-
|
|
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).
|
|
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 {
|
|
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" });
|