@desplega.ai/agent-swarm 1.74.4 → 1.76.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 +1 -1
- package/openapi.json +1264 -46
- package/package.json +2 -2
- package/src/be/db.ts +563 -9
- package/src/be/memory/edges-store.ts +69 -0
- package/src/be/memory/providers/sqlite-store.ts +4 -0
- package/src/be/memory/raters/explicit-self.ts +22 -0
- package/src/be/memory/raters/implicit-citation.ts +44 -0
- package/src/be/memory/raters/llm-client.ts +172 -0
- package/src/be/memory/raters/llm-summarizer.ts +218 -0
- package/src/be/memory/raters/llm.ts +375 -0
- package/src/be/memory/raters/noop.ts +14 -0
- package/src/be/memory/raters/registry.ts +86 -0
- package/src/be/memory/raters/retrieval.ts +88 -0
- package/src/be/memory/raters/run-server-raters.ts +97 -0
- package/src/be/memory/raters/store.ts +228 -0
- package/src/be/memory/raters/types.ts +101 -0
- package/src/be/memory/reranker.ts +32 -2
- package/src/be/memory/retrieval-store.ts +116 -0
- package/src/be/memory/types.ts +3 -0
- package/src/be/migrations/051_memory_posteriors_and_retrieval.sql +67 -0
- package/src/be/migrations/052_memory_edges.sql +36 -0
- package/src/be/migrations/053_agent_waiting_for_credentials_status.sql +61 -0
- package/src/be/migrations/054_agent_harness_provider.sql +21 -0
- package/src/be/migrations/055_agent_cred_status.sql +15 -0
- package/src/be/migrations/056_drop_agent_tasks_source_check.sql +139 -0
- package/src/be/migrations/057_inbox_item_state.sql +27 -0
- package/src/be/migrations/058_task_templates.sql +31 -0
- package/src/be/swarm-config-guard.ts +24 -0
- package/src/commands/credential-wait.ts +186 -0
- package/src/commands/provider-credentials.ts +434 -0
- package/src/commands/runner.ts +253 -21
- package/src/hooks/hook.ts +143 -66
- package/src/http/agents.ts +191 -1
- package/src/http/config.ts +11 -1
- package/src/http/core.ts +5 -0
- package/src/http/inbox-state.ts +89 -0
- package/src/http/index.ts +10 -0
- package/src/http/memory.ts +230 -1
- package/src/http/sessions.ts +86 -0
- package/src/http/status.ts +665 -0
- package/src/http/task-templates.ts +51 -0
- package/src/http/tasks.ts +85 -5
- package/src/http/users.ts +134 -0
- package/src/prompts/memories.ts +62 -0
- package/src/providers/claude-adapter.ts +22 -0
- package/src/providers/claude-managed-adapter.ts +24 -0
- package/src/providers/codex-adapter.ts +43 -1
- package/src/providers/devin-adapter.ts +18 -0
- package/src/providers/index.ts +7 -0
- package/src/providers/opencode-adapter.ts +60 -0
- package/src/providers/pi-mono-adapter.ts +71 -0
- package/src/providers/types.ts +34 -0
- package/src/server.ts +2 -0
- package/src/slack/handlers.ts +0 -1
- package/src/tests/agents-harness-provider.test.ts +333 -0
- package/src/tests/credential-check.test.ts +367 -0
- package/src/tests/credential-status-api.test.ts +223 -0
- package/src/tests/credential-status-routing.test.ts +150 -0
- package/src/tests/credential-wait.test.ts +282 -0
- package/src/tests/harness-provider-resolution.test.ts +242 -0
- package/src/tests/jira-sync.test.ts +1 -1
- package/src/tests/memory-edges.test.ts +722 -0
- package/src/tests/memory-rate-endpoint.test.ts +330 -0
- package/src/tests/memory-rate-tool.test.ts +252 -0
- package/src/tests/memory-rater-e2e.test.ts +578 -0
- package/src/tests/memory-rater-implicit-citation.test.ts +304 -0
- package/src/tests/memory-rater-llm-summarizer.test.ts +317 -0
- package/src/tests/memory-rater-llm.test.ts +964 -0
- package/src/tests/memory-rater-store.test.ts +249 -0
- package/src/tests/memory-reranker.test.ts +161 -2
- package/src/tests/migration-runner-regressions.test.ts +17 -2
- package/src/tests/mocks/mock-llm-rater-client.ts +35 -0
- package/src/tests/run-server-raters.test.ts +291 -0
- package/src/tests/sessions.test.ts +141 -0
- package/src/tests/status.test.ts +843 -0
- package/src/tests/stop-hook-task-resolution.test.ts +98 -0
- package/src/tests/template-recommendations.test.ts +148 -0
- package/src/tests/tool-annotations.test.ts +2 -2
- package/src/tests/use-dismissible-card.test.ts +140 -0
- package/src/tools/memory-rate.ts +166 -0
- package/src/tools/memory-search.ts +18 -0
- package/src/tools/store-progress.ts +37 -0
- package/src/tools/swarm-config/set-config.ts +17 -1
- package/src/tools/tool-config.ts +1 -0
- package/src/types.ts +122 -1
- package/src/utils/harness-provider.ts +32 -0
- package/tsconfig.json +0 -2
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
2
|
+
import { unlink } from "node:fs/promises";
|
|
3
|
+
import {
|
|
4
|
+
closeDb,
|
|
5
|
+
createAgent,
|
|
6
|
+
getAgentById,
|
|
7
|
+
getIdleWorkersWithCapacity,
|
|
8
|
+
initDb,
|
|
9
|
+
updateAgentCredentialState,
|
|
10
|
+
} from "../be/db";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Phase 3 of the worker credential safe-loop plan
|
|
14
|
+
* (thoughts/taras/plans/2026-05-06-worker-credential-safe-loop.md).
|
|
15
|
+
*
|
|
16
|
+
* Verifies that:
|
|
17
|
+
* - The migration applies cleanly to a fresh DB.
|
|
18
|
+
* - `waiting_for_credentials` is a valid status enum value.
|
|
19
|
+
* - `credentialMissing` round-trips through the helper + reader.
|
|
20
|
+
* - `getIdleWorkersWithCapacity` (the dispatcher's read site) routes
|
|
21
|
+
* around blocked workers — they're implicitly excluded by the
|
|
22
|
+
* `status = 'idle'` predicate.
|
|
23
|
+
* - `updateAgentCredentialState(ready=true)` transitions blocked → idle
|
|
24
|
+
* and clears the missing list.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
const TEST_DB_PATH = "./test-credential-status-routing.sqlite";
|
|
28
|
+
|
|
29
|
+
describe("Phase 3 — credential status routing", () => {
|
|
30
|
+
beforeAll(async () => {
|
|
31
|
+
try {
|
|
32
|
+
await unlink(TEST_DB_PATH);
|
|
33
|
+
} catch {
|
|
34
|
+
// first run
|
|
35
|
+
}
|
|
36
|
+
initDb(TEST_DB_PATH);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
afterAll(async () => {
|
|
40
|
+
closeDb();
|
|
41
|
+
try {
|
|
42
|
+
await unlink(TEST_DB_PATH);
|
|
43
|
+
await unlink(`${TEST_DB_PATH}-wal`);
|
|
44
|
+
await unlink(`${TEST_DB_PATH}-shm`);
|
|
45
|
+
} catch {
|
|
46
|
+
// best-effort
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("migration applies on fresh DB — waiting_for_credentials enum + credentialMissing column exist", () => {
|
|
51
|
+
// The fact that initDb succeeded above means migrations applied. Now
|
|
52
|
+
// verify we can actually create an agent and persist the new state
|
|
53
|
+
// without hitting the old CHECK constraint.
|
|
54
|
+
const agent = createAgent({
|
|
55
|
+
name: "routing-blocked",
|
|
56
|
+
isLead: false,
|
|
57
|
+
status: "idle",
|
|
58
|
+
capabilities: [],
|
|
59
|
+
maxTasks: 1,
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
const updated = updateAgentCredentialState(agent.id, false, [
|
|
63
|
+
"CLAUDE_CODE_OAUTH_TOKEN",
|
|
64
|
+
"ANTHROPIC_API_KEY",
|
|
65
|
+
]);
|
|
66
|
+
expect(updated).not.toBeNull();
|
|
67
|
+
expect(updated!.status).toBe("waiting_for_credentials");
|
|
68
|
+
expect(updated!.credentialMissing).toEqual(["CLAUDE_CODE_OAUTH_TOKEN", "ANTHROPIC_API_KEY"]);
|
|
69
|
+
|
|
70
|
+
// Read-back through getAgentById should preserve the JSON parse.
|
|
71
|
+
const refetched = getAgentById(agent.id);
|
|
72
|
+
expect(refetched!.status).toBe("waiting_for_credentials");
|
|
73
|
+
expect(refetched!.credentialMissing).toEqual(["CLAUDE_CODE_OAUTH_TOKEN", "ANTHROPIC_API_KEY"]);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("dispatcher routes around blocked workers — getIdleWorkersWithCapacity skips them", () => {
|
|
77
|
+
// Sanity: clear DB state by creating fresh agents in this test.
|
|
78
|
+
const ready = createAgent({
|
|
79
|
+
name: "routing-ready",
|
|
80
|
+
isLead: false,
|
|
81
|
+
status: "idle",
|
|
82
|
+
capabilities: [],
|
|
83
|
+
maxTasks: 5,
|
|
84
|
+
});
|
|
85
|
+
const blocked = createAgent({
|
|
86
|
+
name: "routing-blocked-2",
|
|
87
|
+
isLead: false,
|
|
88
|
+
status: "idle",
|
|
89
|
+
capabilities: [],
|
|
90
|
+
maxTasks: 5,
|
|
91
|
+
});
|
|
92
|
+
updateAgentCredentialState(blocked.id, false, ["DEVIN_API_KEY"]);
|
|
93
|
+
|
|
94
|
+
const idleWorkers = getIdleWorkersWithCapacity();
|
|
95
|
+
const idleIds = idleWorkers.map((a) => a.id);
|
|
96
|
+
expect(idleIds).toContain(ready.id);
|
|
97
|
+
expect(idleIds).not.toContain(blocked.id);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("transition waiting → idle: dispatcher picks the agent up again", () => {
|
|
101
|
+
const agent = createAgent({
|
|
102
|
+
name: "routing-recovery",
|
|
103
|
+
isLead: false,
|
|
104
|
+
status: "idle",
|
|
105
|
+
capabilities: [],
|
|
106
|
+
maxTasks: 5,
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
// Park the agent.
|
|
110
|
+
updateAgentCredentialState(agent.id, false, ["OPENAI_API_KEY"]);
|
|
111
|
+
expect(getIdleWorkersWithCapacity().some((a) => a.id === agent.id)).toBe(false);
|
|
112
|
+
|
|
113
|
+
// Simulate creds arriving.
|
|
114
|
+
const recovered = updateAgentCredentialState(agent.id, true, null);
|
|
115
|
+
expect(recovered!.status).toBe("idle");
|
|
116
|
+
expect(recovered!.credentialMissing).toBeNull();
|
|
117
|
+
|
|
118
|
+
// Dispatcher should now pick the agent up.
|
|
119
|
+
expect(getIdleWorkersWithCapacity().some((a) => a.id === agent.id)).toBe(true);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("ready=true clears any prior missing list even if missing[] is provided", () => {
|
|
123
|
+
const agent = createAgent({
|
|
124
|
+
name: "routing-clear",
|
|
125
|
+
isLead: false,
|
|
126
|
+
status: "idle",
|
|
127
|
+
capabilities: [],
|
|
128
|
+
maxTasks: 1,
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
updateAgentCredentialState(agent.id, false, ["X", "Y"]);
|
|
132
|
+
// Even if a caller passes a non-null `missing` with `ready=true`, the helper
|
|
133
|
+
// canonicalises to NULL so the dashboard doesn't render a stale list.
|
|
134
|
+
const cleared = updateAgentCredentialState(agent.id, true, ["X"]);
|
|
135
|
+
expect(cleared!.status).toBe("idle");
|
|
136
|
+
expect(cleared!.credentialMissing).toBeNull();
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test("isLead agents are not eligible (predicate also filters isLead = 0)", () => {
|
|
140
|
+
const lead = createAgent({
|
|
141
|
+
name: "routing-lead",
|
|
142
|
+
isLead: true,
|
|
143
|
+
status: "idle",
|
|
144
|
+
capabilities: [],
|
|
145
|
+
maxTasks: 1,
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
expect(getIdleWorkersWithCapacity().some((a) => a.id === lead.id)).toBe(false);
|
|
149
|
+
});
|
|
150
|
+
});
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { awaitCredentials, BootMaxWaitExceededError } from "../commands/credential-wait";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Capture-only logger so test output stays clean and we can assert on
|
|
6
|
+
* specific lines emitted by the loop.
|
|
7
|
+
*/
|
|
8
|
+
function makeLogger() {
|
|
9
|
+
const lines: string[] = [];
|
|
10
|
+
return {
|
|
11
|
+
fn: (line: string) => lines.push(line),
|
|
12
|
+
lines,
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Track every sleep duration the loop requests so we can assert on backoff. */
|
|
17
|
+
function makeSleeper() {
|
|
18
|
+
const calls: number[] = [];
|
|
19
|
+
return {
|
|
20
|
+
fn: (ms: number) => {
|
|
21
|
+
calls.push(ms);
|
|
22
|
+
return Promise.resolve();
|
|
23
|
+
},
|
|
24
|
+
calls,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Save and restore env vars touched by the loop. */
|
|
29
|
+
function withEnv(snapshot: Record<string, string | undefined>) {
|
|
30
|
+
const previous: Record<string, string | undefined> = {};
|
|
31
|
+
for (const k of Object.keys(snapshot)) previous[k] = process.env[k];
|
|
32
|
+
return () => {
|
|
33
|
+
for (const k of Object.keys(snapshot)) {
|
|
34
|
+
if (previous[k] === undefined) delete process.env[k];
|
|
35
|
+
else process.env[k] = previous[k]!;
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
describe("awaitCredentials", () => {
|
|
41
|
+
let restore: () => void = () => {};
|
|
42
|
+
|
|
43
|
+
beforeEach(() => {
|
|
44
|
+
// Wipe any harness creds the test runner might inherit so the loop
|
|
45
|
+
// starts in the "blocked" state for tests that expect it.
|
|
46
|
+
restore = withEnv({
|
|
47
|
+
CLAUDE_CODE_OAUTH_TOKEN: undefined,
|
|
48
|
+
ANTHROPIC_API_KEY: undefined,
|
|
49
|
+
OPENAI_API_KEY: undefined,
|
|
50
|
+
OPENROUTER_API_KEY: undefined,
|
|
51
|
+
CODEX_OAUTH: undefined,
|
|
52
|
+
DEVIN_API_KEY: undefined,
|
|
53
|
+
DEVIN_ORG_ID: undefined,
|
|
54
|
+
});
|
|
55
|
+
delete process.env.CLAUDE_CODE_OAUTH_TOKEN;
|
|
56
|
+
delete process.env.ANTHROPIC_API_KEY;
|
|
57
|
+
delete process.env.OPENAI_API_KEY;
|
|
58
|
+
delete process.env.OPENROUTER_API_KEY;
|
|
59
|
+
delete process.env.CODEX_OAUTH;
|
|
60
|
+
delete process.env.DEVIN_API_KEY;
|
|
61
|
+
delete process.env.DEVIN_ORG_ID;
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
afterEach(() => {
|
|
65
|
+
restore();
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("immediate-return when creds are already present", async () => {
|
|
69
|
+
const log = makeLogger();
|
|
70
|
+
const sleeper = makeSleeper();
|
|
71
|
+
let refreshCalls = 0;
|
|
72
|
+
|
|
73
|
+
const status = await awaitCredentials({
|
|
74
|
+
provider: "claude",
|
|
75
|
+
initialEnv: { CLAUDE_CODE_OAUTH_TOKEN: "tok" },
|
|
76
|
+
refreshEnv: async () => {
|
|
77
|
+
refreshCalls += 1;
|
|
78
|
+
return {};
|
|
79
|
+
},
|
|
80
|
+
sleep: sleeper.fn,
|
|
81
|
+
log: log.fn,
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
expect(status.ready).toBe(true);
|
|
85
|
+
expect(refreshCalls).toBe(0);
|
|
86
|
+
expect(sleeper.calls.length).toBe(0);
|
|
87
|
+
expect(log.lines.some((l) => l.startsWith("[boot] credentials ready"))).toBe(true);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("loops until refreshEnv yields a valid credential", async () => {
|
|
91
|
+
const log = makeLogger();
|
|
92
|
+
const sleeper = makeSleeper();
|
|
93
|
+
const ticks: Array<{ ready: boolean; missing: string[]; attempt: number }> = [];
|
|
94
|
+
|
|
95
|
+
let callCount = 0;
|
|
96
|
+
const status = await awaitCredentials({
|
|
97
|
+
provider: "claude",
|
|
98
|
+
initialEnv: {}, // empty
|
|
99
|
+
refreshEnv: async () => {
|
|
100
|
+
callCount += 1;
|
|
101
|
+
// First two refreshes return nothing; third yields the token.
|
|
102
|
+
if (callCount < 3) return {};
|
|
103
|
+
return { CLAUDE_CODE_OAUTH_TOKEN: "tok" };
|
|
104
|
+
},
|
|
105
|
+
sleep: sleeper.fn,
|
|
106
|
+
log: log.fn,
|
|
107
|
+
onTick: (s, attempt) => ticks.push({ ready: s.ready, missing: [...s.missing], attempt }),
|
|
108
|
+
backoff: { initialMs: 100, maxMs: 1000, maxWaitSeconds: 0 },
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
expect(status.ready).toBe(true);
|
|
112
|
+
expect(callCount).toBe(3);
|
|
113
|
+
// Backoff sequence should have doubled until cap: 100ms, 200ms, 400ms.
|
|
114
|
+
expect(sleeper.calls).toEqual([100, 200, 400]);
|
|
115
|
+
// onTick fires per iteration (3 waiting ticks + 1 final ready tick).
|
|
116
|
+
expect(ticks.length).toBe(4);
|
|
117
|
+
expect(ticks[0]!.ready).toBe(false);
|
|
118
|
+
expect(ticks[ticks.length - 1]!.ready).toBe(true);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test("backoff caps at maxMs", async () => {
|
|
122
|
+
const log = makeLogger();
|
|
123
|
+
const sleeper = makeSleeper();
|
|
124
|
+
|
|
125
|
+
let callCount = 0;
|
|
126
|
+
await awaitCredentials({
|
|
127
|
+
provider: "claude",
|
|
128
|
+
initialEnv: {},
|
|
129
|
+
refreshEnv: async () => {
|
|
130
|
+
callCount += 1;
|
|
131
|
+
// Resolve only after 6 iterations.
|
|
132
|
+
if (callCount < 6) return {};
|
|
133
|
+
return { CLAUDE_CODE_OAUTH_TOKEN: "tok" };
|
|
134
|
+
},
|
|
135
|
+
sleep: sleeper.fn,
|
|
136
|
+
log: log.fn,
|
|
137
|
+
backoff: { initialMs: 100, maxMs: 500, maxWaitSeconds: 0 },
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
// 100, 200, 400, then capped at 500 forever.
|
|
141
|
+
expect(sleeper.calls).toEqual([100, 200, 400, 500, 500, 500]);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test("BOOT_MAX_WAIT_SECONDS throws BootMaxWaitExceededError when exceeded", async () => {
|
|
145
|
+
const log = makeLogger();
|
|
146
|
+
let fakeNow = 0;
|
|
147
|
+
|
|
148
|
+
await expect(
|
|
149
|
+
awaitCredentials({
|
|
150
|
+
provider: "claude",
|
|
151
|
+
initialEnv: {},
|
|
152
|
+
refreshEnv: async () => ({}), // never resolves
|
|
153
|
+
sleep: async (ms) => {
|
|
154
|
+
fakeNow += ms;
|
|
155
|
+
},
|
|
156
|
+
now: () => fakeNow,
|
|
157
|
+
log: log.fn,
|
|
158
|
+
backoff: { initialMs: 1000, maxMs: 1000, maxWaitSeconds: 5 },
|
|
159
|
+
}),
|
|
160
|
+
).rejects.toBeInstanceOf(BootMaxWaitExceededError);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test("onTick errors are non-fatal", async () => {
|
|
164
|
+
const log = makeLogger();
|
|
165
|
+
const sleeper = makeSleeper();
|
|
166
|
+
|
|
167
|
+
let onTickCalls = 0;
|
|
168
|
+
let refreshCalls = 0;
|
|
169
|
+
const status = await awaitCredentials({
|
|
170
|
+
provider: "claude",
|
|
171
|
+
initialEnv: {},
|
|
172
|
+
refreshEnv: async () => {
|
|
173
|
+
refreshCalls += 1;
|
|
174
|
+
return refreshCalls >= 1 ? { CLAUDE_CODE_OAUTH_TOKEN: "tok" } : {};
|
|
175
|
+
},
|
|
176
|
+
sleep: sleeper.fn,
|
|
177
|
+
log: log.fn,
|
|
178
|
+
onTick: () => {
|
|
179
|
+
onTickCalls += 1;
|
|
180
|
+
throw new Error("status report failed");
|
|
181
|
+
},
|
|
182
|
+
backoff: { initialMs: 1, maxMs: 1, maxWaitSeconds: 0 },
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
expect(status.ready).toBe(true);
|
|
186
|
+
expect(onTickCalls).toBe(2); // one waiting tick + one final ready tick
|
|
187
|
+
expect(log.lines.some((l) => l.includes("onTick error"))).toBe(true);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test("refreshEnv errors are non-fatal — loop continues to next tick", async () => {
|
|
191
|
+
const log = makeLogger();
|
|
192
|
+
const sleeper = makeSleeper();
|
|
193
|
+
|
|
194
|
+
let refreshCalls = 0;
|
|
195
|
+
const status = await awaitCredentials({
|
|
196
|
+
provider: "claude",
|
|
197
|
+
initialEnv: {},
|
|
198
|
+
refreshEnv: async () => {
|
|
199
|
+
refreshCalls += 1;
|
|
200
|
+
if (refreshCalls === 1) throw new Error("network blip");
|
|
201
|
+
return { CLAUDE_CODE_OAUTH_TOKEN: "tok" };
|
|
202
|
+
},
|
|
203
|
+
sleep: sleeper.fn,
|
|
204
|
+
log: log.fn,
|
|
205
|
+
backoff: { initialMs: 1, maxMs: 1, maxWaitSeconds: 0 },
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
expect(status.ready).toBe(true);
|
|
209
|
+
expect(refreshCalls).toBe(2);
|
|
210
|
+
expect(log.lines.some((l) => l.includes("env refresh failed"))).toBe(true);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
test("merges refreshed env into process.env", async () => {
|
|
214
|
+
const log = makeLogger();
|
|
215
|
+
delete process.env.CLAUDE_CODE_OAUTH_TOKEN;
|
|
216
|
+
|
|
217
|
+
const status = await awaitCredentials({
|
|
218
|
+
provider: "claude",
|
|
219
|
+
initialEnv: {},
|
|
220
|
+
refreshEnv: async () => ({ CLAUDE_CODE_OAUTH_TOKEN: "fresh-tok" }),
|
|
221
|
+
sleep: async () => {},
|
|
222
|
+
log: log.fn,
|
|
223
|
+
backoff: { initialMs: 1, maxMs: 1, maxWaitSeconds: 0 },
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
expect(status.ready).toBe(true);
|
|
227
|
+
// After the loop returns ready, process.env reflects the fresh value.
|
|
228
|
+
expect(process.env.CLAUDE_CODE_OAUTH_TOKEN).toBe("fresh-tok");
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
test("backoff config falls back to env-var defaults when override absent", async () => {
|
|
232
|
+
const log = makeLogger();
|
|
233
|
+
const sleeper = makeSleeper();
|
|
234
|
+
|
|
235
|
+
process.env.BOOT_INITIAL_BACKOFF_MS = "50";
|
|
236
|
+
process.env.BOOT_MAX_BACKOFF_MS = "100";
|
|
237
|
+
|
|
238
|
+
let callCount = 0;
|
|
239
|
+
await awaitCredentials({
|
|
240
|
+
provider: "claude",
|
|
241
|
+
initialEnv: { BOOT_INITIAL_BACKOFF_MS: "50", BOOT_MAX_BACKOFF_MS: "100" },
|
|
242
|
+
refreshEnv: async () => {
|
|
243
|
+
callCount += 1;
|
|
244
|
+
return callCount >= 4 ? { CLAUDE_CODE_OAUTH_TOKEN: "tok" } : {};
|
|
245
|
+
},
|
|
246
|
+
sleep: sleeper.fn,
|
|
247
|
+
log: log.fn,
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
// 50, 100 (capped), 100, 100.
|
|
251
|
+
expect(sleeper.calls).toEqual([50, 100, 100, 100]);
|
|
252
|
+
|
|
253
|
+
delete process.env.BOOT_INITIAL_BACKOFF_MS;
|
|
254
|
+
delete process.env.BOOT_MAX_BACKOFF_MS;
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
test("forwards CredCheckOptions for file-based providers", async () => {
|
|
258
|
+
const log = makeLogger();
|
|
259
|
+
const probedPaths: string[] = [];
|
|
260
|
+
|
|
261
|
+
const status = await awaitCredentials({
|
|
262
|
+
provider: "codex",
|
|
263
|
+
initialEnv: { HOME: "/home/worker" },
|
|
264
|
+
refreshEnv: async () => ({}),
|
|
265
|
+
sleep: async () => {},
|
|
266
|
+
log: log.fn,
|
|
267
|
+
backoff: { initialMs: 1, maxMs: 1, maxWaitSeconds: 0 },
|
|
268
|
+
credCheckOptions: {
|
|
269
|
+
homeDir: "/home/worker",
|
|
270
|
+
fs: {
|
|
271
|
+
existsSync: (p: string) => {
|
|
272
|
+
probedPaths.push(p);
|
|
273
|
+
return p === "/home/worker/.codex/auth.json";
|
|
274
|
+
},
|
|
275
|
+
},
|
|
276
|
+
},
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
expect(status.ready).toBe(true);
|
|
280
|
+
expect(probedPaths).toContain("/home/worker/.codex/auth.json");
|
|
281
|
+
});
|
|
282
|
+
});
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Coverage for the swarm_config-overrides-HARNESS_PROVIDER work:
|
|
3
|
+
*
|
|
4
|
+
* - `resolveHarnessProvider` precedence (resolvedEnv > fallbackEnv > "claude")
|
|
5
|
+
* and invalid-value fallback.
|
|
6
|
+
* - `validateConfigValue` rejects unknown providers (used by HTTP +
|
|
7
|
+
* MCP write paths).
|
|
8
|
+
* - `getResolvedConfig` honours scope precedence (repo > agent > global)
|
|
9
|
+
* for HARNESS_PROVIDER, mirroring how MODEL_OVERRIDE already works.
|
|
10
|
+
* - End-to-end through `PUT /api/config`: a typo'd HARNESS_PROVIDER is
|
|
11
|
+
* rejected with 400 instead of being silently stored.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
15
|
+
import { unlink } from "node:fs/promises";
|
|
16
|
+
import { createServer as createHttpServer, type Server } from "node:http";
|
|
17
|
+
import {
|
|
18
|
+
closeDb,
|
|
19
|
+
createAgent,
|
|
20
|
+
getDb,
|
|
21
|
+
getResolvedConfig,
|
|
22
|
+
initDb,
|
|
23
|
+
upsertSwarmConfig,
|
|
24
|
+
} from "../be/db";
|
|
25
|
+
import { validateConfigValue } from "../be/swarm-config-guard";
|
|
26
|
+
import { handleConfig } from "../http/config";
|
|
27
|
+
import { resolveHarnessProvider } from "../utils/harness-provider";
|
|
28
|
+
|
|
29
|
+
const TEST_DB_PATH = "./test-harness-provider-resolution.sqlite";
|
|
30
|
+
const TEST_PORT = 13061;
|
|
31
|
+
|
|
32
|
+
async function removeDbFiles(path: string): Promise<void> {
|
|
33
|
+
for (const suffix of ["", "-wal", "-shm"]) {
|
|
34
|
+
try {
|
|
35
|
+
await unlink(path + suffix);
|
|
36
|
+
} catch (error) {
|
|
37
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function makeTestServer(): Server {
|
|
43
|
+
return createHttpServer(async (req, res) => {
|
|
44
|
+
const url = new URL(req.url ?? "/", `http://localhost:${TEST_PORT}`);
|
|
45
|
+
const pathSegments = url.pathname.split("/").filter(Boolean);
|
|
46
|
+
const queryParams = url.searchParams;
|
|
47
|
+
try {
|
|
48
|
+
if (await handleConfig(req, res, pathSegments, queryParams)) return;
|
|
49
|
+
} catch (err) {
|
|
50
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
51
|
+
res.end(JSON.stringify({ error: (err as Error).message }));
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
res.writeHead(404, { "Content-Type": "application/json" });
|
|
55
|
+
res.end(JSON.stringify({ error: "Not found" }));
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
let server: Server;
|
|
60
|
+
const baseUrl = `http://localhost:${TEST_PORT}`;
|
|
61
|
+
|
|
62
|
+
beforeAll(async () => {
|
|
63
|
+
await removeDbFiles(TEST_DB_PATH);
|
|
64
|
+
initDb(TEST_DB_PATH);
|
|
65
|
+
server = makeTestServer();
|
|
66
|
+
await new Promise<void>((resolve) => {
|
|
67
|
+
server.listen(TEST_PORT, () => resolve());
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
afterAll(async () => {
|
|
72
|
+
await new Promise<void>((resolve) => {
|
|
73
|
+
server.close(() => resolve());
|
|
74
|
+
});
|
|
75
|
+
closeDb();
|
|
76
|
+
await removeDbFiles(TEST_DB_PATH);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
beforeEach(() => {
|
|
80
|
+
getDb().prepare("DELETE FROM swarm_config").run();
|
|
81
|
+
getDb().prepare("DELETE FROM agents").run();
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
// ─── resolveHarnessProvider ──────────────────────────────────────────────────
|
|
85
|
+
|
|
86
|
+
describe("resolveHarnessProvider", () => {
|
|
87
|
+
test("returns 'claude' when neither env has HARNESS_PROVIDER", () => {
|
|
88
|
+
expect(resolveHarnessProvider({}, {})).toBe("claude");
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("returns the value from resolvedEnv (swarm_config overlay) when present", () => {
|
|
92
|
+
expect(
|
|
93
|
+
resolveHarnessProvider({ HARNESS_PROVIDER: "codex" }, { HARNESS_PROVIDER: "claude" }),
|
|
94
|
+
).toBe("codex");
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("falls back to fallbackEnv when resolvedEnv lacks the key", () => {
|
|
98
|
+
expect(resolveHarnessProvider({}, { HARNESS_PROVIDER: "pi" })).toBe("pi");
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("ignores empty string in resolvedEnv and falls back", () => {
|
|
102
|
+
expect(resolveHarnessProvider({ HARNESS_PROVIDER: " " }, { HARNESS_PROVIDER: "codex" })).toBe(
|
|
103
|
+
"codex",
|
|
104
|
+
);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("invalid value falls back to 'claude' (does not throw)", () => {
|
|
108
|
+
expect(resolveHarnessProvider({ HARNESS_PROVIDER: "not-a-provider" }, {})).toBe("claude");
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("trims whitespace before validating", () => {
|
|
112
|
+
expect(resolveHarnessProvider({ HARNESS_PROVIDER: " codex " }, {})).toBe("codex");
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
// ─── validateConfigValue ─────────────────────────────────────────────────────
|
|
117
|
+
|
|
118
|
+
describe("validateConfigValue", () => {
|
|
119
|
+
test("returns null for keys without a validator", () => {
|
|
120
|
+
expect(validateConfigValue("FOO_BAR", "anything")).toBeNull();
|
|
121
|
+
expect(validateConfigValue("MODEL_OVERRIDE", "sonnet")).toBeNull();
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test("accepts a valid HARNESS_PROVIDER", () => {
|
|
125
|
+
expect(validateConfigValue("HARNESS_PROVIDER", "codex")).toBeNull();
|
|
126
|
+
expect(validateConfigValue("harness_provider", "claude")).toBeNull(); // case-insensitive
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test("rejects an unknown HARNESS_PROVIDER with a helpful error", () => {
|
|
130
|
+
const err = validateConfigValue("HARNESS_PROVIDER", "claude-cod");
|
|
131
|
+
expect(err).not.toBeNull();
|
|
132
|
+
expect(err).toMatch(/HARNESS_PROVIDER/);
|
|
133
|
+
expect(err).toMatch(/claude/);
|
|
134
|
+
expect(err).toMatch(/codex/);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test("rejects non-string values for HARNESS_PROVIDER", () => {
|
|
138
|
+
expect(validateConfigValue("HARNESS_PROVIDER", 42)).not.toBeNull();
|
|
139
|
+
expect(validateConfigValue("HARNESS_PROVIDER", null)).not.toBeNull();
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
// ─── getResolvedConfig — scope precedence for HARNESS_PROVIDER ───────────────
|
|
144
|
+
|
|
145
|
+
describe("getResolvedConfig precedence for HARNESS_PROVIDER", () => {
|
|
146
|
+
test("agent scope wins over global scope", () => {
|
|
147
|
+
const a = createAgent({
|
|
148
|
+
name: "scope-test-1",
|
|
149
|
+
isLead: false,
|
|
150
|
+
status: "idle",
|
|
151
|
+
capabilities: [],
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
upsertSwarmConfig({ scope: "global", key: "HARNESS_PROVIDER", value: "claude" });
|
|
155
|
+
upsertSwarmConfig({
|
|
156
|
+
scope: "agent",
|
|
157
|
+
scopeId: a.id,
|
|
158
|
+
key: "HARNESS_PROVIDER",
|
|
159
|
+
value: "codex",
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
const resolved = getResolvedConfig(a.id);
|
|
163
|
+
const harness = resolved.find((c) => c.key === "HARNESS_PROVIDER");
|
|
164
|
+
expect(harness?.value).toBe("codex");
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test("global scope applies when no agent-scoped row exists", () => {
|
|
168
|
+
const a = createAgent({
|
|
169
|
+
name: "scope-test-2",
|
|
170
|
+
isLead: false,
|
|
171
|
+
status: "idle",
|
|
172
|
+
capabilities: [],
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
upsertSwarmConfig({ scope: "global", key: "HARNESS_PROVIDER", value: "pi" });
|
|
176
|
+
|
|
177
|
+
const resolved = getResolvedConfig(a.id);
|
|
178
|
+
const harness = resolved.find((c) => c.key === "HARNESS_PROVIDER");
|
|
179
|
+
expect(harness?.value).toBe("pi");
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
test("nothing resolved when no rows exist (env fallback handled by runner)", () => {
|
|
183
|
+
const resolved = getResolvedConfig("agent-nonexistent");
|
|
184
|
+
expect(resolved.find((c) => c.key === "HARNESS_PROVIDER")).toBeUndefined();
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
// ─── PUT /api/config — guard rejects invalid HARNESS_PROVIDER ────────────────
|
|
189
|
+
|
|
190
|
+
describe("PUT /api/config rejects invalid HARNESS_PROVIDER", () => {
|
|
191
|
+
test("400 when value is not in ProviderNameSchema", async () => {
|
|
192
|
+
const res = await fetch(`${baseUrl}/api/config`, {
|
|
193
|
+
method: "PUT",
|
|
194
|
+
headers: { "Content-Type": "application/json" },
|
|
195
|
+
body: JSON.stringify({
|
|
196
|
+
scope: "global",
|
|
197
|
+
key: "HARNESS_PROVIDER",
|
|
198
|
+
value: "not-a-real-provider",
|
|
199
|
+
}),
|
|
200
|
+
});
|
|
201
|
+
expect(res.status).toBe(400);
|
|
202
|
+
const body = (await res.json()) as { error: string };
|
|
203
|
+
expect(body.error).toMatch(/HARNESS_PROVIDER/);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
test("200 for a valid value, persists row", async () => {
|
|
207
|
+
const res = await fetch(`${baseUrl}/api/config`, {
|
|
208
|
+
method: "PUT",
|
|
209
|
+
headers: { "Content-Type": "application/json" },
|
|
210
|
+
body: JSON.stringify({
|
|
211
|
+
scope: "global",
|
|
212
|
+
key: "HARNESS_PROVIDER",
|
|
213
|
+
value: "codex",
|
|
214
|
+
}),
|
|
215
|
+
});
|
|
216
|
+
expect(res.status).toBe(200);
|
|
217
|
+
|
|
218
|
+
const rows = getResolvedConfig();
|
|
219
|
+
const harness = rows.find((c) => c.key === "HARNESS_PROVIDER");
|
|
220
|
+
expect(harness?.value).toBe("codex");
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
test("400 still rejects via PUT when scope=agent", async () => {
|
|
224
|
+
const a = createAgent({
|
|
225
|
+
name: "scope-test-3",
|
|
226
|
+
isLead: false,
|
|
227
|
+
status: "idle",
|
|
228
|
+
capabilities: [],
|
|
229
|
+
});
|
|
230
|
+
const res = await fetch(`${baseUrl}/api/config`, {
|
|
231
|
+
method: "PUT",
|
|
232
|
+
headers: { "Content-Type": "application/json" },
|
|
233
|
+
body: JSON.stringify({
|
|
234
|
+
scope: "agent",
|
|
235
|
+
scopeId: a.id,
|
|
236
|
+
key: "HARNESS_PROVIDER",
|
|
237
|
+
value: "claude-codex",
|
|
238
|
+
}),
|
|
239
|
+
});
|
|
240
|
+
expect(res.status).toBe(400);
|
|
241
|
+
});
|
|
242
|
+
});
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { afterAll, beforeAll, beforeEach, describe, expect,
|
|
1
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
2
2
|
import { unlink } from "node:fs/promises";
|
|
3
3
|
import { closeDb, completeTask, createAgent, getDb, getTaskById, initDb } from "../be/db";
|
|
4
4
|
import { upsertOAuthApp } from "../be/db-queries/oauth";
|