@herdctl/core 5.2.2 → 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 +2 -0
- 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 +22 -22
- 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,1024 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import { mkdir, writeFile, rm, realpath } from "node:fs/promises";
|
|
3
|
+
import { join, resolve } from "node:path";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { loadConfig, FleetNameCollisionError, } from "../loader.js";
|
|
6
|
+
import { ConfigError } from "../parser.js";
|
|
7
|
+
// =============================================================================
|
|
8
|
+
// Test helpers
|
|
9
|
+
// =============================================================================
|
|
10
|
+
async function createTempDir() {
|
|
11
|
+
const baseDir = join(tmpdir(), `herdctl-fleet-naming-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
|
12
|
+
await mkdir(baseDir, { recursive: true });
|
|
13
|
+
return await realpath(baseDir);
|
|
14
|
+
}
|
|
15
|
+
async function createFile(filePath, content) {
|
|
16
|
+
await mkdir(join(filePath, ".."), { recursive: true });
|
|
17
|
+
await writeFile(filePath, content, "utf-8");
|
|
18
|
+
}
|
|
19
|
+
const fixturesDir = resolve(__dirname, "fixtures", "fleet-composition");
|
|
20
|
+
// =============================================================================
|
|
21
|
+
// Fleet Name Resolution Tests
|
|
22
|
+
// =============================================================================
|
|
23
|
+
describe("fleet name resolution", () => {
|
|
24
|
+
let tempDir;
|
|
25
|
+
beforeEach(async () => {
|
|
26
|
+
tempDir = await createTempDir();
|
|
27
|
+
});
|
|
28
|
+
afterEach(async () => {
|
|
29
|
+
await rm(tempDir, { recursive: true, force: true });
|
|
30
|
+
});
|
|
31
|
+
describe("priority order", () => {
|
|
32
|
+
it("parent explicit name overrides sub-fleet fleet.name", async () => {
|
|
33
|
+
await createFile(join(tempDir, "herdctl.yaml"), `
|
|
34
|
+
version: 1
|
|
35
|
+
fleets:
|
|
36
|
+
- path: ./sub/herdctl.yaml
|
|
37
|
+
name: parent-chosen
|
|
38
|
+
`);
|
|
39
|
+
await createFile(join(tempDir, "sub", "herdctl.yaml"), `
|
|
40
|
+
version: 1
|
|
41
|
+
fleet:
|
|
42
|
+
name: sub-fleet-own-name
|
|
43
|
+
agents:
|
|
44
|
+
- path: ./agents/worker.yaml
|
|
45
|
+
`);
|
|
46
|
+
await createFile(join(tempDir, "sub", "agents", "worker.yaml"), "name: worker");
|
|
47
|
+
const result = await loadConfig(tempDir, { env: {}, envFile: false });
|
|
48
|
+
expect(result.agents[0].fleetPath).toEqual(["parent-chosen"]);
|
|
49
|
+
expect(result.agents[0].qualifiedName).toBe("parent-chosen.worker");
|
|
50
|
+
});
|
|
51
|
+
it("sub-fleet fleet.name used when no parent override", async () => {
|
|
52
|
+
await createFile(join(tempDir, "herdctl.yaml"), `
|
|
53
|
+
version: 1
|
|
54
|
+
fleets:
|
|
55
|
+
- path: ./sub/herdctl.yaml
|
|
56
|
+
`);
|
|
57
|
+
await createFile(join(tempDir, "sub", "herdctl.yaml"), `
|
|
58
|
+
version: 1
|
|
59
|
+
fleet:
|
|
60
|
+
name: self-declared
|
|
61
|
+
agents:
|
|
62
|
+
- path: ./agents/worker.yaml
|
|
63
|
+
`);
|
|
64
|
+
await createFile(join(tempDir, "sub", "agents", "worker.yaml"), "name: worker");
|
|
65
|
+
const result = await loadConfig(tempDir, { env: {}, envFile: false });
|
|
66
|
+
expect(result.agents[0].fleetPath).toEqual(["self-declared"]);
|
|
67
|
+
expect(result.agents[0].qualifiedName).toBe("self-declared.worker");
|
|
68
|
+
});
|
|
69
|
+
it("directory name used when neither parent nor sub-fleet provides name", async () => {
|
|
70
|
+
await createFile(join(tempDir, "herdctl.yaml"), `
|
|
71
|
+
version: 1
|
|
72
|
+
fleets:
|
|
73
|
+
- path: ./my-cool-project/herdctl.yaml
|
|
74
|
+
`);
|
|
75
|
+
await createFile(join(tempDir, "my-cool-project", "herdctl.yaml"), `
|
|
76
|
+
version: 1
|
|
77
|
+
agents:
|
|
78
|
+
- path: ./agents/worker.yaml
|
|
79
|
+
`);
|
|
80
|
+
await createFile(join(tempDir, "my-cool-project", "agents", "worker.yaml"), "name: worker");
|
|
81
|
+
const result = await loadConfig(tempDir, { env: {}, envFile: false });
|
|
82
|
+
expect(result.agents[0].fleetPath).toEqual(["my-cool-project"]);
|
|
83
|
+
expect(result.agents[0].qualifiedName).toBe("my-cool-project.worker");
|
|
84
|
+
});
|
|
85
|
+
it("parent override takes priority when both parent name and fleet.name are present", async () => {
|
|
86
|
+
// Both parent name and sub-fleet fleet.name exist; parent wins
|
|
87
|
+
await createFile(join(tempDir, "herdctl.yaml"), `
|
|
88
|
+
version: 1
|
|
89
|
+
fleets:
|
|
90
|
+
- path: ./subdir/herdctl.yaml
|
|
91
|
+
name: parent-override
|
|
92
|
+
`);
|
|
93
|
+
await createFile(join(tempDir, "subdir", "herdctl.yaml"), `
|
|
94
|
+
version: 1
|
|
95
|
+
fleet:
|
|
96
|
+
name: fleet-self-name
|
|
97
|
+
agents:
|
|
98
|
+
- path: ./agents/a.yaml
|
|
99
|
+
- path: ./agents/b.yaml
|
|
100
|
+
`);
|
|
101
|
+
await createFile(join(tempDir, "subdir", "agents", "a.yaml"), "name: agent-a");
|
|
102
|
+
await createFile(join(tempDir, "subdir", "agents", "b.yaml"), "name: agent-b");
|
|
103
|
+
const result = await loadConfig(tempDir, { env: {}, envFile: false });
|
|
104
|
+
// Both agents should use parent-override in their fleetPath
|
|
105
|
+
for (const agent of result.agents) {
|
|
106
|
+
expect(agent.fleetPath).toEqual(["parent-override"]);
|
|
107
|
+
expect(agent.qualifiedName).toMatch(/^parent-override\./);
|
|
108
|
+
}
|
|
109
|
+
expect(result.agents.find((a) => a.name === "agent-a").qualifiedName).toBe("parent-override.agent-a");
|
|
110
|
+
expect(result.agents.find((a) => a.name === "agent-b").qualifiedName).toBe("parent-override.agent-b");
|
|
111
|
+
});
|
|
112
|
+
it("directory name fallback uses containing directory, not config file name", async () => {
|
|
113
|
+
// The config is at deeply/nested/path/herdctl.yaml => directory is "path"
|
|
114
|
+
await createFile(join(tempDir, "herdctl.yaml"), `
|
|
115
|
+
version: 1
|
|
116
|
+
fleets:
|
|
117
|
+
- path: ./deeply/nested/path/herdctl.yaml
|
|
118
|
+
`);
|
|
119
|
+
await createFile(join(tempDir, "deeply", "nested", "path", "herdctl.yaml"), `
|
|
120
|
+
version: 1
|
|
121
|
+
agents:
|
|
122
|
+
- path: ./agents/worker.yaml
|
|
123
|
+
`);
|
|
124
|
+
await createFile(join(tempDir, "deeply", "nested", "path", "agents", "worker.yaml"), "name: worker");
|
|
125
|
+
const result = await loadConfig(tempDir, { env: {}, envFile: false });
|
|
126
|
+
// Directory name is "path" (the immediate parent of herdctl.yaml)
|
|
127
|
+
expect(result.agents[0].fleetPath).toEqual(["path"]);
|
|
128
|
+
expect(result.agents[0].qualifiedName).toBe("path.worker");
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
describe("valid fleet names", () => {
|
|
132
|
+
it("accepts fleet name with hyphens", async () => {
|
|
133
|
+
await createFile(join(tempDir, "herdctl.yaml"), `
|
|
134
|
+
version: 1
|
|
135
|
+
fleets:
|
|
136
|
+
- path: ./sub/herdctl.yaml
|
|
137
|
+
name: my-cool-project
|
|
138
|
+
`);
|
|
139
|
+
await createFile(join(tempDir, "sub", "herdctl.yaml"), `
|
|
140
|
+
version: 1
|
|
141
|
+
agents:
|
|
142
|
+
- path: ./agents/worker.yaml
|
|
143
|
+
`);
|
|
144
|
+
await createFile(join(tempDir, "sub", "agents", "worker.yaml"), "name: worker");
|
|
145
|
+
const result = await loadConfig(tempDir, { env: {}, envFile: false });
|
|
146
|
+
expect(result.agents[0].qualifiedName).toBe("my-cool-project.worker");
|
|
147
|
+
});
|
|
148
|
+
it("accepts fleet name with underscores", async () => {
|
|
149
|
+
await createFile(join(tempDir, "herdctl.yaml"), `
|
|
150
|
+
version: 1
|
|
151
|
+
fleets:
|
|
152
|
+
- path: ./sub/herdctl.yaml
|
|
153
|
+
name: my_cool_project
|
|
154
|
+
`);
|
|
155
|
+
await createFile(join(tempDir, "sub", "herdctl.yaml"), `
|
|
156
|
+
version: 1
|
|
157
|
+
agents:
|
|
158
|
+
- path: ./agents/worker.yaml
|
|
159
|
+
`);
|
|
160
|
+
await createFile(join(tempDir, "sub", "agents", "worker.yaml"), "name: worker");
|
|
161
|
+
const result = await loadConfig(tempDir, { env: {}, envFile: false });
|
|
162
|
+
expect(result.agents[0].qualifiedName).toBe("my_cool_project.worker");
|
|
163
|
+
});
|
|
164
|
+
it("accepts fleet name with mixed hyphens and underscores", async () => {
|
|
165
|
+
await createFile(join(tempDir, "herdctl.yaml"), `
|
|
166
|
+
version: 1
|
|
167
|
+
fleets:
|
|
168
|
+
- path: ./sub/herdctl.yaml
|
|
169
|
+
name: my-cool_project
|
|
170
|
+
`);
|
|
171
|
+
await createFile(join(tempDir, "sub", "herdctl.yaml"), `
|
|
172
|
+
version: 1
|
|
173
|
+
agents:
|
|
174
|
+
- path: ./agents/worker.yaml
|
|
175
|
+
`);
|
|
176
|
+
await createFile(join(tempDir, "sub", "agents", "worker.yaml"), "name: worker");
|
|
177
|
+
const result = await loadConfig(tempDir, { env: {}, envFile: false });
|
|
178
|
+
expect(result.agents[0].qualifiedName).toBe("my-cool_project.worker");
|
|
179
|
+
});
|
|
180
|
+
it("accepts fleet name starting with a number", async () => {
|
|
181
|
+
await createFile(join(tempDir, "herdctl.yaml"), `
|
|
182
|
+
version: 1
|
|
183
|
+
fleets:
|
|
184
|
+
- path: ./sub/herdctl.yaml
|
|
185
|
+
name: 42project
|
|
186
|
+
`);
|
|
187
|
+
await createFile(join(tempDir, "sub", "herdctl.yaml"), `
|
|
188
|
+
version: 1
|
|
189
|
+
agents:
|
|
190
|
+
- path: ./agents/worker.yaml
|
|
191
|
+
`);
|
|
192
|
+
await createFile(join(tempDir, "sub", "agents", "worker.yaml"), "name: worker");
|
|
193
|
+
const result = await loadConfig(tempDir, { env: {}, envFile: false });
|
|
194
|
+
expect(result.agents[0].qualifiedName).toBe("42project.worker");
|
|
195
|
+
});
|
|
196
|
+
it("accepts single-character fleet name", async () => {
|
|
197
|
+
await createFile(join(tempDir, "herdctl.yaml"), `
|
|
198
|
+
version: 1
|
|
199
|
+
fleets:
|
|
200
|
+
- path: ./sub/herdctl.yaml
|
|
201
|
+
name: x
|
|
202
|
+
`);
|
|
203
|
+
await createFile(join(tempDir, "sub", "herdctl.yaml"), `
|
|
204
|
+
version: 1
|
|
205
|
+
agents:
|
|
206
|
+
- path: ./agents/worker.yaml
|
|
207
|
+
`);
|
|
208
|
+
await createFile(join(tempDir, "sub", "agents", "worker.yaml"), "name: worker");
|
|
209
|
+
const result = await loadConfig(tempDir, { env: {}, envFile: false });
|
|
210
|
+
expect(result.agents[0].qualifiedName).toBe("x.worker");
|
|
211
|
+
});
|
|
212
|
+
it("accepts fleet name from fleet.name with hyphens", async () => {
|
|
213
|
+
await createFile(join(tempDir, "herdctl.yaml"), `
|
|
214
|
+
version: 1
|
|
215
|
+
fleets:
|
|
216
|
+
- path: ./sub/herdctl.yaml
|
|
217
|
+
`);
|
|
218
|
+
await createFile(join(tempDir, "sub", "herdctl.yaml"), `
|
|
219
|
+
version: 1
|
|
220
|
+
fleet:
|
|
221
|
+
name: hyphen-name
|
|
222
|
+
agents:
|
|
223
|
+
- path: ./agents/worker.yaml
|
|
224
|
+
`);
|
|
225
|
+
await createFile(join(tempDir, "sub", "agents", "worker.yaml"), "name: worker");
|
|
226
|
+
const result = await loadConfig(tempDir, { env: {}, envFile: false });
|
|
227
|
+
expect(result.agents[0].qualifiedName).toBe("hyphen-name.worker");
|
|
228
|
+
});
|
|
229
|
+
it("accepts fleet name from directory with underscores", async () => {
|
|
230
|
+
await createFile(join(tempDir, "herdctl.yaml"), `
|
|
231
|
+
version: 1
|
|
232
|
+
fleets:
|
|
233
|
+
- path: ./my_project_dir/herdctl.yaml
|
|
234
|
+
`);
|
|
235
|
+
await createFile(join(tempDir, "my_project_dir", "herdctl.yaml"), `
|
|
236
|
+
version: 1
|
|
237
|
+
agents:
|
|
238
|
+
- path: ./agents/worker.yaml
|
|
239
|
+
`);
|
|
240
|
+
await createFile(join(tempDir, "my_project_dir", "agents", "worker.yaml"), "name: worker");
|
|
241
|
+
const result = await loadConfig(tempDir, { env: {}, envFile: false });
|
|
242
|
+
expect(result.agents[0].qualifiedName).toBe("my_project_dir.worker");
|
|
243
|
+
});
|
|
244
|
+
});
|
|
245
|
+
describe("invalid fleet names", () => {
|
|
246
|
+
it("rejects fleet name with dots via fleet.name", async () => {
|
|
247
|
+
await createFile(join(tempDir, "herdctl.yaml"), `
|
|
248
|
+
version: 1
|
|
249
|
+
fleets:
|
|
250
|
+
- path: ./sub/herdctl.yaml
|
|
251
|
+
`);
|
|
252
|
+
await createFile(join(tempDir, "sub", "herdctl.yaml"), `
|
|
253
|
+
version: 1
|
|
254
|
+
fleet:
|
|
255
|
+
name: my.project
|
|
256
|
+
agents:
|
|
257
|
+
- path: ./agents/worker.yaml
|
|
258
|
+
`);
|
|
259
|
+
await createFile(join(tempDir, "sub", "agents", "worker.yaml"), "name: worker");
|
|
260
|
+
await expect(loadConfig(tempDir, { env: {}, envFile: false })).rejects.toThrow(/invalid/i);
|
|
261
|
+
});
|
|
262
|
+
it("rejects fleet name starting with a hyphen via fleet.name", async () => {
|
|
263
|
+
await createFile(join(tempDir, "herdctl.yaml"), `
|
|
264
|
+
version: 1
|
|
265
|
+
fleets:
|
|
266
|
+
- path: ./sub/herdctl.yaml
|
|
267
|
+
`);
|
|
268
|
+
await createFile(join(tempDir, "sub", "herdctl.yaml"), `
|
|
269
|
+
version: 1
|
|
270
|
+
fleet:
|
|
271
|
+
name: -starts-with-hyphen
|
|
272
|
+
agents:
|
|
273
|
+
- path: ./agents/worker.yaml
|
|
274
|
+
`);
|
|
275
|
+
await createFile(join(tempDir, "sub", "agents", "worker.yaml"), "name: worker");
|
|
276
|
+
await expect(loadConfig(tempDir, { env: {}, envFile: false })).rejects.toThrow(/invalid/i);
|
|
277
|
+
});
|
|
278
|
+
it("rejects fleet name starting with underscore via fleet.name", async () => {
|
|
279
|
+
await createFile(join(tempDir, "herdctl.yaml"), `
|
|
280
|
+
version: 1
|
|
281
|
+
fleets:
|
|
282
|
+
- path: ./sub/herdctl.yaml
|
|
283
|
+
`);
|
|
284
|
+
await createFile(join(tempDir, "sub", "herdctl.yaml"), `
|
|
285
|
+
version: 1
|
|
286
|
+
fleet:
|
|
287
|
+
name: _underscore_start
|
|
288
|
+
agents:
|
|
289
|
+
- path: ./agents/worker.yaml
|
|
290
|
+
`);
|
|
291
|
+
await createFile(join(tempDir, "sub", "agents", "worker.yaml"), "name: worker");
|
|
292
|
+
await expect(loadConfig(tempDir, { env: {}, envFile: false })).rejects.toThrow(/invalid/i);
|
|
293
|
+
});
|
|
294
|
+
it("rejects fleet name with dots when derived from directory name", async () => {
|
|
295
|
+
// Create a directory with a dot in its name
|
|
296
|
+
await createFile(join(tempDir, "herdctl.yaml"), `
|
|
297
|
+
version: 1
|
|
298
|
+
fleets:
|
|
299
|
+
- path: ./my.project/herdctl.yaml
|
|
300
|
+
`);
|
|
301
|
+
await createFile(join(tempDir, "my.project", "herdctl.yaml"), `
|
|
302
|
+
version: 1
|
|
303
|
+
agents:
|
|
304
|
+
- path: ./agents/worker.yaml
|
|
305
|
+
`);
|
|
306
|
+
await createFile(join(tempDir, "my.project", "agents", "worker.yaml"), "name: worker");
|
|
307
|
+
await expect(loadConfig(tempDir, { env: {}, envFile: false })).rejects.toThrow(/invalid/i);
|
|
308
|
+
});
|
|
309
|
+
it("rejects fleet name with dots via parent name override (schema validation)", async () => {
|
|
310
|
+
// Parent name override with dots should fail at schema level
|
|
311
|
+
await createFile(join(tempDir, "herdctl.yaml"), `
|
|
312
|
+
version: 1
|
|
313
|
+
fleets:
|
|
314
|
+
- path: ./sub/herdctl.yaml
|
|
315
|
+
name: "my.bad.name"
|
|
316
|
+
`);
|
|
317
|
+
await createFile(join(tempDir, "sub", "herdctl.yaml"), `
|
|
318
|
+
version: 1
|
|
319
|
+
agents:
|
|
320
|
+
- path: ./agents/worker.yaml
|
|
321
|
+
`);
|
|
322
|
+
await createFile(join(tempDir, "sub", "agents", "worker.yaml"), "name: worker");
|
|
323
|
+
// Should fail during config parsing (Zod regex on FleetReferenceSchema.name)
|
|
324
|
+
await expect(loadConfig(tempDir, { env: {}, envFile: false })).rejects.toThrow();
|
|
325
|
+
});
|
|
326
|
+
it("provides clear error message for invalid fleet names", async () => {
|
|
327
|
+
await createFile(join(tempDir, "herdctl.yaml"), `
|
|
328
|
+
version: 1
|
|
329
|
+
fleets:
|
|
330
|
+
- path: ./sub/herdctl.yaml
|
|
331
|
+
`);
|
|
332
|
+
await createFile(join(tempDir, "sub", "herdctl.yaml"), `
|
|
333
|
+
version: 1
|
|
334
|
+
fleet:
|
|
335
|
+
name: has.dots.in.name
|
|
336
|
+
agents:
|
|
337
|
+
- path: ./agents/worker.yaml
|
|
338
|
+
`);
|
|
339
|
+
await createFile(join(tempDir, "sub", "agents", "worker.yaml"), "name: worker");
|
|
340
|
+
try {
|
|
341
|
+
await loadConfig(tempDir, { env: {}, envFile: false });
|
|
342
|
+
expect.fail("Should have thrown an error");
|
|
343
|
+
}
|
|
344
|
+
catch (error) {
|
|
345
|
+
expect(error).toBeInstanceOf(ConfigError);
|
|
346
|
+
const msg = error.message;
|
|
347
|
+
expect(msg).toContain("has.dots.in.name");
|
|
348
|
+
expect(msg.toLowerCase()).toContain("invalid");
|
|
349
|
+
}
|
|
350
|
+
});
|
|
351
|
+
it("rejects fleet name with spaces via fleet.name", async () => {
|
|
352
|
+
await createFile(join(tempDir, "herdctl.yaml"), `
|
|
353
|
+
version: 1
|
|
354
|
+
fleets:
|
|
355
|
+
- path: ./sub/herdctl.yaml
|
|
356
|
+
`);
|
|
357
|
+
await createFile(join(tempDir, "sub", "herdctl.yaml"), `
|
|
358
|
+
version: 1
|
|
359
|
+
fleet:
|
|
360
|
+
name: "has spaces"
|
|
361
|
+
agents:
|
|
362
|
+
- path: ./agents/worker.yaml
|
|
363
|
+
`);
|
|
364
|
+
await createFile(join(tempDir, "sub", "agents", "worker.yaml"), "name: worker");
|
|
365
|
+
await expect(loadConfig(tempDir, { env: {}, envFile: false })).rejects.toThrow(/invalid/i);
|
|
366
|
+
});
|
|
367
|
+
});
|
|
368
|
+
describe("qualified name construction", () => {
|
|
369
|
+
it("root-level agents have qualifiedName equal to their local name", async () => {
|
|
370
|
+
await createFile(join(tempDir, "herdctl.yaml"), `
|
|
371
|
+
version: 1
|
|
372
|
+
agents:
|
|
373
|
+
- path: ./agents/monitor.yaml
|
|
374
|
+
`);
|
|
375
|
+
await createFile(join(tempDir, "agents", "monitor.yaml"), "name: monitor");
|
|
376
|
+
const result = await loadConfig(tempDir, { env: {}, envFile: false });
|
|
377
|
+
expect(result.agents[0].qualifiedName).toBe("monitor");
|
|
378
|
+
expect(result.agents[0].fleetPath).toEqual([]);
|
|
379
|
+
});
|
|
380
|
+
it("single-level sub-fleet agents use fleetName.agentName", async () => {
|
|
381
|
+
await createFile(join(tempDir, "herdctl.yaml"), `
|
|
382
|
+
version: 1
|
|
383
|
+
fleets:
|
|
384
|
+
- path: ./sub/herdctl.yaml
|
|
385
|
+
name: myfleet
|
|
386
|
+
`);
|
|
387
|
+
await createFile(join(tempDir, "sub", "herdctl.yaml"), `
|
|
388
|
+
version: 1
|
|
389
|
+
agents:
|
|
390
|
+
- path: ./agents/worker.yaml
|
|
391
|
+
`);
|
|
392
|
+
await createFile(join(tempDir, "sub", "agents", "worker.yaml"), "name: worker");
|
|
393
|
+
const result = await loadConfig(tempDir, { env: {}, envFile: false });
|
|
394
|
+
expect(result.agents[0].qualifiedName).toBe("myfleet.worker");
|
|
395
|
+
});
|
|
396
|
+
it("deeply nested agents include all intermediate fleet names", async () => {
|
|
397
|
+
const result = await loadConfig(join(fixturesDir, "deep-nesting", "root.yaml"), { env: {}, envFile: false });
|
|
398
|
+
// Should have agents at 4 levels: root, level1, level2, level3
|
|
399
|
+
expect(result.agents).toHaveLength(4);
|
|
400
|
+
const rootAgent = result.agents.find((a) => a.name === "root-agent");
|
|
401
|
+
expect(rootAgent).toBeDefined();
|
|
402
|
+
expect(rootAgent.qualifiedName).toBe("root-agent");
|
|
403
|
+
expect(rootAgent.fleetPath).toEqual([]);
|
|
404
|
+
const l1Agent = result.agents.find((a) => a.name === "l1-agent");
|
|
405
|
+
expect(l1Agent).toBeDefined();
|
|
406
|
+
expect(l1Agent.qualifiedName).toBe("level1.l1-agent");
|
|
407
|
+
expect(l1Agent.fleetPath).toEqual(["level1"]);
|
|
408
|
+
const l2Agent = result.agents.find((a) => a.name === "l2-agent");
|
|
409
|
+
expect(l2Agent).toBeDefined();
|
|
410
|
+
expect(l2Agent.qualifiedName).toBe("level1.level2.l2-agent");
|
|
411
|
+
expect(l2Agent.fleetPath).toEqual(["level1", "level2"]);
|
|
412
|
+
const l3Agent = result.agents.find((a) => a.name === "l3-agent");
|
|
413
|
+
expect(l3Agent).toBeDefined();
|
|
414
|
+
expect(l3Agent.qualifiedName).toBe("level1.level2.level3.l3-agent");
|
|
415
|
+
expect(l3Agent.fleetPath).toEqual(["level1", "level2", "level3"]);
|
|
416
|
+
});
|
|
417
|
+
it("same agent name in different sub-fleets produces different qualified names", async () => {
|
|
418
|
+
await createFile(join(tempDir, "herdctl.yaml"), `
|
|
419
|
+
version: 1
|
|
420
|
+
fleets:
|
|
421
|
+
- path: ./fleet-a/herdctl.yaml
|
|
422
|
+
- path: ./fleet-b/herdctl.yaml
|
|
423
|
+
`);
|
|
424
|
+
await createFile(join(tempDir, "fleet-a", "herdctl.yaml"), `
|
|
425
|
+
version: 1
|
|
426
|
+
fleet:
|
|
427
|
+
name: fleet-a
|
|
428
|
+
agents:
|
|
429
|
+
- path: ./agents/worker.yaml
|
|
430
|
+
`);
|
|
431
|
+
await createFile(join(tempDir, "fleet-b", "herdctl.yaml"), `
|
|
432
|
+
version: 1
|
|
433
|
+
fleet:
|
|
434
|
+
name: fleet-b
|
|
435
|
+
agents:
|
|
436
|
+
- path: ./agents/worker.yaml
|
|
437
|
+
`);
|
|
438
|
+
await createFile(join(tempDir, "fleet-a", "agents", "worker.yaml"), "name: worker");
|
|
439
|
+
await createFile(join(tempDir, "fleet-b", "agents", "worker.yaml"), "name: worker");
|
|
440
|
+
const result = await loadConfig(tempDir, { env: {}, envFile: false });
|
|
441
|
+
expect(result.agents).toHaveLength(2);
|
|
442
|
+
const fleetAWorker = result.agents.find((a) => a.qualifiedName === "fleet-a.worker");
|
|
443
|
+
const fleetBWorker = result.agents.find((a) => a.qualifiedName === "fleet-b.worker");
|
|
444
|
+
expect(fleetAWorker).toBeDefined();
|
|
445
|
+
expect(fleetBWorker).toBeDefined();
|
|
446
|
+
expect(fleetAWorker.qualifiedName).not.toBe(fleetBWorker.qualifiedName);
|
|
447
|
+
});
|
|
448
|
+
});
|
|
449
|
+
});
|
|
450
|
+
// =============================================================================
|
|
451
|
+
// Defaults Merging Edge Case Tests
|
|
452
|
+
// =============================================================================
|
|
453
|
+
describe("defaults merging edge cases", () => {
|
|
454
|
+
let tempDir;
|
|
455
|
+
beforeEach(async () => {
|
|
456
|
+
tempDir = await createTempDir();
|
|
457
|
+
});
|
|
458
|
+
afterEach(async () => {
|
|
459
|
+
await rm(tempDir, { recursive: true, force: true });
|
|
460
|
+
});
|
|
461
|
+
describe("5-level merge priority", () => {
|
|
462
|
+
it("sub-fleet defaults override super-fleet defaults for model", async () => {
|
|
463
|
+
// Priority level 2 (sub-fleet defaults) > level 1 (super-fleet defaults)
|
|
464
|
+
await createFile(join(tempDir, "herdctl.yaml"), `
|
|
465
|
+
version: 1
|
|
466
|
+
defaults:
|
|
467
|
+
model: super-model
|
|
468
|
+
max_turns: 200
|
|
469
|
+
fleets:
|
|
470
|
+
- path: ./sub/herdctl.yaml
|
|
471
|
+
`);
|
|
472
|
+
await createFile(join(tempDir, "sub", "herdctl.yaml"), `
|
|
473
|
+
version: 1
|
|
474
|
+
fleet:
|
|
475
|
+
name: sub
|
|
476
|
+
defaults:
|
|
477
|
+
model: sub-model
|
|
478
|
+
agents:
|
|
479
|
+
- path: ./agents/worker.yaml
|
|
480
|
+
`);
|
|
481
|
+
await createFile(join(tempDir, "sub", "agents", "worker.yaml"), "name: worker");
|
|
482
|
+
const result = await loadConfig(tempDir, { env: {}, envFile: false });
|
|
483
|
+
// sub-fleet model wins over super-fleet model
|
|
484
|
+
expect(result.agents[0].model).toBe("sub-model");
|
|
485
|
+
// super-fleet max_turns fills the gap (sub-fleet doesn't set it)
|
|
486
|
+
expect(result.agents[0].max_turns).toBe(200);
|
|
487
|
+
});
|
|
488
|
+
it("super-fleet defaults fill gaps when sub-fleet sets nothing", async () => {
|
|
489
|
+
// Priority level 1 acts as gap-filler
|
|
490
|
+
await createFile(join(tempDir, "herdctl.yaml"), `
|
|
491
|
+
version: 1
|
|
492
|
+
defaults:
|
|
493
|
+
model: super-model
|
|
494
|
+
max_turns: 100
|
|
495
|
+
permission_mode: plan
|
|
496
|
+
fleets:
|
|
497
|
+
- path: ./sub/herdctl.yaml
|
|
498
|
+
`);
|
|
499
|
+
await createFile(join(tempDir, "sub", "herdctl.yaml"), `
|
|
500
|
+
version: 1
|
|
501
|
+
fleet:
|
|
502
|
+
name: sub
|
|
503
|
+
agents:
|
|
504
|
+
- path: ./agents/worker.yaml
|
|
505
|
+
`);
|
|
506
|
+
await createFile(join(tempDir, "sub", "agents", "worker.yaml"), "name: worker");
|
|
507
|
+
const result = await loadConfig(tempDir, { env: {}, envFile: false });
|
|
508
|
+
expect(result.agents[0].model).toBe("super-model");
|
|
509
|
+
expect(result.agents[0].max_turns).toBe(100);
|
|
510
|
+
expect(result.agents[0].permission_mode).toBe("plan");
|
|
511
|
+
});
|
|
512
|
+
it("agent own config overrides both super-fleet and sub-fleet defaults", async () => {
|
|
513
|
+
// Priority level 3 (agent's own config) > levels 1 and 2
|
|
514
|
+
await createFile(join(tempDir, "herdctl.yaml"), `
|
|
515
|
+
version: 1
|
|
516
|
+
defaults:
|
|
517
|
+
model: super-model
|
|
518
|
+
max_turns: 200
|
|
519
|
+
fleets:
|
|
520
|
+
- path: ./sub/herdctl.yaml
|
|
521
|
+
`);
|
|
522
|
+
await createFile(join(tempDir, "sub", "herdctl.yaml"), `
|
|
523
|
+
version: 1
|
|
524
|
+
fleet:
|
|
525
|
+
name: sub
|
|
526
|
+
defaults:
|
|
527
|
+
model: sub-model
|
|
528
|
+
max_turns: 50
|
|
529
|
+
agents:
|
|
530
|
+
- path: ./agents/worker.yaml
|
|
531
|
+
`);
|
|
532
|
+
await createFile(join(tempDir, "sub", "agents", "worker.yaml"), `
|
|
533
|
+
name: worker
|
|
534
|
+
model: agent-model
|
|
535
|
+
max_turns: 10
|
|
536
|
+
`);
|
|
537
|
+
const result = await loadConfig(tempDir, { env: {}, envFile: false });
|
|
538
|
+
expect(result.agents[0].model).toBe("agent-model");
|
|
539
|
+
expect(result.agents[0].max_turns).toBe(10);
|
|
540
|
+
});
|
|
541
|
+
it("per-agent overrides from sub-fleet agents entry override agent config", async () => {
|
|
542
|
+
// Priority level 4 (per-agent overrides from sub-fleet's agents entry)
|
|
543
|
+
await createFile(join(tempDir, "herdctl.yaml"), `
|
|
544
|
+
version: 1
|
|
545
|
+
fleets:
|
|
546
|
+
- path: ./sub/herdctl.yaml
|
|
547
|
+
`);
|
|
548
|
+
await createFile(join(tempDir, "sub", "herdctl.yaml"), `
|
|
549
|
+
version: 1
|
|
550
|
+
fleet:
|
|
551
|
+
name: sub
|
|
552
|
+
agents:
|
|
553
|
+
- path: ./agents/worker.yaml
|
|
554
|
+
overrides:
|
|
555
|
+
model: per-agent-override
|
|
556
|
+
max_turns: 999
|
|
557
|
+
`);
|
|
558
|
+
await createFile(join(tempDir, "sub", "agents", "worker.yaml"), `
|
|
559
|
+
name: worker
|
|
560
|
+
model: agent-model
|
|
561
|
+
max_turns: 10
|
|
562
|
+
`);
|
|
563
|
+
const result = await loadConfig(tempDir, { env: {}, envFile: false });
|
|
564
|
+
// Per-agent overrides win
|
|
565
|
+
expect(result.agents[0].model).toBe("per-agent-override");
|
|
566
|
+
expect(result.agents[0].max_turns).toBe(999);
|
|
567
|
+
});
|
|
568
|
+
it("per-fleet defaults override from super-fleet forcefully overrides sub-fleet defaults", async () => {
|
|
569
|
+
// Priority level 5: per-fleet overrides on the fleets entry
|
|
570
|
+
await createFile(join(tempDir, "herdctl.yaml"), `
|
|
571
|
+
version: 1
|
|
572
|
+
defaults:
|
|
573
|
+
model: super-default
|
|
574
|
+
fleets:
|
|
575
|
+
- path: ./sub/herdctl.yaml
|
|
576
|
+
overrides:
|
|
577
|
+
defaults:
|
|
578
|
+
model: forced-override-model
|
|
579
|
+
`);
|
|
580
|
+
await createFile(join(tempDir, "sub", "herdctl.yaml"), `
|
|
581
|
+
version: 1
|
|
582
|
+
fleet:
|
|
583
|
+
name: sub
|
|
584
|
+
defaults:
|
|
585
|
+
model: sub-model
|
|
586
|
+
max_turns: 50
|
|
587
|
+
agents:
|
|
588
|
+
- path: ./agents/worker.yaml
|
|
589
|
+
`);
|
|
590
|
+
await createFile(join(tempDir, "sub", "agents", "worker.yaml"), "name: worker");
|
|
591
|
+
const result = await loadConfig(tempDir, { env: {}, envFile: false });
|
|
592
|
+
// forced-override-model from per-fleet overrides should win
|
|
593
|
+
expect(result.agents[0].model).toBe("forced-override-model");
|
|
594
|
+
// max_turns still from sub-fleet defaults
|
|
595
|
+
expect(result.agents[0].max_turns).toBe(50);
|
|
596
|
+
});
|
|
597
|
+
it("full 5-level priority chain with all levels set", async () => {
|
|
598
|
+
// All 5 levels set model, verify highest applicable priority wins
|
|
599
|
+
await createFile(join(tempDir, "herdctl.yaml"), `
|
|
600
|
+
version: 1
|
|
601
|
+
defaults:
|
|
602
|
+
model: level1-super-default
|
|
603
|
+
fleets:
|
|
604
|
+
- path: ./sub/herdctl.yaml
|
|
605
|
+
overrides:
|
|
606
|
+
defaults:
|
|
607
|
+
model: level5-fleet-override
|
|
608
|
+
`);
|
|
609
|
+
await createFile(join(tempDir, "sub", "herdctl.yaml"), `
|
|
610
|
+
version: 1
|
|
611
|
+
fleet:
|
|
612
|
+
name: sub
|
|
613
|
+
defaults:
|
|
614
|
+
model: level2-sub-default
|
|
615
|
+
agents:
|
|
616
|
+
- path: ./agents/worker.yaml
|
|
617
|
+
overrides:
|
|
618
|
+
model: level4-per-agent-override
|
|
619
|
+
`);
|
|
620
|
+
await createFile(join(tempDir, "sub", "agents", "worker.yaml"), `
|
|
621
|
+
name: worker
|
|
622
|
+
model: level3-agent-own
|
|
623
|
+
`);
|
|
624
|
+
const result = await loadConfig(tempDir, { env: {}, envFile: false });
|
|
625
|
+
// The merge order: effective defaults = deepMerge(super-default, sub-default + fleet-override)
|
|
626
|
+
// Then: mergeAgentConfig(effective, agent) => agent's own model wins over defaults
|
|
627
|
+
// Then: per-agent overrides are applied last on the agent level
|
|
628
|
+
// So level4-per-agent-override should win over level3-agent-own
|
|
629
|
+
expect(result.agents[0].model).toBe("level4-per-agent-override");
|
|
630
|
+
});
|
|
631
|
+
});
|
|
632
|
+
describe("multi-level defaults inheritance (fixture-based)", () => {
|
|
633
|
+
it("sub-fleet model wins over super-fleet model for inheriting agent", async () => {
|
|
634
|
+
const result = await loadConfig(join(fixturesDir, "defaults-cascade", "root.yaml"), { env: {}, envFile: false });
|
|
635
|
+
const inheritor = result.agents.find((a) => a.name === "inheritor");
|
|
636
|
+
expect(inheritor).toBeDefined();
|
|
637
|
+
// sub-fleet model (sub-model) overrides super-fleet model (super-model)
|
|
638
|
+
expect(inheritor.model).toBe("sub-model");
|
|
639
|
+
// max_turns from super-fleet gap-fills
|
|
640
|
+
expect(inheritor.max_turns).toBe(200);
|
|
641
|
+
});
|
|
642
|
+
it("agent own model wins over both fleet defaults", async () => {
|
|
643
|
+
const result = await loadConfig(join(fixturesDir, "defaults-cascade", "root.yaml"), { env: {}, envFile: false });
|
|
644
|
+
const ownModel = result.agents.find((a) => a.name === "own-model");
|
|
645
|
+
expect(ownModel).toBeDefined();
|
|
646
|
+
// Agent sets its own model and max_turns
|
|
647
|
+
expect(ownModel.model).toBe("agent-model");
|
|
648
|
+
expect(ownModel.max_turns).toBe(5);
|
|
649
|
+
});
|
|
650
|
+
});
|
|
651
|
+
describe("3-level nesting defaults (fixture-based)", () => {
|
|
652
|
+
it("correct model reaches agents at each level in deep nesting", async () => {
|
|
653
|
+
const result = await loadConfig(join(fixturesDir, "deep-nesting", "root.yaml"), { env: {}, envFile: false });
|
|
654
|
+
// root-agent: gets root defaults (model: root-model, max_turns: 100)
|
|
655
|
+
const rootAgent = result.agents.find((a) => a.name === "root-agent");
|
|
656
|
+
expect(rootAgent.model).toBe("root-model");
|
|
657
|
+
expect(rootAgent.max_turns).toBe(100);
|
|
658
|
+
// l1-agent: level1 defaults override root defaults (model: level1-model, max_turns: 50)
|
|
659
|
+
const l1Agent = result.agents.find((a) => a.name === "l1-agent");
|
|
660
|
+
expect(l1Agent.model).toBe("level1-model");
|
|
661
|
+
expect(l1Agent.max_turns).toBe(50);
|
|
662
|
+
// l2-agent: level2 defaults override (model: level2-model), max_turns from level1 (50)
|
|
663
|
+
const l2Agent = result.agents.find((a) => a.name === "l2-agent");
|
|
664
|
+
expect(l2Agent.model).toBe("level2-model");
|
|
665
|
+
expect(l2Agent.max_turns).toBe(50);
|
|
666
|
+
// l3-agent: level3 sets no defaults, so inherits from level2 (model: level2-model, max_turns: 50)
|
|
667
|
+
const l3Agent = result.agents.find((a) => a.name === "l3-agent");
|
|
668
|
+
expect(l3Agent.model).toBe("level2-model");
|
|
669
|
+
expect(l3Agent.max_turns).toBe(50);
|
|
670
|
+
});
|
|
671
|
+
});
|
|
672
|
+
describe("defaults merging with no defaults", () => {
|
|
673
|
+
it("agent with no fleet defaults gets no extra fields", async () => {
|
|
674
|
+
await createFile(join(tempDir, "herdctl.yaml"), `
|
|
675
|
+
version: 1
|
|
676
|
+
fleets:
|
|
677
|
+
- path: ./sub/herdctl.yaml
|
|
678
|
+
`);
|
|
679
|
+
await createFile(join(tempDir, "sub", "herdctl.yaml"), `
|
|
680
|
+
version: 1
|
|
681
|
+
fleet:
|
|
682
|
+
name: sub
|
|
683
|
+
agents:
|
|
684
|
+
- path: ./agents/worker.yaml
|
|
685
|
+
`);
|
|
686
|
+
await createFile(join(tempDir, "sub", "agents", "worker.yaml"), "name: worker");
|
|
687
|
+
const result = await loadConfig(tempDir, { env: {}, envFile: false });
|
|
688
|
+
// No defaults at any level, so model and max_turns should be undefined
|
|
689
|
+
expect(result.agents[0].model).toBeUndefined();
|
|
690
|
+
expect(result.agents[0].max_turns).toBeUndefined();
|
|
691
|
+
});
|
|
692
|
+
it("super-fleet defaults do not override sub-fleet agent explicit values", async () => {
|
|
693
|
+
await createFile(join(tempDir, "herdctl.yaml"), `
|
|
694
|
+
version: 1
|
|
695
|
+
defaults:
|
|
696
|
+
model: super-model
|
|
697
|
+
max_turns: 999
|
|
698
|
+
fleets:
|
|
699
|
+
- path: ./sub/herdctl.yaml
|
|
700
|
+
`);
|
|
701
|
+
await createFile(join(tempDir, "sub", "herdctl.yaml"), `
|
|
702
|
+
version: 1
|
|
703
|
+
fleet:
|
|
704
|
+
name: sub
|
|
705
|
+
agents:
|
|
706
|
+
- path: ./agents/worker.yaml
|
|
707
|
+
`);
|
|
708
|
+
await createFile(join(tempDir, "sub", "agents", "worker.yaml"), `
|
|
709
|
+
name: worker
|
|
710
|
+
model: my-model
|
|
711
|
+
max_turns: 5
|
|
712
|
+
`);
|
|
713
|
+
const result = await loadConfig(tempDir, { env: {}, envFile: false });
|
|
714
|
+
// Agent's own values should win
|
|
715
|
+
expect(result.agents[0].model).toBe("my-model");
|
|
716
|
+
expect(result.agents[0].max_turns).toBe(5);
|
|
717
|
+
});
|
|
718
|
+
});
|
|
719
|
+
describe("defaults merging with per-fleet override on defaults", () => {
|
|
720
|
+
it("per-fleet overrides on defaults merge into sub-fleet defaults before agent application", async () => {
|
|
721
|
+
// Super-fleet overrides sub-fleet's defaults via fleet-level overrides
|
|
722
|
+
await createFile(join(tempDir, "herdctl.yaml"), `
|
|
723
|
+
version: 1
|
|
724
|
+
fleets:
|
|
725
|
+
- path: ./sub/herdctl.yaml
|
|
726
|
+
overrides:
|
|
727
|
+
defaults:
|
|
728
|
+
max_turns: 777
|
|
729
|
+
`);
|
|
730
|
+
await createFile(join(tempDir, "sub", "herdctl.yaml"), `
|
|
731
|
+
version: 1
|
|
732
|
+
fleet:
|
|
733
|
+
name: sub
|
|
734
|
+
defaults:
|
|
735
|
+
model: sub-model
|
|
736
|
+
max_turns: 50
|
|
737
|
+
agents:
|
|
738
|
+
- path: ./agents/worker.yaml
|
|
739
|
+
`);
|
|
740
|
+
await createFile(join(tempDir, "sub", "agents", "worker.yaml"), "name: worker");
|
|
741
|
+
const result = await loadConfig(tempDir, { env: {}, envFile: false });
|
|
742
|
+
// Fleet-level override replaces sub-fleet's max_turns
|
|
743
|
+
expect(result.agents[0].max_turns).toBe(777);
|
|
744
|
+
// sub-fleet model still applies
|
|
745
|
+
expect(result.agents[0].model).toBe("sub-model");
|
|
746
|
+
});
|
|
747
|
+
it("per-fleet defaults override applies to all agents in the sub-fleet", async () => {
|
|
748
|
+
await createFile(join(tempDir, "herdctl.yaml"), `
|
|
749
|
+
version: 1
|
|
750
|
+
fleets:
|
|
751
|
+
- path: ./sub/herdctl.yaml
|
|
752
|
+
overrides:
|
|
753
|
+
defaults:
|
|
754
|
+
model: forced-model
|
|
755
|
+
`);
|
|
756
|
+
await createFile(join(tempDir, "sub", "herdctl.yaml"), `
|
|
757
|
+
version: 1
|
|
758
|
+
fleet:
|
|
759
|
+
name: sub
|
|
760
|
+
defaults:
|
|
761
|
+
model: sub-model
|
|
762
|
+
agents:
|
|
763
|
+
- path: ./agents/a.yaml
|
|
764
|
+
- path: ./agents/b.yaml
|
|
765
|
+
`);
|
|
766
|
+
await createFile(join(tempDir, "sub", "agents", "a.yaml"), "name: agent-a");
|
|
767
|
+
await createFile(join(tempDir, "sub", "agents", "b.yaml"), "name: agent-b");
|
|
768
|
+
const result = await loadConfig(tempDir, { env: {}, envFile: false });
|
|
769
|
+
// Both agents should get the forced model from fleet-level override
|
|
770
|
+
for (const agent of result.agents) {
|
|
771
|
+
expect(agent.model).toBe("forced-model");
|
|
772
|
+
}
|
|
773
|
+
});
|
|
774
|
+
});
|
|
775
|
+
});
|
|
776
|
+
// =============================================================================
|
|
777
|
+
// Structural Edge Case Tests
|
|
778
|
+
// =============================================================================
|
|
779
|
+
describe("fleet structural edge cases", () => {
|
|
780
|
+
let tempDir;
|
|
781
|
+
beforeEach(async () => {
|
|
782
|
+
tempDir = await createTempDir();
|
|
783
|
+
});
|
|
784
|
+
afterEach(async () => {
|
|
785
|
+
await rm(tempDir, { recursive: true, force: true });
|
|
786
|
+
});
|
|
787
|
+
describe("empty/missing fleets and agents", () => {
|
|
788
|
+
it("empty fleets array behaves exactly as no fleets", async () => {
|
|
789
|
+
await createFile(join(tempDir, "herdctl.yaml"), `
|
|
790
|
+
version: 1
|
|
791
|
+
fleets: []
|
|
792
|
+
agents:
|
|
793
|
+
- path: ./agents/worker.yaml
|
|
794
|
+
`);
|
|
795
|
+
await createFile(join(tempDir, "agents", "worker.yaml"), "name: worker");
|
|
796
|
+
const result = await loadConfig(tempDir, { env: {}, envFile: false });
|
|
797
|
+
expect(result.agents).toHaveLength(1);
|
|
798
|
+
expect(result.agents[0].qualifiedName).toBe("worker");
|
|
799
|
+
expect(result.agents[0].fleetPath).toEqual([]);
|
|
800
|
+
});
|
|
801
|
+
it("fleet with fleets but no agents is valid", async () => {
|
|
802
|
+
const result = await loadConfig(join(fixturesDir, "fleets-only", "root.yaml"), { env: {}, envFile: false });
|
|
803
|
+
expect(result.agents).toHaveLength(2);
|
|
804
|
+
expect(result.agents.find((a) => a.name === "worker-a")).toBeDefined();
|
|
805
|
+
expect(result.agents.find((a) => a.name === "worker-b")).toBeDefined();
|
|
806
|
+
});
|
|
807
|
+
it("fleet with agents but no fleets behaves exactly as before", async () => {
|
|
808
|
+
await createFile(join(tempDir, "herdctl.yaml"), `
|
|
809
|
+
version: 1
|
|
810
|
+
agents:
|
|
811
|
+
- path: ./agents/a.yaml
|
|
812
|
+
- path: ./agents/b.yaml
|
|
813
|
+
`);
|
|
814
|
+
await createFile(join(tempDir, "agents", "a.yaml"), "name: agent-a");
|
|
815
|
+
await createFile(join(tempDir, "agents", "b.yaml"), "name: agent-b");
|
|
816
|
+
const result = await loadConfig(tempDir, { env: {}, envFile: false });
|
|
817
|
+
expect(result.agents).toHaveLength(2);
|
|
818
|
+
for (const agent of result.agents) {
|
|
819
|
+
expect(agent.fleetPath).toEqual([]);
|
|
820
|
+
expect(agent.qualifiedName).toBe(agent.name);
|
|
821
|
+
}
|
|
822
|
+
});
|
|
823
|
+
it("fleet with no agents and no fleets produces empty agent list", async () => {
|
|
824
|
+
await createFile(join(tempDir, "herdctl.yaml"), `
|
|
825
|
+
version: 1
|
|
826
|
+
`);
|
|
827
|
+
const result = await loadConfig(tempDir, { env: {}, envFile: false });
|
|
828
|
+
expect(result.agents).toEqual([]);
|
|
829
|
+
});
|
|
830
|
+
it("sub-fleet with no agents is valid", async () => {
|
|
831
|
+
await createFile(join(tempDir, "herdctl.yaml"), `
|
|
832
|
+
version: 1
|
|
833
|
+
fleets:
|
|
834
|
+
- path: ./sub/herdctl.yaml
|
|
835
|
+
agents:
|
|
836
|
+
- path: ./agents/root-agent.yaml
|
|
837
|
+
`);
|
|
838
|
+
await createFile(join(tempDir, "sub", "herdctl.yaml"), `
|
|
839
|
+
version: 1
|
|
840
|
+
fleet:
|
|
841
|
+
name: empty-sub
|
|
842
|
+
agents: []
|
|
843
|
+
`);
|
|
844
|
+
await createFile(join(tempDir, "agents", "root-agent.yaml"), "name: root-agent");
|
|
845
|
+
const result = await loadConfig(tempDir, { env: {}, envFile: false });
|
|
846
|
+
// Only root-agent, nothing from empty sub-fleet
|
|
847
|
+
expect(result.agents).toHaveLength(1);
|
|
848
|
+
expect(result.agents[0].name).toBe("root-agent");
|
|
849
|
+
});
|
|
850
|
+
});
|
|
851
|
+
describe("deeply nested fleets", () => {
|
|
852
|
+
it("4-level nesting produces correct qualified names (fixture-based)", async () => {
|
|
853
|
+
const result = await loadConfig(join(fixturesDir, "deep-nesting", "root.yaml"), { env: {}, envFile: false });
|
|
854
|
+
expect(result.agents).toHaveLength(4);
|
|
855
|
+
// Verify qualified names for all levels
|
|
856
|
+
const names = result.agents.map((a) => a.qualifiedName).sort();
|
|
857
|
+
expect(names).toEqual([
|
|
858
|
+
"level1.l1-agent",
|
|
859
|
+
"level1.level2.l2-agent",
|
|
860
|
+
"level1.level2.level3.l3-agent",
|
|
861
|
+
"root-agent",
|
|
862
|
+
]);
|
|
863
|
+
});
|
|
864
|
+
it("fleetPath arrays grow correctly with depth", async () => {
|
|
865
|
+
const result = await loadConfig(join(fixturesDir, "deep-nesting", "root.yaml"), { env: {}, envFile: false });
|
|
866
|
+
const l3Agent = result.agents.find((a) => a.name === "l3-agent");
|
|
867
|
+
expect(l3Agent.fleetPath).toEqual(["level1", "level2", "level3"]);
|
|
868
|
+
expect(l3Agent.fleetPath).toHaveLength(3);
|
|
869
|
+
});
|
|
870
|
+
});
|
|
871
|
+
describe("fleet with only fleets (pure delegation)", () => {
|
|
872
|
+
it("all agents come from sub-fleets, none from root (fixture-based)", async () => {
|
|
873
|
+
const result = await loadConfig(join(fixturesDir, "fleets-only", "root.yaml"), { env: {}, envFile: false });
|
|
874
|
+
// All agents should have non-empty fleetPath
|
|
875
|
+
for (const agent of result.agents) {
|
|
876
|
+
expect(agent.fleetPath.length).toBeGreaterThan(0);
|
|
877
|
+
}
|
|
878
|
+
});
|
|
879
|
+
it("qualified names include sub-fleet names for delegation fleet", async () => {
|
|
880
|
+
const result = await loadConfig(join(fixturesDir, "fleets-only", "root.yaml"), { env: {}, envFile: false });
|
|
881
|
+
const workerA = result.agents.find((a) => a.name === "worker-a");
|
|
882
|
+
const workerB = result.agents.find((a) => a.name === "worker-b");
|
|
883
|
+
expect(workerA.qualifiedName).toBe("sub-a.worker-a");
|
|
884
|
+
expect(workerB.qualifiedName).toBe("sub-b.worker-b");
|
|
885
|
+
});
|
|
886
|
+
});
|
|
887
|
+
describe("fleet name collision edge cases", () => {
|
|
888
|
+
it("same name at different levels does NOT collide", async () => {
|
|
889
|
+
// "same-name" at level 1, and "same-name" nested inside another fleet at level 2
|
|
890
|
+
// These are at different levels so no collision
|
|
891
|
+
await createFile(join(tempDir, "herdctl.yaml"), `
|
|
892
|
+
version: 1
|
|
893
|
+
fleets:
|
|
894
|
+
- path: ./fleet-a/herdctl.yaml
|
|
895
|
+
name: fleet-a
|
|
896
|
+
- path: ./fleet-b/herdctl.yaml
|
|
897
|
+
name: fleet-b
|
|
898
|
+
`);
|
|
899
|
+
await createFile(join(tempDir, "fleet-a", "herdctl.yaml"), `
|
|
900
|
+
version: 1
|
|
901
|
+
fleets:
|
|
902
|
+
- path: ./nested/herdctl.yaml
|
|
903
|
+
name: same-name
|
|
904
|
+
`);
|
|
905
|
+
await createFile(join(tempDir, "fleet-a", "nested", "herdctl.yaml"), `
|
|
906
|
+
version: 1
|
|
907
|
+
agents:
|
|
908
|
+
- path: ./agents/worker.yaml
|
|
909
|
+
`);
|
|
910
|
+
await createFile(join(tempDir, "fleet-a", "nested", "agents", "worker.yaml"), "name: worker-a-nested");
|
|
911
|
+
await createFile(join(tempDir, "fleet-b", "herdctl.yaml"), `
|
|
912
|
+
version: 1
|
|
913
|
+
fleets:
|
|
914
|
+
- path: ./nested/herdctl.yaml
|
|
915
|
+
name: same-name
|
|
916
|
+
`);
|
|
917
|
+
await createFile(join(tempDir, "fleet-b", "nested", "herdctl.yaml"), `
|
|
918
|
+
version: 1
|
|
919
|
+
agents:
|
|
920
|
+
- path: ./agents/worker.yaml
|
|
921
|
+
`);
|
|
922
|
+
await createFile(join(tempDir, "fleet-b", "nested", "agents", "worker.yaml"), "name: worker-b-nested");
|
|
923
|
+
// Should NOT throw — "same-name" is at different levels (under fleet-a vs fleet-b)
|
|
924
|
+
const result = await loadConfig(tempDir, { env: {}, envFile: false });
|
|
925
|
+
expect(result.agents).toHaveLength(2);
|
|
926
|
+
expect(result.agents.find((a) => a.qualifiedName === "fleet-a.same-name.worker-a-nested")).toBeDefined();
|
|
927
|
+
expect(result.agents.find((a) => a.qualifiedName === "fleet-b.same-name.worker-b-nested")).toBeDefined();
|
|
928
|
+
});
|
|
929
|
+
it("directory-derived names that collide are detected", async () => {
|
|
930
|
+
// Two sub-fleets in directories both named "project" with no explicit names
|
|
931
|
+
await createFile(join(tempDir, "herdctl.yaml"), `
|
|
932
|
+
version: 1
|
|
933
|
+
fleets:
|
|
934
|
+
- path: ./path-a/project/herdctl.yaml
|
|
935
|
+
- path: ./path-b/project/herdctl.yaml
|
|
936
|
+
`);
|
|
937
|
+
await createFile(join(tempDir, "path-a", "project", "herdctl.yaml"), `
|
|
938
|
+
version: 1
|
|
939
|
+
agents: []
|
|
940
|
+
`);
|
|
941
|
+
await createFile(join(tempDir, "path-b", "project", "herdctl.yaml"), `
|
|
942
|
+
version: 1
|
|
943
|
+
agents: []
|
|
944
|
+
`);
|
|
945
|
+
// Both resolve to "project" directory name => collision
|
|
946
|
+
await expect(loadConfig(tempDir, { env: {}, envFile: false })).rejects.toThrow(FleetNameCollisionError);
|
|
947
|
+
});
|
|
948
|
+
it("collision between explicit name and directory-derived name", async () => {
|
|
949
|
+
// One fleet has explicit name "myfleet", another's directory derives to "myfleet"
|
|
950
|
+
await createFile(join(tempDir, "herdctl.yaml"), `
|
|
951
|
+
version: 1
|
|
952
|
+
fleets:
|
|
953
|
+
- path: ./first/herdctl.yaml
|
|
954
|
+
name: myfleet
|
|
955
|
+
- path: ./myfleet/herdctl.yaml
|
|
956
|
+
`);
|
|
957
|
+
await createFile(join(tempDir, "first", "herdctl.yaml"), `
|
|
958
|
+
version: 1
|
|
959
|
+
agents: []
|
|
960
|
+
`);
|
|
961
|
+
await createFile(join(tempDir, "myfleet", "herdctl.yaml"), `
|
|
962
|
+
version: 1
|
|
963
|
+
agents: []
|
|
964
|
+
`);
|
|
965
|
+
await expect(loadConfig(tempDir, { env: {}, envFile: false })).rejects.toThrow(FleetNameCollisionError);
|
|
966
|
+
});
|
|
967
|
+
});
|
|
968
|
+
describe("mixed root agents and sub-fleet agents", () => {
|
|
969
|
+
it("root agents and sub-fleet agents coexist correctly", async () => {
|
|
970
|
+
await createFile(join(tempDir, "herdctl.yaml"), `
|
|
971
|
+
version: 1
|
|
972
|
+
fleets:
|
|
973
|
+
- path: ./sub/herdctl.yaml
|
|
974
|
+
agents:
|
|
975
|
+
- path: ./agents/root-worker.yaml
|
|
976
|
+
`);
|
|
977
|
+
await createFile(join(tempDir, "sub", "herdctl.yaml"), `
|
|
978
|
+
version: 1
|
|
979
|
+
fleet:
|
|
980
|
+
name: sub
|
|
981
|
+
agents:
|
|
982
|
+
- path: ./agents/sub-worker.yaml
|
|
983
|
+
`);
|
|
984
|
+
await createFile(join(tempDir, "agents", "root-worker.yaml"), "name: root-worker");
|
|
985
|
+
await createFile(join(tempDir, "sub", "agents", "sub-worker.yaml"), "name: sub-worker");
|
|
986
|
+
const result = await loadConfig(tempDir, { env: {}, envFile: false });
|
|
987
|
+
expect(result.agents).toHaveLength(2);
|
|
988
|
+
const rootWorker = result.agents.find((a) => a.name === "root-worker");
|
|
989
|
+
const subWorker = result.agents.find((a) => a.name === "sub-worker");
|
|
990
|
+
expect(rootWorker.fleetPath).toEqual([]);
|
|
991
|
+
expect(rootWorker.qualifiedName).toBe("root-worker");
|
|
992
|
+
expect(subWorker.fleetPath).toEqual(["sub"]);
|
|
993
|
+
expect(subWorker.qualifiedName).toBe("sub.sub-worker");
|
|
994
|
+
});
|
|
995
|
+
it("root defaults apply to root agents but not sub-fleet agents with their own defaults", async () => {
|
|
996
|
+
await createFile(join(tempDir, "herdctl.yaml"), `
|
|
997
|
+
version: 1
|
|
998
|
+
defaults:
|
|
999
|
+
model: root-model
|
|
1000
|
+
fleets:
|
|
1001
|
+
- path: ./sub/herdctl.yaml
|
|
1002
|
+
agents:
|
|
1003
|
+
- path: ./agents/root-worker.yaml
|
|
1004
|
+
`);
|
|
1005
|
+
await createFile(join(tempDir, "sub", "herdctl.yaml"), `
|
|
1006
|
+
version: 1
|
|
1007
|
+
fleet:
|
|
1008
|
+
name: sub
|
|
1009
|
+
defaults:
|
|
1010
|
+
model: sub-model
|
|
1011
|
+
agents:
|
|
1012
|
+
- path: ./agents/sub-worker.yaml
|
|
1013
|
+
`);
|
|
1014
|
+
await createFile(join(tempDir, "agents", "root-worker.yaml"), "name: root-worker");
|
|
1015
|
+
await createFile(join(tempDir, "sub", "agents", "sub-worker.yaml"), "name: sub-worker");
|
|
1016
|
+
const result = await loadConfig(tempDir, { env: {}, envFile: false });
|
|
1017
|
+
const rootWorker = result.agents.find((a) => a.name === "root-worker");
|
|
1018
|
+
const subWorker = result.agents.find((a) => a.name === "sub-worker");
|
|
1019
|
+
expect(rootWorker.model).toBe("root-model");
|
|
1020
|
+
expect(subWorker.model).toBe("sub-model");
|
|
1021
|
+
});
|
|
1022
|
+
});
|
|
1023
|
+
});
|
|
1024
|
+
//# sourceMappingURL=fleet-naming-and-defaults.test.js.map
|