@desplega.ai/agent-swarm 2.7.2 → 2.7.3
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 +6 -0
- package/dist/{acp-adapter-624yge7a.js → acp-adapter-pdzw76ja.js} +1 -1
- package/dist/{actions-rd9xn281.js → actions-a90cfpca.js} +21 -21
- package/dist/{amp-adapter-k27hc00p.js → amp-adapter-4vtywcjk.js} +2 -2
- package/dist/{app-sjm3xc7g.js → app-8k68gygk.js} +3 -3
- package/dist/{assistant-6jbzs8wa.js → assistant-ck01q2yg.js} +32 -21
- package/dist/{boot-reembed-e2348eqc.js → boot-reembed-33spavpd.js} +1 -1
- package/dist/{boot-reembed-w53pd5xt.js → boot-reembed-xzge83a2.js} +1 -1
- package/dist/{boot-scrub-sweep-cpy4yg97.js → boot-scrub-sweep-mxvb8dr3.js} +3 -3
- package/dist/{boot-seeds-f6j4t6k6.js → boot-seeds-rpm8bb5r.js} +4 -4
- package/dist/{claude-adapter-v3ac8esd.js → claude-adapter-q12bcfj9.js} +3 -3
- package/dist/{claude-managed-setup-yyrkqvtm.js → claude-managed-setup-11vb6q89.js} +1 -1
- package/dist/{claude-sdk-session-85j69v6d.js → claude-sdk-session-gkv1107j.js} +10325 -10243
- package/dist/{cli-rs59z1qx.js → cli-07pcqwaj.js} +1 -1
- package/dist/{cli-6vq3ymqm.js → cli-0tkm519n.js} +1 -1
- package/dist/{cli-s7cm1927.js → cli-0xzbk52h.js} +4 -4
- package/dist/{cli-g0zc1b40.js → cli-0zae6bs6.js} +2 -2
- package/dist/{cli-p8yrsmfn.js → cli-10kjx0dm.js} +1 -1
- package/dist/{cli-7xsq63pe.js → cli-157ykn37.js} +10 -9
- package/dist/{cli-vjcj9k2p.js → cli-2h0rfmqk.js} +1 -1
- package/dist/{cli-9wasax9f.js → cli-35nzvqaf.js} +1 -1
- package/dist/{cli-e1p7dqe1.js → cli-4aydmmdy.js} +24 -3
- package/dist/{cli-942pp3bc.js → cli-54ax65rn.js} +3 -3
- package/dist/{cli-10cp92df.js → cli-5ke7gxwr.js} +6 -6
- package/dist/{cli-femw4q81.js → cli-6151a7dr.js} +2 -2
- package/dist/{cli-3cywb44q.js → cli-77eq8txs.js} +85 -3
- package/dist/{cli-atpfk4yd.js → cli-7cwrwb5x.js} +5 -5
- package/dist/{cli-bfbpvccx.js → cli-8a1k6mat.js} +61 -49
- package/dist/{cli-c18941tx.js → cli-8ch3b2r7.js} +3 -3
- package/dist/{cli-rs2s1t05.js → cli-9fk56y8c.js} +4 -4
- package/dist/{cli-0wx63vvw.js → cli-9km88w11.js} +1 -1
- package/dist/{cli-qy0z3mfj.js → cli-9vttw9nz.js} +56 -46
- package/dist/{cli-mzgppb7q.js → cli-a0w50zw9.js} +4 -4
- package/dist/{cli-z8a4mfz1.js → cli-aqp59jmv.js} +4 -4
- package/dist/{cli-5d8wd50e.js → cli-bs0fwb23.js} +1 -1
- package/dist/{cli-fa4kxxx3.js → cli-ct5920jb.js} +2 -2
- package/dist/{cli-0awbtmv2.js → cli-dek8hqjc.js} +10 -10
- package/dist/{cli-qp6adcmw.js → cli-e24w2mvk.js} +1 -1
- package/dist/{cli-t3npd8hm.js → cli-ghfhn1qc.js} +1 -1
- package/dist/{cli-jmcf8cnz.js → cli-ja369zab.js} +1 -1
- package/dist/{cli-wtjg6je3.js → cli-jhh7tbx6.js} +1 -1
- package/dist/{cli-e19bgatr.js → cli-jmk31131.js} +2 -2
- package/dist/{cli-2cj8fqv1.js → cli-kjcdzea8.js} +1 -1
- package/dist/{cli-1xpr5992.js → cli-kjha1q9k.js} +3 -3
- package/dist/{cli-9jfty8px.js → cli-ktcdczth.js} +538 -30
- package/dist/{cli-exd52qn4.js → cli-mcmpbpcc.js} +2 -2
- package/dist/{cli-2m2p3snc.js → cli-mmf43vcp.js} +6 -6
- package/dist/{cli-b2rhbaty.js → cli-pnm99spe.js} +10 -10
- package/dist/{cli-9jn9cj0q.js → cli-v9jk2k63.js} +1 -1
- package/dist/{cli-mt7bxjfd.js → cli-vhgcwppn.js} +1 -1
- package/dist/{cli-qa4hgda3.js → cli-vvz64zc5.js} +14 -13
- package/dist/{cli-txr0z97c.js → cli-wmhajw68.js} +171 -32
- package/dist/{cli-hzrmv8pg.js → cli-x38rmhap.js} +3 -3
- package/dist/{cli-hq9hd4hp.js → cli-xz6qjdqj.js} +1 -1
- package/dist/{cli-g9ypd5en.js → cli-z52gq55b.js} +4 -0
- package/dist/{cli-2qbmjfta.js → cli-z6219sd3.js} +4 -4
- package/dist/cli.js +19 -19
- package/dist/{codex-adapter-zm1bkdde.js → codex-adapter-46kbvvkx.js} +1 -1
- package/dist/{codex-hook-tkag7fxq.js → codex-hook-tm9ecrd5.js} +1 -1
- package/dist/{codex-session-runner-8k02mqw2.js → codex-session-runner-qy93hdrv.js} +1 -1
- package/dist/{commands-nw1dn4av.js → commands-hwdhacfx.js} +2 -2
- package/dist/{controller-3kkkmps0.js → controller-hjza5p1f.js} +3 -3
- package/dist/{controllers-connect-n2c4167r.js → controllers-connect-7m25cay2.js} +20 -20
- package/dist/{cursor-adapter-h5028emw.js → cursor-adapter-1qh7ps8w.js} +1 -1
- package/dist/{db-76y2msnf.js → db-w23k9301.js} +2 -2
- package/dist/{db-8rk0p8j1.js → db-wm8by383.js} +3 -3
- package/dist/{dispatcher-xp4wsx30.js → dispatcher-en25ebgc.js} +18 -18
- package/dist/{dsh-adapter-vyrr31bn.js → dsh-adapter-zkme32vd.js} +1 -1
- package/dist/{e2b-eesfj689.js → e2b-9z9mvt2p.js} +1 -1
- package/dist/{e2b-03nha1wz.js → e2b-yhvn70es.js} +1 -1
- package/dist/{embedding-space-ejefyep1.js → embedding-space-rgf4aypt.js} +6 -6
- package/dist/{embeddings-w297xaw0.js → embeddings-z9cmxhx1.js} +1 -1
- package/dist/{external-channel-mf9vp1fe.js → external-channel-g6vv85zv.js} +2 -2
- package/dist/{grok-adapter-mpzyyprz.js → grok-adapter-f6n1tpq3.js} +2 -2
- package/dist/handlers-ss5948rq.js +42 -0
- package/dist/{heartbeat-qnaycjzs.js → heartbeat-5xcdzz45.js} +21 -21
- package/dist/{hook-h6sqfzxr.js → hook-6423rme3.js} +2 -2
- package/dist/{hook-4zqdgea4.js → hook-8dk1csdd.js} +4 -4
- package/dist/{http-65ts210h.js → http-6mfh2ww9.js} +255 -60
- package/dist/{index-sqe7f6mj.js → index-0js6bgff.js} +1 -1
- package/dist/{index-farm1zsn.js → index-0ng1dd1t.js} +10937 -9527
- package/dist/{index-k3ewf1jg.js → index-5eyn1wc5.js} +18 -18
- package/dist/{index-vp4wa3qn.js → index-71gnebsn.js} +18 -18
- package/dist/{index-hdfad00b.js → index-q5gzpjdd.js} +12 -10
- package/dist/index-z3b1vfjx.js +44 -0
- package/dist/{keepalive-52k6skha.js → keepalive-cymckeg6.js} +3 -3
- package/dist/{lead-y9rj0gr1.js → lead-baknj2dh.js} +29 -29
- package/dist/{maintenance-f86wqfq6.js → maintenance-dfrnsx3n.js} +1 -1
- package/dist/{oauth-refresh-sweep-vddv9ky7.js → oauth-refresh-sweep-5dztk8x6.js} +2 -2
- package/dist/{onboard-8z2qk6gq.js → onboard-h36vja6y.js} +11 -6
- package/dist/{otel-impl-epet9j18.js → otel-impl-jerq8vz8.js} +1 -1
- package/dist/{pricing-refresh-6wm42a51.js → pricing-refresh-t3nx5krn.js} +3 -3
- package/dist/{rbac-roles-2kfcs1sc.js → rbac-roles-dw1awawd.js} +2 -2
- package/dist/{rbac-roles-13a0vj6d.js → rbac-roles-km0fc3e8.js} +1 -1
- package/dist/{registry-ajhexe44.js → registry-hzfh268v.js} +1 -1
- package/dist/{render-v2-cy8zsfsf.js → render-v2-65x4ayj8.js} +3 -3
- package/dist/{resume-c783qe6b.js → resume-61znk3hj.js} +6 -6
- package/dist/{session-access-s4tv0az8.js → session-access-4yc9htrg.js} +4 -4
- package/dist/{session-source-backfill-qzapz0ne.js → session-source-backfill-kr5bdjtp.js} +2 -2
- package/dist/{setup-st7htx1k.js → setup-9h61yr2p.js} +1 -1
- package/dist/{work-objects-ftj4zw70.js → work-objects-x7rb53qn.js} +27 -22
- package/dist/{worker-859fk25k.js → worker-7f3khgn0.js} +29 -29
- package/openapi.json +134 -3
- package/package.json +5 -5
- package/src/be/db/agents.ts +16 -1
- package/src/be/db/runtime.ts +25 -15
- package/src/be/db/session-summaries.ts +120 -0
- package/src/be/db/tasks/read.ts +7 -4
- package/src/be/db/tasks/search-index.ts +159 -0
- package/src/be/db/tasks/write.ts +3 -0
- package/src/be/db-queries/slack-inbound.ts +77 -0
- package/src/be/db.ts +27 -8
- package/src/be/mcp-overflow-sweep.ts +39 -0
- package/src/be/migrations/253_session_summaries.sql +300 -0
- package/src/be/migrations/254_query_audit_indexes.sql +25 -0
- package/src/be/migrations/256_agent_slack_icon.sql +2 -0
- package/src/be/migrations/257_agent_tasks_fts.sql +55 -0
- package/src/comb/config.ts +6 -2
- package/src/comb/requester.ts +69 -0
- package/src/comb/review-batch.ts +24 -2
- package/src/commands/onboard/agent-fs-version.ts +1 -1
- package/src/commands/onboard/compose-generator.ts +7 -2
- package/src/commands/onboard/env-generator.ts +8 -3
- package/src/commands/runner.ts +41 -24
- package/src/fs/agent-fs-provider.ts +9 -0
- package/src/heartbeat/heartbeat.ts +13 -6
- package/src/http/agents.ts +10 -2
- package/src/http/index.ts +15 -0
- package/src/http/integrations.ts +20 -0
- package/src/http/list-total-cache.ts +62 -0
- package/src/http/sessions.ts +9 -2
- package/src/http/slack-inbound.ts +1 -0
- package/src/http/status.ts +4 -1
- package/src/http/tasks.ts +12 -2
- package/src/providers/amp-adapter.ts +1 -1
- package/src/providers/cursor-adapter.ts +1 -1
- package/src/providers/dsh-adapter.ts +1 -1
- package/src/queue-stall-alarm.ts +4 -1
- package/src/slack/actions.ts +2 -2
- package/src/slack/app-icon.ts +50 -0
- package/src/slack/app.ts +13 -1
- package/src/slack/assistant.ts +22 -1
- package/src/slack/event-dedup.test.ts +11 -0
- package/src/slack/event-dedup.ts +8 -4
- package/src/slack/inbound-dispatch.ts +58 -0
- package/src/slack/render-v2.ts +7 -8
- package/src/slack/responses.ts +21 -8
- package/src/slack/socket-ack.ts +237 -0
- package/src/slack/thread-buffer.ts +3 -5
- package/src/slack/watcher.ts +2 -2
- package/src/slack/work-objects.ts +5 -1
- package/src/tests/agent-fs-release-notes.test.ts +40 -0
- package/src/tests/agent-slack-icon.test.ts +85 -0
- package/src/tests/approval-cancellation-migration.test.ts +2 -0
- package/src/tests/asset-key-migration.test.ts +26 -0
- package/src/tests/comb-default.test.ts +159 -0
- package/src/tests/comb-review-batch.test.ts +192 -4
- package/src/tests/comb-status.test.ts +65 -2
- package/src/tests/db-agents.test.ts +13 -0
- package/src/tests/db-tasks-read.test.ts +10 -15
- package/src/tests/deploy-one-box.test.ts +161 -0
- package/src/tests/deploy-stubs.ts +68 -0
- package/src/tests/docs-index-footprint.test.js +3 -3
- package/src/tests/dsh-adapter.test.ts +1 -1
- package/src/tests/e2e-codex-auth-chain.test.ts +18 -1
- package/src/tests/fixtures/deploy-stubs/bun +3 -0
- package/src/tests/fixtures/deploy-stubs/bunx +27 -0
- package/src/tests/fixtures/deploy-stubs/curl +26 -0
- package/src/tests/fixtures/deploy-stubs/docker +122 -0
- package/src/tests/fixtures/onboard/content.compose.yml.txt +5 -5
- package/src/tests/fixtures/onboard/dev.compose.yml.txt +4 -4
- package/src/tests/fixtures/onboard/full-all.compose.yml.txt +12 -12
- package/src/tests/fixtures/onboard/full.compose.yml.txt +12 -12
- package/src/tests/fixtures/onboard/research.compose.yml.txt +4 -4
- package/src/tests/fixtures/onboard/solo.compose.yml.txt +2 -2
- package/src/tests/heartbeat-expiry.test.ts +151 -25
- package/src/tests/http-api-integration.test.ts +31 -0
- package/src/tests/human-free-reclassify.test.ts +6 -0
- package/src/tests/install-sh-bind.test.ts +242 -0
- package/src/tests/install-sh-tunnel.test.ts +89 -0
- package/src/tests/kv-storage.test.ts +28 -0
- package/src/tests/list-endpoint-slimming.test.ts +3 -0
- package/src/tests/list-total-cache.test.ts +63 -0
- package/src/tests/mcp-overflow-sweep.test.ts +84 -0
- package/src/tests/onboard-compose.test.ts +17 -3
- package/src/tests/onboard-env.test.ts +13 -0
- package/src/tests/onboarding.test.ts +1 -1
- package/src/tests/preload.ts +1 -0
- package/src/tests/query-audit-plans.test.ts +314 -0
- package/src/tests/script-connections.test.ts +6 -0
- package/src/tests/session-summaries.test.ts +384 -0
- package/src/tests/sessions-tree.test.ts +5 -1
- package/src/tests/slack-app-icon.test.ts +90 -0
- package/src/tests/slack-external-channel-egress.test.ts +1 -1
- package/src/tests/slack-inbound-receipts.test.ts +34 -0
- package/src/tests/slack-inline-output.test.ts +29 -2
- package/src/tests/slack-render-v2-query-plans.test.ts +3 -2
- package/src/tests/slack-render-v2.test.ts +35 -0
- package/src/tests/slack-socket-ack.test.ts +553 -0
- package/src/tests/task-lineage.test.ts +3 -0
- package/src/tests/task-search-filter.test.ts +4 -4
- package/src/tests/task-search-fts.test.ts +320 -0
- package/src/tests/update-profile-auth.test.ts +28 -0
- package/src/tools/get-swarm.ts +1 -0
- package/src/tools/get-tasks.ts +6 -1
- package/src/tools/join-swarm.ts +1 -0
- package/src/tools/my-agent-info.ts +1 -0
- package/src/tools/slack-post.ts +2 -2
- package/src/tools/slack-reply.ts +2 -2
- package/src/tools/slack-start-thread.ts +2 -2
- package/src/tools/slack-update.ts +1 -1
- package/src/tools/update-profile.ts +20 -4
- package/src/tools/utils.ts +3 -4
- package/src/types.ts +47 -0
- package/src/utils/constants.ts +127 -5
- package/templates/official/lead/HEARTBEAT.md +4 -2
- package/templates/official/lead/config.json +2 -2
- package/templates/skills/swarm-docs/content.md +2 -0
- package/dist/handlers-7x8sywgr.js +0 -42
- package/dist/index-mc30r1hs.js +0 -44
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
defaultSlackEmoji,
|
|
4
|
+
parseSlackIconInput,
|
|
5
|
+
slackEmojiGlyph,
|
|
6
|
+
} from "../../apps/ui/src/lib/slack-icon";
|
|
7
|
+
import { getAgentEmoji } from "../slack/responses";
|
|
8
|
+
import { type Agent, AgentSlackIconSchema } from "../types";
|
|
9
|
+
|
|
10
|
+
function agent(overrides: Partial<Agent> = {}): Agent {
|
|
11
|
+
return { id: "a1", name: "Owl Worker", isLead: false, status: "idle", ...overrides } as Agent;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// Each raw input, the icon the server receives for it, and whether both the
|
|
15
|
+
// zod schema and the dashboard parser accept it.
|
|
16
|
+
const PARITY_CASES: Array<[string, boolean]> = [
|
|
17
|
+
[":owl:", true],
|
|
18
|
+
[":+1:", true],
|
|
19
|
+
[":bee-lgtm:", true],
|
|
20
|
+
[":thumbs_up:", true],
|
|
21
|
+
["https://example.com/owl.png", true],
|
|
22
|
+
["https://example.com/owl.png?size=64", true],
|
|
23
|
+
["http://example.com/owl.png", false],
|
|
24
|
+
["https://", false],
|
|
25
|
+
["https://exa mple.com/owl.png", false],
|
|
26
|
+
["https://[::1", false],
|
|
27
|
+
["https://user:pass@example.com/owl.png", false],
|
|
28
|
+
["https://user@example.com/owl.png", false],
|
|
29
|
+
[`https://example.com/${"a".repeat(2048)}`, false],
|
|
30
|
+
["javascript:alert(1)", false],
|
|
31
|
+
[":owl face:", false],
|
|
32
|
+
[":wave::skin-tone-3:", false],
|
|
33
|
+
[`:${"a".repeat(101)}:`, false],
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
function asIcon(raw: string) {
|
|
37
|
+
return /^https?:/i.test(raw) ? { type: "url", url: raw } : { type: "emoji", emoji: raw };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
describe("Slack icon validation parity", () => {
|
|
41
|
+
for (const [raw, accepted] of PARITY_CASES) {
|
|
42
|
+
test(`${raw.slice(0, 48)} is ${accepted ? "accepted" : "rejected"} by both`, () => {
|
|
43
|
+
expect(AgentSlackIconSchema.safeParse(asIcon(raw)).success).toBe(accepted);
|
|
44
|
+
const parsed = parseSlackIconInput(raw);
|
|
45
|
+
expect(parsed.ok).toBe(accepted);
|
|
46
|
+
if (parsed.ok) expect(parsed.icon).toEqual(asIcon(raw) as never);
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
test("the schema rejects shortcodes the dashboard would normalise", () => {
|
|
51
|
+
expect(AgentSlackIconSchema.safeParse({ type: "emoji", emoji: "owl" }).success).toBe(false);
|
|
52
|
+
expect(AgentSlackIconSchema.safeParse({ type: "emoji", emoji: ":Owl:" }).success).toBe(false);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("the dashboard normalises bare and capitalised shortcodes, and blank resets", () => {
|
|
56
|
+
expect(parseSlackIconInput(" owl ")).toEqual({
|
|
57
|
+
ok: true,
|
|
58
|
+
icon: { type: "emoji", emoji: ":owl:" },
|
|
59
|
+
});
|
|
60
|
+
expect(parseSlackIconInput(":Owl:")).toEqual({
|
|
61
|
+
ok: true,
|
|
62
|
+
icon: { type: "emoji", emoji: ":owl:" },
|
|
63
|
+
});
|
|
64
|
+
expect(parseSlackIconInput(" ")).toEqual({ ok: true, icon: null });
|
|
65
|
+
expect(parseSlackIconInput("http://example.com/owl.png")).toEqual({
|
|
66
|
+
ok: false,
|
|
67
|
+
error: "Use an https URL.",
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
describe("default emoji parity", () => {
|
|
73
|
+
test("the dashboard picks the same default as getAgentEmoji", () => {
|
|
74
|
+
const agents = [
|
|
75
|
+
agent({ isLead: true, name: "Neo" }),
|
|
76
|
+
...["Owl Worker", "Coder", "Reviewer", "Mobile Coder", "e2e-worker-a", "Ω", ""].map((name) =>
|
|
77
|
+
agent({ name }),
|
|
78
|
+
),
|
|
79
|
+
];
|
|
80
|
+
for (const a of agents) {
|
|
81
|
+
expect(defaultSlackEmoji(a)).toBe(getAgentEmoji(a));
|
|
82
|
+
expect(slackEmojiGlyph(getAgentEmoji(a))).not.toBeNull();
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
});
|
|
@@ -24,6 +24,8 @@ describe("migration 140 approval cancellation lifecycle", () => {
|
|
|
24
24
|
runMigrations(db);
|
|
25
25
|
db.run("DELETE FROM approval_requests");
|
|
26
26
|
db.run("DROP TRIGGER cancel_approval_request_after_workflow_step_cancel");
|
|
27
|
+
// Reads approval_requests from another table, so the rebuild's rename would fail.
|
|
28
|
+
db.run("DROP TRIGGER session_summary_recompute_apply");
|
|
27
29
|
db.run("DROP TABLE approval_requests");
|
|
28
30
|
db.run(`CREATE TABLE approval_requests (
|
|
29
31
|
id TEXT PRIMARY KEY,
|
|
@@ -20,7 +20,23 @@ async function removeDb(path: string): Promise<void> {
|
|
|
20
20
|
}
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
+
/** Migration 253's triggers read agent_tasks."key", so a pre-115 fixture predates them too. */
|
|
24
|
+
function dropMigration253ForHistoricalFixture(): void {
|
|
25
|
+
const db = getDb();
|
|
26
|
+
const triggers = db
|
|
27
|
+
.prepare<{ name: string }, []>(
|
|
28
|
+
"SELECT name FROM sqlite_master WHERE type = 'trigger' AND name LIKE 'session\\_%' ESCAPE '\\'",
|
|
29
|
+
)
|
|
30
|
+
.all();
|
|
31
|
+
for (const trigger of triggers) db.run(`DROP TRIGGER "${trigger.name}"`);
|
|
32
|
+
db.run("DROP VIEW session_summary_recompute");
|
|
33
|
+
db.run("DROP TABLE session_summaries");
|
|
34
|
+
db.run("DROP TABLE session_members");
|
|
35
|
+
db.run("DELETE FROM _migrations WHERE version = 253");
|
|
36
|
+
}
|
|
37
|
+
|
|
23
38
|
function dropMigration115ForHistoricalFixture(): void {
|
|
39
|
+
dropMigration253ForHistoricalFixture();
|
|
24
40
|
const db = getDb();
|
|
25
41
|
const triggers = db
|
|
26
42
|
.prepare<{ name: string }, []>(
|
|
@@ -482,6 +498,16 @@ describe("asset namespace key migrations", () => {
|
|
|
482
498
|
expect(getDb().query('SELECT "key" FROM agent_tasks WHERE id = ?').get(taskId)).toEqual({
|
|
483
499
|
key: "shared/",
|
|
484
500
|
});
|
|
501
|
+
// The rebuild keeps the session summary triggers, including the ones on other tables.
|
|
502
|
+
expect(
|
|
503
|
+
getDb()
|
|
504
|
+
.query(
|
|
505
|
+
`SELECT
|
|
506
|
+
(SELECT COUNT(*) FROM sqlite_master WHERE type = 'trigger' AND name = 'session_task_insert') AS onTasks,
|
|
507
|
+
(SELECT COUNT(*) FROM sqlite_master WHERE type = 'trigger' AND name = 'session_summary_recompute_apply') AS recompute`,
|
|
508
|
+
)
|
|
509
|
+
.get(),
|
|
510
|
+
).toEqual({ onTasks: 1, recompute: 1 });
|
|
485
511
|
expect(
|
|
486
512
|
getDb()
|
|
487
513
|
.prepare<
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { getCombConfig } from "../comb/config";
|
|
3
|
+
import { getCombState, isBrowserReachableUrl, isCombEnabled } from "../utils/constants";
|
|
4
|
+
|
|
5
|
+
const ENV_KEYS = [
|
|
6
|
+
"COMB_ENABLED",
|
|
7
|
+
"AGENT_FS_API_URL",
|
|
8
|
+
"AGENT_FS_PUBLIC_URL",
|
|
9
|
+
"AGENT_FS_PROXY",
|
|
10
|
+
"MCP_BASE_URL",
|
|
11
|
+
"PUBLIC_MCP_BASE_URL",
|
|
12
|
+
];
|
|
13
|
+
const saved = new Map<string, string | undefined>();
|
|
14
|
+
|
|
15
|
+
beforeEach(() => {
|
|
16
|
+
for (const key of ENV_KEYS) {
|
|
17
|
+
saved.set(key, process.env[key]);
|
|
18
|
+
delete process.env[key];
|
|
19
|
+
}
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
afterEach(() => {
|
|
23
|
+
for (const key of ENV_KEYS) {
|
|
24
|
+
const value = saved.get(key);
|
|
25
|
+
if (value === undefined) delete process.env[key];
|
|
26
|
+
else process.env[key] = value;
|
|
27
|
+
}
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
describe("isBrowserReachableUrl", () => {
|
|
31
|
+
test("public hosts and addresses are reachable", () => {
|
|
32
|
+
for (const url of [
|
|
33
|
+
"https://fs.example.com",
|
|
34
|
+
"https://fs.example.com:7433/",
|
|
35
|
+
"http://8.8.8.8:7433",
|
|
36
|
+
"http://172.32.0.1",
|
|
37
|
+
"http://172.15.0.1",
|
|
38
|
+
"http://[2001:db8::1]:7433",
|
|
39
|
+
"http://[2606:4700::1111]:7433",
|
|
40
|
+
"http://[::ffff:8.8.8.8]:7433",
|
|
41
|
+
"http://[::ffff:7f00:1]:7433",
|
|
42
|
+
]) {
|
|
43
|
+
expect(isBrowserReachableUrl(url)).toBe(true);
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("loopback is reachable: a localhost URL is the local dev setup", () => {
|
|
48
|
+
for (const url of ["http://localhost:7433", "http://127.0.0.1:7433", "http://[::1]:7433"]) {
|
|
49
|
+
expect(isBrowserReachableUrl(url)).toBe(true);
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("single-label docker hosts are not", () => {
|
|
54
|
+
for (const url of ["http://agent-fs:7433", "http://agent-fs", "http://swarm-agent-fs:7433"]) {
|
|
55
|
+
expect(isBrowserReachableUrl(url)).toBe(false);
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("internal suffixes are not", () => {
|
|
60
|
+
for (const url of [
|
|
61
|
+
"http://agent-fs.railway.internal:7433",
|
|
62
|
+
"http://agent-fs.internal",
|
|
63
|
+
"http://agent-fs.default.svc:7433",
|
|
64
|
+
"http://agent-fs.default.svc.cluster.local:7433",
|
|
65
|
+
"http://agent-fs.internal.:7433",
|
|
66
|
+
]) {
|
|
67
|
+
expect(isBrowserReachableUrl(url)).toBe(false);
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("private, link-local and unique-local addresses are not", () => {
|
|
72
|
+
for (const url of [
|
|
73
|
+
"http://10.0.0.5:7433",
|
|
74
|
+
"http://172.16.0.1",
|
|
75
|
+
"http://172.31.255.255",
|
|
76
|
+
"http://192.168.1.10:7433",
|
|
77
|
+
"http://169.254.169.254",
|
|
78
|
+
"http://[fd00::1]:7433",
|
|
79
|
+
"http://[fe80::1]:7433",
|
|
80
|
+
"http://[::ffff:10.0.0.5]:7433",
|
|
81
|
+
"http://[::ffff:192.168.1.10]:7433",
|
|
82
|
+
"http://[::ffff:172.16.0.1]:7433",
|
|
83
|
+
"http://[::ffff:169.254.169.254]",
|
|
84
|
+
"http://[::]:7433",
|
|
85
|
+
"http://[ff02::1]:7433",
|
|
86
|
+
"http://[ff00::1]:7433",
|
|
87
|
+
]) {
|
|
88
|
+
expect(isBrowserReachableUrl(url)).toBe(false);
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("an unparseable value is not reachable", () => {
|
|
93
|
+
expect(isBrowserReachableUrl("agent-fs:7433")).toBe(false);
|
|
94
|
+
expect(isBrowserReachableUrl("")).toBe(false);
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
describe("getCombState", () => {
|
|
99
|
+
test("nothing set: off, agent-fs missing", () => {
|
|
100
|
+
expect(getCombState()).toEqual({
|
|
101
|
+
enabled: false,
|
|
102
|
+
explicit: false,
|
|
103
|
+
reason: "AGENT_FS_API_URL is not set.",
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("explicit true still needs AGENT_FS_API_URL, but not a browser-reachable one", () => {
|
|
108
|
+
process.env.COMB_ENABLED = "true";
|
|
109
|
+
expect(isCombEnabled()).toBe(false);
|
|
110
|
+
process.env.AGENT_FS_API_URL = "http://agent-fs:7433";
|
|
111
|
+
expect(getCombState()).toEqual({ enabled: true, explicit: true, reason: null });
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("explicit false wins over every reachable URL", () => {
|
|
115
|
+
process.env.COMB_ENABLED = "false";
|
|
116
|
+
process.env.AGENT_FS_API_URL = "https://fs.example.com";
|
|
117
|
+
process.env.AGENT_FS_PUBLIC_URL = "https://fs.example.com";
|
|
118
|
+
process.env.AGENT_FS_PROXY = "true";
|
|
119
|
+
expect(getCombState()).toMatchObject({ enabled: false, explicit: true });
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("unset: the reachable-URL conditions decide", () => {
|
|
123
|
+
process.env.AGENT_FS_API_URL = "http://agent-fs:7433";
|
|
124
|
+
expect(isCombEnabled()).toBe(false);
|
|
125
|
+
process.env.AGENT_FS_PROXY = "true";
|
|
126
|
+
expect(getCombState()).toMatchObject({ enabled: true, explicit: false });
|
|
127
|
+
delete process.env.AGENT_FS_PROXY;
|
|
128
|
+
process.env.AGENT_FS_PUBLIC_URL = " ";
|
|
129
|
+
expect(isCombEnabled()).toBe(false);
|
|
130
|
+
process.env.AGENT_FS_PUBLIC_URL = "https://fs.example.com";
|
|
131
|
+
expect(isCombEnabled()).toBe(true);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test("unset with the proxy on: the public API URL decides, not AGENT_FS_API_URL", () => {
|
|
135
|
+
process.env.AGENT_FS_API_URL = "http://agent-fs:7433";
|
|
136
|
+
process.env.AGENT_FS_PROXY = "true";
|
|
137
|
+
process.env.MCP_BASE_URL = "http://api:3013";
|
|
138
|
+
const state = getCombState();
|
|
139
|
+
expect(state).toMatchObject({ enabled: false, explicit: false });
|
|
140
|
+
expect(state.reason).toContain("PUBLIC_MCP_BASE_URL");
|
|
141
|
+
expect(getCombConfig()).toMatchObject({ enabled: false, apiUrl: "http://api:3013/agentfs" });
|
|
142
|
+
process.env.PUBLIC_MCP_BASE_URL = "https://swarm.example.com";
|
|
143
|
+
expect(getCombState()).toMatchObject({ enabled: true, explicit: false });
|
|
144
|
+
expect(getCombConfig().apiUrl).toBe("https://swarm.example.com/agentfs");
|
|
145
|
+
delete process.env.PUBLIC_MCP_BASE_URL;
|
|
146
|
+
process.env.MCP_BASE_URL = "http://[::ffff:10.0.0.5]:3013";
|
|
147
|
+
expect(isCombEnabled()).toBe(false);
|
|
148
|
+
process.env.COMB_ENABLED = "true";
|
|
149
|
+
expect(isCombEnabled()).toBe(true);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test("an unrecognized COMB_ENABLED counts as unset", () => {
|
|
153
|
+
process.env.COMB_ENABLED = "treu";
|
|
154
|
+
process.env.AGENT_FS_API_URL = "http://localhost:7433";
|
|
155
|
+
expect(getCombState()).toMatchObject({ enabled: true, explicit: false });
|
|
156
|
+
process.env.AGENT_FS_API_URL = "http://agent-fs:7433";
|
|
157
|
+
expect(getCombState()).toMatchObject({ enabled: false, explicit: false });
|
|
158
|
+
});
|
|
159
|
+
});
|
|
@@ -29,7 +29,8 @@ import {
|
|
|
29
29
|
upsertPromptTemplate,
|
|
30
30
|
} from "../be/db";
|
|
31
31
|
import { installExtension } from "../be/extensions/db";
|
|
32
|
-
import { mintToken } from "../be/users";
|
|
32
|
+
import { findUserByExternalId, linkIdentity, mintToken } from "../be/users";
|
|
33
|
+
import { AGENT_FS_IDENTITY_KIND } from "../comb/requester";
|
|
33
34
|
import {
|
|
34
35
|
REVIEW_BATCH_MAX,
|
|
35
36
|
REVIEW_BATCH_READ_CONCURRENCY,
|
|
@@ -39,7 +40,11 @@ import {
|
|
|
39
40
|
sendReviewBatch,
|
|
40
41
|
} from "../comb/review-batch";
|
|
41
42
|
import { enableExtension, stopExtensionRuntime } from "../extensions/lifecycle";
|
|
42
|
-
import type {
|
|
43
|
+
import type {
|
|
44
|
+
AgentFsComment,
|
|
45
|
+
AgentFsDriveMember,
|
|
46
|
+
AgentFsFileVersion,
|
|
47
|
+
} from "../fs/agent-fs-provider";
|
|
43
48
|
import { resetFileStorageProvider } from "../fs/registry";
|
|
44
49
|
import { handleComb } from "../http/comb";
|
|
45
50
|
import { handleCore } from "../http/core";
|
|
@@ -67,20 +72,27 @@ const ENV_KEYS = [
|
|
|
67
72
|
"AGENT_FS_REQUEST_TIMEOUT_MS",
|
|
68
73
|
"APP_URL",
|
|
69
74
|
"RBAC_ENABLED",
|
|
75
|
+
"TRUST_BODY_REQUESTED_BY_USER_ID",
|
|
70
76
|
];
|
|
71
77
|
const savedEnv = new Map<string, string | undefined>();
|
|
72
78
|
|
|
73
79
|
/**
|
|
74
80
|
* An in-memory agent-fs behind a real HTTP server: `/auth/me`, `comment-get`,
|
|
75
|
-
* `log`, and `comment-add` replies. The swarm reaches it through the registry
|
|
81
|
+
* `log`, `drive-members`, and `comment-add` replies. The swarm reaches it through the registry
|
|
76
82
|
* provider (env), like production.
|
|
77
83
|
*/
|
|
78
84
|
class FakeAgentFs {
|
|
79
85
|
comments = new Map<string, AgentFsComment>();
|
|
80
86
|
/** `log` answers, by stored version path (exact match, like agent-fs). */
|
|
81
87
|
versions = new Map<string, AgentFsFileVersion[]>();
|
|
88
|
+
/** `drive-members` answer. */
|
|
89
|
+
members: AgentFsDriveMember[] = [];
|
|
82
90
|
failReplies = false;
|
|
83
91
|
failGets = false;
|
|
92
|
+
failMembers = false;
|
|
93
|
+
failAuthMe = false;
|
|
94
|
+
/** `drive-members` calls. */
|
|
95
|
+
memberReads = 0;
|
|
84
96
|
/** `comment-get` calls: total, in flight now, the most in flight at once, and a delay per call. */
|
|
85
97
|
gets = 0;
|
|
86
98
|
getsInFlight = 0;
|
|
@@ -111,7 +123,10 @@ class FakeAgentFs {
|
|
|
111
123
|
return Response.json({ error: "UNAUTHORIZED" }, { status: 401 });
|
|
112
124
|
}
|
|
113
125
|
const url = new URL(request.url);
|
|
114
|
-
if (url.pathname === "/auth/me")
|
|
126
|
+
if (url.pathname === "/auth/me") {
|
|
127
|
+
if (this.failAuthMe) return Response.json({ error: "DOWN" }, { status: 500 });
|
|
128
|
+
return Response.json({ userId: SERVICE_USER });
|
|
129
|
+
}
|
|
115
130
|
if (url.pathname !== `/orgs/${ORG}/ops` || request.method !== "POST") {
|
|
116
131
|
return new Response("not found", { status: 404 });
|
|
117
132
|
}
|
|
@@ -128,6 +143,11 @@ class FakeAgentFs {
|
|
|
128
143
|
if (!comment) return Response.json({ error: "NOT_FOUND" }, { status: 404 });
|
|
129
144
|
return Response.json({ comment, replies: this.repliesTo(comment.id) });
|
|
130
145
|
}
|
|
146
|
+
if (body.op === "drive-members") {
|
|
147
|
+
this.memberReads++;
|
|
148
|
+
if (this.failMembers) return Response.json({ error: "INTERNAL" }, { status: 500 });
|
|
149
|
+
return Response.json({ members: this.members });
|
|
150
|
+
}
|
|
131
151
|
if (body.op === "log") {
|
|
132
152
|
return Response.json({ versions: this.versions.get(body.path as string) ?? [] });
|
|
133
153
|
}
|
|
@@ -238,8 +258,12 @@ afterAll(async () => {
|
|
|
238
258
|
beforeEach(async () => {
|
|
239
259
|
fake.comments.clear();
|
|
240
260
|
fake.versions.clear();
|
|
261
|
+
fake.members = [];
|
|
262
|
+
fake.memberReads = 0;
|
|
241
263
|
fake.failReplies = false;
|
|
242
264
|
fake.failGets = false;
|
|
265
|
+
fake.failMembers = false;
|
|
266
|
+
fake.failAuthMe = false;
|
|
243
267
|
fake.gets = 0;
|
|
244
268
|
fake.maxGetsInFlight = 0;
|
|
245
269
|
fake.getDelayMs = 0;
|
|
@@ -650,6 +674,170 @@ describe("sendReviewBatch", () => {
|
|
|
650
674
|
});
|
|
651
675
|
});
|
|
652
676
|
|
|
677
|
+
describe("requester from the comment authors", () => {
|
|
678
|
+
/** A swarm user plus the agent-fs drive member with the same email. */
|
|
679
|
+
async function member(name: string): Promise<{ userId: string; author: string; email: string }> {
|
|
680
|
+
const email = `${name.toLowerCase()}-${crypto.randomUUID()}@example.test`;
|
|
681
|
+
const user = await createUser({ name, email });
|
|
682
|
+
const author = `af-${crypto.randomUUID()}`;
|
|
683
|
+
fake.members.push({ userId: author, email, displayName: name });
|
|
684
|
+
return { userId: user.id, author, email };
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
/** A swarm user linked to the swarm service account's agent-fs id, once per file. */
|
|
688
|
+
async function linkServiceAccount(): Promise<void> {
|
|
689
|
+
if (await findUserByExternalId(AGENT_FS_IDENTITY_KIND, SERVICE_USER)) return;
|
|
690
|
+
const service = await createUser({
|
|
691
|
+
name: "Service",
|
|
692
|
+
email: `svc-${crypto.randomUUID()}@x.test`,
|
|
693
|
+
});
|
|
694
|
+
await linkIdentity(service.id, AGENT_FS_IDENTITY_KIND, SERVICE_USER, {
|
|
695
|
+
kind: "system",
|
|
696
|
+
id: "test",
|
|
697
|
+
});
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
test("one author: the swarm user with the agent-fs member's email", async () => {
|
|
701
|
+
const ana = await member("Ana");
|
|
702
|
+
const a = fake.add({ body: "@swarm a", author: ana.author });
|
|
703
|
+
const b = fake.add({ body: "@swarm b", author: ana.author });
|
|
704
|
+
|
|
705
|
+
const result = await sendReviewBatch(batch([a.id, b.id]));
|
|
706
|
+
|
|
707
|
+
const task = await getTaskById(result.taskId as string);
|
|
708
|
+
expect(task?.requestedByUserId).toBe(ana.userId);
|
|
709
|
+
expect(task?.task).toContain(`Requested by: Ana <${ana.email}>`);
|
|
710
|
+
expect(fake.memberReads).toBe(1);
|
|
711
|
+
});
|
|
712
|
+
|
|
713
|
+
test("a linked agent-fs identity maps without reading the drive members", async () => {
|
|
714
|
+
const user = await createUser({
|
|
715
|
+
name: "Linked",
|
|
716
|
+
email: `linked-${crypto.randomUUID()}@x.test`,
|
|
717
|
+
});
|
|
718
|
+
const author = `af-${crypto.randomUUID()}`;
|
|
719
|
+
await linkIdentity(user.id, AGENT_FS_IDENTITY_KIND, author, { kind: "system", id: "test" });
|
|
720
|
+
const a = fake.add({ body: "@swarm a", author });
|
|
721
|
+
|
|
722
|
+
const result = await sendReviewBatch(batch([a.id]));
|
|
723
|
+
|
|
724
|
+
expect((await getTaskById(result.taskId as string))?.requestedByUserId).toBe(user.id);
|
|
725
|
+
expect(fake.memberReads).toBe(0);
|
|
726
|
+
});
|
|
727
|
+
|
|
728
|
+
test("several authors: the author of the newest mapped comment", async () => {
|
|
729
|
+
const ana = await member("Ana");
|
|
730
|
+
const bo = await member("Bo");
|
|
731
|
+
const older = fake.add({ body: "@swarm a", author: bo.author });
|
|
732
|
+
const newest = fake.add({ body: "@swarm b", author: ana.author });
|
|
733
|
+
const middle = fake.add({ body: "@swarm c", author: bo.author });
|
|
734
|
+
middle.createdAt = new Date(Date.parse(older.createdAt) + 1).toISOString();
|
|
735
|
+
newest.createdAt = new Date(Date.parse(older.createdAt) + 60_000).toISOString();
|
|
736
|
+
// The newest comment of all has an author with no swarm user: skipped.
|
|
737
|
+
const stranger = fake.add({
|
|
738
|
+
body: "@swarm d",
|
|
739
|
+
author: "af-stranger",
|
|
740
|
+
createdAt: new Date(Date.parse(older.createdAt) + 120_000).toISOString(),
|
|
741
|
+
});
|
|
742
|
+
|
|
743
|
+
// Bo is first, last mapped, and the majority: only "newest" picks Ana.
|
|
744
|
+
const result = await sendReviewBatch(batch([middle.id, newest.id, older.id, stranger.id]));
|
|
745
|
+
|
|
746
|
+
const task = await getTaskById(result.taskId as string);
|
|
747
|
+
expect(task?.requestedByUserId).toBe(ana.userId);
|
|
748
|
+
expect(task?.task).toContain("Requested by: Ana <");
|
|
749
|
+
});
|
|
750
|
+
|
|
751
|
+
test("no author maps: no requester, as before", async () => {
|
|
752
|
+
const a = fake.add({ body: "@swarm a", author: "af-stranger" });
|
|
753
|
+
fake.members.push({ userId: "af-stranger", email: "nobody-here@example.test" });
|
|
754
|
+
|
|
755
|
+
const result = await sendReviewBatch(batch([a.id]));
|
|
756
|
+
|
|
757
|
+
const task = await getTaskById(result.taskId as string);
|
|
758
|
+
expect(task?.requestedByUserId).toBeUndefined();
|
|
759
|
+
expect(task?.task).toContain("Requested by: an operator (swarm API key)");
|
|
760
|
+
});
|
|
761
|
+
|
|
762
|
+
test("a failed drive-members read leaves the requester empty and still sends", async () => {
|
|
763
|
+
const ana = await member("Ana");
|
|
764
|
+
fake.failMembers = true;
|
|
765
|
+
const a = fake.add({ body: "@swarm a", author: ana.author });
|
|
766
|
+
|
|
767
|
+
const result = await sendReviewBatch(batch([a.id]));
|
|
768
|
+
|
|
769
|
+
expect(result.sent).toEqual([a.id]);
|
|
770
|
+
expect((await getTaskById(result.taskId as string))?.requestedByUserId).toBeUndefined();
|
|
771
|
+
});
|
|
772
|
+
|
|
773
|
+
test("the swarm service account is never the requester", async () => {
|
|
774
|
+
await linkServiceAccount();
|
|
775
|
+
const a = fake.add({ body: "@swarm a", author: SERVICE_USER });
|
|
776
|
+
|
|
777
|
+
const result = await sendReviewBatch(batch([a.id]));
|
|
778
|
+
|
|
779
|
+
expect((await getTaskById(result.taskId as string))?.requestedByUserId).toBeUndefined();
|
|
780
|
+
});
|
|
781
|
+
|
|
782
|
+
test("an unknown service identity leaves the requester empty and still sends", async () => {
|
|
783
|
+
await linkServiceAccount();
|
|
784
|
+
fake.failAuthMe = true;
|
|
785
|
+
const a = fake.add({ body: "@swarm a", author: SERVICE_USER });
|
|
786
|
+
|
|
787
|
+
const result = await sendReviewBatch(batch([a.id]));
|
|
788
|
+
|
|
789
|
+
expect(result.sent).toEqual([a.id]);
|
|
790
|
+
expect((await getTaskById(result.taskId as string))?.requestedByUserId).toBeUndefined();
|
|
791
|
+
expect(fake.memberReads).toBe(0);
|
|
792
|
+
});
|
|
793
|
+
|
|
794
|
+
test("TRUST_BODY_REQUESTED_BY_USER_ID=false: no author requester, and the send still works", async () => {
|
|
795
|
+
process.env.TRUST_BODY_REQUESTED_BY_USER_ID = "false";
|
|
796
|
+
const ana = await member("Ana");
|
|
797
|
+
const a = fake.add({ body: "@swarm a", author: ana.author });
|
|
798
|
+
|
|
799
|
+
const result = await sendReviewBatch(batch([a.id]));
|
|
800
|
+
|
|
801
|
+
expect(result.sent).toEqual([a.id]);
|
|
802
|
+
expect((await getTaskById(result.taskId as string))?.requestedByUserId).toBeUndefined();
|
|
803
|
+
expect(fake.memberReads).toBe(0);
|
|
804
|
+
});
|
|
805
|
+
|
|
806
|
+
test("an authenticated sender wins, and the body cannot name a requester", async () => {
|
|
807
|
+
const ana = await member("Ana");
|
|
808
|
+
const sender = await createUser({
|
|
809
|
+
name: "Sender",
|
|
810
|
+
email: `sender-${crypto.randomUUID()}@x.test`,
|
|
811
|
+
});
|
|
812
|
+
const { plaintext } = await mintToken(sender.id, "comb-test", { kind: "system", id: "test" });
|
|
813
|
+
const a = fake.add({ body: "@swarm a", author: ana.author });
|
|
814
|
+
const b = fake.add({ body: "@swarm b", author: ana.author });
|
|
815
|
+
|
|
816
|
+
const asUser = await post(
|
|
817
|
+
{ orgId: ORG, driveId: DRIVE, commentIds: [a.id], scopePath: "/" },
|
|
818
|
+
{ Authorization: `Bearer ${plaintext}` },
|
|
819
|
+
);
|
|
820
|
+
expect(asUser.status).toBe(201);
|
|
821
|
+
const userTask = await getTaskById(((await asUser.json()) as ReviewBatchResult).taskId ?? "");
|
|
822
|
+
expect(userTask?.requestedByUserId).toBe(sender.id);
|
|
823
|
+
expect(fake.memberReads).toBe(0);
|
|
824
|
+
|
|
825
|
+
// Operator key: a requestedByUserId in the body is dropped, the author maps.
|
|
826
|
+
const asOperator = await post({
|
|
827
|
+
orgId: ORG,
|
|
828
|
+
driveId: DRIVE,
|
|
829
|
+
commentIds: [b.id],
|
|
830
|
+
scopePath: "/",
|
|
831
|
+
requestedByUserId: sender.id,
|
|
832
|
+
});
|
|
833
|
+
expect(asOperator.status).toBe(201);
|
|
834
|
+
const operatorTask = await getTaskById(
|
|
835
|
+
((await asOperator.json()) as ReviewBatchResult).taskId ?? "",
|
|
836
|
+
);
|
|
837
|
+
expect(operatorTask?.requestedByUserId).toBe(ana.userId);
|
|
838
|
+
});
|
|
839
|
+
});
|
|
840
|
+
|
|
653
841
|
describe("POST /api/comb/review-batches", () => {
|
|
654
842
|
test("the dashboard batch cap and note limit match the server's", () => {
|
|
655
843
|
expect(COMB_BATCH_MAX).toBe(REVIEW_BATCH_MAX);
|
|
@@ -62,11 +62,67 @@ afterEach(() => {
|
|
|
62
62
|
});
|
|
63
63
|
|
|
64
64
|
describe("/status agent_fs.comb", () => {
|
|
65
|
-
test("is off
|
|
65
|
+
test("unset and AGENT_FS_API_URL is a docker-internal host: off, and says what is missing", async () => {
|
|
66
66
|
process.env.AGENT_FS_API_URL = "http://agent-fs:7433";
|
|
67
67
|
const result = await comb();
|
|
68
68
|
expect(result.enabled).toBe(false);
|
|
69
69
|
expect(result.api_url).toBe("http://agent-fs:7433");
|
|
70
|
+
expect(result.reason).toContain("AGENT_FS_PUBLIC_URL or AGENT_FS_PROXY=true");
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("unset and AGENT_FS_PROXY on: on, through the API", async () => {
|
|
74
|
+
process.env.AGENT_FS_API_URL = "http://agent-fs:7433";
|
|
75
|
+
process.env.PUBLIC_MCP_BASE_URL = "https://swarm.example.com";
|
|
76
|
+
process.env.AGENT_FS_PROXY = "true";
|
|
77
|
+
const result = await comb();
|
|
78
|
+
expect(result.enabled).toBe(true);
|
|
79
|
+
expect(result.reason).toBeNull();
|
|
80
|
+
expect(result.api_url).toBe("https://swarm.example.com/agentfs");
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("unset, AGENT_FS_PROXY on and an internal API address: off, and names PUBLIC_MCP_BASE_URL", async () => {
|
|
84
|
+
process.env.AGENT_FS_API_URL = "http://agent-fs:7433";
|
|
85
|
+
process.env.MCP_BASE_URL = "http://api:3013";
|
|
86
|
+
process.env.AGENT_FS_PROXY = "true";
|
|
87
|
+
const result = await comb();
|
|
88
|
+
expect(result.enabled).toBe(false);
|
|
89
|
+
expect(result.reason).toContain("PUBLIC_MCP_BASE_URL");
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("unset and an IPv4-mapped private IPv6 AGENT_FS_API_URL: off", async () => {
|
|
93
|
+
process.env.AGENT_FS_API_URL = "http://[::ffff:10.0.0.5]:7433";
|
|
94
|
+
const result = await comb();
|
|
95
|
+
expect(result.enabled).toBe(false);
|
|
96
|
+
expect(result.reason).toContain("not reachable from a browser");
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("unset and AGENT_FS_PUBLIC_URL set: on", async () => {
|
|
100
|
+
process.env.AGENT_FS_API_URL = "http://agent-fs:7433";
|
|
101
|
+
process.env.AGENT_FS_PUBLIC_URL = "https://fs.example.com";
|
|
102
|
+
const result = await comb();
|
|
103
|
+
expect(result.enabled).toBe(true);
|
|
104
|
+
expect(result.api_url).toBe("https://fs.example.com");
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("unset and a browser-reachable AGENT_FS_API_URL: on", async () => {
|
|
108
|
+
process.env.AGENT_FS_API_URL = "https://fs.example.com";
|
|
109
|
+
expect((await comb()).enabled).toBe(true);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("COMB_ENABLED=false wins over a reachable URL", async () => {
|
|
113
|
+
process.env.AGENT_FS_API_URL = "http://agent-fs:7433";
|
|
114
|
+
process.env.AGENT_FS_PUBLIC_URL = "https://fs.example.com";
|
|
115
|
+
process.env.COMB_ENABLED = "false";
|
|
116
|
+
const result = await comb();
|
|
117
|
+
expect(result.enabled).toBe(false);
|
|
118
|
+
expect(result.reason).toBe("COMB_ENABLED is false.");
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test("unset and no AGENT_FS_API_URL: off, and says so", async () => {
|
|
122
|
+
process.env.AGENT_FS_PUBLIC_URL = "https://fs.example.com";
|
|
123
|
+
const result = await comb();
|
|
124
|
+
expect(result.enabled).toBe(false);
|
|
125
|
+
expect(result.reason).toBe("AGENT_FS_API_URL is not set.");
|
|
70
126
|
});
|
|
71
127
|
|
|
72
128
|
test("stays off with the flag on but no AGENT_FS_API_URL", async () => {
|
|
@@ -81,7 +137,12 @@ describe("/status agent_fs.comb", () => {
|
|
|
81
137
|
process.env.COMB_ENABLED = value;
|
|
82
138
|
expect((await comb()).enabled).toBe(true);
|
|
83
139
|
}
|
|
84
|
-
for (const value of ["false", "0", "
|
|
140
|
+
for (const value of ["false", "0", " FALSE "]) {
|
|
141
|
+
process.env.COMB_ENABLED = value;
|
|
142
|
+
expect((await comb()).enabled).toBe(false);
|
|
143
|
+
}
|
|
144
|
+
// Unrecognized or blank means unset: the docker-internal URL keeps it off.
|
|
145
|
+
for (const value of ["yes", ""]) {
|
|
85
146
|
process.env.COMB_ENABLED = value;
|
|
86
147
|
expect((await comb()).enabled).toBe(false);
|
|
87
148
|
}
|
|
@@ -150,6 +211,7 @@ describe("/status agent_fs.comb", () => {
|
|
|
150
211
|
result = await comb();
|
|
151
212
|
expect(result).toEqual({
|
|
152
213
|
enabled: true,
|
|
214
|
+
reason: null,
|
|
153
215
|
api_url: "http://agent-fs:7433",
|
|
154
216
|
live_url: "https://live.example.com",
|
|
155
217
|
org_id: "org-1",
|
|
@@ -177,6 +239,7 @@ describe("/status agent_fs.comb", () => {
|
|
|
177
239
|
process.env.AGENT_FS_DEFAULT_DRIVE_ID = "drive-1";
|
|
178
240
|
|
|
179
241
|
// Comb off: no lookup.
|
|
242
|
+
process.env.COMB_ENABLED = "false";
|
|
180
243
|
expect((await comb()).service_user_id).toBeNull();
|
|
181
244
|
expect(meCalls).toBe(0);
|
|
182
245
|
|
|
@@ -77,6 +77,7 @@ test("unmoved profile and idle-worker callers share agent mapping defaults", asy
|
|
|
77
77
|
credStatus: null,
|
|
78
78
|
harnessProvider: null,
|
|
79
79
|
avatar: null,
|
|
80
|
+
slackIcon: null,
|
|
80
81
|
});
|
|
81
82
|
const updated = await db.updateAgentProfile(agent.id, {
|
|
82
83
|
role: "coder",
|
|
@@ -89,3 +90,15 @@ test("unmoved profile and idle-worker callers share agent mapping defaults", asy
|
|
|
89
90
|
.run("UPDATE agents SET avatar = ? WHERE id = ?", ["invalid json", agent.id]);
|
|
90
91
|
expect((await db.getAgentById(agent.id))?.avatar).toBeNull();
|
|
91
92
|
});
|
|
93
|
+
|
|
94
|
+
test("slackIcon is set, kept when omitted, reset by null, and parsed safely", async () => {
|
|
95
|
+
const agent = await db.createAgent(worker);
|
|
96
|
+
const owl = { type: "emoji" as const, emoji: ":owl:" };
|
|
97
|
+
expect((await db.updateAgentProfile(agent.id, { slackIcon: owl }))?.slackIcon).toEqual(owl);
|
|
98
|
+
expect((await db.updateAgentProfile(agent.id, { role: "coder" }))?.slackIcon).toEqual(owl);
|
|
99
|
+
expect((await db.updateAgentProfile(agent.id, { slackIcon: null }))?.slackIcon).toBeNull();
|
|
100
|
+
await db
|
|
101
|
+
.getDbClient()
|
|
102
|
+
.run("UPDATE agents SET slack_icon = ? WHERE id = ?", ["invalid json", agent.id]);
|
|
103
|
+
expect((await db.getAgentById(agent.id))?.slackIcon).toBeNull();
|
|
104
|
+
});
|