@crvouga/dev-fleet 0.8.0 → 0.9.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 (204) hide show
  1. package/README.md +65 -9
  2. package/agents/audit.md +18 -18
  3. package/ansible/agents/agents.yml +134 -0
  4. package/ansible/agents/templates/antigravity.settings.json.j2 +3 -0
  5. package/ansible/agents/templates/claude.settings.json.j2 +3 -0
  6. package/ansible/agents/templates/codex.config.toml.j2 +11 -0
  7. package/ansible/agents/templates/gemini.settings.json.j2 +14 -0
  8. package/ansible/agents/templates/grok.config.toml.j2 +19 -0
  9. package/ansible/agents/templates/opencode.json.j2 +24 -0
  10. package/ansible/agents/templates/receipt.json.j2 +10 -0
  11. package/ansible/ansible.cfg +14 -0
  12. package/ansible/github/git.yml +82 -0
  13. package/ansible/macos/manager-privilege.yml +55 -9
  14. package/ansible/macos/manager.yml +154 -7
  15. package/ansible/macos/policy.json +13 -0
  16. package/ansible/macos/t3-policy.yml +5 -3
  17. package/ansible/macos/vault-renew.yml +85 -0
  18. package/ansible/monitoring/agent-darwin.yml +3 -0
  19. package/ansible/monitoring/agent.yml +2 -0
  20. package/ansible/monitoring/hub-darwin.yml +3 -0
  21. package/ansible/monitoring/hub-route-darwin.yml +1 -0
  22. package/ansible/net/remote-probe.sh +4 -1
  23. package/ansible/platforms.json +2 -2
  24. package/ansible/pyproject.toml +5 -0
  25. package/ansible/ubuntu/baseline.yml +39 -13
  26. package/ansible/ubuntu/disk-gc.py +6 -2
  27. package/ansible/ubuntu/firewall.nft.j2 +50 -0
  28. package/ansible/ubuntu/policy.json +125 -6
  29. package/ansible/ubuntu/reboot.yml +89 -0
  30. package/ansible/ubuntu/recovery.yml +13 -3
  31. package/ansible/ubuntu/t3/policy.json +3 -0
  32. package/ansible/ubuntu/t3-desktop.yml +23 -2
  33. package/ansible/ubuntu/t3-headless.yml +128 -0
  34. package/ansible/ubuntu/t3-health.sh +24 -10
  35. package/ansible/ubuntu/t3-idle.py +37 -2
  36. package/ansible/ubuntu/t3-policy.yml +4 -1
  37. package/ansible/ubuntu/t3-route.yml +1 -0
  38. package/ansible/ubuntu/tailscale.yml +54 -8
  39. package/ansible/ubuntu/tasks/browser.yml +3 -0
  40. package/ansible/ubuntu/tasks/containers-off.yml +43 -0
  41. package/ansible/ubuntu/tasks/docker.yml +53 -6
  42. package/ansible/ubuntu/tasks/dpkg.yml +4 -0
  43. package/ansible/ubuntu/tasks/features.yml +22 -0
  44. package/ansible/ubuntu/tasks/firewall.yml +172 -0
  45. package/ansible/ubuntu/tasks/install.yml +2 -1
  46. package/ansible/ubuntu/tasks/packages.yml +4 -2
  47. package/ansible/ubuntu/tasks/release.yml +6 -0
  48. package/ansible/ubuntu/tasks/resources.yml +6 -4
  49. package/ansible/ubuntu/tasks/services.yml +2 -2
  50. package/ansible/ubuntu/tasks/sizing.yml +49 -0
  51. package/ansible/ubuntu/tasks/snapshot.yml +14 -3
  52. package/ansible/ubuntu/tasks/ssh.yml +61 -4
  53. package/ansible/ubuntu/tasks/t3-restart.yml +3 -1
  54. package/ansible/ubuntu/tasks/user.yml +1 -0
  55. package/ansible/ubuntu/worker-account.yml +117 -0
  56. package/ansible/uv.lock +265 -0
  57. package/bin/devfleet.mjs +42 -4
  58. package/bootstrap/fleet-remote-bootstrap.sh +91 -25
  59. package/bootstrap/global-config.ts +29 -3
  60. package/bootstrap/package-smoke.ts +127 -0
  61. package/bootstrap/pins.ts +189 -0
  62. package/bootstrap/release-notes.ts +88 -0
  63. package/bootstrap/release-version.ts +41 -7
  64. package/bootstrap/worker-features.ts +114 -0
  65. package/bootstrap/worker-seed.yaml +7 -3
  66. package/bootstrap/worker-tools.ts +39 -9
  67. package/cliproxy/README.md +1 -1
  68. package/cliproxy/docs/health-workflow.example.yml +20 -143
  69. package/docs/authentication.md +19 -11
  70. package/docs/consumer-setup.md +42 -27
  71. package/docs/convergence.md +67 -0
  72. package/docs/declared.md +61 -34
  73. package/docs/machine-setup.md +73 -7
  74. package/docs/manager-setup.md +10 -3
  75. package/docs/operations.md +64 -9
  76. package/docs/releasing.md +49 -1
  77. package/docs/runbooks.md +98 -7
  78. package/docs/state.md +23 -11
  79. package/docs/support-matrix.md +40 -4
  80. package/docs/topology.md +67 -6
  81. package/docs/ubuntu-sync.md +78 -28
  82. package/docs/worker-contract.md +122 -19
  83. package/docs/worker-requirements.md +107 -0
  84. package/examples/fleet.json +1 -0
  85. package/examples/workers-from-infrastructure/main.tf +132 -0
  86. package/examples/workers-from-infrastructure/worker-records.json +14 -0
  87. package/llms.txt +3 -1
  88. package/mise.lock +29 -0
  89. package/mise.toml +5 -3
  90. package/package.json +2 -2
  91. package/schema/check-ids.json +1967 -170
  92. package/schema/check-ids.schema.json +34 -0
  93. package/schema/fleet.schema.json +327 -8
  94. package/schema/output/check.schema.json +1 -1
  95. package/schema/output/fleet-state-definition.schema.json +14 -0
  96. package/schema/output/fleet-sync-schedule.schema.json +26 -0
  97. package/schema/output/fleet-sync.schema.json +18 -0
  98. package/schema/output/report.schema.json +3 -2
  99. package/schema/worker-record.schema.json +76 -0
  100. package/src/agents/converge.ts +140 -24
  101. package/src/agents/files.ts +2 -32
  102. package/src/agents/native-verify.ts +34 -3
  103. package/src/agents/secrets.ts +39 -4
  104. package/src/check-owners.ts +61 -2
  105. package/src/check-registry.ts +630 -0
  106. package/src/cli/agent-guide.ts +3 -3
  107. package/src/cli/cli.ts +4 -0
  108. package/src/cli/fleet-manifest.ts +9 -3
  109. package/src/cli/fleet-node-lifecycle.ts +313 -64
  110. package/src/cli/fleet-node-park.ts +238 -0
  111. package/src/cli/fleet-node-seed.ts +51 -35
  112. package/src/cli/fleet-state.ts +2 -1
  113. package/src/cli/fleet-sync-checkout.ts +7 -0
  114. package/src/cli/fleet-sync-enrollment.ts +193 -32
  115. package/src/cli/fleet-sync-forward.ts +4 -0
  116. package/src/cli/fleet-sync-manager.ts +103 -33
  117. package/src/cli/fleet-sync-output.ts +31 -11
  118. package/src/cli/fleet-sync-plan.ts +65 -9
  119. package/src/cli/fleet-sync-records.ts +113 -0
  120. package/src/cli/fleet-sync-remote.ts +151 -9
  121. package/src/cli/fleet-sync-schedule.ts +14 -4
  122. package/src/cli/fleet-sync-state.ts +28 -16
  123. package/src/cli/fleet-sync-vault.ts +77 -0
  124. package/src/cli/fleet-sync-worker.ts +214 -0
  125. package/src/cli/fleet-sync.ts +150 -19
  126. package/src/cli/fleet-validate.ts +59 -21
  127. package/src/cli/fleet-watch.ts +7 -1
  128. package/src/cli/fleet.ts +87 -5
  129. package/src/cli/managed-health.ts +25 -1
  130. package/src/cli/register-fleet.ts +115 -4
  131. package/src/cli/registry.ts +14 -0
  132. package/src/cli/tailnet.ts +43 -10
  133. package/src/cli/ui-data.ts +3 -1
  134. package/src/cli/worker-usage-report.ts +63 -0
  135. package/src/clock.ts +42 -0
  136. package/src/definition.ts +332 -9
  137. package/src/diagnostics.ts +5 -2
  138. package/src/exposure.ts +102 -0
  139. package/src/fleet-config.ts +3 -1
  140. package/src/git-identity.ts +76 -3
  141. package/src/ide/ide.ts +5 -0
  142. package/src/ide/t3.ts +12 -2
  143. package/src/input-hashes.ts +6 -0
  144. package/src/machine/machine-class.ts +59 -0
  145. package/src/machine/platform.ts +90 -11
  146. package/src/machine/state.ts +37 -15
  147. package/src/machine/support-matrix.ts +11 -9
  148. package/src/machine-health.ts +28 -5
  149. package/src/manager-health.ts +148 -2
  150. package/src/manager-sync.ts +65 -4
  151. package/src/monitoring/beszel.ts +8 -8
  152. package/src/monitoring/enroll.ts +8 -1
  153. package/src/monitoring/hub-access.ts +10 -4
  154. package/src/monitoring/systems.ts +138 -0
  155. package/src/network-definition.ts +13 -4
  156. package/src/node-converge-linux.ts +136 -27
  157. package/src/node-converge.ts +13 -3
  158. package/src/platform/ansible-receipt.ts +17 -2
  159. package/src/platform/ansible.ts +40 -4
  160. package/src/platform/manager-host.ts +25 -8
  161. package/src/platform/resource-verify.ts +18 -7
  162. package/src/platform/t3-policy.ts +24 -10
  163. package/src/platform/t3-service.ts +109 -34
  164. package/src/platform/ubuntu-release.ts +24 -0
  165. package/src/platform/ubuntu-verify.ts +31 -3
  166. package/src/platform/ubuntu-worker.ts +58 -0
  167. package/src/platform/worker-platform.ts +72 -0
  168. package/src/protocol.ts +1 -1
  169. package/src/public-reachability.ts +186 -0
  170. package/src/remote-bootstrap.ts +50 -12
  171. package/src/remote-operation.ts +18 -4
  172. package/src/repair-plan.ts +23 -12
  173. package/src/report.ts +2 -1
  174. package/src/schedule-check.ts +21 -2
  175. package/src/secrets.ts +4 -0
  176. package/src/service-auth.ts +55 -48
  177. package/src/ssh-connection.ts +141 -0
  178. package/src/ssh.ts +63 -24
  179. package/src/state/store.ts +4 -2
  180. package/src/state/sync.ts +35 -6
  181. package/src/state/workers.ts +140 -0
  182. package/src/t3/pairing.ts +59 -0
  183. package/src/t3/peer-link.ts +333 -0
  184. package/src/t3/version.ts +1 -1
  185. package/src/tailnet-cleanup.ts +185 -0
  186. package/src/tailnet-control.ts +346 -0
  187. package/src/tailscale.ts +1 -1
  188. package/src/toolchain-checks.ts +11 -1
  189. package/src/types.ts +4 -2
  190. package/src/worker-account.ts +63 -0
  191. package/src/worker-activity.ts +303 -0
  192. package/src/worker-capacity.ts +31 -0
  193. package/src/worker-exposure.ts +388 -0
  194. package/src/worker-features.ts +201 -0
  195. package/src/worker-host-checks.ts +71 -20
  196. package/src/worker-manifest.ts +27 -4
  197. package/src/worker-parking.ts +70 -0
  198. package/src/worker-policy.ts +17 -1
  199. package/src/worker-readiness.ts +15 -3
  200. package/src/worker-reboot.ts +129 -0
  201. package/src/worker-records.ts +305 -0
  202. package/src/worker-seed.ts +16 -11
  203. package/src/worker-sizing.ts +76 -0
  204. package/src/cli/schedule-logs.ts +0 -36
