@desplega.ai/agent-swarm 1.83.0 → 1.83.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/openapi.json +177 -10
- package/package.json +6 -6
- package/src/artifact-sdk/server.ts +23 -1
- package/src/be/budget-admission.ts +28 -4
- package/src/be/budget-refusal-notify.ts +19 -3
- package/src/be/db-queries/oauth.ts +43 -0
- package/src/be/db.ts +37 -4
- package/src/be/migrations/074_user_budget_scope.sql +85 -0
- package/src/be/schedules/validate.ts +21 -0
- package/src/be/skill-sync.ts +65 -15
- package/src/commands/resume-session.ts +118 -0
- package/src/commands/runner.ts +178 -121
- package/src/http/core.ts +4 -1
- package/src/http/index.ts +16 -0
- package/src/http/integrations.ts +26 -0
- package/src/http/mcp-user.ts +111 -0
- package/src/http/poll.ts +19 -5
- package/src/http/schedules.ts +35 -10
- package/src/http/skills.ts +27 -2
- package/src/http/users.ts +107 -2
- package/src/jira/client.ts +3 -5
- package/src/jira/oauth.ts +1 -0
- package/src/jira/sync.ts +2 -2
- package/src/oauth/ensure-token.ts +1 -0
- package/src/oauth/wrapper.ts +38 -7
- package/src/providers/claude-adapter.ts +7 -2
- package/src/providers/claude-managed-adapter.ts +1 -1
- package/src/providers/codex-adapter.ts +30 -0
- package/src/providers/opencode-adapter.ts +149 -14
- package/src/providers/pi-mono-adapter.ts +41 -1
- package/src/providers/types.ts +1 -1
- package/src/server-user.ts +117 -0
- package/src/tests/artifact-sdk.test.ts +23 -19
- package/src/tests/budget-user-scope.test.ts +376 -0
- package/src/tests/claude-managed-adapter.test.ts +6 -0
- package/src/tests/codex-adapter.test.ts +192 -0
- package/src/tests/codex-rate-limit-parse.test.ts +256 -0
- package/src/tests/db-queries-oauth.test.ts +43 -0
- package/src/tests/ensure-token.test.ts +93 -0
- package/src/tests/error-tracker.test.ts +52 -0
- package/src/tests/fetch-resolved-env.test.ts +33 -20
- package/src/tests/http-api-integration.test.ts +36 -0
- package/src/tests/http-users.test.ts +29 -1
- package/src/tests/mcp-user-route.test.ts +325 -0
- package/src/tests/opencode-adapter.test.ts +75 -0
- package/src/tests/pi-mono-adapter.test.ts +21 -1
- package/src/tests/rate-limit-event.test.ts +69 -6
- package/src/tests/resume-session.test.ts +93 -0
- package/src/tests/runner-skills-refresh.test.ts +200 -0
- package/src/tests/schedule-validation-helper.test.ts +51 -0
- package/src/tests/skill-sync.test.ts +73 -9
- package/src/tests/skills-signature.test.ts +141 -0
- package/src/tests/task-tools-ctx.test.ts +100 -0
- package/src/tests/task-tools-ownership.test.ts +167 -0
- package/src/tests/update-schedule-mcp-tool.test.ts +161 -0
- package/src/tests/user-token-routes.test.ts +221 -0
- package/src/tools/cancel-task.ts +137 -83
- package/src/tools/get-task-details.ts +73 -59
- package/src/tools/get-tasks.ts +134 -126
- package/src/tools/schedules/update-schedule.ts +48 -8
- package/src/tools/send-task.ts +312 -312
- package/src/tools/slack-upload-file.ts +17 -5
- package/src/tools/task-action.ts +464 -367
- package/src/tools/task-tool-ctx.ts +43 -0
- package/src/types.ts +6 -2
- package/src/utils/error-tracker.ts +122 -9
- package/src/utils/skills-refresh.ts +123 -0
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { resolveResumeSession } from "../commands/resume-session";
|
|
3
|
+
|
|
4
|
+
describe("resolveResumeSession", () => {
|
|
5
|
+
test("allows local Claude resume for UUID session ids", () => {
|
|
6
|
+
const resolution = resolveResumeSession("claude", [
|
|
7
|
+
{
|
|
8
|
+
source: "task",
|
|
9
|
+
sessionId: "69dbe5a1-1130-45eb-983f-58a7a13c9c3c",
|
|
10
|
+
provider: "claude",
|
|
11
|
+
},
|
|
12
|
+
]);
|
|
13
|
+
|
|
14
|
+
expect(resolution.resumeSessionId).toBe("69dbe5a1-1130-45eb-983f-58a7a13c9c3c");
|
|
15
|
+
expect(resolution.source).toBe("task");
|
|
16
|
+
expect(resolution.provider).toBe("claude");
|
|
17
|
+
expect(resolution.skipped).toEqual([]);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test("rejects non-UUID ids for local Claude resume", () => {
|
|
21
|
+
const resolution = resolveResumeSession("claude", [
|
|
22
|
+
{
|
|
23
|
+
source: "task",
|
|
24
|
+
sessionId: "ses_19c145de3ffeD9qLlntj8SRO28",
|
|
25
|
+
provider: "claude",
|
|
26
|
+
},
|
|
27
|
+
]);
|
|
28
|
+
|
|
29
|
+
expect(resolution.resumeSessionId).toBeUndefined();
|
|
30
|
+
expect(resolution.skipped).toHaveLength(1);
|
|
31
|
+
expect(resolution.skipped[0]?.reason).toBe("Claude CLI --resume requires a UUID session id");
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("normalizes legacy managed Claude rows to claude-managed", () => {
|
|
35
|
+
const resolution = resolveResumeSession("claude-managed", [
|
|
36
|
+
{
|
|
37
|
+
source: "parent",
|
|
38
|
+
sessionId: "sesn_resume_xyz",
|
|
39
|
+
provider: "claude",
|
|
40
|
+
providerMeta: { managed: true },
|
|
41
|
+
},
|
|
42
|
+
]);
|
|
43
|
+
|
|
44
|
+
expect(resolution.resumeSessionId).toBe("sesn_resume_xyz");
|
|
45
|
+
expect(resolution.source).toBe("parent");
|
|
46
|
+
expect(resolution.provider).toBe("claude-managed");
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("skips mismatched provider sessions and falls back to parent", () => {
|
|
50
|
+
const resolution = resolveResumeSession("claude", [
|
|
51
|
+
{
|
|
52
|
+
source: "task",
|
|
53
|
+
sessionId: "thread-codex",
|
|
54
|
+
provider: "codex",
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
source: "parent",
|
|
58
|
+
sessionId: "69dbe5a1-1130-45eb-983f-58a7a13c9c3c",
|
|
59
|
+
provider: "claude",
|
|
60
|
+
},
|
|
61
|
+
]);
|
|
62
|
+
|
|
63
|
+
expect(resolution.resumeSessionId).toBe("69dbe5a1-1130-45eb-983f-58a7a13c9c3c");
|
|
64
|
+
expect(resolution.source).toBe("parent");
|
|
65
|
+
expect(resolution.skipped).toHaveLength(1);
|
|
66
|
+
expect(resolution.skipped[0]?.reason).toContain("does not match current provider");
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("rejects legacy unknown non-UUID Claude session ids", () => {
|
|
70
|
+
const resolution = resolveResumeSession("claude", [
|
|
71
|
+
{
|
|
72
|
+
source: "task",
|
|
73
|
+
sessionId: "ses_19c145de3ffeD9qLlntj8SRO28",
|
|
74
|
+
},
|
|
75
|
+
]);
|
|
76
|
+
|
|
77
|
+
expect(resolution.resumeSessionId).toBeUndefined();
|
|
78
|
+
expect(resolution.skipped[0]?.reason).toBe("legacy Claude resume requires a UUID session id");
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("does not resume providers without runner resume support", () => {
|
|
82
|
+
const resolution = resolveResumeSession("pi", [
|
|
83
|
+
{
|
|
84
|
+
source: "task",
|
|
85
|
+
sessionId: "pi-session",
|
|
86
|
+
provider: "pi",
|
|
87
|
+
},
|
|
88
|
+
]);
|
|
89
|
+
|
|
90
|
+
expect(resolution.resumeSessionId).toBeUndefined();
|
|
91
|
+
expect(resolution.skipped[0]?.reason).toBe("provider pi does not support runner resume");
|
|
92
|
+
});
|
|
93
|
+
});
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Coverage for the worker-side `refreshSkillsIfChanged()` helper. The helper
|
|
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.
|
|
7
|
+
*/
|
|
8
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
9
|
+
import { refreshSkillsIfChanged, type SkillsRefreshContext } from "../utils/skills-refresh";
|
|
10
|
+
|
|
11
|
+
// ── Bun.serve() stub backing fake signature/list/sync endpoints ──────────────
|
|
12
|
+
|
|
13
|
+
type StubState = {
|
|
14
|
+
signatureHash: string;
|
|
15
|
+
signatureStatus: number;
|
|
16
|
+
syncStatus: number;
|
|
17
|
+
skillsBody: {
|
|
18
|
+
skills: { name: string; description: string; isActive: boolean; isEnabled: boolean }[];
|
|
19
|
+
signature: string;
|
|
20
|
+
};
|
|
21
|
+
calls: { signature: number; list: number; sync: number };
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const state: StubState = {
|
|
25
|
+
signatureHash: "hash-v1",
|
|
26
|
+
signatureStatus: 200,
|
|
27
|
+
syncStatus: 200,
|
|
28
|
+
skillsBody: {
|
|
29
|
+
skills: [
|
|
30
|
+
{ name: "alpha", description: "first skill", isActive: true, isEnabled: true },
|
|
31
|
+
{ name: "beta", description: "second skill", isActive: true, isEnabled: true },
|
|
32
|
+
],
|
|
33
|
+
signature: "hash-v1",
|
|
34
|
+
},
|
|
35
|
+
calls: { signature: 0, list: 0, sync: 0 },
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
let server: ReturnType<typeof Bun.serve> | null = null;
|
|
39
|
+
let baseUrl = "";
|
|
40
|
+
|
|
41
|
+
describe("refreshSkillsIfChanged", () => {
|
|
42
|
+
beforeAll(() => {
|
|
43
|
+
server = Bun.serve({
|
|
44
|
+
port: 0,
|
|
45
|
+
fetch(req) {
|
|
46
|
+
const url = new URL(req.url);
|
|
47
|
+
if (url.pathname.endsWith("/skills/signature")) {
|
|
48
|
+
state.calls.signature++;
|
|
49
|
+
if (state.signatureStatus !== 200) {
|
|
50
|
+
return new Response("err", { status: state.signatureStatus });
|
|
51
|
+
}
|
|
52
|
+
return Response.json({
|
|
53
|
+
hash: state.signatureHash,
|
|
54
|
+
count: state.skillsBody.skills.length,
|
|
55
|
+
generatedAt: new Date().toISOString(),
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
if (url.pathname.match(/\/api\/agents\/[^/]+\/skills$/)) {
|
|
59
|
+
state.calls.list++;
|
|
60
|
+
return Response.json({
|
|
61
|
+
skills: state.skillsBody.skills,
|
|
62
|
+
total: state.skillsBody.skills.length,
|
|
63
|
+
signature: state.skillsBody.signature,
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
if (url.pathname === "/api/skills/sync-filesystem") {
|
|
67
|
+
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: [] });
|
|
72
|
+
}
|
|
73
|
+
return new Response("not found", { status: 404 });
|
|
74
|
+
},
|
|
75
|
+
});
|
|
76
|
+
baseUrl = `http://localhost:${server.port}`;
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
afterAll(() => {
|
|
80
|
+
server?.stop(true);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
function makeCtx(): SkillsRefreshContext {
|
|
84
|
+
return {
|
|
85
|
+
apiUrl: baseUrl,
|
|
86
|
+
swarmUrl: baseUrl,
|
|
87
|
+
apiKey: "test-key",
|
|
88
|
+
agentId: "agent-1",
|
|
89
|
+
role: "worker",
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
test("first call populates summary and updates the cached hash", async () => {
|
|
94
|
+
state.calls = { signature: 0, list: 0, sync: 0 };
|
|
95
|
+
state.signatureHash = "hash-v1";
|
|
96
|
+
state.skillsBody.signature = "hash-v1";
|
|
97
|
+
|
|
98
|
+
const lastHash = { current: null as string | null };
|
|
99
|
+
const result = await refreshSkillsIfChanged(makeCtx(), lastHash);
|
|
100
|
+
|
|
101
|
+
expect(result.changed).toBe(true);
|
|
102
|
+
expect(result.summary).toEqual([
|
|
103
|
+
{ name: "alpha", description: "first skill" },
|
|
104
|
+
{ name: "beta", description: "second skill" },
|
|
105
|
+
]);
|
|
106
|
+
expect(lastHash.current).toBe("hash-v1");
|
|
107
|
+
expect(state.calls).toEqual({ signature: 1, list: 1, sync: 1 });
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("subsequent call with unchanged hash skips list + sync", async () => {
|
|
111
|
+
state.calls = { signature: 0, list: 0, sync: 0 };
|
|
112
|
+
const lastHash = { current: "hash-v1" };
|
|
113
|
+
|
|
114
|
+
const result = await refreshSkillsIfChanged(makeCtx(), lastHash);
|
|
115
|
+
|
|
116
|
+
expect(result.changed).toBe(false);
|
|
117
|
+
expect(result.summary).toBeUndefined();
|
|
118
|
+
expect(lastHash.current).toBe("hash-v1");
|
|
119
|
+
expect(state.calls).toEqual({ signature: 1, list: 0, sync: 0 });
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("hash drift refetches list and updates cached hash to the list's snapshot", async () => {
|
|
123
|
+
state.calls = { signature: 0, list: 0, sync: 0 };
|
|
124
|
+
state.signatureHash = "hash-v2";
|
|
125
|
+
state.skillsBody.signature = "hash-v2";
|
|
126
|
+
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 },
|
|
130
|
+
];
|
|
131
|
+
|
|
132
|
+
const lastHash = { current: "hash-v1" };
|
|
133
|
+
const result = await refreshSkillsIfChanged(makeCtx(), lastHash);
|
|
134
|
+
|
|
135
|
+
expect(result.changed).toBe(true);
|
|
136
|
+
expect(result.summary).toHaveLength(3);
|
|
137
|
+
expect(lastHash.current).toBe("hash-v2");
|
|
138
|
+
expect(state.calls).toEqual({ signature: 1, list: 1, sync: 1 });
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test("filters out inactive or disabled skills from the summary", async () => {
|
|
142
|
+
state.calls = { signature: 0, list: 0, sync: 0 };
|
|
143
|
+
state.signatureHash = "hash-v3";
|
|
144
|
+
state.skillsBody.signature = "hash-v3";
|
|
145
|
+
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 },
|
|
149
|
+
];
|
|
150
|
+
|
|
151
|
+
const lastHash = { current: "hash-v2" };
|
|
152
|
+
const result = await refreshSkillsIfChanged(makeCtx(), lastHash);
|
|
153
|
+
|
|
154
|
+
expect(result.changed).toBe(true);
|
|
155
|
+
expect(result.summary).toEqual([{ name: "active", description: "kept" }]);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test("transient 5xx on signature endpoint returns changed:false without touching list/sync", async () => {
|
|
159
|
+
state.calls = { signature: 0, list: 0, sync: 0 };
|
|
160
|
+
state.signatureStatus = 503;
|
|
161
|
+
|
|
162
|
+
const lastHash = { current: "hash-v3" };
|
|
163
|
+
const result = await refreshSkillsIfChanged(makeCtx(), lastHash);
|
|
164
|
+
|
|
165
|
+
expect(result.changed).toBe(false);
|
|
166
|
+
expect(lastHash.current).toBe("hash-v3");
|
|
167
|
+
expect(state.calls).toEqual({ signature: 1, list: 0, sync: 0 });
|
|
168
|
+
|
|
169
|
+
state.signatureStatus = 200; // restore for any later tests
|
|
170
|
+
});
|
|
171
|
+
|
|
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.
|
|
174
|
+
state.signatureHash = "hash-v4";
|
|
175
|
+
state.skillsBody.signature = "hash-v4";
|
|
176
|
+
state.skillsBody.skills = [
|
|
177
|
+
{ name: "alpha", description: "first", isActive: true, isEnabled: true },
|
|
178
|
+
];
|
|
179
|
+
state.syncStatus = 503;
|
|
180
|
+
state.calls = { signature: 0, list: 0, sync: 0 };
|
|
181
|
+
|
|
182
|
+
const lastHash = { current: "hash-prev" };
|
|
183
|
+
const first = await refreshSkillsIfChanged(makeCtx(), lastHash);
|
|
184
|
+
|
|
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.
|
|
188
|
+
expect(first.changed).toBe(true);
|
|
189
|
+
expect(first.summary).toEqual([{ name: "alpha", description: "first" }]);
|
|
190
|
+
expect(lastHash.current).toBe("hash-prev");
|
|
191
|
+
expect(state.calls).toEqual({ signature: 1, list: 1, sync: 1 });
|
|
192
|
+
|
|
193
|
+
// Sync recovers — next poll retries because cached hash still differs.
|
|
194
|
+
state.syncStatus = 200;
|
|
195
|
+
const second = await refreshSkillsIfChanged(makeCtx(), lastHash);
|
|
196
|
+
expect(second.changed).toBe(true);
|
|
197
|
+
expect(lastHash.current).toBe("hash-v4");
|
|
198
|
+
expect(state.calls).toEqual({ signature: 2, list: 2, sync: 2 });
|
|
199
|
+
});
|
|
200
|
+
});
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { mergeScheduleTiming, validateRecurringTiming } from "../be/schedules/validate";
|
|
3
|
+
|
|
4
|
+
describe("mergeScheduleTiming", () => {
|
|
5
|
+
test("explicit null overrides existing", () => {
|
|
6
|
+
const result = mergeScheduleTiming(
|
|
7
|
+
{ cronExpression: "0 * * * *", intervalMs: null },
|
|
8
|
+
{ cronExpression: null },
|
|
9
|
+
);
|
|
10
|
+
expect(result).toEqual({ mergedCron: null, mergedInterval: null });
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
test("undefined preserves existing", () => {
|
|
14
|
+
const result = mergeScheduleTiming({ cronExpression: "0 * * * *", intervalMs: null }, {});
|
|
15
|
+
expect(result).toEqual({ mergedCron: "0 * * * *", mergedInterval: null });
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test("explicit value overrides existing", () => {
|
|
19
|
+
const result = mergeScheduleTiming(
|
|
20
|
+
{ cronExpression: "0 * * * *", intervalMs: null },
|
|
21
|
+
{ cronExpression: "0 2 * * *" },
|
|
22
|
+
);
|
|
23
|
+
expect(result).toEqual({ mergedCron: "0 2 * * *", mergedInterval: null });
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
describe("validateRecurringTiming", () => {
|
|
28
|
+
test("cron-only existing + { cronExpression: null } patch → both-null error", () => {
|
|
29
|
+
const merged = mergeScheduleTiming(
|
|
30
|
+
{ cronExpression: "0 * * * *", intervalMs: null },
|
|
31
|
+
{ cronExpression: null },
|
|
32
|
+
);
|
|
33
|
+
expect(validateRecurringTiming(merged)).toEqual({ kind: "both-null" });
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("cron-only existing + { cronExpression: null, intervalMs: 60000 } → valid", () => {
|
|
37
|
+
const merged = mergeScheduleTiming(
|
|
38
|
+
{ cronExpression: "0 * * * *", intervalMs: null },
|
|
39
|
+
{ cronExpression: null, intervalMs: 60000 },
|
|
40
|
+
);
|
|
41
|
+
expect(validateRecurringTiming(merged)).toBeNull();
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("both existing populated, patch nulls both → both-null error", () => {
|
|
45
|
+
const merged = mergeScheduleTiming(
|
|
46
|
+
{ cronExpression: "0 * * * *", intervalMs: 60000 },
|
|
47
|
+
{ cronExpression: null, intervalMs: null },
|
|
48
|
+
);
|
|
49
|
+
expect(validateRecurringTiming(merged)).toEqual({ kind: "both-null" });
|
|
50
|
+
});
|
|
51
|
+
});
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
2
|
-
import { existsSync, mkdirSync, readFileSync, rmSync } from "node:fs";
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { unlink } from "node:fs/promises";
|
|
4
4
|
import { tmpdir } from "node:os";
|
|
5
5
|
import { join } from "node:path";
|
|
6
6
|
import { closeDb, createAgent, createSkill, initDb, installSkill } from "../be/db";
|
|
7
7
|
import { syncSkillsToFilesystem } from "../be/skill-sync";
|
|
8
8
|
|
|
9
|
+
const SWARM_MARKER = ".swarm-managed";
|
|
10
|
+
|
|
9
11
|
const TEST_DB_PATH = `./test-skill-sync-${process.pid}.sqlite`;
|
|
10
12
|
const FAKE_HOME = join(tmpdir(), `skill-sync-test-${process.pid}`);
|
|
11
13
|
|
|
@@ -80,20 +82,40 @@ describe("syncSkillsToFilesystem", () => {
|
|
|
80
82
|
expect(readFileSync(skillFile, "utf-8")).toContain("Test body.");
|
|
81
83
|
});
|
|
82
84
|
|
|
83
|
-
test("syncs
|
|
85
|
+
test("syncs simple skills to codex directory", () => {
|
|
86
|
+
const result = syncSkillsToFilesystem(agentId, "codex", FAKE_HOME);
|
|
87
|
+
|
|
88
|
+
expect(result.errors).toHaveLength(0);
|
|
89
|
+
expect(result.synced).toBeGreaterThanOrEqual(1);
|
|
90
|
+
|
|
91
|
+
const skillFile = join(FAKE_HOME, ".codex", "skills", "test-skill", "SKILL.md");
|
|
92
|
+
expect(existsSync(skillFile)).toBe(true);
|
|
93
|
+
expect(readFileSync(skillFile, "utf-8")).toContain("Test body.");
|
|
94
|
+
|
|
95
|
+
// Verify claude and pi paths were NOT written when targeting codex only
|
|
96
|
+
const claudeOnlyFile = join(FAKE_HOME, ".claude", "skills", "codex-only-marker", "SKILL.md");
|
|
97
|
+
const piOnlyFile = join(FAKE_HOME, ".pi", "agent", "skills", "codex-only-marker", "SKILL.md");
|
|
98
|
+
expect(existsSync(claudeOnlyFile)).toBe(false);
|
|
99
|
+
expect(existsSync(piOnlyFile)).toBe(false);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("syncs to claude, pi, and codex when harnessType is 'all'", () => {
|
|
84
103
|
// Clean up first to get accurate count
|
|
85
104
|
rmSync(join(FAKE_HOME, ".claude"), { recursive: true, force: true });
|
|
86
105
|
rmSync(join(FAKE_HOME, ".pi"), { recursive: true, force: true });
|
|
106
|
+
rmSync(join(FAKE_HOME, ".codex"), { recursive: true, force: true });
|
|
87
107
|
|
|
88
|
-
const result = syncSkillsToFilesystem(agentId, "
|
|
108
|
+
const result = syncSkillsToFilesystem(agentId, "all", FAKE_HOME);
|
|
89
109
|
|
|
90
110
|
expect(result.errors).toHaveLength(0);
|
|
91
|
-
expect(result.synced).toBe(
|
|
111
|
+
expect(result.synced).toBe(3); // 1 skill × 3 dirs
|
|
92
112
|
|
|
93
113
|
const claudeFile = join(FAKE_HOME, ".claude", "skills", "test-skill", "SKILL.md");
|
|
94
114
|
const piFile = join(FAKE_HOME, ".pi", "agent", "skills", "test-skill", "SKILL.md");
|
|
115
|
+
const codexFile = join(FAKE_HOME, ".codex", "skills", "test-skill", "SKILL.md");
|
|
95
116
|
expect(existsSync(claudeFile)).toBe(true);
|
|
96
117
|
expect(existsSync(piFile)).toBe(true);
|
|
118
|
+
expect(existsSync(codexFile)).toBe(true);
|
|
97
119
|
});
|
|
98
120
|
|
|
99
121
|
test("skips complex skills", () => {
|
|
@@ -103,9 +125,11 @@ describe("syncSkillsToFilesystem", () => {
|
|
|
103
125
|
expect(existsSync(complexDir)).toBe(false);
|
|
104
126
|
});
|
|
105
127
|
|
|
106
|
-
test("removes stale skill directories", () => {
|
|
128
|
+
test("removes stale swarm-managed skill directories", () => {
|
|
129
|
+
// Mark this stale dir as swarm-managed (mirrors what an earlier sync would have done)
|
|
107
130
|
const staleDir = join(FAKE_HOME, ".claude", "skills", "old-removed-skill");
|
|
108
131
|
mkdirSync(staleDir, { recursive: true });
|
|
132
|
+
writeFileSync(join(staleDir, SWARM_MARKER), "");
|
|
109
133
|
expect(existsSync(staleDir)).toBe(true);
|
|
110
134
|
|
|
111
135
|
const result = syncSkillsToFilesystem(agentId, "claude", FAKE_HOME);
|
|
@@ -114,21 +138,61 @@ describe("syncSkillsToFilesystem", () => {
|
|
|
114
138
|
expect(existsSync(staleDir)).toBe(false);
|
|
115
139
|
});
|
|
116
140
|
|
|
117
|
-
test("
|
|
141
|
+
test("removes stale swarm-managed codex skill directories", () => {
|
|
142
|
+
const staleCodexDir = join(FAKE_HOME, ".codex", "skills", "old-codex-skill");
|
|
143
|
+
mkdirSync(staleCodexDir, { recursive: true });
|
|
144
|
+
writeFileSync(join(staleCodexDir, SWARM_MARKER), "");
|
|
145
|
+
expect(existsSync(staleCodexDir)).toBe(true);
|
|
146
|
+
|
|
147
|
+
const result = syncSkillsToFilesystem(agentId, "codex", FAKE_HOME);
|
|
148
|
+
|
|
149
|
+
expect(result.removed).toBeGreaterThanOrEqual(1);
|
|
150
|
+
expect(existsSync(staleCodexDir)).toBe(false);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test("leaves foreign (unmarked) skill directories alone — local-dev safety", () => {
|
|
154
|
+
// Simulate a user-installed codex skill in their personal ~/.codex/skills
|
|
155
|
+
// that the swarm did NOT create. The cleanup pass MUST NOT remove it.
|
|
156
|
+
const foreignDir = join(FAKE_HOME, ".codex", "skills", "user-personal-skill");
|
|
157
|
+
mkdirSync(foreignDir, { recursive: true });
|
|
158
|
+
writeFileSync(join(foreignDir, "SKILL.md"), "user's own skill — keep me");
|
|
159
|
+
// No SWARM_MARKER file → not ours to manage.
|
|
160
|
+
expect(existsSync(foreignDir)).toBe(true);
|
|
161
|
+
|
|
162
|
+
syncSkillsToFilesystem(agentId, "codex", FAKE_HOME);
|
|
163
|
+
|
|
164
|
+
expect(existsSync(foreignDir)).toBe(true);
|
|
165
|
+
expect(readFileSync(join(foreignDir, "SKILL.md"), "utf-8")).toBe("user's own skill — keep me");
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test("written skill directories carry the swarm-managed marker", () => {
|
|
169
|
+
// Clean up first
|
|
170
|
+
rmSync(join(FAKE_HOME, ".claude"), { recursive: true, force: true });
|
|
171
|
+
|
|
172
|
+
syncSkillsToFilesystem(agentId, "claude", FAKE_HOME);
|
|
173
|
+
|
|
174
|
+
const marker = join(FAKE_HOME, ".claude", "skills", "test-skill", SWARM_MARKER);
|
|
175
|
+
expect(existsSync(marker)).toBe(true);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test("defaults to 'all' when no harnessType provided", () => {
|
|
118
179
|
// Clean up first
|
|
119
180
|
rmSync(join(FAKE_HOME, ".claude"), { recursive: true, force: true });
|
|
120
181
|
rmSync(join(FAKE_HOME, ".pi"), { recursive: true, force: true });
|
|
182
|
+
rmSync(join(FAKE_HOME, ".codex"), { recursive: true, force: true });
|
|
121
183
|
|
|
122
|
-
// Use '
|
|
123
|
-
const result = syncSkillsToFilesystem(agentId, "
|
|
184
|
+
// Use 'all' explicitly with homeOverride (default harnessType would use real home)
|
|
185
|
+
const result = syncSkillsToFilesystem(agentId, "all", FAKE_HOME);
|
|
124
186
|
|
|
125
187
|
expect(result.errors).toHaveLength(0);
|
|
126
|
-
expect(result.synced).toBe(
|
|
188
|
+
expect(result.synced).toBe(3);
|
|
127
189
|
|
|
128
190
|
const claudeFile = join(FAKE_HOME, ".claude", "skills", "test-skill", "SKILL.md");
|
|
129
191
|
const piFile = join(FAKE_HOME, ".pi", "agent", "skills", "test-skill", "SKILL.md");
|
|
192
|
+
const codexFile = join(FAKE_HOME, ".codex", "skills", "test-skill", "SKILL.md");
|
|
130
193
|
expect(existsSync(claudeFile)).toBe(true);
|
|
131
194
|
expect(existsSync(piFile)).toBe(true);
|
|
195
|
+
expect(existsSync(codexFile)).toBe(true);
|
|
132
196
|
});
|
|
133
197
|
|
|
134
198
|
test("returns empty result for agent with no skills", () => {
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
2
|
+
import { unlink } from "node:fs/promises";
|
|
3
|
+
import {
|
|
4
|
+
closeDb,
|
|
5
|
+
createAgent,
|
|
6
|
+
createSkill,
|
|
7
|
+
initDb,
|
|
8
|
+
installSkill,
|
|
9
|
+
toggleAgentSkill,
|
|
10
|
+
uninstallSkill,
|
|
11
|
+
updateSkill,
|
|
12
|
+
} from "../be/db";
|
|
13
|
+
import { computeAgentSkillsSignature } from "../be/skill-sync";
|
|
14
|
+
|
|
15
|
+
const TEST_DB_PATH = `./test-skills-signature-${process.pid}.sqlite`;
|
|
16
|
+
|
|
17
|
+
describe("computeAgentSkillsSignature", () => {
|
|
18
|
+
let agentId: string;
|
|
19
|
+
let otherAgentId: string;
|
|
20
|
+
let skill1Id: string;
|
|
21
|
+
let skill2Id: string;
|
|
22
|
+
|
|
23
|
+
beforeAll(() => {
|
|
24
|
+
initDb(TEST_DB_PATH);
|
|
25
|
+
|
|
26
|
+
const agent = createAgent({
|
|
27
|
+
name: "Signature Test Worker",
|
|
28
|
+
description: "Test agent",
|
|
29
|
+
role: "worker",
|
|
30
|
+
isLead: false,
|
|
31
|
+
status: "idle",
|
|
32
|
+
maxTasks: 1,
|
|
33
|
+
capabilities: [],
|
|
34
|
+
});
|
|
35
|
+
agentId = agent.id;
|
|
36
|
+
|
|
37
|
+
const otherAgent = createAgent({
|
|
38
|
+
name: "Signature Test Other",
|
|
39
|
+
description: "Independent agent",
|
|
40
|
+
role: "worker",
|
|
41
|
+
isLead: false,
|
|
42
|
+
status: "idle",
|
|
43
|
+
maxTasks: 1,
|
|
44
|
+
capabilities: [],
|
|
45
|
+
});
|
|
46
|
+
otherAgentId = otherAgent.id;
|
|
47
|
+
|
|
48
|
+
const skill1 = createSkill({
|
|
49
|
+
name: "sig-skill-1",
|
|
50
|
+
description: "First skill",
|
|
51
|
+
content: "---\nname: sig-skill-1\ndescription: First skill\n---\nBody 1.",
|
|
52
|
+
type: "personal",
|
|
53
|
+
scope: "agent",
|
|
54
|
+
});
|
|
55
|
+
skill1Id = skill1.id;
|
|
56
|
+
|
|
57
|
+
const skill2 = createSkill({
|
|
58
|
+
name: "sig-skill-2",
|
|
59
|
+
description: "Second skill",
|
|
60
|
+
content: "---\nname: sig-skill-2\ndescription: Second skill\n---\nBody 2.",
|
|
61
|
+
type: "personal",
|
|
62
|
+
scope: "agent",
|
|
63
|
+
});
|
|
64
|
+
skill2Id = skill2.id;
|
|
65
|
+
|
|
66
|
+
installSkill(agentId, skill1Id);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
afterAll(async () => {
|
|
70
|
+
closeDb();
|
|
71
|
+
await unlink(TEST_DB_PATH).catch(() => {});
|
|
72
|
+
await unlink(`${TEST_DB_PATH}-wal`).catch(() => {});
|
|
73
|
+
await unlink(`${TEST_DB_PATH}-shm`).catch(() => {});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("returns identical hash across no-op calls (deterministic)", () => {
|
|
77
|
+
const sig1 = computeAgentSkillsSignature(agentId);
|
|
78
|
+
const sig2 = computeAgentSkillsSignature(agentId);
|
|
79
|
+
expect(sig1.hash).toBe(sig2.hash);
|
|
80
|
+
expect(sig1.count).toBe(sig2.count);
|
|
81
|
+
expect(sig1.hash).toHaveLength(64); // sha256 hex
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("hash changes when a new skill is installed", () => {
|
|
85
|
+
const before = computeAgentSkillsSignature(agentId);
|
|
86
|
+
installSkill(agentId, skill2Id);
|
|
87
|
+
const after = computeAgentSkillsSignature(agentId);
|
|
88
|
+
expect(after.hash).not.toBe(before.hash);
|
|
89
|
+
expect(after.count).toBe(before.count + 1);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("hash changes when a skill is uninstalled", () => {
|
|
93
|
+
const before = computeAgentSkillsSignature(agentId);
|
|
94
|
+
uninstallSkill(agentId, skill2Id);
|
|
95
|
+
const after = computeAgentSkillsSignature(agentId);
|
|
96
|
+
expect(after.hash).not.toBe(before.hash);
|
|
97
|
+
expect(after.count).toBe(before.count - 1);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("hash changes when a skill is toggled inactive", () => {
|
|
101
|
+
installSkill(agentId, skill2Id);
|
|
102
|
+
const before = computeAgentSkillsSignature(agentId);
|
|
103
|
+
toggleAgentSkill(agentId, skill2Id, false);
|
|
104
|
+
const after = computeAgentSkillsSignature(agentId);
|
|
105
|
+
expect(after.hash).not.toBe(before.hash);
|
|
106
|
+
// Toggling inactive removes it from the active+enabled view used by getAgentSkills
|
|
107
|
+
expect(after.count).toBe(before.count - 1);
|
|
108
|
+
|
|
109
|
+
// Re-activate so subsequent tests have a known state
|
|
110
|
+
toggleAgentSkill(agentId, skill2Id, true);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("hash changes when updateSkill mutates a skill (via lastUpdatedAt bump)", async () => {
|
|
114
|
+
const before = computeAgentSkillsSignature(agentId);
|
|
115
|
+
// updateSkill always bumps lastUpdatedAt — even an isEnabled no-op flip back is enough.
|
|
116
|
+
// Wait 5ms to guarantee a different ISO timestamp.
|
|
117
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
118
|
+
updateSkill(skill1Id, { description: "First skill (updated)" });
|
|
119
|
+
const after = computeAgentSkillsSignature(agentId);
|
|
120
|
+
expect(after.hash).not.toBe(before.hash);
|
|
121
|
+
expect(after.count).toBe(before.count);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test("agent A's signature is independent of mutations to agent B's skills", () => {
|
|
125
|
+
installSkill(otherAgentId, skill1Id);
|
|
126
|
+
const aBefore = computeAgentSkillsSignature(agentId);
|
|
127
|
+
const bBefore = computeAgentSkillsSignature(otherAgentId);
|
|
128
|
+
|
|
129
|
+
// Mutate agent B: install another skill, toggle, uninstall
|
|
130
|
+
installSkill(otherAgentId, skill2Id);
|
|
131
|
+
toggleAgentSkill(otherAgentId, skill1Id, false);
|
|
132
|
+
uninstallSkill(otherAgentId, skill2Id);
|
|
133
|
+
|
|
134
|
+
const aAfter = computeAgentSkillsSignature(agentId);
|
|
135
|
+
const bAfter = computeAgentSkillsSignature(otherAgentId);
|
|
136
|
+
|
|
137
|
+
expect(aAfter.hash).toBe(aBefore.hash);
|
|
138
|
+
expect(aAfter.count).toBe(aBefore.count);
|
|
139
|
+
expect(bAfter.hash).not.toBe(bBefore.hash);
|
|
140
|
+
});
|
|
141
|
+
});
|