@crvouga/dev-fleet 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +237 -37
- package/agents/setup.md +42 -12
- package/ansible/macos/manager.yml +7 -7
- package/ansible/macos/policy.json +2 -10
- package/ansible/monitoring/README.md +86 -25
- package/ansible/monitoring/agent.yml +40 -2
- package/ansible/monitoring/hub-route-darwin.yml +51 -0
- package/ansible/monitoring/lock.json +5 -0
- package/ansible/pyproject.toml +12 -0
- package/ansible/ubuntu/baseline.yml +1 -1
- package/ansible/ubuntu/disk-gc.py +336 -0
- package/ansible/ubuntu/policy.json +12 -3
- package/ansible/ubuntu/tailscale.yml +8 -5
- package/ansible/ubuntu/tasks/resources.yml +61 -1
- package/ansible/uv.lock +190 -0
- package/bootstrap/bootstrap.sh +25 -18
- package/bootstrap/doctor.ts +46 -1
- package/bootstrap/fleet-manager-run.sh +42 -15
- package/bootstrap/fleet-remote-bootstrap.sh +85 -12
- package/bootstrap/global-config.ts +64 -6
- package/bootstrap/install-launcher.sh +3 -0
- package/bootstrap/npm.ts +10 -2
- package/bootstrap/release-version.ts +157 -0
- package/docs/consumer-setup.md +408 -0
- package/docs/declared.md +89 -0
- package/docs/machine-setup.md +399 -0
- package/docs/operations.md +276 -0
- package/docs/releasing.md +103 -0
- package/docs/state.md +254 -0
- package/docs/topology.md +67 -0
- package/docs/ubuntu-sync.md +351 -0
- package/docs/worker-contract.md +67 -0
- package/examples/fleet.json +5 -0
- package/llms.txt +35 -0
- package/mise.lock +29 -0
- package/mise.toml +12 -1
- package/package.json +11 -2
- package/schema/fleet.schema.json +71 -15
- package/src/agents/fleet-verify.ts +9 -0
- package/src/agents/native-verify.ts +167 -22
- package/src/agents/provider-errors.ts +9 -2
- package/src/agents/registry.ts +7 -1
- package/src/cli/agent-guide.ts +39 -5
- package/src/cli/app.ts +11 -4
- package/src/cli/cli.ts +7 -0
- package/src/cli/command-index.ts +7 -0
- package/src/cli/fleet-state.ts +499 -0
- package/src/cli/fleet-sync-checkout.ts +56 -17
- package/src/cli/fleet-sync-enrollment.ts +19 -23
- package/src/cli/fleet-sync-forward.ts +1 -1
- package/src/cli/fleet-sync-manager.ts +64 -8
- package/src/cli/fleet-sync-monitoring.ts +5 -5
- package/src/cli/fleet-sync-output.ts +4 -1
- package/src/cli/fleet-sync-remote.ts +11 -5
- package/src/cli/fleet-sync-state.ts +86 -0
- package/src/cli/fleet-sync.ts +123 -57
- package/src/cli/fleet.ts +4 -2
- package/src/cli/managed-health.ts +4 -4
- package/src/cli/node-sync.ts +7 -0
- package/src/cli/program.ts +20 -4
- package/src/cli/project.ts +51 -0
- package/src/cli/register-fleet.ts +51 -1
- package/src/cli/registry.ts +28 -1
- package/src/cli/roster.ts +46 -30
- package/src/cli/settings.ts +15 -12
- package/src/cli/source-operations.ts +27 -3
- package/src/cli/tailnet.ts +2 -2
- package/src/definition.ts +31 -1
- package/src/diagnostics.ts +1 -1
- package/src/fleet-config.ts +105 -19
- package/src/machine/source.ts +3 -3
- package/src/machine/state.ts +15 -27
- package/src/manager-sync.ts +8 -3
- package/src/monitoring/agent-evidence.ts +146 -0
- package/src/monitoring/apply.ts +15 -3
- package/src/monitoring/beszel.ts +11 -4
- package/src/monitoring/enroll.ts +2 -1
- package/src/monitoring/hub-access.ts +4 -3
- package/src/monitoring/hub-backup.ts +121 -0
- package/src/monitoring/hub-viewer.ts +8 -8
- package/src/monitoring/hub.ts +3 -3
- package/src/monitoring/manager-hub.ts +30 -14
- package/src/node-converge-linux.ts +2 -0
- package/src/package-info.ts +45 -0
- package/src/platform/ansible.ts +19 -15
- package/src/platform/resource-verify.ts +8 -4
- package/src/platform/ubuntu-audit.ts +5 -2
- package/src/remote-bootstrap.ts +17 -19
- package/src/repair-catalog.ts +1 -1
- package/src/runner.ts +1 -1
- package/src/state/bucket.ts +208 -0
- package/src/state/check.ts +74 -0
- package/src/state/connection.ts +92 -0
- package/src/state/enrollment.ts +45 -0
- package/src/state/legacy.ts +89 -0
- package/src/state/litestream.ts +143 -0
- package/src/state/operator.ts +20 -0
- package/src/state/paths.ts +40 -0
- package/src/state/roster.ts +62 -0
- package/src/state/store.ts +169 -0
- package/src/state/sync.ts +352 -0
- package/src/t3-endpoint.ts +64 -8
- package/src/t3-linked-check.ts +23 -1
- package/src/toolchain-checks.ts +25 -0
- package/src/types.ts +4 -0
- package/src/worker-access-checks.ts +56 -3
- package/src/worker-host-checks.ts +5 -0
- package/src/workstation-checks.ts +1 -1
- package/src/index.ts +0 -6
- package/src/test-fixtures.ts +0 -36
package/package.json
CHANGED
|
@@ -1,16 +1,21 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@crvouga/dev-fleet",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Keeps a set of development machines in sync: one manager and many workers",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": "github:crvouga/dev-fleet",
|
|
7
7
|
"homepage": "https://github.com/crvouga/dev-fleet#readme",
|
|
8
|
+
"bugs": {
|
|
9
|
+
"url": "https://github.com/crvouga/dev-fleet/issues"
|
|
10
|
+
},
|
|
8
11
|
"type": "module",
|
|
9
12
|
"bin": {
|
|
10
13
|
"devfleet": "./bin/devfleet.mjs"
|
|
11
14
|
},
|
|
12
15
|
"files": [
|
|
13
16
|
"bin",
|
|
17
|
+
"llms.txt",
|
|
18
|
+
"docs",
|
|
14
19
|
"src",
|
|
15
20
|
"agents",
|
|
16
21
|
"ansible",
|
|
@@ -52,6 +57,9 @@
|
|
|
52
57
|
"yaml": "^2.8.0"
|
|
53
58
|
},
|
|
54
59
|
"devDependencies": {
|
|
60
|
+
"@cucumber/cucumber-expressions": "20.1.0",
|
|
61
|
+
"@cucumber/gherkin": "42.0.1",
|
|
62
|
+
"@cucumber/messages": "34.2.1",
|
|
55
63
|
"@eslint/js": "^9.25.0",
|
|
56
64
|
"@types/bun": "^1.3.9",
|
|
57
65
|
"@types/node": "^22.15.29",
|
|
@@ -63,5 +71,6 @@
|
|
|
63
71
|
"packageManager": "bun@1.4.4",
|
|
64
72
|
"engines": {
|
|
65
73
|
"node": ">=22"
|
|
66
|
-
}
|
|
74
|
+
},
|
|
75
|
+
"gitHead": "6dee8455d48203125eb5c0546c3c4446034fca85"
|
|
67
76
|
}
|
package/schema/fleet.schema.json
CHANGED
|
@@ -27,7 +27,8 @@
|
|
|
27
27
|
"properties": {
|
|
28
28
|
"id": {
|
|
29
29
|
"type": "string",
|
|
30
|
-
"pattern": "^[a-z][a-z0-9-]{0,62}$"
|
|
30
|
+
"pattern": "^[a-z][a-z0-9-]{0,62}$",
|
|
31
|
+
"description": "A short stable id for the manager, used in reports."
|
|
31
32
|
},
|
|
32
33
|
"os": {
|
|
33
34
|
"const": "macOS"
|
|
@@ -39,11 +40,13 @@
|
|
|
39
40
|
"properties": {
|
|
40
41
|
"tailscaleName": {
|
|
41
42
|
"type": "string",
|
|
42
|
-
"pattern": "^[a-zA-Z0-9][a-zA-Z0-9._-]{0,252}$"
|
|
43
|
+
"pattern": "^[a-zA-Z0-9][a-zA-Z0-9._-]{0,252}$",
|
|
44
|
+
"description": "The manager's Tailscale MagicDNS name, without the trailing dot (tailscale status --json: .Self.DNSName)."
|
|
43
45
|
},
|
|
44
46
|
"user": {
|
|
45
47
|
"type": "string",
|
|
46
|
-
"pattern": "^[a-z_][a-z0-9_.-]{0,31}$"
|
|
48
|
+
"pattern": "^[a-z_][a-z0-9_.-]{0,31}$",
|
|
49
|
+
"description": "The manager's login name (id -un)."
|
|
47
50
|
}
|
|
48
51
|
}
|
|
49
52
|
},
|
|
@@ -64,11 +67,13 @@
|
|
|
64
67
|
"properties": {
|
|
65
68
|
"id": {
|
|
66
69
|
"type": "string",
|
|
67
|
-
"pattern": "^[a-z][a-z0-9-]{0,62}$"
|
|
70
|
+
"pattern": "^[a-z][a-z0-9-]{0,62}$",
|
|
71
|
+
"description": "A short stable id for the worker. The first worker listed is the canary: a revision new to the fleet must pass there before the others receive it."
|
|
68
72
|
},
|
|
69
73
|
"ssh": {
|
|
70
74
|
"type": "string",
|
|
71
|
-
"pattern": "^[a-zA-Z0-9][a-zA-Z0-9._-]{0,252}$"
|
|
75
|
+
"pattern": "^[a-zA-Z0-9][a-zA-Z0-9._-]{0,252}$",
|
|
76
|
+
"description": "The worker's Host alias in the manager's ~/.ssh/config. It must work with BatchMode=yes and a host key already in known_hosts."
|
|
72
77
|
},
|
|
73
78
|
"os": {
|
|
74
79
|
"const": "linux",
|
|
@@ -81,21 +86,25 @@
|
|
|
81
86
|
"properties": {
|
|
82
87
|
"tailscaleName": {
|
|
83
88
|
"type": "string",
|
|
84
|
-
"pattern": "^[a-zA-Z0-9][a-zA-Z0-9._-]{0,252}$"
|
|
89
|
+
"pattern": "^[a-zA-Z0-9][a-zA-Z0-9._-]{0,252}$",
|
|
90
|
+
"description": "The worker's Tailscale MagicDNS name, without the trailing dot."
|
|
85
91
|
},
|
|
86
92
|
"user": {
|
|
87
93
|
"type": "string",
|
|
88
|
-
"pattern": "^[a-z_][a-z0-9_-]{0,31}$"
|
|
94
|
+
"pattern": "^[a-z_][a-z0-9_-]{0,31}$",
|
|
95
|
+
"description": "The non-root worker account that can use sudo."
|
|
89
96
|
},
|
|
90
97
|
"sshPort": {
|
|
91
98
|
"type": "integer",
|
|
92
99
|
"minimum": 1,
|
|
93
|
-
"maximum": 65535
|
|
100
|
+
"maximum": 65535,
|
|
101
|
+
"description": "The port sshd listens on, normally 22."
|
|
94
102
|
},
|
|
95
103
|
"t3Port": {
|
|
96
104
|
"type": "integer",
|
|
97
105
|
"minimum": 1,
|
|
98
|
-
"maximum": 65535
|
|
106
|
+
"maximum": 65535,
|
|
107
|
+
"description": "The port the worker T3 server listens on, normally 3773."
|
|
99
108
|
}
|
|
100
109
|
}
|
|
101
110
|
}
|
|
@@ -163,7 +172,8 @@
|
|
|
163
172
|
"architectures": {
|
|
164
173
|
"const": ["amd64", "arm64"]
|
|
165
174
|
}
|
|
166
|
-
}
|
|
175
|
+
},
|
|
176
|
+
"description": "The supported worker release and architectures (fixed)."
|
|
167
177
|
},
|
|
168
178
|
"worker": {
|
|
169
179
|
"type": "object",
|
|
@@ -191,15 +201,18 @@
|
|
|
191
201
|
"workstation",
|
|
192
202
|
"worker"
|
|
193
203
|
]
|
|
194
|
-
}
|
|
204
|
+
},
|
|
205
|
+
"description": "Readiness checks every worker must pass; normally all six."
|
|
195
206
|
},
|
|
196
207
|
"githubLogin": {
|
|
197
208
|
"type": "string",
|
|
198
|
-
"pattern": "^[a-zA-Z0-9-]{1,39}$"
|
|
209
|
+
"pattern": "^[a-zA-Z0-9-]{1,39}$",
|
|
210
|
+
"description": "The GitHub login every worker must be signed in as."
|
|
199
211
|
},
|
|
200
212
|
"vaultUser": {
|
|
201
213
|
"type": "string",
|
|
202
|
-
"pattern": "^[a-zA-Z0-9_.-]{1,64}$"
|
|
214
|
+
"pattern": "^[a-zA-Z0-9_.-]{1,64}$",
|
|
215
|
+
"description": "The Vault user each worker's own token is issued for."
|
|
203
216
|
},
|
|
204
217
|
"toolchain": {
|
|
205
218
|
"const": "mise.lock"
|
|
@@ -216,13 +229,14 @@
|
|
|
216
229
|
"neverSleep": {
|
|
217
230
|
"const": true
|
|
218
231
|
}
|
|
219
|
-
}
|
|
232
|
+
},
|
|
233
|
+
"description": "Policy every worker must meet. All but the two logins and the capabilities are fixed."
|
|
220
234
|
},
|
|
221
235
|
"source": {
|
|
222
236
|
"type": "object",
|
|
223
237
|
"additionalProperties": false,
|
|
224
238
|
"required": ["repository"],
|
|
225
|
-
"description": "The Git repository
|
|
239
|
+
"description": "The Git repository machines take dev-fleet's code from: the one the package you run was published from, or your fork. Nodes only accept source from it.",
|
|
226
240
|
"properties": {
|
|
227
241
|
"repository": {
|
|
228
242
|
"type": "string",
|
|
@@ -230,6 +244,48 @@
|
|
|
230
244
|
}
|
|
231
245
|
}
|
|
232
246
|
},
|
|
247
|
+
"state": {
|
|
248
|
+
"description": "The bucket that holds the fleet state (the roster, the approved revision and the configuration last applied). Any S3-compatible service, or a directory. Never credentials: a devfleet command reads those from DEVFLEET_BUCKET_ACCESS_KEY_ID and DEVFLEET_BUCKET_SECRET_ACCESS_KEY.",
|
|
249
|
+
"oneOf": [
|
|
250
|
+
{
|
|
251
|
+
"type": "object",
|
|
252
|
+
"additionalProperties": false,
|
|
253
|
+
"required": ["bucket"],
|
|
254
|
+
"properties": {
|
|
255
|
+
"bucket": {
|
|
256
|
+
"type": "string",
|
|
257
|
+
"pattern": "^[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$"
|
|
258
|
+
},
|
|
259
|
+
"endpoint": {
|
|
260
|
+
"type": "string",
|
|
261
|
+
"description": "The https origin of the S3 service; omit for AWS S3.",
|
|
262
|
+
"pattern": "^https://[A-Za-z0-9.-]{1,253}(:[0-9]{1,5})?$"
|
|
263
|
+
},
|
|
264
|
+
"region": {
|
|
265
|
+
"type": "string",
|
|
266
|
+
"pattern": "^[a-z0-9-]{1,32}$"
|
|
267
|
+
},
|
|
268
|
+
"prefix": {
|
|
269
|
+
"type": "string",
|
|
270
|
+
"description": "Key prefix, so one bucket can hold several fleets. Default: dev-fleet.",
|
|
271
|
+
"pattern": "^[A-Za-z0-9][A-Za-z0-9._/-]{0,199}$"
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
},
|
|
275
|
+
{
|
|
276
|
+
"type": "object",
|
|
277
|
+
"additionalProperties": false,
|
|
278
|
+
"required": ["directory"],
|
|
279
|
+
"properties": {
|
|
280
|
+
"directory": {
|
|
281
|
+
"type": "string",
|
|
282
|
+
"description": "An absolute path on an attached disk or a mounted share.",
|
|
283
|
+
"pattern": "^/"
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
]
|
|
288
|
+
},
|
|
233
289
|
"secrets": {
|
|
234
290
|
"description": "Where this fleet keeps its secrets. env: DEVFLEET_SECRET_<NAME>; file: one file per secret; sops: one encrypted document; vault: a KV v2 location per secret.",
|
|
235
291
|
"oneOf": [
|
|
@@ -135,6 +135,14 @@ const UPSTREAM: Record<string, string> = {
|
|
|
135
135
|
'The provider did not answer within the bounded verification deadline. Retry later.',
|
|
136
136
|
};
|
|
137
137
|
|
|
138
|
+
/** The declared route cannot work as it is: it fails, and stays failed,
|
|
139
|
+
* until the route is changed. Retrying or signing in again changes nothing.
|
|
140
|
+
*/
|
|
141
|
+
const INCOMPATIBLE: Record<string, string> = {
|
|
142
|
+
PROVIDER_PROTOCOL_INCOMPATIBLE:
|
|
143
|
+
'The gateway does not serve the protocol this route uses (no such endpoint, method or media type). Nothing on this machine can fix it: change the route in agents/registry.json to one listed under "Supported alternatives" in agents/setup.md, then run fleet-sync.',
|
|
144
|
+
};
|
|
145
|
+
|
|
138
146
|
export function inferenceFailure(provider: string, code: string): Check {
|
|
139
147
|
if (QUOTA.has(code))
|
|
140
148
|
return {
|
|
@@ -150,6 +158,7 @@ export function inferenceFailure(provider: string, code: string): Check {
|
|
|
150
158
|
remedy: {
|
|
151
159
|
action:
|
|
152
160
|
upstream ??
|
|
161
|
+
INCOMPATIBLE[code] ??
|
|
153
162
|
`Verify unattended Vault access and the declared ${provider} model on the singleton gateway. Preserve OAuth state; do not log native auth on proxy clients.`,
|
|
154
163
|
},
|
|
155
164
|
};
|
|
@@ -23,6 +23,8 @@ export const NATIVE_AGENTS = [
|
|
|
23
23
|
binary: 'claude',
|
|
24
24
|
pin: 'claude',
|
|
25
25
|
wrapper: 'claude-proxy.ts',
|
|
26
|
+
// T3's binaryPath: the Claude Agent SDK runs a `.ts` path with node.
|
|
27
|
+
launcher: 'claude-proxy',
|
|
26
28
|
args: ['-p', PROMPT],
|
|
27
29
|
},
|
|
28
30
|
{
|
|
@@ -30,6 +32,7 @@ export const NATIVE_AGENTS = [
|
|
|
30
32
|
binary: 'codex',
|
|
31
33
|
pin: 'codex',
|
|
32
34
|
wrapper: 'codex-proxy.ts',
|
|
35
|
+
launcher: 'codex-proxy.ts',
|
|
33
36
|
args: ['exec', '--skip-git-repo-check', PROMPT],
|
|
34
37
|
},
|
|
35
38
|
{
|
|
@@ -37,6 +40,7 @@ export const NATIVE_AGENTS = [
|
|
|
37
40
|
binary: 'grok',
|
|
38
41
|
pin: 'http:grok',
|
|
39
42
|
wrapper: 'grok-proxy.ts',
|
|
43
|
+
launcher: 'grok-proxy.ts',
|
|
40
44
|
args: ['-p', PROMPT],
|
|
41
45
|
},
|
|
42
46
|
{
|
|
@@ -44,6 +48,7 @@ export const NATIVE_AGENTS = [
|
|
|
44
48
|
binary: 'antigravity',
|
|
45
49
|
pin: 'http:antigravity',
|
|
46
50
|
wrapper: 'antigravity-proxy.ts',
|
|
51
|
+
launcher: 'antigravity-proxy.ts',
|
|
47
52
|
args: [
|
|
48
53
|
'-p',
|
|
49
54
|
PROMPT,
|
|
@@ -58,14 +63,31 @@ export const NATIVE_AGENTS = [
|
|
|
58
63
|
binary: 'opencode',
|
|
59
64
|
pin: 'aqua:anomalyco/opencode',
|
|
60
65
|
wrapper: 'opencode-proxy.ts',
|
|
66
|
+
launcher: 'opencode-proxy.ts',
|
|
61
67
|
args: ['run', '--model', 'personal_proxy/devin/muse-wonder-1-0', PROMPT],
|
|
62
68
|
},
|
|
63
69
|
] as const;
|
|
64
70
|
|
|
65
71
|
type Agent = (typeof NATIVE_AGENTS)[number];
|
|
66
72
|
|
|
73
|
+
/** The user service T3 runs as on a worker, and so the environment every
|
|
74
|
+
* agent it starts inherits.
|
|
75
|
+
*/
|
|
76
|
+
export const T3_SERVICE = 't3code-desktop.service';
|
|
77
|
+
|
|
78
|
+
/** How a session is launched: by the sync, through Bun on the pinned PATH,
|
|
79
|
+
* or exactly as the T3 service starts it, with that service's environment
|
|
80
|
+
* and nothing of the sync's.
|
|
81
|
+
*/
|
|
82
|
+
type Launch =
|
|
83
|
+
| { kind: 'sync' }
|
|
84
|
+
| { kind: 'service'; env: Record<string, string> }
|
|
85
|
+
| { kind: 'incomplete' };
|
|
86
|
+
|
|
67
87
|
/** Same versions everywhere (from the shared mise.lock) and a working signed-in
|
|
68
88
|
* session through each T3 launcher. Agents run concurrently and are bounded.
|
|
89
|
+
* With `t3Service` (a Linux worker), sessions run in the environment of the
|
|
90
|
+
* T3 user service, where T3 itself starts them, not in the sync's.
|
|
69
91
|
*/
|
|
70
92
|
export async function verifyNativeAgents(
|
|
71
93
|
node: string,
|
|
@@ -75,10 +97,15 @@ export async function verifyNativeAgents(
|
|
|
75
97
|
/** Proxy routes already found out of quota; their sessions are not run. */
|
|
76
98
|
outOfQuota?: Set<string>;
|
|
77
99
|
cache?: VerifyCache;
|
|
100
|
+
t3Service?: boolean;
|
|
78
101
|
} = {}
|
|
79
102
|
): Promise<Check[]> {
|
|
80
103
|
const { outOfQuota = new Set<string>(), cache = {} } = options;
|
|
81
104
|
const pins = parsePins(await readFile(join(workspace, 'mise.toml'), 'utf8'));
|
|
105
|
+
const environment = options.t3Service
|
|
106
|
+
? await serviceLaunch(run)
|
|
107
|
+
: { launch: { kind: 'sync' } as Launch, checks: [] };
|
|
108
|
+
const { launch } = environment;
|
|
82
109
|
const cwd = await mkdtemp(join(tmpdir(), 'fleet-agents-'));
|
|
83
110
|
try {
|
|
84
111
|
const results = await Promise.all(
|
|
@@ -86,21 +113,24 @@ export async function verifyNativeAgents(
|
|
|
86
113
|
await versionCheck(agent, pins[agent.pin], run),
|
|
87
114
|
outOfQuota.has(agent.name)
|
|
88
115
|
? quotaPass(agent)
|
|
89
|
-
:
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
node
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
116
|
+
: launch.kind === 'incomplete'
|
|
117
|
+
? incompleteSession(agent)
|
|
118
|
+
: await cachedCheck(
|
|
119
|
+
`agent-${node}-${agent.name}`,
|
|
120
|
+
cacheKey(
|
|
121
|
+
node,
|
|
122
|
+
agent,
|
|
123
|
+
pins[agent.pin] ?? '',
|
|
124
|
+
registry,
|
|
125
|
+
await sourceKey(launcherSources(workspace, agent))
|
|
126
|
+
),
|
|
127
|
+
() =>
|
|
128
|
+
sessionCheck(agent, { node, workspace, cwd, launch }, run),
|
|
129
|
+
cache
|
|
97
130
|
),
|
|
98
|
-
() => sessionCheck(agent, { node, workspace, cwd }, run),
|
|
99
|
-
cache
|
|
100
|
-
),
|
|
101
131
|
])
|
|
102
132
|
);
|
|
103
|
-
return results.flat();
|
|
133
|
+
return [...environment.checks, ...results.flat()];
|
|
104
134
|
} finally {
|
|
105
135
|
await rm(cwd, { recursive: true, force: true });
|
|
106
136
|
}
|
|
@@ -168,19 +198,26 @@ function quotaPass(agent: Agent): Check {
|
|
|
168
198
|
|
|
169
199
|
async function sessionCheck(
|
|
170
200
|
agent: Agent,
|
|
171
|
-
context: { node: string; workspace: string; cwd: string },
|
|
201
|
+
context: { node: string; workspace: string; cwd: string; launch: Launch },
|
|
172
202
|
run: Runner
|
|
173
203
|
): Promise<Check> {
|
|
204
|
+
const agents = join(context.workspace, 'agents');
|
|
205
|
+
const command =
|
|
206
|
+
context.launch.kind === 'service'
|
|
207
|
+
? [join(agents, agent.launcher), ...agent.args]
|
|
208
|
+
: ['bun', join(agents, agent.wrapper), ...agent.args];
|
|
209
|
+
const env =
|
|
210
|
+
context.launch.kind === 'service'
|
|
211
|
+
? { ...context.launch.env, WS_AGENT_NODE: context.node }
|
|
212
|
+
: { WS_AGENT_NODE: context.node, PATH: pinnedPath() };
|
|
174
213
|
const launch = () =>
|
|
175
|
-
run(
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
}
|
|
183
|
-
);
|
|
214
|
+
run(command, {
|
|
215
|
+
cwd: context.cwd,
|
|
216
|
+
env,
|
|
217
|
+
...(context.launch.kind === 'service' ? { isolatedEnv: true } : {}),
|
|
218
|
+
input: '',
|
|
219
|
+
timeoutMs: 150_000,
|
|
220
|
+
});
|
|
184
221
|
const expected = /\bOK\b/;
|
|
185
222
|
let result = await launch();
|
|
186
223
|
// One retry: a CLI's first launch on a node may still be initializing.
|
|
@@ -196,6 +233,114 @@ async function sessionCheck(
|
|
|
196
233
|
};
|
|
197
234
|
}
|
|
198
235
|
|
|
236
|
+
/** The environment of the T3 user service: what its user manager holds
|
|
237
|
+
* (PATH included) and what the unit adds. Every launcher starts with
|
|
238
|
+
* `#!/usr/bin/env bun` or runs `bun`, so that PATH must find Bun.
|
|
239
|
+
*/
|
|
240
|
+
async function serviceLaunch(
|
|
241
|
+
run: Runner
|
|
242
|
+
): Promise<{ launch: Launch; checks: Check[] }> {
|
|
243
|
+
const manager = await run(['systemctl', '--user', 'show-environment'], {
|
|
244
|
+
timeoutMs: 15_000,
|
|
245
|
+
});
|
|
246
|
+
const unit = await run(
|
|
247
|
+
[
|
|
248
|
+
'systemctl',
|
|
249
|
+
'--user',
|
|
250
|
+
'show',
|
|
251
|
+
T3_SERVICE,
|
|
252
|
+
'--property=Environment',
|
|
253
|
+
'--value',
|
|
254
|
+
],
|
|
255
|
+
{ timeoutMs: 15_000 }
|
|
256
|
+
);
|
|
257
|
+
if (!manager.ok || !unit.ok)
|
|
258
|
+
return {
|
|
259
|
+
launch: { kind: 'sync' },
|
|
260
|
+
checks: [
|
|
261
|
+
environmentCheck(
|
|
262
|
+
'warn',
|
|
263
|
+
'AGENT_SERVICE_ENVIRONMENT_UNVERIFIED',
|
|
264
|
+
'The environment of the T3 service could not be read, so sessions were launched from the sync instead. Inspect the user service manager of this worker: systemctl --user show-environment.'
|
|
265
|
+
),
|
|
266
|
+
],
|
|
267
|
+
};
|
|
268
|
+
const env = {
|
|
269
|
+
...parseAssignments(manager.stdout.split('\n')),
|
|
270
|
+
...parseAssignments(words(unit.stdout)),
|
|
271
|
+
};
|
|
272
|
+
const bun = await run(['/bin/sh', '-c', 'command -v bun'], {
|
|
273
|
+
env,
|
|
274
|
+
isolatedEnv: true,
|
|
275
|
+
timeoutMs: 15_000,
|
|
276
|
+
});
|
|
277
|
+
if (!bun.ok)
|
|
278
|
+
return {
|
|
279
|
+
launch: { kind: 'incomplete' },
|
|
280
|
+
checks: [
|
|
281
|
+
environmentCheck(
|
|
282
|
+
'fail',
|
|
283
|
+
'AGENT_SERVICE_ENVIRONMENT_INCOMPLETE',
|
|
284
|
+
INCOMPLETE_REMEDY
|
|
285
|
+
),
|
|
286
|
+
],
|
|
287
|
+
};
|
|
288
|
+
return {
|
|
289
|
+
launch: { kind: 'service', env },
|
|
290
|
+
checks: [
|
|
291
|
+
environmentCheck(
|
|
292
|
+
'pass',
|
|
293
|
+
'The T3 service environment finds Bun; every session was launched with it alone.'
|
|
294
|
+
),
|
|
295
|
+
],
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const INCOMPLETE_REMEDY = `The PATH of ${T3_SERVICE} does not find Bun, so T3 cannot start any agent launcher even though they work from a shell. Put the pinned shims on the PATH of the user service manager through the baseline (never by hand), then run fleet-sync.`;
|
|
300
|
+
|
|
301
|
+
function environmentCheck(
|
|
302
|
+
status: Check['status'],
|
|
303
|
+
summary: string,
|
|
304
|
+
remedy?: string
|
|
305
|
+
): Check {
|
|
306
|
+
return {
|
|
307
|
+
id: 'agent:service-environment',
|
|
308
|
+
status,
|
|
309
|
+
summary,
|
|
310
|
+
...(remedy ? { remedy: { action: remedy } } : {}),
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function incompleteSession(agent: Agent): Check {
|
|
315
|
+
return {
|
|
316
|
+
id: `agent:${agent.name}:session`,
|
|
317
|
+
status: 'fail',
|
|
318
|
+
summary: 'AGENT_SERVICE_ENVIRONMENT_INCOMPLETE',
|
|
319
|
+
remedy: { action: INCOMPLETE_REMEDY },
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/** `KEY=value` lines or words; a value systemd quoted loses its quotes. */
|
|
324
|
+
function parseAssignments(entries: string[]): Record<string, string> {
|
|
325
|
+
const env: Record<string, string> = {};
|
|
326
|
+
for (const entry of entries) {
|
|
327
|
+
const match = /^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/s.exec(entry.trim());
|
|
328
|
+
if (match) env[match[1]!] = unquote(match[2]!);
|
|
329
|
+
}
|
|
330
|
+
return env;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/** `systemctl show -p Environment` separates assignments with spaces and
|
|
334
|
+
* double-quotes one that holds a space.
|
|
335
|
+
*/
|
|
336
|
+
function words(line: string): string[] {
|
|
337
|
+
return line.trim().match(/(?:[^\s"]+|"[^"]*")+/g) ?? [];
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function unquote(value: string): string {
|
|
341
|
+
return value.replace(/"([^"]*)"/g, '$1').replace(/^\$'(.*)'$/s, '$1');
|
|
342
|
+
}
|
|
343
|
+
|
|
199
344
|
/** A stable code only; raw CLI output is never reported. */
|
|
200
345
|
function sessionFailure(output: string): string {
|
|
201
346
|
if (/missing or out of date/.test(output)) return 'AGENT_PROFILE_STALE';
|
|
@@ -8,9 +8,15 @@ export class ProviderVerificationError extends AgentConfigurationError {
|
|
|
8
8
|
super(message);
|
|
9
9
|
}
|
|
10
10
|
}
|
|
11
|
+
/** The gateway has no such route, method or media type for this protocol:
|
|
12
|
+
* the route as declared cannot work, whatever the account.
|
|
13
|
+
*/
|
|
14
|
+
const INCOMPATIBLE = new Set([404, 405, 415, 501]);
|
|
15
|
+
|
|
11
16
|
export function providerHttpFailure(status: number): ProviderVerificationError {
|
|
12
|
-
const code =
|
|
13
|
-
|
|
17
|
+
const code = INCOMPATIBLE.has(status)
|
|
18
|
+
? 'PROVIDER_PROTOCOL_INCOMPATIBLE'
|
|
19
|
+
: status === 401 || status === 403
|
|
14
20
|
? 'PROVIDER_AUTH_REJECTED'
|
|
15
21
|
: status === 429
|
|
16
22
|
? 'PROVIDER_RATE_LIMITED'
|
|
@@ -27,6 +33,7 @@ const SAFE_CODES = new Set([
|
|
|
27
33
|
'PROVIDER_RATE_LIMITED',
|
|
28
34
|
'PROVIDER_UPSTREAM_UNAVAILABLE',
|
|
29
35
|
'PROVIDER_PROTOCOL_REJECTED',
|
|
36
|
+
'PROVIDER_PROTOCOL_INCOMPATIBLE',
|
|
30
37
|
'PROVIDER_MODEL_UNAVAILABLE',
|
|
31
38
|
'PROVIDER_OUTPUT_UNVERIFIED',
|
|
32
39
|
'PROVIDER_TOOL_RESULT_UNVERIFIED',
|
package/src/agents/registry.ts
CHANGED
|
@@ -14,7 +14,13 @@ export type Selection = {
|
|
|
14
14
|
/** The tracked registry plus the proxy endpoint from this fleet's config. */
|
|
15
15
|
export const registry = {
|
|
16
16
|
...source,
|
|
17
|
-
proxy: {
|
|
17
|
+
proxy: {
|
|
18
|
+
// Read on use: a command that reaches no proxy needs no configuration.
|
|
19
|
+
get endpoint(): string {
|
|
20
|
+
return fleet.services.proxy.endpoint;
|
|
21
|
+
},
|
|
22
|
+
...source.proxy,
|
|
23
|
+
},
|
|
18
24
|
};
|
|
19
25
|
|
|
20
26
|
export function select(node: string, tool: string, profile: string): Selection {
|
package/src/cli/agent-guide.ts
CHANGED
|
@@ -4,6 +4,7 @@ const SAFETY = [
|
|
|
4
4
|
'No arguments prints help, including on a TTY. --json returns this document. The menu requires --interactive; --json and --non-interactive never prompt.',
|
|
5
5
|
'Diagnostics, onboarding instructions, schema, catalogs, scripts and repair previews are read-only.',
|
|
6
6
|
'fleet-sync, fleet-node-sync, t3-apply and t3-cleanup apply by invocation; --dry-run previews. t3-apply --import ID explicitly adopts ownership. --json never implies a dry run. fleet-node-add and fleet-repair require --apply --yes.',
|
|
7
|
+
'fleet-state alone is read-only; --init, --import, --push and --restore each act on invocation. Bucket credentials come from DEVFLEET_BUCKET_ACCESS_KEY_ID and DEVFLEET_BUCKET_SECRET_ACCESS_KEY or a terminal prompt, never from arguments.',
|
|
7
8
|
'Do not copy credentials, forward SSH agents, bypass host keys, provision infrastructure or execute unreviewed remedies.',
|
|
8
9
|
'A healthy manager does not prove another machine can connect to it. Run fleet-status from each intended manager.',
|
|
9
10
|
'fleet-repair executes only allowlisted owner actions, then reruns diagnostics. Native logins, host trust and resource permissions remain explicit operator steps. Pass the reviewed planHash as --plan-hash to reject a changed plan.',
|
|
@@ -14,6 +15,7 @@ const SAFETY = [
|
|
|
14
15
|
const EXAMPLES = [
|
|
15
16
|
'devfleet fleet-node-sync',
|
|
16
17
|
'devfleet fleet-status',
|
|
18
|
+
'devfleet fleet-state --json',
|
|
17
19
|
'devfleet t3-status',
|
|
18
20
|
'devfleet fleet-status --local --workspace /path/to/personal --json',
|
|
19
21
|
'devfleet fleet-status --json --strict --timeout 120000',
|
|
@@ -28,6 +30,23 @@ const EXAMPLES = [
|
|
|
28
30
|
'devfleet fleet-repair --local --repair workspace --plan-hash <reviewed-sha256> --apply --yes --json',
|
|
29
31
|
];
|
|
30
32
|
|
|
33
|
+
/** For an agent working in another project, which is every project that uses
|
|
34
|
+
* dev-fleet: a change to dev-fleet is asked for, never made in place.
|
|
35
|
+
*/
|
|
36
|
+
function support(release: Release) {
|
|
37
|
+
return {
|
|
38
|
+
issues: release.issues ?? null,
|
|
39
|
+
report:
|
|
40
|
+
'A defect in dev-fleet, or something it cannot do yet, is raised as an issue in the dev-fleet repository: one issue per bug or feature request, after searching the open ones. From any other project that is the only way to change dev-fleet. Tell the operator what is blocked and link the issue.',
|
|
41
|
+
never:
|
|
42
|
+
'Do not edit the installed package, a checkout fleet-sync owns or a file dev-fleet manages on a machine: the next install or fleet-sync replaces each of them. Do not clone dev-fleet to patch it, vendor a modified copy, or hide the defect behind a workaround in the project that holds fleet.json.',
|
|
43
|
+
include:
|
|
44
|
+
'devfleet --version, the command and its flags, the error code or the failing check IDs from --json, what happened and what was expected.',
|
|
45
|
+
withhold:
|
|
46
|
+
'The issue tracker is public. Leave out credentials, the fleet configuration, hostnames, accounts, domains, tailnet names, paths under a home directory and raw subprocess output.',
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
31
50
|
export function agentGuide(release: Release) {
|
|
32
51
|
return {
|
|
33
52
|
version: 1,
|
|
@@ -36,21 +55,29 @@ export function agentGuide(release: Release) {
|
|
|
36
55
|
releasedAt: release.releasedAt,
|
|
37
56
|
invocation: 'devfleet <command> --json --non-interactive',
|
|
38
57
|
discovery: 'devfleet commands --json',
|
|
58
|
+
setup:
|
|
59
|
+
'Setting up a fleet from a project: llms.txt and docs/consumer-setup.md in this package (node_modules/@crvouga/dev-fleet/), also at https://github.com/crvouga/dev-fleet/blob/main/docs/consumer-setup.md',
|
|
39
60
|
defaultCommand: 'help',
|
|
40
61
|
help: 'devfleet <command> --help',
|
|
41
62
|
interactive:
|
|
42
63
|
'devfleet --interactive (explicit opt-in; TTY required, disabled in CI)',
|
|
43
64
|
groups: {
|
|
44
65
|
'fleet-*':
|
|
45
|
-
'Developer computers: fleet-status, fleet-discover, fleet-sync, fleet-node-sync, fleet-repair, fleet-nodes, fleet-node-add',
|
|
66
|
+
'Developer computers: fleet-status, fleet-discover, fleet-sync, fleet-state, fleet-node-sync, fleet-repair, fleet-nodes, fleet-node-add',
|
|
46
67
|
't3-*': 'Declared T3 provider instances: t3-status, t3-apply, t3-cleanup',
|
|
47
68
|
cliproxy: 'The CLI proxy service on the manager; arguments are forwarded',
|
|
48
69
|
},
|
|
49
70
|
configuration: {
|
|
50
71
|
workspace:
|
|
51
|
-
'--workspace PATH, PERSONAL_WORKSPACE,
|
|
72
|
+
'The published package itself. Run from a checkout (development): --workspace PATH, PERSONAL_WORKSPACE, then the dev-fleet checkout the command is started in',
|
|
52
73
|
roster:
|
|
53
|
-
'--config PATH, PERSONAL_FLEET_CONFIG, closest ancestor .fleet.local.json, then
|
|
74
|
+
'--config PATH, PERSONAL_FLEET_CONFIG, closest ancestor .fleet.local.json, then the roster in the fleet state',
|
|
75
|
+
definition:
|
|
76
|
+
'DEVFLEET_CONFIG, then the closest fleet.json in the working directory or above it (the operator’s project), then the configuration fleet-sync last applied from the fleet state (the devfleet CLI on the manager), then the copy fleet-sync installed at ~/.config/dev-fleet/fleet.json',
|
|
77
|
+
state:
|
|
78
|
+
'DEVFLEET_STATE or ~/.local/share/dev-fleet/state: a local copy of the roster, the approved revision and the configuration last applied. The bucket named under "state" in the fleet configuration is the source of truth; its credentials are passed in DEVFLEET_BUCKET_ACCESS_KEY_ID and DEVFLEET_BUCKET_SECRET_ACCESS_KEY, or saved by devfleet fleet-state --init in ~/.config/dev-fleet/bucket.json for unattended runs. devfleet fleet-state --json compares the copies',
|
|
79
|
+
revision:
|
|
80
|
+
'The commit the published package names (devfleet --version). fleet-sync converges every machine to it; --revision SHA selects another',
|
|
54
81
|
},
|
|
55
82
|
output: {
|
|
56
83
|
stdout:
|
|
@@ -70,13 +97,20 @@ export function agentGuide(release: Release) {
|
|
|
70
97
|
'Default fleet-status tests the manager and all configured nodes. --local tests only this manager; --tailnet and --workers cover Tailscale computers. A missing roster fails explicitly. T3/VS Code end-to-end checks remain manual.',
|
|
71
98
|
},
|
|
72
99
|
safety: SAFETY,
|
|
100
|
+
support: support(release),
|
|
101
|
+
toolchain:
|
|
102
|
+
'On a fleet machine the global mise configuration belongs to dev-fleet: fleet-sync publishes it, and fleet-status fails tool:global-mise-config when anything replaced it. A project pins its own tools in a project mise.toml (mise install --locked in its checkout) and never publishes a global one.',
|
|
103
|
+
library:
|
|
104
|
+
'None. The package is the devfleet command: it declares no main or exports, and nothing under src/ is a supported import; any release may change it. Ask for a command in an issue instead.',
|
|
73
105
|
fleetSync: {
|
|
74
106
|
command: 'devfleet fleet-sync',
|
|
75
107
|
preview: 'devfleet fleet-sync --dry-run --json',
|
|
76
|
-
approval:
|
|
108
|
+
approval:
|
|
109
|
+
'Applies on invocation without a prompt; --dry-run previews and changes no machine: on the manager it only checks the release out into dev-fleet’s cache and installs the Bun it pins, never the toolchain, the global mise configuration or shell profiles',
|
|
77
110
|
scope:
|
|
78
111
|
'Whole enrolled Ubuntu fleet over verified SSH; --node filters diagnostics/rollout',
|
|
79
|
-
state:
|
|
112
|
+
state:
|
|
113
|
+
'The approved and applied revision are kept in the fleet state and uploaded to its bucket after every run; the report carries the state:replica check',
|
|
80
114
|
readiness:
|
|
81
115
|
'Full Ubuntu acceptance is incomplete and fails closed; do not override it or claim deployment success.',
|
|
82
116
|
},
|
package/src/cli/app.ts
CHANGED
|
@@ -3,13 +3,20 @@ import { resolveSettings, type Settings } from './settings';
|
|
|
3
3
|
import { COMMANDS } from './registry';
|
|
4
4
|
import { execute } from './program';
|
|
5
5
|
import { parseArgs } from 'node:util';
|
|
6
|
-
import
|
|
6
|
+
import { packageInfo } from '../package-info';
|
|
7
7
|
|
|
8
8
|
/** The manifest carries `0.0.0-development` in the repository; the release
|
|
9
|
-
* workflow stamps the tag's version into the package it
|
|
9
|
+
* workflow stamps the tag's version, and the commit, into the package it
|
|
10
|
+
* publishes.
|
|
10
11
|
*/
|
|
11
|
-
|
|
12
|
-
const
|
|
12
|
+
const PACKAGE = packageInfo();
|
|
13
|
+
export const VERSION = PACKAGE.version;
|
|
14
|
+
const RELEASE = {
|
|
15
|
+
version: VERSION,
|
|
16
|
+
releasedAt: null,
|
|
17
|
+
revision: PACKAGE.revision,
|
|
18
|
+
issues: PACKAGE.issues,
|
|
19
|
+
};
|
|
13
20
|
|
|
14
21
|
export function wantsInteractive(
|
|
15
22
|
args: string[],
|