@@ -109,11 +109,15 @@ export function forwardedFlags(
109
109
  const switches: [boolean | undefined, string][] = [
110
110
  [options.all, '--all'],
111
111
  [options.vaultPasswordLogin, '--vault-password-login'],
112
+ [options.restartBusy, '--restart-busy'],
112
113
  [options.dryRun, '--dry-run'],
113
114
  [options.apply, '--apply'],
114
115
  [options.yes, '--yes'],
115
116
  [options.json, '--json'],
116
117
  [options.nonInteractive, '--non-interactive'],
118
+ [options.maintenanceReboot, '--maintenance-reboot'],
119
+ [options.force, '--force'],
120
+ [options.takeOver, '--take-over'],
117
121
  ];
118
122
  for (const [on, flag] of switches) if (on) flags.push(flag);
119
123
  return flags;
@@ -13,12 +13,16 @@ import { IDES } from '../ide';
13
13
  import { enabled, managerChecks } from '../ide/orchestrate';
14
14
  import { idesOf } from '../ide/definition';
15
15
  import { convergeGitIdentity, gitChecks } from '../git-identity';
16
- import { ansibleCommand, runAnsible } from '../platform/ansible';
16
+ import { ansibleCommand } from '../platform/ansible';
17
+ import { runAnsibleWithReceipt } from '../platform/ansible-receipt';
18
+ import macos from '../../ansible/macos/policy.json';
19
+ import { scheduleIntervals } from '../schedule-check';
17
20
  import { convergeGithub } from '../github/converge';
18
21
  import { PACKAGE_ROOT } from '../package-info';
19
22
  import { managerHealth } from '../manager-health';
23
+ import { holdManagerVault } from './fleet-sync-vault';
20
24
  import { readFileSync } from 'node:fs';
21
- import { homedir } from 'node:os';
25
+ import { homedir, userInfo } from 'node:os';
22
26
  import {
23
27
  MACOS_MANAGER,
24
28
  managerHost,
@@ -49,11 +53,20 @@ export async function convergeManager(
49
53
  if (input.apply) installConfig();
50
54
  checks.push(
51
55
  input.apply
52
- ? await holdManagerPower(run, input.workspace, host)
56
+ ? await convergeManagerPolicy(run, definition, host)
53
57
  : await managerPower(run, input.workspace, host)
54
58
  );
59
+ if (input.apply)
60
+ checks.push(await managerPower(run, input.workspace, host));
55
61
  checks.push(await managerBootRecovery(run, input.workspace, host));
56
62
  checks.push(await managerDocker(run));
63
+ // The renewal job is a launch agent: macOS managers only.
64
+ if (host.platform === 'macOS')
65
+ checks.push(
66
+ ...(await attempt('manager:vault-renewal', () =>
67
+ holdManagerVault(run, { apply: input.apply, root: PACKAGE_ROOT })
68
+ ))
69
+ );
57
70
  // Each enabled IDE: its settings, pinned version and policy.
58
71
  checks.push(
59
72
  ...(await managerChecks(enabled(idesOf(definition), IDES), {
@@ -231,19 +244,24 @@ export async function managerIdentity(
231
244
 
232
245
  /** Workers depend on the manager's services, so it never sleeps on AC power.
233
246
  * Measures every setting `ansible/macos/manager.yml` declares, from the same
234
- * policy file the playbook applies.
247
+ * policy file the playbook applies. A setting pmset does not list on AC power
248
+ * is one this Mac does not have (a virtual Mac has no womp): the playbook
249
+ * skips it, so it is not applicable here either.
235
250
  */
236
251
  export async function managerPower(
237
252
  run: Runner,
238
253
  workspace = '.',
239
254
  host: ManagerHost = MACOS_MANAGER
240
255
  ): Promise<Check> {
241
- const drifted = await host.powerDrift(run);
256
+ const { drifted, notApplicable } = await host.powerDrift(run);
242
257
  return drifted.length === 0
243
258
  ? {
244
259
  id: 'manager:power',
245
260
  status: 'pass',
246
- summary: 'The manager never sleeps on AC power.',
261
+ summary:
262
+ notApplicable.length === 0
263
+ ? 'The manager never sleeps on AC power.'
264
+ : `The manager never sleeps on AC power; not applicable on this Mac: ${notApplicable.join(' ')}.`,
247
265
  }
248
266
  : {
249
267
  id: 'manager:power',
@@ -291,41 +309,93 @@ export async function managerBootRecovery(
291
309
  };
292
310
  }
293
311
 
294
- /** Whether the one grant manager-privilege.yml installs is in place: this
295
- * user may run `pmset -c` without a password. Lists, never runs.
296
- */
297
- async function powerGranted(run: Runner): Promise<boolean> {
298
- return (
299
- await run(['sudo', '-n', '-l', '/usr/bin/pmset', '-c', 'sleep', '0'], {
300
- timeoutMs: 10_000,
301
- })
302
- ).ok;
303
- }
312
+ /** The receipt of the manager's own playbook, beside the workers' ones. */
313
+ export const MANAGER_RECEIPT = 'macos-manager';
304
314
 
305
- /** On every applying sync: a drifted power policy is applied with
306
- * manager.yml when the grant is in place, then measured again. Without the
307
- * grant the check says how to give it, once.
315
+ /** On every applying sync the manager applies `ansible/macos/manager.yml`
316
+ * like a worker applies its baseline: in check mode first, then for real,
317
+ * with a receipt that skips an unchanged playbook while its postconditions
318
+ * hold, and a full re-apply at least once a day. It never asks for a
319
+ * password: what needs root goes through the grant manager-privilege.yml
320
+ * installs once, and the playbook skips it without the grant.
308
321
  */
309
- export async function holdManagerPower(
322
+ export async function convergeManagerPolicy(
310
323
  run: Runner,
311
- workspace = '.',
324
+ definition: Pick<FleetDefinition, 'manager'>,
312
325
  host: ManagerHost = MACOS_MANAGER,
313
- { root = PACKAGE_ROOT, scratch }: { root?: string; scratch?: string } = {}
326
+ {
327
+ root = PACKAGE_ROOT,
328
+ scratch,
329
+ receipts,
330
+ uid = userInfo().uid,
331
+ now,
332
+ }: {
333
+ root?: string;
334
+ scratch?: string;
335
+ receipts?: string;
336
+ uid?: number;
337
+ now?: number;
338
+ } = {}
314
339
  ): Promise<Check> {
315
- const before = await managerPower(run, workspace, host);
316
- if (
317
- before.status === 'pass' ||
318
- host.platform !== 'macOS' ||
319
- !(await powerGranted(run))
320
- )
321
- return before;
322
- await runAnsible(
340
+ if (host.platform !== 'macOS')
341
+ return {
342
+ id: 'manager:playbook',
343
+ status: 'not-applicable',
344
+ summary: 'The manager playbook is for a macOS manager.',
345
+ };
346
+ const result = await runAnsibleWithReceipt(
323
347
  run,
324
348
  join(root, 'ansible/macos/manager.yml'),
325
- { input: '{}', timeoutMs: 120_000 },
326
- scratch
349
+ {
350
+ cwd: join(root, 'ansible/macos'),
351
+ timeoutMs: 300_000,
352
+ input: JSON.stringify({
353
+ fleet_watch_minutes: scheduleIntervals(definition.manager).watchMinutes,
354
+ }),
355
+ },
356
+ {
357
+ name: MANAGER_RECEIPT,
358
+ sources: [join(root, 'ansible/macos')],
359
+ checkFirst: true,
360
+ verify: async () =>
361
+ (await host.powerDrift(run)).drifted.length === 0 &&
362
+ (await watchLoaded(run, uid)),
363
+ ...(receipts ? { directory: receipts } : {}),
364
+ ...(scratch ? { scratch } : {}),
365
+ ...(now === undefined ? {} : { now }),
366
+ }
327
367
  );
328
- return managerPower(run, workspace, host);
368
+ if (result.ok)
369
+ return {
370
+ id: 'manager:playbook',
371
+ status: 'pass',
372
+ summary: result.cached
373
+ ? 'The manager playbook is unchanged since it was last applied, and its postconditions hold.'
374
+ : 'The manager playbook was checked, then applied.',
375
+ };
376
+ return {
377
+ id: 'manager:playbook',
378
+ status: 'fail',
379
+ summary:
380
+ result.stage === 'check'
381
+ ? 'MANAGER_PLAYBOOK_CHECK_FAILED'
382
+ : 'MANAGER_PLAYBOOK_FAILED',
383
+ remedy: {
384
+ action: `Run the playbook in check mode on the manager and read the failing task: ${ansibleCommand().join(' ')} --check --diff ${join(root, 'ansible/macos/manager.yml')}. Nothing in it needs a password; what needs root comes from the one grant (manager-privilege.yml).`,
385
+ },
386
+ };
387
+ }
388
+
389
+ /** Whether launchd holds the fleet-watch job manager.yml installs. Reads only. */
390
+ export async function watchLoaded(run: Runner, uid: number): Promise<boolean> {
391
+ return (
392
+ await run(
393
+ ['/bin/launchctl', 'print', `gui/${uid}/${macos.fleetWatch.label}`],
394
+ {
395
+ timeoutMs: 10_000,
396
+ }
397
+ )
398
+ ).ok;
329
399
  }
330
400
 
331
401
  /** The Docker Desktop release `mise.toml` pins, as the engine names itself. */
@@ -51,17 +51,19 @@ const COLUMNS: { title: string; matches: (id: string) => boolean }[] = [
51
51
  },
52
52
  ];
53
53
 
54
- const ORDER: Status[] = ['fail', 'warn', 'skip', 'pass'];
54
+ const ORDER: Status[] = ['fail', 'warn', 'skip', 'not-applicable', 'pass'];
55
55
  const WORD: Record<Status, string> = {
56
56
  fail: 'FAIL',
57
57
  warn: 'WARN',
58
58
  skip: 'SKIP',
59
+ 'not-applicable': 'N/A',
59
60
  pass: 'PASS',
60
61
  };
61
62
  const SYMBOL: Record<Status, string> = {
62
63
  fail: '✖',
63
64
  warn: '▲',
64
65
  skip: '○',
66
+ 'not-applicable': '–',
65
67
  pass: '✔',
66
68
  };
67
69
 
@@ -104,7 +106,7 @@ export function managerText(
104
106
  const lines: string[] = [header(c, report, failing, options)];
105
107
  lines.push('', ...grid(c, report.nodes, mark, options.color === true));
106
108
  lines.push('', totals(c, counts, options.color === true));
107
- for (const status of ['fail', 'warn', 'skip'] as const) {
109
+ for (const status of ['fail', 'warn', 'skip', 'not-applicable'] as const) {
108
110
  const problems = groupProblems(
109
111
  all.filter((entry) => entry.check.status === status)
110
112
  );
@@ -120,6 +122,8 @@ export function managerText(
120
122
  lines.push(
121
123
  `${c.dim('report ')} ${options.reportPath} ${c.dim('(full JSON; or rerun with --json)')}`
122
124
  );
125
+ if (report.enrolled?.length)
126
+ lines.push(`${c.dim('enrolled ')} ${report.enrolled.join(', ')}`);
123
127
  if (report.candidates)
124
128
  lines.push(
125
129
  `${c.dim('tailnet ')} ${report.candidates} unmanaged device(s) seen; discovery never enrolls them`
@@ -138,10 +142,11 @@ export function stableUrls(text: string): string {
138
142
  return text.replace(TAILNET_URL, 'its private tailnet address');
139
143
  }
140
144
 
141
- const SECTION: Record<'fail' | 'warn' | 'skip', string> = {
145
+ const SECTION: Record<'fail' | 'warn' | 'skip' | 'not-applicable', string> = {
142
146
  fail: 'Failing — fix these',
143
147
  warn: 'Warnings — outside the fleet; sync still succeeds',
144
148
  skip: 'Not observable on this hardware',
149
+ 'not-applicable': 'Not applicable to this machine — with the reason',
145
150
  };
146
151
 
147
152
  function header(
@@ -268,22 +273,32 @@ function totals(
268
273
  counts: Record<Status, number>,
269
274
  color: boolean
270
275
  ): string {
271
- return ORDER.map((status) => {
272
- const text = `${color ? `${SYMBOL[status]} ` : ''}${counts[status]} ${LABEL[status]}`;
273
- // A zero count is not a signal: never show red/yellow for "0 failing".
274
- return counts[status] ? paint(c, status, text) : c.dim(text);
275
- }).join(c.dim(' '));
276
+ // Not-applicable checks are listed only on machines that have some.
277
+ return ORDER.filter((status) => status !== 'not-applicable' || counts[status])
278
+ .map((status) => {
279
+ const text = `${color ? `${SYMBOL[status]} ` : ''}${counts[status]} ${LABEL[status]}`;
280
+ // A zero count is not a signal: never show red/yellow for "0 failing".
281
+ return counts[status] ? paint(c, status, text) : c.dim(text);
282
+ })
283
+ .join(c.dim(' '));
276
284
  }
277
285
 
278
286
  const LABEL: Record<Status, string> = {
279
287
  fail: 'failing',
280
288
  warn: 'warnings',
281
289
  skip: 'skipped',
290
+ 'not-applicable': 'not applicable',
282
291
  pass: 'passing',
283
292
  };
284
293
 
285
294
  function count(checks: Check[]): Record<Status, number> {
286
- const counts: Record<Status, number> = { pass: 0, warn: 0, fail: 0, skip: 0 };
295
+ const counts: Record<Status, number> = {
296
+ pass: 0,
297
+ warn: 0,
298
+ fail: 0,
299
+ skip: 0,
300
+ 'not-applicable': 0,
301
+ };
287
302
  for (const check of checks)
288
303
  counts[
289
304
  blocking(check) || check.status !== 'fail' ? check.status : 'warn'
@@ -295,7 +310,12 @@ function worst(checks: Check[]): Status | null {
295
310
  if (!checks.length) return null;
296
311
  if (checks.some(blocking)) return 'fail';
297
312
  if (
298
- checks.some((check) => check.status !== 'pass' && check.status !== 'skip')
313
+ checks.some(
314
+ (check) =>
315
+ check.status !== 'pass' &&
316
+ check.status !== 'skip' &&
317
+ check.status !== 'not-applicable'
318
+ )
299
319
  )
300
320
  return 'warn';
301
321
  return checks.every((check) => check.status === 'skip') ? 'skip' : 'pass';
@@ -306,7 +326,7 @@ function paint(c: ChalkInstance, status: Status, text: string): string {
306
326
  ? c.red(text)
307
327
  : status === 'warn'
308
328
  ? c.yellow(text)
309
- : status === 'skip'
329
+ : status === 'skip' || status === 'not-applicable'
310
330
  ? c.dim(text)
311
331
  : c.green(text);
312
332
  }
@@ -1,6 +1,7 @@
1
1
  import { join } from 'node:path';
2
2
  import type { Standing } from '../state/sync';
3
3
  import type { Runner } from '../types';
4
+ import { featureRemovals, workerFeatures } from '../worker-features';
4
5
 
5
6
  /** A tool whose pin changes between the applied and the selected revision. */
6
7
  export interface ToolChange {
@@ -31,6 +32,9 @@ export interface SyncPlan {
31
32
  tools: ToolChange[];
32
33
  restarts: Restart[];
33
34
  nodes: { node: string; from: string | null; to: string; changes: boolean }[];
35
+ /** What a sync removes from a worker because the fleet configuration
36
+ * turned one of its features off since it was last applied. */
37
+ removals: { node: string; removes: string[] }[];
34
38
  }
35
39
 
36
40
  /** The coding agents' CLIs: a new version is used by the next session, and a
@@ -84,16 +88,24 @@ export function toolChanges(
84
88
  }
85
89
 
86
90
  /** What restarts follow from the changes, as the playbooks and the toolchain
87
- * setup act on them.
91
+ * setup act on them. A worker's T3 restarts only while it runs no agent turn
92
+ * (`ansible/ubuntu/tasks/t3-restart.yml`) unless `--restart-busy` asks for it
93
+ * anyway: only then do running turns end.
88
94
  */
89
- export function restarts(tools: ToolChange[], files: string[]): Restart[] {
95
+ export function restarts(
96
+ tools: ToolChange[],
97
+ files: string[],
98
+ restartBusy = false
99
+ ): Restart[] {
90
100
  const found: Restart[] = [];
101
+ const when = restartBusy
102
+ ? 'at once (--restart-busy): its running agent turns end.'
103
+ : 'once it runs no agent turn: running turns keep going and the restart is pending until then.';
91
104
  if (tools.some((change) => change.tool === 't3'))
92
105
  found.push({
93
106
  service: 't3',
94
- reason:
95
- 'T3 is upgraded and restarted on every machine; its running agent turns end.',
96
- endsRunningTurns: true,
107
+ reason: `T3 is upgraded on every worker and restarted ${when}`,
108
+ endsRunningTurns: restartBusy,
97
109
  });
98
110
  else if (
99
111
  files.some((file) =>
@@ -102,9 +114,8 @@ export function restarts(tools: ToolChange[], files: string[]): Restart[] {
102
114
  )
103
115
  found.push({
104
116
  service: 't3',
105
- reason:
106
- 'The managed T3 policy may change: a T3 that has not loaded it is restarted, and its running agent turns end.',
107
- endsRunningTurns: true,
117
+ reason: `The managed T3 policy may change: a T3 that has not loaded it is restarted ${when}`,
118
+ endsRunningTurns: restartBusy,
108
119
  });
109
120
  const agents = tools.filter((change) => AGENT_TOOLS.includes(change.tool));
110
121
  if (agents.length)
@@ -136,6 +147,11 @@ export async function syncPlan(
136
147
  state: Standing;
137
148
  nodes: string[];
138
149
  sources: string[];
150
+ /** `--restart-busy`: a T3 restart does not wait for idle. */
151
+ restartBusy?: boolean;
152
+ /** The fleet configuration last applied (from the fleet state), when
153
+ * there is one, and the one this sync applies. */
154
+ definitions?: { applied: unknown; selected: unknown };
139
155
  },
140
156
  run: Runner
141
157
  ): Promise<SyncPlan> {
@@ -163,16 +179,54 @@ export async function syncPlan(
163
179
  revision: { from: input.from, to: input.to },
164
180
  known: changes !== null,
165
181
  tools: changes?.tools ?? [],
166
- restarts: changes ? restarts(changes.tools, changes.files) : [],
182
+ restarts: changes
183
+ ? restarts(changes.tools, changes.files, input.restartBusy === true)
184
+ : [],
167
185
  nodes: input.nodes.map((node) => ({
168
186
  node,
169
187
  from: input.from,
170
188
  to: input.to,
171
189
  changes: differs,
172
190
  })),
191
+ removals: input.definitions ? removals(input.nodes, input.definitions) : [],
173
192
  };
174
193
  }
175
194
 
195
+ /** Per worker, what turning its features off removes. Nothing is known to
196
+ * be removed from a worker the applied configuration does not name. */
197
+ function removals(
198
+ nodes: string[],
199
+ definitions: { applied: unknown; selected: unknown }
200
+ ): SyncPlan['removals'] {
201
+ const workers = (definition: unknown): Record<string, unknown>[] => {
202
+ const list =
203
+ typeof definition === 'object' && definition !== null
204
+ ? (definition as Record<string, unknown>)['workers']
205
+ : undefined;
206
+ return Array.isArray(list)
207
+ ? list.filter(
208
+ (entry): entry is Record<string, unknown> =>
209
+ typeof entry === 'object' && entry !== null
210
+ )
211
+ : [];
212
+ };
213
+ return nodes.flatMap((node) => {
214
+ const before = workers(definitions.applied).find(
215
+ (entry) => entry['id'] === node
216
+ );
217
+ if (!before) return [];
218
+ const network = workers(definitions.selected).find(
219
+ (entry) => entry['id'] === node
220
+ )?.['network'] as { user?: unknown } | undefined;
221
+ const removes = featureRemovals(
222
+ workerFeatures(definitions.applied, node),
223
+ workerFeatures(definitions.selected, node),
224
+ typeof network?.user === 'string' ? network.user : 'the worker user'
225
+ );
226
+ return removes.length ? [{ node, removes }] : [];
227
+ });
228
+ }
229
+
176
230
  export const planSources = (workspace: string, root: string): string[] => [
177
231
  workspace,
178
232
  join(root, 'source'),
@@ -196,5 +250,7 @@ export function planText(plan: SyncPlan): string {
196
250
  );
197
251
  for (const restart of plan.restarts)
198
252
  lines.push(` Restarts ${restart.service}: ${restart.reason}`);
253
+ for (const removal of plan.removals ?? [])
254
+ lines.push(` Removes from ${removal.node}: ${removal.removes.join(', ')}`);
199
255
  return lines.join('\n');
200
256
  }
@@ -0,0 +1,113 @@
1
+ import type { FleetDefinition, MachineDefinition } from '../definition';
2
+ import { readRosterText } from '../state/roster';
3
+ import { changeWorkers, readWorkerStates } from '../state/workers';
4
+ import { parseConfig } from '../config';
5
+ import type { Check, FleetNode, Runner } from '../types';
6
+ import {
7
+ lifecycleServices,
8
+ retireWorker,
9
+ type NodeLifecycleServices,
10
+ } from './fleet-node-lifecycle';
11
+
12
+ /** The workers of the fleet as worker records describe them come and go
13
+ * with the records: a record that appears is a declared worker, enrolled by
14
+ * the next sync like any other; one that disappears is retired by the same
15
+ * path as fleet-node-retire. fleet-sync calls this before it reads the
16
+ * roster. It records which workers came from records, so that only those
17
+ * are ever retired by themselves; a worker removed from fleet.json by hand
18
+ * must still be retired first.
19
+ */
20
+ export async function reconcileRecords(
21
+ definition: FleetDefinition,
22
+ roster: string,
23
+ run: Runner,
24
+ {
25
+ apply,
26
+ services = lifecycleServices,
27
+ }: {
28
+ apply: boolean;
29
+ services?: (roster: string) => NodeLifecycleServices;
30
+ }
31
+ ): Promise<Check[]> {
32
+ const states = readWorkerStates().workers;
33
+ const declared = new Set(definition.workers.map((worker) => worker.id));
34
+ const vanished = Object.entries(states)
35
+ .filter(([id, entry]) => entry.origin === 'records' && !declared.has(id))
36
+ .map(([id]) => id);
37
+ const entries = await rosterEntries(roster);
38
+ const lifecycle = { ...services(roster), run };
39
+ const checks: Check[] = [];
40
+ for (const id of vanished) {
41
+ const retired = await retireWorker(
42
+ gone(id, entries.get(id)),
43
+ entries.get(id),
44
+ lifecycle,
45
+ apply
46
+ );
47
+ const steps = (retired.data as { nodes: { checks: Check[] }[] }).nodes[0]!
48
+ .checks;
49
+ const failed = steps.filter((step) => step.status === 'fail');
50
+ checks.push(
51
+ failed.length
52
+ ? {
53
+ id: 'enrollment:record',
54
+ status: 'fail',
55
+ summary: 'WORKER_RECORD_RETIREMENT_FAILED',
56
+ remedy: {
57
+ action: `${id}'s worker record disappeared, and retiring it failed at ${failed.map((step) => step.id).join(', ')}. Follow that remedy, then run fleet-sync again.`,
58
+ },
59
+ }
60
+ : {
61
+ id: 'enrollment:record',
62
+ status: apply ? 'pass' : 'skip',
63
+ summary: `${apply ? '' : 'Planned: '}${id}'s worker record disappeared, so it is retired: ${steps.map((step) => step.id).join(', ')}.`,
64
+ }
65
+ );
66
+ }
67
+ const fresh = definition.workers.filter(
68
+ (worker) => worker.origin === 'records' && !states[worker.id]?.origin
69
+ );
70
+ if (apply && fresh.length)
71
+ await changeWorkers(lifecycle.state(), (current) =>
72
+ Object.fromEntries(
73
+ fresh.map((worker) => [
74
+ worker.id,
75
+ { ...current[worker.id], origin: 'records' as const },
76
+ ])
77
+ )
78
+ );
79
+ return checks;
80
+ }
81
+
82
+ async function rosterEntries(path: string): Promise<Map<string, FleetNode>> {
83
+ const text = await readRosterText(path).catch(() => null);
84
+ if (text === null) return new Map();
85
+ try {
86
+ return new Map(
87
+ parseConfig(JSON.parse(text) as unknown).nodes.map((node) => [
88
+ node.id,
89
+ node,
90
+ ])
91
+ );
92
+ } catch {
93
+ return new Map();
94
+ }
95
+ }
96
+
97
+ /** A worker whose record is gone, as far as retiring it needs: reached from
98
+ * declared data (so its pin is the fleet state's), under the name the roster
99
+ * last knew. Its user and ports are never used to retire it.
100
+ */
101
+ function gone(id: string, entry: FleetNode | undefined): MachineDefinition {
102
+ return {
103
+ id,
104
+ os: 'linux',
105
+ origin: 'records',
106
+ network: {
107
+ tailscaleName: entry?.ssh ?? id,
108
+ user: 'root',
109
+ sshPort: 22,
110
+ t3Port: 3773,
111
+ },
112
+ };
113
+ }