@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.
Files changed (91) hide show
  1. package/dist/__tests__/fleet-composition-integration.test.d.ts +15 -0
  2. package/dist/__tests__/fleet-composition-integration.test.d.ts.map +1 -0
  3. package/dist/__tests__/fleet-composition-integration.test.js +942 -0
  4. package/dist/__tests__/fleet-composition-integration.test.js.map +1 -0
  5. package/dist/config/__tests__/fleet-error-messages.test.d.ts +9 -0
  6. package/dist/config/__tests__/fleet-error-messages.test.d.ts.map +1 -0
  7. package/dist/config/__tests__/fleet-error-messages.test.js +321 -0
  8. package/dist/config/__tests__/fleet-error-messages.test.js.map +1 -0
  9. package/dist/config/__tests__/fleet-loading.test.d.ts +2 -0
  10. package/dist/config/__tests__/fleet-loading.test.d.ts.map +1 -0
  11. package/dist/config/__tests__/fleet-loading.test.js +627 -0
  12. package/dist/config/__tests__/fleet-loading.test.js.map +1 -0
  13. package/dist/config/__tests__/fleet-naming-and-defaults.test.d.ts +2 -0
  14. package/dist/config/__tests__/fleet-naming-and-defaults.test.d.ts.map +1 -0
  15. package/dist/config/__tests__/fleet-naming-and-defaults.test.js +1024 -0
  16. package/dist/config/__tests__/fleet-naming-and-defaults.test.js.map +1 -0
  17. package/dist/config/__tests__/schema.test.js +172 -1
  18. package/dist/config/__tests__/schema.test.js.map +1 -1
  19. package/dist/config/index.d.ts +2 -2
  20. package/dist/config/index.d.ts.map +1 -1
  21. package/dist/config/index.js +2 -2
  22. package/dist/config/index.js.map +1 -1
  23. package/dist/config/loader.d.ts +63 -2
  24. package/dist/config/loader.d.ts.map +1 -1
  25. package/dist/config/loader.js +309 -74
  26. package/dist/config/loader.js.map +1 -1
  27. package/dist/config/schema.d.ts +58 -0
  28. package/dist/config/schema.d.ts.map +1 -1
  29. package/dist/config/schema.js +29 -0
  30. package/dist/config/schema.js.map +1 -1
  31. package/dist/fleet-manager/__tests__/config-reload-qualified.test.d.ts +8 -0
  32. package/dist/fleet-manager/__tests__/config-reload-qualified.test.d.ts.map +1 -0
  33. package/dist/fleet-manager/__tests__/config-reload-qualified.test.js +416 -0
  34. package/dist/fleet-manager/__tests__/config-reload-qualified.test.js.map +1 -0
  35. package/dist/fleet-manager/__tests__/integration.test.js +2 -2
  36. package/dist/fleet-manager/__tests__/integration.test.js.map +1 -1
  37. package/dist/fleet-manager/chat-manager-interface.d.ts +6 -6
  38. package/dist/fleet-manager/chat-manager-interface.d.ts.map +1 -1
  39. package/dist/fleet-manager/config-reload.d.ts.map +1 -1
  40. package/dist/fleet-manager/config-reload.js +17 -14
  41. package/dist/fleet-manager/config-reload.js.map +1 -1
  42. package/dist/fleet-manager/fleet-manager.d.ts +9 -5
  43. package/dist/fleet-manager/fleet-manager.d.ts.map +1 -1
  44. package/dist/fleet-manager/fleet-manager.js +18 -9
  45. package/dist/fleet-manager/fleet-manager.js.map +1 -1
  46. package/dist/fleet-manager/job-control.js +9 -9
  47. package/dist/fleet-manager/job-control.js.map +1 -1
  48. package/dist/fleet-manager/schedule-executor.js +13 -13
  49. package/dist/fleet-manager/schedule-executor.js.map +1 -1
  50. package/dist/fleet-manager/schedule-management.d.ts.map +1 -1
  51. package/dist/fleet-manager/schedule-management.js +22 -16
  52. package/dist/fleet-manager/schedule-management.js.map +1 -1
  53. package/dist/fleet-manager/status-queries.d.ts +5 -1
  54. package/dist/fleet-manager/status-queries.d.ts.map +1 -1
  55. package/dist/fleet-manager/status-queries.js +16 -8
  56. package/dist/fleet-manager/status-queries.js.map +1 -1
  57. package/dist/fleet-manager/types.d.ts +12 -1
  58. package/dist/fleet-manager/types.d.ts.map +1 -1
  59. package/dist/runner/__tests__/job-executor.test.js +2 -0
  60. package/dist/runner/__tests__/job-executor.test.js.map +1 -1
  61. package/dist/runner/__tests__/sdk-adapter.test.js +2 -0
  62. package/dist/runner/__tests__/sdk-adapter.test.js.map +1 -1
  63. package/dist/scheduler/__tests__/schedule-runner.test.js +2 -0
  64. package/dist/scheduler/__tests__/schedule-runner.test.js.map +1 -1
  65. package/dist/scheduler/__tests__/scheduler.test.js +5 -3
  66. package/dist/scheduler/__tests__/scheduler.test.js.map +1 -1
  67. package/dist/scheduler/schedule-runner.js +12 -12
  68. package/dist/scheduler/schedule-runner.js.map +1 -1
  69. package/dist/scheduler/scheduler.js +27 -27
  70. package/dist/scheduler/scheduler.js.map +1 -1
  71. package/dist/state/__tests__/session.test.js +27 -0
  72. package/dist/state/__tests__/session.test.js.map +1 -1
  73. package/dist/state/fleet-state.d.ts +2 -2
  74. package/dist/state/fleet-state.js +2 -2
  75. package/dist/state/schemas/session-info.d.ts +5 -2
  76. package/dist/state/schemas/session-info.d.ts.map +1 -1
  77. package/dist/state/schemas/session-info.js +5 -2
  78. package/dist/state/schemas/session-info.js.map +1 -1
  79. package/dist/state/session.d.ts +4 -1
  80. package/dist/state/session.d.ts.map +1 -1
  81. package/dist/state/session.js +4 -1
  82. package/dist/state/session.js.map +1 -1
  83. package/dist/state/utils/__tests__/path-safety.test.js +18 -3
  84. package/dist/state/utils/__tests__/path-safety.test.js.map +1 -1
  85. package/dist/state/utils/path-safety.d.ts +4 -2
  86. package/dist/state/utils/path-safety.d.ts.map +1 -1
  87. package/dist/state/utils/path-safety.js +5 -3
  88. package/dist/state/utils/path-safety.js.map +1 -1
  89. package/dist/work-sources/__tests__/manager.test.js +2 -0
  90. package/dist/work-sources/__tests__/manager.test.js.map +1 -1
  91. 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