@herdctl/core 5.2.1 → 5.3.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/dist/__tests__/fleet-composition-integration.test.d.ts +15 -0
- package/dist/__tests__/fleet-composition-integration.test.d.ts.map +1 -0
- package/dist/__tests__/fleet-composition-integration.test.js +942 -0
- package/dist/__tests__/fleet-composition-integration.test.js.map +1 -0
- package/dist/config/__tests__/fleet-error-messages.test.d.ts +9 -0
- package/dist/config/__tests__/fleet-error-messages.test.d.ts.map +1 -0
- package/dist/config/__tests__/fleet-error-messages.test.js +321 -0
- package/dist/config/__tests__/fleet-error-messages.test.js.map +1 -0
- package/dist/config/__tests__/fleet-loading.test.d.ts +2 -0
- package/dist/config/__tests__/fleet-loading.test.d.ts.map +1 -0
- package/dist/config/__tests__/fleet-loading.test.js +627 -0
- package/dist/config/__tests__/fleet-loading.test.js.map +1 -0
- package/dist/config/__tests__/fleet-naming-and-defaults.test.d.ts +2 -0
- package/dist/config/__tests__/fleet-naming-and-defaults.test.d.ts.map +1 -0
- package/dist/config/__tests__/fleet-naming-and-defaults.test.js +1024 -0
- package/dist/config/__tests__/fleet-naming-and-defaults.test.js.map +1 -0
- package/dist/config/__tests__/schema.test.js +172 -1
- package/dist/config/__tests__/schema.test.js.map +1 -1
- package/dist/config/index.d.ts +2 -2
- package/dist/config/index.d.ts.map +1 -1
- package/dist/config/index.js +2 -2
- package/dist/config/index.js.map +1 -1
- package/dist/config/loader.d.ts +63 -2
- package/dist/config/loader.d.ts.map +1 -1
- package/dist/config/loader.js +309 -74
- package/dist/config/loader.js.map +1 -1
- package/dist/config/schema.d.ts +58 -0
- package/dist/config/schema.d.ts.map +1 -1
- package/dist/config/schema.js +29 -0
- package/dist/config/schema.js.map +1 -1
- package/dist/fleet-manager/__tests__/config-reload-qualified.test.d.ts +8 -0
- package/dist/fleet-manager/__tests__/config-reload-qualified.test.d.ts.map +1 -0
- package/dist/fleet-manager/__tests__/config-reload-qualified.test.js +416 -0
- package/dist/fleet-manager/__tests__/config-reload-qualified.test.js.map +1 -0
- package/dist/fleet-manager/__tests__/integration.test.js +2 -2
- package/dist/fleet-manager/__tests__/integration.test.js.map +1 -1
- package/dist/fleet-manager/chat-manager-interface.d.ts +6 -6
- package/dist/fleet-manager/chat-manager-interface.d.ts.map +1 -1
- package/dist/fleet-manager/config-reload.d.ts.map +1 -1
- package/dist/fleet-manager/config-reload.js +17 -14
- package/dist/fleet-manager/config-reload.js.map +1 -1
- package/dist/fleet-manager/fleet-manager.d.ts +9 -5
- package/dist/fleet-manager/fleet-manager.d.ts.map +1 -1
- package/dist/fleet-manager/fleet-manager.js +18 -9
- package/dist/fleet-manager/fleet-manager.js.map +1 -1
- package/dist/fleet-manager/job-control.js +9 -9
- package/dist/fleet-manager/job-control.js.map +1 -1
- package/dist/fleet-manager/schedule-executor.js +13 -13
- package/dist/fleet-manager/schedule-executor.js.map +1 -1
- package/dist/fleet-manager/schedule-management.d.ts.map +1 -1
- package/dist/fleet-manager/schedule-management.js +22 -16
- package/dist/fleet-manager/schedule-management.js.map +1 -1
- package/dist/fleet-manager/status-queries.d.ts +5 -1
- package/dist/fleet-manager/status-queries.d.ts.map +1 -1
- package/dist/fleet-manager/status-queries.js +16 -8
- package/dist/fleet-manager/status-queries.js.map +1 -1
- package/dist/fleet-manager/types.d.ts +12 -1
- package/dist/fleet-manager/types.d.ts.map +1 -1
- package/dist/runner/__tests__/job-executor.test.js +2 -0
- package/dist/runner/__tests__/job-executor.test.js.map +1 -1
- package/dist/runner/__tests__/sdk-adapter.test.js +2 -0
- package/dist/runner/__tests__/sdk-adapter.test.js.map +1 -1
- package/dist/scheduler/__tests__/schedule-runner.test.js +2 -0
- package/dist/scheduler/__tests__/schedule-runner.test.js.map +1 -1
- package/dist/scheduler/__tests__/scheduler.test.js +5 -3
- package/dist/scheduler/__tests__/scheduler.test.js.map +1 -1
- package/dist/scheduler/schedule-runner.js +12 -12
- package/dist/scheduler/schedule-runner.js.map +1 -1
- package/dist/scheduler/scheduler.js +27 -27
- package/dist/scheduler/scheduler.js.map +1 -1
- package/dist/state/__tests__/session.test.js +27 -0
- package/dist/state/__tests__/session.test.js.map +1 -1
- package/dist/state/fleet-state.d.ts +2 -2
- package/dist/state/fleet-state.js +2 -2
- package/dist/state/schemas/session-info.d.ts +5 -2
- package/dist/state/schemas/session-info.d.ts.map +1 -1
- package/dist/state/schemas/session-info.js +5 -2
- package/dist/state/schemas/session-info.js.map +1 -1
- package/dist/state/session.d.ts +4 -1
- package/dist/state/session.d.ts.map +1 -1
- package/dist/state/session.js +4 -1
- package/dist/state/session.js.map +1 -1
- package/dist/state/utils/__tests__/path-safety.test.js +18 -3
- package/dist/state/utils/__tests__/path-safety.test.js.map +1 -1
- package/dist/state/utils/path-safety.d.ts +4 -2
- package/dist/state/utils/path-safety.d.ts.map +1 -1
- package/dist/state/utils/path-safety.js +5 -3
- package/dist/state/utils/path-safety.js.map +1 -1
- package/dist/work-sources/__tests__/manager.test.js +2 -0
- package/dist/work-sources/__tests__/manager.test.js.map +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,942 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fleet Composition End-to-End Integration Tests
|
|
3
|
+
*
|
|
4
|
+
* These tests exercise the full fleet composition pipeline from config loading
|
|
5
|
+
* through FleetManager initialization and runtime operations. They verify that
|
|
6
|
+
* the entire system works correctly with nested fleets, qualified names, and
|
|
7
|
+
* all related features.
|
|
8
|
+
*
|
|
9
|
+
* These are higher-level integration tests that complement the unit tests in:
|
|
10
|
+
* - packages/core/src/config/__tests__/fleet-loading.test.ts
|
|
11
|
+
* - packages/core/src/config/__tests__/fleet-naming-and-defaults.test.ts
|
|
12
|
+
* - packages/core/src/fleet-manager/__tests__/config-reload-qualified.test.ts
|
|
13
|
+
*/
|
|
14
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
|
15
|
+
import { mkdir, writeFile, rm, realpath } from "node:fs/promises";
|
|
16
|
+
import { join, resolve } from "node:path";
|
|
17
|
+
import { tmpdir } from "node:os";
|
|
18
|
+
import { FleetManager } from "../fleet-manager/fleet-manager.js";
|
|
19
|
+
import { loadConfig, FleetCycleError, FleetNameCollisionError, } from "../config/index.js";
|
|
20
|
+
import { computeConfigChanges } from "../fleet-manager/config-reload.js";
|
|
21
|
+
// Mock the Claude SDK to avoid actual API calls
|
|
22
|
+
vi.mock("@anthropic-ai/claude-agent-sdk", () => ({
|
|
23
|
+
query: vi.fn(),
|
|
24
|
+
}));
|
|
25
|
+
// =============================================================================
|
|
26
|
+
// Test Helpers
|
|
27
|
+
// =============================================================================
|
|
28
|
+
async function createTempDir() {
|
|
29
|
+
const baseDir = join(tmpdir(), `herdctl-integration-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
|
30
|
+
await mkdir(baseDir, { recursive: true });
|
|
31
|
+
return await realpath(baseDir);
|
|
32
|
+
}
|
|
33
|
+
async function createFile(filePath, content) {
|
|
34
|
+
await mkdir(join(filePath, ".."), { recursive: true });
|
|
35
|
+
await writeFile(filePath, content, "utf-8");
|
|
36
|
+
}
|
|
37
|
+
function createSilentLogger() {
|
|
38
|
+
return {
|
|
39
|
+
debug: vi.fn(),
|
|
40
|
+
info: vi.fn(),
|
|
41
|
+
warn: vi.fn(),
|
|
42
|
+
error: vi.fn(),
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
const fixturesDir = resolve(__dirname, "../config/__tests__/fixtures/fleet-composition");
|
|
46
|
+
// =============================================================================
|
|
47
|
+
// Full Pipeline Integration Tests
|
|
48
|
+
// =============================================================================
|
|
49
|
+
describe("Fleet Composition End-to-End Integration", () => {
|
|
50
|
+
describe("Full Pipeline: loadConfig to Flattened Result", () => {
|
|
51
|
+
it("loads multi-level fleet and produces correct flat agent list", async () => {
|
|
52
|
+
const result = await loadConfig(join(fixturesDir, "root.yaml"), {
|
|
53
|
+
env: {},
|
|
54
|
+
envFile: false,
|
|
55
|
+
});
|
|
56
|
+
// Verify total agent count: 2 from project-a + 1 from renamed-b + 1 root monitor
|
|
57
|
+
expect(result.agents).toHaveLength(4);
|
|
58
|
+
// Verify each agent has correct qualified name
|
|
59
|
+
const qualifiedNames = result.agents.map((a) => a.qualifiedName).sort();
|
|
60
|
+
expect(qualifiedNames).toEqual([
|
|
61
|
+
"monitor",
|
|
62
|
+
"project-a.engineer",
|
|
63
|
+
"project-a.security-auditor",
|
|
64
|
+
"renamed-b.designer",
|
|
65
|
+
]);
|
|
66
|
+
// Verify fleet paths are correct
|
|
67
|
+
const monitor = result.agents.find((a) => a.qualifiedName === "monitor");
|
|
68
|
+
expect(monitor?.fleetPath).toEqual([]);
|
|
69
|
+
const auditor = result.agents.find((a) => a.qualifiedName === "project-a.security-auditor");
|
|
70
|
+
expect(auditor?.fleetPath).toEqual(["project-a"]);
|
|
71
|
+
});
|
|
72
|
+
it("deeply nested fleet produces correct qualified names at all levels", async () => {
|
|
73
|
+
const result = await loadConfig(join(fixturesDir, "deep-nesting/root.yaml"), { env: {}, envFile: false });
|
|
74
|
+
// 4 agents: root-agent, l1-agent, l2-agent, l3-agent
|
|
75
|
+
expect(result.agents).toHaveLength(4);
|
|
76
|
+
// Verify qualified names include all intermediate fleet names
|
|
77
|
+
const byQualified = new Map(result.agents.map((a) => [a.qualifiedName, a]));
|
|
78
|
+
expect(byQualified.get("root-agent")?.fleetPath).toEqual([]);
|
|
79
|
+
expect(byQualified.get("level1.l1-agent")?.fleetPath).toEqual(["level1"]);
|
|
80
|
+
expect(byQualified.get("level1.level2.l2-agent")?.fleetPath).toEqual([
|
|
81
|
+
"level1",
|
|
82
|
+
"level2",
|
|
83
|
+
]);
|
|
84
|
+
expect(byQualified.get("level1.level2.level3.l3-agent")?.fleetPath).toEqual(["level1", "level2", "level3"]);
|
|
85
|
+
});
|
|
86
|
+
it("guarantees qualified name uniqueness across entire fleet tree", async () => {
|
|
87
|
+
const result = await loadConfig(join(fixturesDir, "root.yaml"), {
|
|
88
|
+
env: {},
|
|
89
|
+
envFile: false,
|
|
90
|
+
});
|
|
91
|
+
const qualifiedNames = result.agents.map((a) => a.qualifiedName);
|
|
92
|
+
const uniqueNames = new Set(qualifiedNames);
|
|
93
|
+
expect(uniqueNames.size).toBe(qualifiedNames.length);
|
|
94
|
+
});
|
|
95
|
+
it("defaults merge correctly across multiple levels", async () => {
|
|
96
|
+
const result = await loadConfig(join(fixturesDir, "defaults-cascade/root.yaml"), { env: {}, envFile: false });
|
|
97
|
+
// inheritor agent inherits from sub-fleet defaults (sub-model) not super-fleet
|
|
98
|
+
const inheritor = result.agents.find((a) => a.name === "inheritor");
|
|
99
|
+
expect(inheritor?.model).toBe("sub-model");
|
|
100
|
+
// max_turns from super-fleet fills the gap
|
|
101
|
+
expect(inheritor?.max_turns).toBe(200);
|
|
102
|
+
// own-model agent sets its own values
|
|
103
|
+
const ownModel = result.agents.find((a) => a.name === "own-model");
|
|
104
|
+
expect(ownModel?.model).toBe("agent-model");
|
|
105
|
+
expect(ownModel?.max_turns).toBe(5);
|
|
106
|
+
});
|
|
107
|
+
it("web config is suppressed on sub-fleets, honored on root", async () => {
|
|
108
|
+
const result = await loadConfig(join(fixturesDir, "root.yaml"), {
|
|
109
|
+
env: {},
|
|
110
|
+
envFile: false,
|
|
111
|
+
});
|
|
112
|
+
// Root fleet web config should be preserved
|
|
113
|
+
expect(result.fleet.web?.enabled).toBe(true);
|
|
114
|
+
expect(result.fleet.web?.port).toBe(3232);
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
describe("Error Detection in Composition", () => {
|
|
118
|
+
it("detects and reports cycle errors with clear path chain", async () => {
|
|
119
|
+
try {
|
|
120
|
+
await loadConfig(join(fixturesDir, "cycle-root.yaml"), {
|
|
121
|
+
env: {},
|
|
122
|
+
envFile: false,
|
|
123
|
+
});
|
|
124
|
+
expect.fail("Should have thrown FleetCycleError");
|
|
125
|
+
}
|
|
126
|
+
catch (error) {
|
|
127
|
+
expect(error).toBeInstanceOf(FleetCycleError);
|
|
128
|
+
const cycleError = error;
|
|
129
|
+
expect(cycleError.message).toContain("Fleet composition cycle detected");
|
|
130
|
+
expect(cycleError.pathChain.length).toBeGreaterThanOrEqual(2);
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
it("detects and reports fleet name collisions with actionable message", async () => {
|
|
134
|
+
try {
|
|
135
|
+
await loadConfig(join(fixturesDir, "collision-root.yaml"), {
|
|
136
|
+
env: {},
|
|
137
|
+
envFile: false,
|
|
138
|
+
});
|
|
139
|
+
expect.fail("Should have thrown FleetNameCollisionError");
|
|
140
|
+
}
|
|
141
|
+
catch (error) {
|
|
142
|
+
expect(error).toBeInstanceOf(FleetNameCollisionError);
|
|
143
|
+
const collisionError = error;
|
|
144
|
+
expect(collisionError.message).toContain("Fleet name collision");
|
|
145
|
+
expect(collisionError.message).toContain("disambiguate");
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
// =============================================================================
|
|
151
|
+
// FleetManager Integration Tests
|
|
152
|
+
// =============================================================================
|
|
153
|
+
describe("FleetManager with Fleet Composition", () => {
|
|
154
|
+
let tempDir;
|
|
155
|
+
let stateDir;
|
|
156
|
+
beforeEach(async () => {
|
|
157
|
+
tempDir = await createTempDir();
|
|
158
|
+
stateDir = join(tempDir, ".herdctl");
|
|
159
|
+
});
|
|
160
|
+
afterEach(async () => {
|
|
161
|
+
await rm(tempDir, { recursive: true, force: true });
|
|
162
|
+
});
|
|
163
|
+
describe("Initialization with Nested Fleets", () => {
|
|
164
|
+
it("initializes correctly with multi-level fleet config", async () => {
|
|
165
|
+
// Create a multi-level fleet structure
|
|
166
|
+
await createFile(join(tempDir, "herdctl.yaml"), `
|
|
167
|
+
version: 1
|
|
168
|
+
fleets:
|
|
169
|
+
- path: ./project-a/herdctl.yaml
|
|
170
|
+
- path: ./project-b/herdctl.yaml
|
|
171
|
+
agents:
|
|
172
|
+
- path: ./agents/root-monitor.yaml
|
|
173
|
+
`);
|
|
174
|
+
await createFile(join(tempDir, "project-a", "herdctl.yaml"), `
|
|
175
|
+
version: 1
|
|
176
|
+
fleet:
|
|
177
|
+
name: project-a
|
|
178
|
+
agents:
|
|
179
|
+
- path: ./agents/auditor.yaml
|
|
180
|
+
- path: ./agents/engineer.yaml
|
|
181
|
+
`);
|
|
182
|
+
await createFile(join(tempDir, "project-b", "herdctl.yaml"), `
|
|
183
|
+
version: 1
|
|
184
|
+
fleet:
|
|
185
|
+
name: project-b
|
|
186
|
+
fleets:
|
|
187
|
+
- path: ./frontend/herdctl.yaml
|
|
188
|
+
agents:
|
|
189
|
+
- path: ./agents/backend.yaml
|
|
190
|
+
`);
|
|
191
|
+
await createFile(join(tempDir, "project-b", "frontend", "herdctl.yaml"), `
|
|
192
|
+
version: 1
|
|
193
|
+
fleet:
|
|
194
|
+
name: frontend
|
|
195
|
+
agents:
|
|
196
|
+
- path: ./agents/designer.yaml
|
|
197
|
+
`);
|
|
198
|
+
await createFile(join(tempDir, "agents", "root-monitor.yaml"), "name: root-monitor");
|
|
199
|
+
await createFile(join(tempDir, "project-a", "agents", "auditor.yaml"), "name: auditor");
|
|
200
|
+
await createFile(join(tempDir, "project-a", "agents", "engineer.yaml"), "name: engineer");
|
|
201
|
+
await createFile(join(tempDir, "project-b", "agents", "backend.yaml"), "name: backend");
|
|
202
|
+
await createFile(join(tempDir, "project-b", "frontend", "agents", "designer.yaml"), "name: designer");
|
|
203
|
+
const manager = new FleetManager({
|
|
204
|
+
configPath: tempDir,
|
|
205
|
+
stateDir,
|
|
206
|
+
logger: createSilentLogger(),
|
|
207
|
+
});
|
|
208
|
+
await manager.initialize();
|
|
209
|
+
// Verify total agent count
|
|
210
|
+
expect(manager.state.agentCount).toBe(5);
|
|
211
|
+
});
|
|
212
|
+
it("getFleetStatus returns agents with correct qualified names and fleetPath", async () => {
|
|
213
|
+
await createFile(join(tempDir, "herdctl.yaml"), `
|
|
214
|
+
version: 1
|
|
215
|
+
fleets:
|
|
216
|
+
- path: ./sub/herdctl.yaml
|
|
217
|
+
agents:
|
|
218
|
+
- path: ./agents/root-agent.yaml
|
|
219
|
+
`);
|
|
220
|
+
await createFile(join(tempDir, "sub", "herdctl.yaml"), `
|
|
221
|
+
version: 1
|
|
222
|
+
fleet:
|
|
223
|
+
name: sub-fleet
|
|
224
|
+
agents:
|
|
225
|
+
- path: ./agents/sub-agent.yaml
|
|
226
|
+
`);
|
|
227
|
+
await createFile(join(tempDir, "agents", "root-agent.yaml"), "name: root-agent");
|
|
228
|
+
await createFile(join(tempDir, "sub", "agents", "sub-agent.yaml"), "name: sub-agent");
|
|
229
|
+
const manager = new FleetManager({
|
|
230
|
+
configPath: tempDir,
|
|
231
|
+
stateDir,
|
|
232
|
+
logger: createSilentLogger(),
|
|
233
|
+
});
|
|
234
|
+
await manager.initialize();
|
|
235
|
+
const status = await manager.getFleetStatus();
|
|
236
|
+
expect(status.counts.totalAgents).toBe(2);
|
|
237
|
+
const agentInfoList = await manager.getAgentInfo();
|
|
238
|
+
expect(agentInfoList).toHaveLength(2);
|
|
239
|
+
// Verify root agent
|
|
240
|
+
const rootAgent = agentInfoList.find((a) => a.qualifiedName === "root-agent");
|
|
241
|
+
expect(rootAgent).toBeDefined();
|
|
242
|
+
expect(rootAgent.fleetPath).toEqual([]);
|
|
243
|
+
expect(rootAgent.name).toBe("root-agent");
|
|
244
|
+
// Verify sub-fleet agent
|
|
245
|
+
const subAgent = agentInfoList.find((a) => a.qualifiedName === "sub-fleet.sub-agent");
|
|
246
|
+
expect(subAgent).toBeDefined();
|
|
247
|
+
expect(subAgent.fleetPath).toEqual(["sub-fleet"]);
|
|
248
|
+
expect(subAgent.name).toBe("sub-agent");
|
|
249
|
+
});
|
|
250
|
+
it("getAgentInfoByName works with qualified names", async () => {
|
|
251
|
+
await createFile(join(tempDir, "herdctl.yaml"), `
|
|
252
|
+
version: 1
|
|
253
|
+
fleets:
|
|
254
|
+
- path: ./sub/herdctl.yaml
|
|
255
|
+
`);
|
|
256
|
+
await createFile(join(tempDir, "sub", "herdctl.yaml"), `
|
|
257
|
+
version: 1
|
|
258
|
+
fleet:
|
|
259
|
+
name: my-fleet
|
|
260
|
+
agents:
|
|
261
|
+
- path: ./agents/worker.yaml
|
|
262
|
+
`);
|
|
263
|
+
await createFile(join(tempDir, "sub", "agents", "worker.yaml"), `
|
|
264
|
+
name: worker
|
|
265
|
+
description: A worker agent
|
|
266
|
+
`);
|
|
267
|
+
const manager = new FleetManager({
|
|
268
|
+
configPath: tempDir,
|
|
269
|
+
stateDir,
|
|
270
|
+
logger: createSilentLogger(),
|
|
271
|
+
});
|
|
272
|
+
await manager.initialize();
|
|
273
|
+
// Lookup by qualified name
|
|
274
|
+
const agentByQualified = await manager.getAgentInfoByName("my-fleet.worker");
|
|
275
|
+
expect(agentByQualified.qualifiedName).toBe("my-fleet.worker");
|
|
276
|
+
expect(agentByQualified.name).toBe("worker");
|
|
277
|
+
expect(agentByQualified.fleetPath).toEqual(["my-fleet"]);
|
|
278
|
+
expect(agentByQualified.description).toBe("A worker agent");
|
|
279
|
+
// Fallback to local name should also work
|
|
280
|
+
const agentByLocal = await manager.getAgentInfoByName("worker");
|
|
281
|
+
expect(agentByLocal.qualifiedName).toBe("my-fleet.worker");
|
|
282
|
+
});
|
|
283
|
+
it("same local agent name in different fleets produces distinct qualified names", async () => {
|
|
284
|
+
await createFile(join(tempDir, "herdctl.yaml"), `
|
|
285
|
+
version: 1
|
|
286
|
+
fleets:
|
|
287
|
+
- path: ./fleet-a/herdctl.yaml
|
|
288
|
+
- path: ./fleet-b/herdctl.yaml
|
|
289
|
+
`);
|
|
290
|
+
await createFile(join(tempDir, "fleet-a", "herdctl.yaml"), `
|
|
291
|
+
version: 1
|
|
292
|
+
fleet:
|
|
293
|
+
name: fleet-a
|
|
294
|
+
agents:
|
|
295
|
+
- path: ./agents/worker.yaml
|
|
296
|
+
`);
|
|
297
|
+
await createFile(join(tempDir, "fleet-b", "herdctl.yaml"), `
|
|
298
|
+
version: 1
|
|
299
|
+
fleet:
|
|
300
|
+
name: fleet-b
|
|
301
|
+
agents:
|
|
302
|
+
- path: ./agents/worker.yaml
|
|
303
|
+
`);
|
|
304
|
+
await createFile(join(tempDir, "fleet-a", "agents", "worker.yaml"), "name: worker");
|
|
305
|
+
await createFile(join(tempDir, "fleet-b", "agents", "worker.yaml"), "name: worker");
|
|
306
|
+
const manager = new FleetManager({
|
|
307
|
+
configPath: tempDir,
|
|
308
|
+
stateDir,
|
|
309
|
+
logger: createSilentLogger(),
|
|
310
|
+
});
|
|
311
|
+
await manager.initialize();
|
|
312
|
+
const agentInfoList = await manager.getAgentInfo();
|
|
313
|
+
expect(agentInfoList).toHaveLength(2);
|
|
314
|
+
// Both agents named "worker" but with different qualified names
|
|
315
|
+
const fleetAWorker = agentInfoList.find((a) => a.qualifiedName === "fleet-a.worker");
|
|
316
|
+
const fleetBWorker = agentInfoList.find((a) => a.qualifiedName === "fleet-b.worker");
|
|
317
|
+
expect(fleetAWorker).toBeDefined();
|
|
318
|
+
expect(fleetBWorker).toBeDefined();
|
|
319
|
+
expect(fleetAWorker.qualifiedName).not.toBe(fleetBWorker.qualifiedName);
|
|
320
|
+
});
|
|
321
|
+
it("rejects duplicate qualified names with clear error", async () => {
|
|
322
|
+
// Create two agents that would have the same qualified name
|
|
323
|
+
// This shouldn't happen with proper fleet structure, but test defense
|
|
324
|
+
await createFile(join(tempDir, "herdctl.yaml"), `
|
|
325
|
+
version: 1
|
|
326
|
+
agents:
|
|
327
|
+
- path: ./agents/duplicate.yaml
|
|
328
|
+
- path: ./agents/also-duplicate.yaml
|
|
329
|
+
`);
|
|
330
|
+
await createFile(join(tempDir, "agents", "duplicate.yaml"), "name: same-name");
|
|
331
|
+
await createFile(join(tempDir, "agents", "also-duplicate.yaml"), "name: same-name");
|
|
332
|
+
const manager = new FleetManager({
|
|
333
|
+
configPath: tempDir,
|
|
334
|
+
stateDir,
|
|
335
|
+
logger: createSilentLogger(),
|
|
336
|
+
});
|
|
337
|
+
await expect(manager.initialize()).rejects.toThrow(/Duplicate agent qualified name.*"same-name"/);
|
|
338
|
+
});
|
|
339
|
+
});
|
|
340
|
+
describe("Trigger Operations with Qualified Names", () => {
|
|
341
|
+
it("trigger works with qualified name", async () => {
|
|
342
|
+
await createFile(join(tempDir, "herdctl.yaml"), `
|
|
343
|
+
version: 1
|
|
344
|
+
fleets:
|
|
345
|
+
- path: ./sub/herdctl.yaml
|
|
346
|
+
`);
|
|
347
|
+
await createFile(join(tempDir, "sub", "herdctl.yaml"), `
|
|
348
|
+
version: 1
|
|
349
|
+
fleet:
|
|
350
|
+
name: my-fleet
|
|
351
|
+
agents:
|
|
352
|
+
- path: ./agents/worker.yaml
|
|
353
|
+
`);
|
|
354
|
+
await createFile(join(tempDir, "sub", "agents", "worker.yaml"), `
|
|
355
|
+
name: worker
|
|
356
|
+
schedules:
|
|
357
|
+
check:
|
|
358
|
+
type: interval
|
|
359
|
+
interval: 1h
|
|
360
|
+
prompt: Check status
|
|
361
|
+
enabled: false
|
|
362
|
+
`);
|
|
363
|
+
const manager = new FleetManager({
|
|
364
|
+
configPath: tempDir,
|
|
365
|
+
stateDir,
|
|
366
|
+
logger: createSilentLogger(),
|
|
367
|
+
});
|
|
368
|
+
await manager.initialize();
|
|
369
|
+
// Trigger using qualified name
|
|
370
|
+
const result = await manager.trigger("my-fleet.worker", "check");
|
|
371
|
+
expect(result.agentName).toBe("my-fleet.worker");
|
|
372
|
+
expect(result.scheduleName).toBe("check");
|
|
373
|
+
expect(result.jobId).toMatch(/^job-\d{4}-\d{2}-\d{2}-[a-z0-9]{6}$/);
|
|
374
|
+
});
|
|
375
|
+
it("trigger requires qualified name for sub-fleet agents", async () => {
|
|
376
|
+
await createFile(join(tempDir, "herdctl.yaml"), `
|
|
377
|
+
version: 1
|
|
378
|
+
fleets:
|
|
379
|
+
- path: ./sub/herdctl.yaml
|
|
380
|
+
`);
|
|
381
|
+
await createFile(join(tempDir, "sub", "herdctl.yaml"), `
|
|
382
|
+
version: 1
|
|
383
|
+
fleet:
|
|
384
|
+
name: my-fleet
|
|
385
|
+
agents:
|
|
386
|
+
- path: ./agents/worker.yaml
|
|
387
|
+
`);
|
|
388
|
+
await createFile(join(tempDir, "sub", "agents", "worker.yaml"), "name: worker");
|
|
389
|
+
const manager = new FleetManager({
|
|
390
|
+
configPath: tempDir,
|
|
391
|
+
stateDir,
|
|
392
|
+
logger: createSilentLogger(),
|
|
393
|
+
});
|
|
394
|
+
await manager.initialize();
|
|
395
|
+
// Trigger requires qualified name - local name alone won't work
|
|
396
|
+
// (unlike getAgentInfoByName which has fallback)
|
|
397
|
+
const { AgentNotFoundError } = await import("../fleet-manager/errors.js");
|
|
398
|
+
await expect(manager.trigger("worker")).rejects.toThrow(AgentNotFoundError);
|
|
399
|
+
// But qualified name works
|
|
400
|
+
const result = await manager.trigger("my-fleet.worker");
|
|
401
|
+
expect(result.agentName).toBe("my-fleet.worker");
|
|
402
|
+
});
|
|
403
|
+
});
|
|
404
|
+
describe("Schedule Operations with Qualified Names", () => {
|
|
405
|
+
it("getSchedules returns schedules with qualified agent names", async () => {
|
|
406
|
+
await createFile(join(tempDir, "herdctl.yaml"), `
|
|
407
|
+
version: 1
|
|
408
|
+
fleets:
|
|
409
|
+
- path: ./sub/herdctl.yaml
|
|
410
|
+
agents:
|
|
411
|
+
- path: ./agents/root.yaml
|
|
412
|
+
`);
|
|
413
|
+
await createFile(join(tempDir, "sub", "herdctl.yaml"), `
|
|
414
|
+
version: 1
|
|
415
|
+
fleet:
|
|
416
|
+
name: sub
|
|
417
|
+
agents:
|
|
418
|
+
- path: ./agents/scheduled.yaml
|
|
419
|
+
`);
|
|
420
|
+
await createFile(join(tempDir, "agents", "root.yaml"), `
|
|
421
|
+
name: root
|
|
422
|
+
schedules:
|
|
423
|
+
daily:
|
|
424
|
+
type: interval
|
|
425
|
+
interval: 24h
|
|
426
|
+
`);
|
|
427
|
+
await createFile(join(tempDir, "sub", "agents", "scheduled.yaml"), `
|
|
428
|
+
name: scheduled
|
|
429
|
+
schedules:
|
|
430
|
+
hourly:
|
|
431
|
+
type: interval
|
|
432
|
+
interval: 1h
|
|
433
|
+
`);
|
|
434
|
+
const manager = new FleetManager({
|
|
435
|
+
configPath: tempDir,
|
|
436
|
+
stateDir,
|
|
437
|
+
logger: createSilentLogger(),
|
|
438
|
+
});
|
|
439
|
+
await manager.initialize();
|
|
440
|
+
const schedules = await manager.getSchedules();
|
|
441
|
+
expect(schedules).toHaveLength(2);
|
|
442
|
+
// Verify qualified names in schedule agent references
|
|
443
|
+
const rootSchedule = schedules.find((s) => s.name === "daily");
|
|
444
|
+
expect(rootSchedule?.agentName).toBe("root");
|
|
445
|
+
const subSchedule = schedules.find((s) => s.name === "hourly");
|
|
446
|
+
expect(subSchedule?.agentName).toBe("sub.scheduled");
|
|
447
|
+
});
|
|
448
|
+
it("enable/disable schedule works with qualified names", async () => {
|
|
449
|
+
await createFile(join(tempDir, "herdctl.yaml"), `
|
|
450
|
+
version: 1
|
|
451
|
+
fleets:
|
|
452
|
+
- path: ./sub/herdctl.yaml
|
|
453
|
+
`);
|
|
454
|
+
await createFile(join(tempDir, "sub", "herdctl.yaml"), `
|
|
455
|
+
version: 1
|
|
456
|
+
fleet:
|
|
457
|
+
name: my-sub
|
|
458
|
+
agents:
|
|
459
|
+
- path: ./agents/worker.yaml
|
|
460
|
+
`);
|
|
461
|
+
await createFile(join(tempDir, "sub", "agents", "worker.yaml"), `
|
|
462
|
+
name: worker
|
|
463
|
+
schedules:
|
|
464
|
+
check:
|
|
465
|
+
type: interval
|
|
466
|
+
interval: 1h
|
|
467
|
+
`);
|
|
468
|
+
const manager = new FleetManager({
|
|
469
|
+
configPath: tempDir,
|
|
470
|
+
stateDir,
|
|
471
|
+
logger: createSilentLogger(),
|
|
472
|
+
});
|
|
473
|
+
await manager.initialize();
|
|
474
|
+
// Disable using qualified name
|
|
475
|
+
await manager.disableSchedule("my-sub.worker", "check");
|
|
476
|
+
let schedule = await manager.getSchedule("my-sub.worker", "check");
|
|
477
|
+
expect(schedule.status).toBe("disabled");
|
|
478
|
+
// Re-enable
|
|
479
|
+
await manager.enableSchedule("my-sub.worker", "check");
|
|
480
|
+
schedule = await manager.getSchedule("my-sub.worker", "check");
|
|
481
|
+
expect(schedule.status).toBe("idle");
|
|
482
|
+
});
|
|
483
|
+
});
|
|
484
|
+
});
|
|
485
|
+
// =============================================================================
|
|
486
|
+
// Config Reload Integration Tests
|
|
487
|
+
// =============================================================================
|
|
488
|
+
describe("Config Reload with Fleet Composition", () => {
|
|
489
|
+
let tempDir;
|
|
490
|
+
let stateDir;
|
|
491
|
+
beforeEach(async () => {
|
|
492
|
+
tempDir = await createTempDir();
|
|
493
|
+
stateDir = join(tempDir, ".herdctl");
|
|
494
|
+
});
|
|
495
|
+
afterEach(async () => {
|
|
496
|
+
await rm(tempDir, { recursive: true, force: true });
|
|
497
|
+
});
|
|
498
|
+
it("detects new agent in sub-fleet with qualified name", async () => {
|
|
499
|
+
await createFile(join(tempDir, "herdctl.yaml"), `
|
|
500
|
+
version: 1
|
|
501
|
+
fleets:
|
|
502
|
+
- path: ./sub/herdctl.yaml
|
|
503
|
+
`);
|
|
504
|
+
await createFile(join(tempDir, "sub", "herdctl.yaml"), `
|
|
505
|
+
version: 1
|
|
506
|
+
fleet:
|
|
507
|
+
name: my-fleet
|
|
508
|
+
agents:
|
|
509
|
+
- path: ./agents/original.yaml
|
|
510
|
+
`);
|
|
511
|
+
await createFile(join(tempDir, "sub", "agents", "original.yaml"), "name: original");
|
|
512
|
+
const manager = new FleetManager({
|
|
513
|
+
configPath: tempDir,
|
|
514
|
+
stateDir,
|
|
515
|
+
logger: createSilentLogger(),
|
|
516
|
+
});
|
|
517
|
+
await manager.initialize();
|
|
518
|
+
expect(manager.state.agentCount).toBe(1);
|
|
519
|
+
// Add a new agent to the sub-fleet
|
|
520
|
+
await createFile(join(tempDir, "sub", "herdctl.yaml"), `
|
|
521
|
+
version: 1
|
|
522
|
+
fleet:
|
|
523
|
+
name: my-fleet
|
|
524
|
+
agents:
|
|
525
|
+
- path: ./agents/original.yaml
|
|
526
|
+
- path: ./agents/new-agent.yaml
|
|
527
|
+
`);
|
|
528
|
+
await createFile(join(tempDir, "sub", "agents", "new-agent.yaml"), "name: new-agent");
|
|
529
|
+
// Reload and verify
|
|
530
|
+
const reloadResult = await manager.reload();
|
|
531
|
+
expect(reloadResult.agentCount).toBe(2);
|
|
532
|
+
expect(reloadResult.changes).toContainEqual(expect.objectContaining({
|
|
533
|
+
type: "added",
|
|
534
|
+
category: "agent",
|
|
535
|
+
name: "my-fleet.new-agent",
|
|
536
|
+
}));
|
|
537
|
+
});
|
|
538
|
+
it("detects removed sub-fleet agents with qualified names", async () => {
|
|
539
|
+
await createFile(join(tempDir, "herdctl.yaml"), `
|
|
540
|
+
version: 1
|
|
541
|
+
fleets:
|
|
542
|
+
- path: ./project-a/herdctl.yaml
|
|
543
|
+
- path: ./project-b/herdctl.yaml
|
|
544
|
+
`);
|
|
545
|
+
await createFile(join(tempDir, "project-a", "herdctl.yaml"), `
|
|
546
|
+
version: 1
|
|
547
|
+
fleet:
|
|
548
|
+
name: project-a
|
|
549
|
+
agents:
|
|
550
|
+
- path: ./agents/worker.yaml
|
|
551
|
+
`);
|
|
552
|
+
await createFile(join(tempDir, "project-b", "herdctl.yaml"), `
|
|
553
|
+
version: 1
|
|
554
|
+
fleet:
|
|
555
|
+
name: project-b
|
|
556
|
+
agents:
|
|
557
|
+
- path: ./agents/worker.yaml
|
|
558
|
+
`);
|
|
559
|
+
await createFile(join(tempDir, "project-a", "agents", "worker.yaml"), "name: worker");
|
|
560
|
+
await createFile(join(tempDir, "project-b", "agents", "worker.yaml"), "name: worker");
|
|
561
|
+
const manager = new FleetManager({
|
|
562
|
+
configPath: tempDir,
|
|
563
|
+
stateDir,
|
|
564
|
+
logger: createSilentLogger(),
|
|
565
|
+
});
|
|
566
|
+
await manager.initialize();
|
|
567
|
+
expect(manager.state.agentCount).toBe(2);
|
|
568
|
+
// Remove project-b from the fleet
|
|
569
|
+
await createFile(join(tempDir, "herdctl.yaml"), `
|
|
570
|
+
version: 1
|
|
571
|
+
fleets:
|
|
572
|
+
- path: ./project-a/herdctl.yaml
|
|
573
|
+
`);
|
|
574
|
+
const reloadResult = await manager.reload();
|
|
575
|
+
expect(reloadResult.agentCount).toBe(1);
|
|
576
|
+
expect(reloadResult.changes).toContainEqual(expect.objectContaining({
|
|
577
|
+
type: "removed",
|
|
578
|
+
category: "agent",
|
|
579
|
+
name: "project-b.worker",
|
|
580
|
+
}));
|
|
581
|
+
});
|
|
582
|
+
it("detects modified agent config in sub-fleet with qualified name", async () => {
|
|
583
|
+
await createFile(join(tempDir, "herdctl.yaml"), `
|
|
584
|
+
version: 1
|
|
585
|
+
fleets:
|
|
586
|
+
- path: ./sub/herdctl.yaml
|
|
587
|
+
`);
|
|
588
|
+
await createFile(join(tempDir, "sub", "herdctl.yaml"), `
|
|
589
|
+
version: 1
|
|
590
|
+
fleet:
|
|
591
|
+
name: sub
|
|
592
|
+
agents:
|
|
593
|
+
- path: ./agents/changeable.yaml
|
|
594
|
+
`);
|
|
595
|
+
await createFile(join(tempDir, "sub", "agents", "changeable.yaml"), `
|
|
596
|
+
name: changeable
|
|
597
|
+
description: Original description
|
|
598
|
+
`);
|
|
599
|
+
const manager = new FleetManager({
|
|
600
|
+
configPath: tempDir,
|
|
601
|
+
stateDir,
|
|
602
|
+
logger: createSilentLogger(),
|
|
603
|
+
});
|
|
604
|
+
await manager.initialize();
|
|
605
|
+
// Modify the agent's config
|
|
606
|
+
await createFile(join(tempDir, "sub", "agents", "changeable.yaml"), `
|
|
607
|
+
name: changeable
|
|
608
|
+
description: Updated description
|
|
609
|
+
`);
|
|
610
|
+
const reloadResult = await manager.reload();
|
|
611
|
+
expect(reloadResult.changes).toContainEqual(expect.objectContaining({
|
|
612
|
+
type: "modified",
|
|
613
|
+
category: "agent",
|
|
614
|
+
name: "sub.changeable",
|
|
615
|
+
details: expect.stringContaining("description"),
|
|
616
|
+
}));
|
|
617
|
+
});
|
|
618
|
+
it("detects schedule changes with qualified agent names", async () => {
|
|
619
|
+
await createFile(join(tempDir, "herdctl.yaml"), `
|
|
620
|
+
version: 1
|
|
621
|
+
fleets:
|
|
622
|
+
- path: ./sub/herdctl.yaml
|
|
623
|
+
`);
|
|
624
|
+
await createFile(join(tempDir, "sub", "herdctl.yaml"), `
|
|
625
|
+
version: 1
|
|
626
|
+
fleet:
|
|
627
|
+
name: my-fleet
|
|
628
|
+
agents:
|
|
629
|
+
- path: ./agents/scheduled.yaml
|
|
630
|
+
`);
|
|
631
|
+
await createFile(join(tempDir, "sub", "agents", "scheduled.yaml"), `
|
|
632
|
+
name: scheduled
|
|
633
|
+
schedules:
|
|
634
|
+
old-schedule:
|
|
635
|
+
type: interval
|
|
636
|
+
interval: 1h
|
|
637
|
+
`);
|
|
638
|
+
const manager = new FleetManager({
|
|
639
|
+
configPath: tempDir,
|
|
640
|
+
stateDir,
|
|
641
|
+
logger: createSilentLogger(),
|
|
642
|
+
});
|
|
643
|
+
await manager.initialize();
|
|
644
|
+
// Change the schedule
|
|
645
|
+
await createFile(join(tempDir, "sub", "agents", "scheduled.yaml"), `
|
|
646
|
+
name: scheduled
|
|
647
|
+
schedules:
|
|
648
|
+
new-schedule:
|
|
649
|
+
type: interval
|
|
650
|
+
interval: 2h
|
|
651
|
+
`);
|
|
652
|
+
const reloadResult = await manager.reload();
|
|
653
|
+
// Should have schedule removed and added
|
|
654
|
+
expect(reloadResult.changes).toContainEqual(expect.objectContaining({
|
|
655
|
+
type: "removed",
|
|
656
|
+
category: "schedule",
|
|
657
|
+
name: "my-fleet.scheduled/old-schedule",
|
|
658
|
+
}));
|
|
659
|
+
expect(reloadResult.changes).toContainEqual(expect.objectContaining({
|
|
660
|
+
type: "added",
|
|
661
|
+
category: "schedule",
|
|
662
|
+
name: "my-fleet.scheduled/new-schedule",
|
|
663
|
+
}));
|
|
664
|
+
});
|
|
665
|
+
it("no changes reported when sub-fleet ordering changes but content is same", async () => {
|
|
666
|
+
await createFile(join(tempDir, "herdctl.yaml"), `
|
|
667
|
+
version: 1
|
|
668
|
+
fleets:
|
|
669
|
+
- path: ./fleet-a/herdctl.yaml
|
|
670
|
+
- path: ./fleet-b/herdctl.yaml
|
|
671
|
+
`);
|
|
672
|
+
await createFile(join(tempDir, "fleet-a", "herdctl.yaml"), `
|
|
673
|
+
version: 1
|
|
674
|
+
fleet:
|
|
675
|
+
name: fleet-a
|
|
676
|
+
agents:
|
|
677
|
+
- path: ./agents/worker.yaml
|
|
678
|
+
`);
|
|
679
|
+
await createFile(join(tempDir, "fleet-b", "herdctl.yaml"), `
|
|
680
|
+
version: 1
|
|
681
|
+
fleet:
|
|
682
|
+
name: fleet-b
|
|
683
|
+
agents:
|
|
684
|
+
- path: ./agents/worker.yaml
|
|
685
|
+
`);
|
|
686
|
+
await createFile(join(tempDir, "fleet-a", "agents", "worker.yaml"), "name: worker-a");
|
|
687
|
+
await createFile(join(tempDir, "fleet-b", "agents", "worker.yaml"), "name: worker-b");
|
|
688
|
+
const manager = new FleetManager({
|
|
689
|
+
configPath: tempDir,
|
|
690
|
+
stateDir,
|
|
691
|
+
logger: createSilentLogger(),
|
|
692
|
+
});
|
|
693
|
+
await manager.initialize();
|
|
694
|
+
// Reorder fleets (swap order)
|
|
695
|
+
await createFile(join(tempDir, "herdctl.yaml"), `
|
|
696
|
+
version: 1
|
|
697
|
+
fleets:
|
|
698
|
+
- path: ./fleet-b/herdctl.yaml
|
|
699
|
+
- path: ./fleet-a/herdctl.yaml
|
|
700
|
+
`);
|
|
701
|
+
const reloadResult = await manager.reload();
|
|
702
|
+
// No agent changes - just ordering difference
|
|
703
|
+
const agentChanges = reloadResult.changes.filter((c) => c.category === "agent");
|
|
704
|
+
expect(agentChanges).toHaveLength(0);
|
|
705
|
+
});
|
|
706
|
+
});
|
|
707
|
+
// =============================================================================
|
|
708
|
+
// computeConfigChanges Direct Tests
|
|
709
|
+
// =============================================================================
|
|
710
|
+
describe("computeConfigChanges with Qualified Names (Unit-Level)", () => {
|
|
711
|
+
function makeAgent(name, fleetPath = [], overrides = {}) {
|
|
712
|
+
const qualifiedName = fleetPath.length > 0 ? fleetPath.join(".") + "." + name : name;
|
|
713
|
+
return {
|
|
714
|
+
name,
|
|
715
|
+
configPath: `/fake/${name}.yaml`,
|
|
716
|
+
fleetPath: [...fleetPath],
|
|
717
|
+
qualifiedName,
|
|
718
|
+
...overrides,
|
|
719
|
+
};
|
|
720
|
+
}
|
|
721
|
+
function makeConfig(agents) {
|
|
722
|
+
return {
|
|
723
|
+
fleet: { version: 1, agents: [] },
|
|
724
|
+
agents,
|
|
725
|
+
configPath: "/fake/herdctl.yaml",
|
|
726
|
+
configDir: "/fake",
|
|
727
|
+
};
|
|
728
|
+
}
|
|
729
|
+
it("uses qualified name as diff key", () => {
|
|
730
|
+
const oldConfig = makeConfig([
|
|
731
|
+
makeAgent("worker", ["fleet-a"]),
|
|
732
|
+
]);
|
|
733
|
+
const newConfig = makeConfig([
|
|
734
|
+
makeAgent("worker", ["fleet-a"]),
|
|
735
|
+
makeAgent("worker", ["fleet-b"]),
|
|
736
|
+
]);
|
|
737
|
+
const changes = computeConfigChanges(oldConfig, newConfig);
|
|
738
|
+
expect(changes).toContainEqual(expect.objectContaining({
|
|
739
|
+
type: "added",
|
|
740
|
+
category: "agent",
|
|
741
|
+
name: "fleet-b.worker",
|
|
742
|
+
}));
|
|
743
|
+
});
|
|
744
|
+
it("distinguishes same local name in different fleets", () => {
|
|
745
|
+
const oldConfig = makeConfig([
|
|
746
|
+
makeAgent("worker", ["fleet-a"], { description: "A worker" }),
|
|
747
|
+
makeAgent("worker", ["fleet-b"], { description: "B worker" }),
|
|
748
|
+
]);
|
|
749
|
+
const newConfig = makeConfig([
|
|
750
|
+
makeAgent("worker", ["fleet-a"], { description: "A worker updated" }),
|
|
751
|
+
makeAgent("worker", ["fleet-b"], { description: "B worker" }),
|
|
752
|
+
]);
|
|
753
|
+
const changes = computeConfigChanges(oldConfig, newConfig);
|
|
754
|
+
expect(changes).toContainEqual(expect.objectContaining({
|
|
755
|
+
type: "modified",
|
|
756
|
+
category: "agent",
|
|
757
|
+
name: "fleet-a.worker",
|
|
758
|
+
}));
|
|
759
|
+
expect(changes.find((c) => c.name === "fleet-b.worker" && c.type === "modified")).toBeUndefined();
|
|
760
|
+
});
|
|
761
|
+
it("handles deeply nested fleet changes", () => {
|
|
762
|
+
const oldConfig = makeConfig([
|
|
763
|
+
makeAgent("worker", ["a", "b", "c"]),
|
|
764
|
+
]);
|
|
765
|
+
const newConfig = makeConfig([
|
|
766
|
+
makeAgent("worker", ["a", "b", "c"], { description: "updated" }),
|
|
767
|
+
]);
|
|
768
|
+
const changes = computeConfigChanges(oldConfig, newConfig);
|
|
769
|
+
expect(changes).toContainEqual(expect.objectContaining({
|
|
770
|
+
type: "modified",
|
|
771
|
+
category: "agent",
|
|
772
|
+
name: "a.b.c.worker",
|
|
773
|
+
}));
|
|
774
|
+
});
|
|
775
|
+
});
|
|
776
|
+
// =============================================================================
|
|
777
|
+
// Cross-Cutting Verification Tests
|
|
778
|
+
// =============================================================================
|
|
779
|
+
describe("Cross-Cutting Fleet Composition Verification", () => {
|
|
780
|
+
let tempDir;
|
|
781
|
+
let stateDir;
|
|
782
|
+
beforeEach(async () => {
|
|
783
|
+
tempDir = await createTempDir();
|
|
784
|
+
stateDir = join(tempDir, ".herdctl");
|
|
785
|
+
});
|
|
786
|
+
afterEach(async () => {
|
|
787
|
+
await rm(tempDir, { recursive: true, force: true });
|
|
788
|
+
});
|
|
789
|
+
it("backward compatibility: single-fleet config works exactly as before", async () => {
|
|
790
|
+
// Simple single-fleet config without any fleets array
|
|
791
|
+
await createFile(join(tempDir, "herdctl.yaml"), `
|
|
792
|
+
version: 1
|
|
793
|
+
agents:
|
|
794
|
+
- path: ./agents/simple.yaml
|
|
795
|
+
`);
|
|
796
|
+
await createFile(join(tempDir, "agents", "simple.yaml"), `
|
|
797
|
+
name: simple
|
|
798
|
+
description: A simple agent
|
|
799
|
+
`);
|
|
800
|
+
const manager = new FleetManager({
|
|
801
|
+
configPath: tempDir,
|
|
802
|
+
stateDir,
|
|
803
|
+
logger: createSilentLogger(),
|
|
804
|
+
});
|
|
805
|
+
await manager.initialize();
|
|
806
|
+
const agentInfo = await manager.getAgentInfoByName("simple");
|
|
807
|
+
// Qualified name should equal local name
|
|
808
|
+
expect(agentInfo.qualifiedName).toBe("simple");
|
|
809
|
+
expect(agentInfo.name).toBe("simple");
|
|
810
|
+
expect(agentInfo.fleetPath).toEqual([]);
|
|
811
|
+
// Trigger should work with bare name
|
|
812
|
+
const result = await manager.trigger("simple");
|
|
813
|
+
expect(result.agentName).toBe("simple");
|
|
814
|
+
});
|
|
815
|
+
it("fleet hierarchy metadata is preserved through full pipeline", async () => {
|
|
816
|
+
await createFile(join(tempDir, "herdctl.yaml"), `
|
|
817
|
+
version: 1
|
|
818
|
+
fleets:
|
|
819
|
+
- path: ./org/herdctl.yaml
|
|
820
|
+
agents:
|
|
821
|
+
- path: ./agents/global-monitor.yaml
|
|
822
|
+
`);
|
|
823
|
+
await createFile(join(tempDir, "org", "herdctl.yaml"), `
|
|
824
|
+
version: 1
|
|
825
|
+
fleet:
|
|
826
|
+
name: org
|
|
827
|
+
fleets:
|
|
828
|
+
- path: ./team/herdctl.yaml
|
|
829
|
+
agents:
|
|
830
|
+
- path: ./agents/org-worker.yaml
|
|
831
|
+
`);
|
|
832
|
+
await createFile(join(tempDir, "org", "team", "herdctl.yaml"), `
|
|
833
|
+
version: 1
|
|
834
|
+
fleet:
|
|
835
|
+
name: team
|
|
836
|
+
agents:
|
|
837
|
+
- path: ./agents/team-worker.yaml
|
|
838
|
+
`);
|
|
839
|
+
await createFile(join(tempDir, "agents", "global-monitor.yaml"), "name: global-monitor");
|
|
840
|
+
await createFile(join(tempDir, "org", "agents", "org-worker.yaml"), "name: org-worker");
|
|
841
|
+
await createFile(join(tempDir, "org", "team", "agents", "team-worker.yaml"), "name: team-worker");
|
|
842
|
+
const manager = new FleetManager({
|
|
843
|
+
configPath: tempDir,
|
|
844
|
+
stateDir,
|
|
845
|
+
logger: createSilentLogger(),
|
|
846
|
+
});
|
|
847
|
+
await manager.initialize();
|
|
848
|
+
const agentInfoList = await manager.getAgentInfo();
|
|
849
|
+
// Verify hierarchy at each level
|
|
850
|
+
const globalMonitor = agentInfoList.find((a) => a.qualifiedName === "global-monitor");
|
|
851
|
+
expect(globalMonitor?.fleetPath).toEqual([]);
|
|
852
|
+
const orgWorker = agentInfoList.find((a) => a.qualifiedName === "org.org-worker");
|
|
853
|
+
expect(orgWorker?.fleetPath).toEqual(["org"]);
|
|
854
|
+
const teamWorker = agentInfoList.find((a) => a.qualifiedName === "org.team.team-worker");
|
|
855
|
+
expect(teamWorker?.fleetPath).toEqual(["org", "team"]);
|
|
856
|
+
// Verify all agents are accessible by their qualified names
|
|
857
|
+
await expect(manager.getAgentInfoByName("global-monitor")).resolves.toBeDefined();
|
|
858
|
+
await expect(manager.getAgentInfoByName("org.org-worker")).resolves.toBeDefined();
|
|
859
|
+
await expect(manager.getAgentInfoByName("org.team.team-worker")).resolves.toBeDefined();
|
|
860
|
+
});
|
|
861
|
+
it("defaults cascade correctly through nested fleets", async () => {
|
|
862
|
+
await createFile(join(tempDir, "herdctl.yaml"), `
|
|
863
|
+
version: 1
|
|
864
|
+
defaults:
|
|
865
|
+
model: root-model
|
|
866
|
+
max_turns: 100
|
|
867
|
+
fleets:
|
|
868
|
+
- path: ./sub/herdctl.yaml
|
|
869
|
+
`);
|
|
870
|
+
await createFile(join(tempDir, "sub", "herdctl.yaml"), `
|
|
871
|
+
version: 1
|
|
872
|
+
fleet:
|
|
873
|
+
name: sub
|
|
874
|
+
defaults:
|
|
875
|
+
model: sub-model
|
|
876
|
+
agents:
|
|
877
|
+
- path: ./agents/inheritor.yaml
|
|
878
|
+
- path: ./agents/overrider.yaml
|
|
879
|
+
`);
|
|
880
|
+
await createFile(join(tempDir, "sub", "agents", "inheritor.yaml"), `
|
|
881
|
+
name: inheritor
|
|
882
|
+
`);
|
|
883
|
+
await createFile(join(tempDir, "sub", "agents", "overrider.yaml"), `
|
|
884
|
+
name: overrider
|
|
885
|
+
model: agent-model
|
|
886
|
+
max_turns: 5
|
|
887
|
+
`);
|
|
888
|
+
const config = await loadConfig(tempDir, { env: {}, envFile: false });
|
|
889
|
+
const inheritor = config.agents.find((a) => a.name === "inheritor");
|
|
890
|
+
// inheritor gets sub-model from sub-fleet, max_turns from root
|
|
891
|
+
expect(inheritor?.model).toBe("sub-model");
|
|
892
|
+
expect(inheritor?.max_turns).toBe(100);
|
|
893
|
+
const overrider = config.agents.find((a) => a.name === "overrider");
|
|
894
|
+
// overrider uses its own values
|
|
895
|
+
expect(overrider?.model).toBe("agent-model");
|
|
896
|
+
expect(overrider?.max_turns).toBe(5);
|
|
897
|
+
});
|
|
898
|
+
it("fleet name collision is detected and reported clearly", async () => {
|
|
899
|
+
await createFile(join(tempDir, "herdctl.yaml"), `
|
|
900
|
+
version: 1
|
|
901
|
+
fleets:
|
|
902
|
+
- path: ./sub-a/herdctl.yaml
|
|
903
|
+
- path: ./sub-b/herdctl.yaml
|
|
904
|
+
`);
|
|
905
|
+
await createFile(join(tempDir, "sub-a", "herdctl.yaml"), `
|
|
906
|
+
version: 1
|
|
907
|
+
fleet:
|
|
908
|
+
name: same-name
|
|
909
|
+
agents: []
|
|
910
|
+
`);
|
|
911
|
+
await createFile(join(tempDir, "sub-b", "herdctl.yaml"), `
|
|
912
|
+
version: 1
|
|
913
|
+
fleet:
|
|
914
|
+
name: same-name
|
|
915
|
+
agents: []
|
|
916
|
+
`);
|
|
917
|
+
await expect(loadConfig(tempDir, { env: {}, envFile: false })).rejects.toThrow(FleetNameCollisionError);
|
|
918
|
+
});
|
|
919
|
+
it("cycle detection works at any depth", async () => {
|
|
920
|
+
await createFile(join(tempDir, "herdctl.yaml"), `
|
|
921
|
+
version: 1
|
|
922
|
+
fleets:
|
|
923
|
+
- path: ./level1/herdctl.yaml
|
|
924
|
+
`);
|
|
925
|
+
await createFile(join(tempDir, "level1", "herdctl.yaml"), `
|
|
926
|
+
version: 1
|
|
927
|
+
fleet:
|
|
928
|
+
name: level1
|
|
929
|
+
fleets:
|
|
930
|
+
- path: ../level2/herdctl.yaml
|
|
931
|
+
`);
|
|
932
|
+
await createFile(join(tempDir, "level2", "herdctl.yaml"), `
|
|
933
|
+
version: 1
|
|
934
|
+
fleet:
|
|
935
|
+
name: level2
|
|
936
|
+
fleets:
|
|
937
|
+
- path: ../herdctl.yaml
|
|
938
|
+
`);
|
|
939
|
+
await expect(loadConfig(tempDir, { env: {}, envFile: false })).rejects.toThrow(FleetCycleError);
|
|
940
|
+
});
|
|
941
|
+
});
|
|
942
|
+
//# sourceMappingURL=fleet-composition-integration.test.js.map
|