@crvouga/dev-fleet 0.3.0 → 0.4.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 (77) hide show
  1. package/README.md +22 -16
  2. package/ansible/github/git.yml +66 -0
  3. package/ansible/macos/policy.json +11 -1
  4. package/ansible/monitoring/compose.yml +10 -0
  5. package/ansible/monitoring/container-watchdog.py +118 -0
  6. package/ansible/monitoring/hub-darwin.yml +146 -0
  7. package/ansible/net/remote-probe.sh +5 -0
  8. package/ansible/ubuntu/baseline.yml +2 -0
  9. package/ansible/ubuntu/disk-gc.py +25 -3
  10. package/ansible/ubuntu/disk-guard.py +24 -5
  11. package/ansible/ubuntu/policy.json +50 -1
  12. package/ansible/ubuntu/tasks/browser.yml +56 -0
  13. package/ansible/ubuntu/tasks/docker.yml +52 -0
  14. package/ansible/ubuntu/tasks/resources.yml +2 -2
  15. package/ansible/ubuntu/tasks/services.yml +13 -0
  16. package/bootstrap/doctor.ts +32 -6
  17. package/bootstrap/fleet-remote-bootstrap.sh +31 -4
  18. package/bootstrap/setup.ts +23 -8
  19. package/bootstrap/worker-tools.ts +118 -0
  20. package/cliproxy/README.md +1 -1
  21. package/cliproxy/compose.yaml +4 -0
  22. package/cliproxy/scripts/checkpoint.py +1 -1
  23. package/docs/authentication.md +90 -0
  24. package/docs/declared.md +2 -0
  25. package/docs/machine-setup.md +22 -12
  26. package/docs/operations.md +87 -31
  27. package/docs/runbooks.md +380 -0
  28. package/docs/state.md +13 -7
  29. package/docs/ubuntu-sync.md +7 -6
  30. package/examples/fleet.json +19 -1
  31. package/llms.txt +2 -0
  32. package/mise.toml +7 -0
  33. package/package.json +2 -2
  34. package/schema/check-ids.json +522 -0
  35. package/schema/fleet.schema.json +178 -1
  36. package/schema/output/check.schema.json +23 -0
  37. package/schema/output/error.schema.json +14 -0
  38. package/schema/output/fleet-report.schema.json +79 -0
  39. package/schema/output/fleet-sync.schema.json +112 -0
  40. package/schema/output/report.schema.json +38 -0
  41. package/src/auth-checks.ts +3 -0
  42. package/src/cli/agent-guide.ts +5 -3
  43. package/src/cli/fleet-report.ts +126 -0
  44. package/src/cli/fleet-sync-manager.ts +11 -0
  45. package/src/cli/fleet-sync-plan.ts +200 -0
  46. package/src/cli/fleet-sync-remote.ts +3 -1
  47. package/src/cli/fleet-sync-schedule.ts +142 -35
  48. package/src/cli/fleet-sync.ts +90 -8
  49. package/src/cli/fleet-watch.ts +198 -0
  50. package/src/cli/fleet.ts +4 -0
  51. package/src/cli/register-fleet.ts +51 -0
  52. package/src/cli/registry.ts +13 -0
  53. package/src/cli/schedule-logs.ts +36 -0
  54. package/src/clock.ts +63 -0
  55. package/src/definition.ts +142 -5
  56. package/src/git-identity.ts +160 -0
  57. package/src/machine-health.ts +51 -4
  58. package/src/manager-health.ts +205 -0
  59. package/src/manager-sync.ts +12 -2
  60. package/src/monitoring/hub-backup.ts +63 -17
  61. package/src/monitoring/manager-hub.ts +44 -2
  62. package/src/node-converge-linux.ts +16 -5
  63. package/src/notify.ts +239 -0
  64. package/src/remote-bootstrap.ts +87 -25
  65. package/src/remote-operation.ts +213 -0
  66. package/src/schedule-check.ts +40 -0
  67. package/src/secrets.ts +26 -8
  68. package/src/ssh.ts +10 -2
  69. package/src/state/history.ts +172 -0
  70. package/src/state/store.ts +11 -4
  71. package/src/tailscale.ts +51 -0
  72. package/src/toolchain-checks.ts +8 -3
  73. package/src/worker-capability-checks.ts +167 -0
  74. package/src/worker-capacity.ts +19 -7
  75. package/src/worker-host-checks.ts +32 -0
  76. package/src/worker-policy.ts +33 -0
  77. package/src/worker-readiness.ts +10 -1
@@ -13,9 +13,11 @@ import { installConfig } from '../fleet-config';
13
13
  import { IDES } from '../ide';
14
14
  import { enabled, managerChecks } from '../ide/orchestrate';
15
15
  import { idesOf } from '../ide/definition';
16
+ import { convergeGitIdentity, gitChecks } from '../git-identity';
16
17
  import macos from '../../ansible/macos/policy.json';
17
18
  import { ansibleCommand } from '../platform/ansible';
18
19
  import { PACKAGE_ROOT } from '../package-info';
20
+ import { managerHealth } from '../manager-health';
19
21
  import { readFileSync } from 'node:fs';
20
22
  import { join } from 'node:path';
21
23
 
@@ -50,6 +52,7 @@ export async function convergeManager(
50
52
  run,
51
53
  }))
52
54
  );
55
+ checks.push(...(await managerHealth(run)));
53
56
  if (input.apply) {
54
57
  // Same provider profiles and the same Vault gateway credential as every
55
58
  // worker: the instances above launch from these profiles, and the check
@@ -59,6 +62,14 @@ export async function convergeManager(
59
62
  managerAgentProfiles(id, input.workspace, run)
60
63
  ))
61
64
  );
65
+ // The same Git identity and GitHub credentials as every worker.
66
+ checks.push(
67
+ ...(await attempt('git:configuration', () =>
68
+ convergeGitIdentity(input.workspace, definition.git, run)
69
+ ))
70
+ );
71
+ if (definition.git)
72
+ checks.push(...(await gitChecks(definition.git, run)));
62
73
  const providers = await attempt('provider:verification', () =>
63
74
  verifyFleetProviders(id)
64
75
  );
@@ -0,0 +1,200 @@
1
+ import { join } from 'node:path';
2
+ import type { Standing } from '../state/sync';
3
+ import type { Runner } from '../types';
4
+
5
+ /** A tool whose pin changes between the applied and the selected revision. */
6
+ export interface ToolChange {
7
+ tool: string;
8
+ from: string | null;
9
+ to: string | null;
10
+ }
11
+
12
+ /** Something a sync restarts, and whether that ends work in progress. */
13
+ export interface Restart {
14
+ service: string;
15
+ reason: string;
16
+ endsRunningTurns: boolean;
17
+ }
18
+
19
+ /** What `fleet-sync --dry-run` says a sync would change. It is read from
20
+ * Git (the two revisions' tool pins and the files between them) and the
21
+ * fleet state; nothing on a machine is changed to find it out.
22
+ */
23
+ export interface SyncPlan {
24
+ /** How the fleet state stood with the bucket when the plan was made. */
25
+ state: Standing;
26
+ revision: { from: string | null; to: string };
27
+ /** False when the applied revision could not be read: the changes are
28
+ * then unknown, not absent.
29
+ */
30
+ known: boolean;
31
+ tools: ToolChange[];
32
+ restarts: Restart[];
33
+ nodes: { node: string; from: string | null; to: string; changes: boolean }[];
34
+ }
35
+
36
+ /** The coding agents' CLIs: a new version is used by the next session, and a
37
+ * running one keeps the binary it started with.
38
+ */
39
+ const AGENT_TOOLS = [
40
+ 'claude',
41
+ 'codex',
42
+ 'gemini',
43
+ 'copilot',
44
+ 'aqua:anomalyco/opencode',
45
+ 'http:grok',
46
+ 'http:antigravity',
47
+ ];
48
+
49
+ function section(text: string, name: string): string {
50
+ const start = text.search(new RegExp(`^\\[${name}\\]\\s*$`, 'm'));
51
+ if (start < 0) return '';
52
+ const rest = text.slice(start).split('\n').slice(1);
53
+ const end = rest.findIndex((line) => /^\[/.test(line));
54
+ return (end < 0 ? rest : rest.slice(0, end)).join('\n');
55
+ }
56
+
57
+ /** The exact pins under [tools], and T3's under [vars]. */
58
+ export function pins(miseToml: string): Map<string, string> {
59
+ const found = new Map<string, string>();
60
+ for (const line of section(miseToml, 'tools').split('\n')) {
61
+ const match =
62
+ /^\s*"?([A-Za-z0-9@:/._-]+)"?\s*=\s*(?:"([^"]+)"|\{.*?\bversion\s*=\s*"([^"]+)")/.exec(
63
+ line
64
+ );
65
+ if (match) found.set(match[1]!, (match[2] ?? match[3])!);
66
+ }
67
+ const t3 = /^\s*t3_version\s*=\s*"([^"]+)"/m.exec(section(miseToml, 'vars'));
68
+ if (t3) found.set('t3', t3[1]!);
69
+ return found;
70
+ }
71
+
72
+ export function toolChanges(
73
+ before: Map<string, string>,
74
+ after: Map<string, string>
75
+ ): ToolChange[] {
76
+ const names = [...new Set([...before.keys(), ...after.keys()])].sort();
77
+ return names
78
+ .filter((tool) => before.get(tool) !== after.get(tool))
79
+ .map((tool) => ({
80
+ tool,
81
+ from: before.get(tool) ?? null,
82
+ to: after.get(tool) ?? null,
83
+ }));
84
+ }
85
+
86
+ /** What restarts follow from the changes, as the playbooks and the toolchain
87
+ * setup act on them.
88
+ */
89
+ export function restarts(tools: ToolChange[], files: string[]): Restart[] {
90
+ const found: Restart[] = [];
91
+ if (tools.some((change) => change.tool === 't3'))
92
+ found.push({
93
+ service: 't3',
94
+ reason:
95
+ 'T3 is upgraded and restarted on every machine; its running agent turns end.',
96
+ endsRunningTurns: true,
97
+ });
98
+ else if (
99
+ files.some((file) =>
100
+ /^(agents\/|ansible\/ubuntu\/(t3-policy\.yml|t3\/))/.test(file)
101
+ )
102
+ )
103
+ found.push({
104
+ 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,
108
+ });
109
+ const agents = tools.filter((change) => AGENT_TOOLS.includes(change.tool));
110
+ if (agents.length)
111
+ found.push({
112
+ service: 'agent-clis',
113
+ reason: `New sessions use the new ${agents.map((change) => change.tool).join(', ')}; running turns keep the version they started with.`,
114
+ endsRunningTurns: false,
115
+ });
116
+ return found;
117
+ }
118
+
119
+ async function read(
120
+ run: Runner,
121
+ cwd: string,
122
+ args: string[]
123
+ ): Promise<string | null> {
124
+ const result = await run(['git', ...args], { cwd, timeoutMs: 20_000 });
125
+ return result.ok ? result.stdout : null;
126
+ }
127
+
128
+ /** Read-only: the plan of a sync from `from` (the revision the fleet last
129
+ * applied) to `to`. Git objects come from the checkout this runs in or the
130
+ * manager's cache of the source repository; nothing is fetched.
131
+ */
132
+ export async function syncPlan(
133
+ input: {
134
+ from: string | null;
135
+ to: string;
136
+ state: Standing;
137
+ nodes: string[];
138
+ sources: string[];
139
+ },
140
+ run: Runner
141
+ ): Promise<SyncPlan> {
142
+ let changes: { tools: ToolChange[]; files: string[] } | null =
143
+ input.from === input.to ? { tools: [], files: [] } : null;
144
+ for (const cwd of input.from && !changes ? input.sources : []) {
145
+ const before = await read(run, cwd, ['show', `${input.from}:mise.toml`]);
146
+ const after = await read(run, cwd, ['show', `${input.to}:mise.toml`]);
147
+ const files = await read(run, cwd, [
148
+ 'diff',
149
+ '--name-only',
150
+ input.from!,
151
+ input.to,
152
+ ]);
153
+ if (before === null || after === null || files === null) continue;
154
+ changes = {
155
+ tools: toolChanges(pins(before), pins(after)),
156
+ files: files.split('\n').filter(Boolean),
157
+ };
158
+ break;
159
+ }
160
+ const differs = input.from !== input.to;
161
+ return {
162
+ state: input.state,
163
+ revision: { from: input.from, to: input.to },
164
+ known: changes !== null,
165
+ tools: changes?.tools ?? [],
166
+ restarts: changes ? restarts(changes.tools, changes.files) : [],
167
+ nodes: input.nodes.map((node) => ({
168
+ node,
169
+ from: input.from,
170
+ to: input.to,
171
+ changes: differs,
172
+ })),
173
+ };
174
+ }
175
+
176
+ export const planSources = (workspace: string, root: string): string[] => [
177
+ workspace,
178
+ join(root, 'source'),
179
+ ];
180
+
181
+ /** The plan, for people: one line per change. */
182
+ export function planText(plan: SyncPlan): string {
183
+ const short = (revision: string | null) => revision?.slice(0, 12) ?? 'none';
184
+ const lines = [
185
+ plan.revision.from === plan.revision.to
186
+ ? `Plan: the fleet stays at ${short(plan.revision.to)}.`
187
+ : `Plan: ${short(plan.revision.from)} → ${short(plan.revision.to)} on ${plan.nodes.map((node) => node.node).join(', ') || 'no machine'}.`,
188
+ ];
189
+ if (!plan.known)
190
+ lines.push(
191
+ ' The applied revision is not in a local repository: tool and restart changes are unknown.'
192
+ );
193
+ for (const change of plan.tools)
194
+ lines.push(
195
+ ` ${change.tool} ${change.from ?? 'not installed'} → ${change.to ?? 'removed'}`
196
+ );
197
+ for (const restart of plan.restarts)
198
+ lines.push(` Restarts ${restart.service}: ${restart.reason}`);
199
+ return lines.join('\n');
200
+ }
@@ -193,7 +193,9 @@ function deployWithMonitoring(
193
193
  if (ui.interactiveAuthorization) ui.progress.stop();
194
194
  const before = await authorizeVault(node, vault, run);
195
195
  ui.progress.step(node.id, 'Converging (remote lock, install, verify)');
196
- const result = await bootstrapRemote(node, payload, run);
196
+ const result = await bootstrapRemote(node, payload, run, {
197
+ progress: (line) => ui.progress.step(node.id, line),
198
+ });
197
199
  // A fresh node gets vault from the bootstrap; log it in now. Its provider
198
200
  // checks in this run predate the login, so the next sync verifies them.
199
201
  result.checks.push(
@@ -1,13 +1,22 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
1
3
  import { join } from 'node:path';
2
4
  import { currentPlatform } from '../platform';
3
5
  import type { Platform } from '../platform/types';
6
+ import { PACKAGE_ROOT, packageInfo, type PackageInfo } from '../package-info';
7
+ import {
8
+ readHistory,
9
+ recordSchedule,
10
+ type ScheduleRecord,
11
+ } from '../state/history';
12
+ import { scheduleCheck, SCHEDULE_INTERVAL_SECONDS } from '../schedule-check';
4
13
  import type { Runner } from '../types';
5
14
  import type { CommandOutcome } from './contracts';
6
15
  import { machineRoot } from './fleet-sync';
7
16
 
8
- const LABEL = 'personal-fleet-sync';
9
- declare const PERSONAL_VERSION: string | undefined;
10
- declare const PERSONAL_SOURCE_REVISION: string | null;
17
+ /** The launchd label and systemd unit already on running managers. */
18
+ export const SCHEDULE_LABEL = 'personal-fleet-sync';
19
+ const LABEL = SCHEDULE_LABEL;
11
20
  export type ScheduleAction =
12
21
  'enable' | 'pause' | 'resume' | 'disable' | 'status';
13
22
  interface ScheduleInput {
@@ -15,33 +24,145 @@ interface ScheduleInput {
15
24
  revision: string;
16
25
  config: string;
17
26
  authorized: boolean;
27
+ /** The package this CLI runs from; the schedule installs the same one. */
28
+ release?: Release;
29
+ /** Where the schedule is recorded in the fleet state; tests replace it. */
30
+ record?: (schedule: ScheduleRecord) => void;
31
+ /** The manager's caches; tests name their own. */
32
+ root?: string;
33
+ now?: Date;
18
34
  }
35
+
36
+ /** The published package a schedule installs and runs: its name, exact
37
+ * version and the entry point its manifest declares.
38
+ */
39
+ export interface Release extends PackageInfo {
40
+ bin: string | null;
41
+ }
42
+
43
+ export function release(root = PACKAGE_ROOT): Release {
44
+ let bin: unknown = null;
45
+ try {
46
+ const manifest = JSON.parse(
47
+ readFileSync(join(root, 'package.json'), 'utf8')
48
+ ) as { bin?: unknown };
49
+ bin = typeof manifest.bin === 'string' ? manifest.bin : null;
50
+ if (typeof manifest.bin === 'object' && manifest.bin !== null)
51
+ bin = (manifest.bin as Record<string, unknown>)['devfleet'] ?? null;
52
+ } catch {
53
+ bin = null;
54
+ }
55
+ return {
56
+ ...packageInfo(root),
57
+ bin: typeof bin === 'string' ? bin.replace(/^\.\//, '') : null,
58
+ };
59
+ }
60
+
61
+ /** Only a published release can be scheduled: it names an exact version and
62
+ * the commit it was built from, so every unattended run is the same code.
63
+ */
64
+ export function publishedRelease(release: Release): boolean {
65
+ return (
66
+ /^\d+\.\d+\.\d+$/.test(release.version) &&
67
+ release.revision !== null &&
68
+ release.bin !== null &&
69
+ !release.bin.split('/').includes('..')
70
+ );
71
+ }
72
+
73
+ /** What launchd (or the systemd timer) runs: the installed package's own
74
+ * entry point under Node, with the tools fleet-sync installs on PATH. No path
75
+ * into the project or the package the operator ran is written into it.
76
+ */
77
+ export function scheduledCommand(
78
+ release: Release,
79
+ revision: string,
80
+ prefix: string
81
+ ): string[] {
82
+ return [
83
+ '/usr/bin/env',
84
+ 'node',
85
+ join(prefix, 'node_modules', release.name, release.bin ?? ''),
86
+ 'fleet-sync',
87
+ '--revision',
88
+ revision,
89
+ '--apply',
90
+ '--yes',
91
+ '--non-interactive',
92
+ '--json',
93
+ ];
94
+ }
95
+
96
+ export function scheduledEnvironment(home = homedir()): Record<string, string> {
97
+ return {
98
+ PATH: [
99
+ join(home, '.local/share/mise/shims'),
100
+ join(home, '.local/bin'),
101
+ '/opt/homebrew/bin',
102
+ '/usr/local/bin',
103
+ '/usr/bin',
104
+ '/bin',
105
+ ].join(':'),
106
+ DEVFLEET_TRIGGER: 'schedule',
107
+ };
108
+ }
109
+
19
110
  export async function managerSchedule(
20
111
  input: ScheduleInput,
21
112
  run: Runner,
22
113
  platform: Platform = currentPlatform()
23
114
  ): Promise<CommandOutcome> {
24
- if (input.action === 'status')
25
- return {
26
- exitCode: 0,
27
- data: {
28
- version: 2,
29
- command: 'fleet-sync',
30
- execution: 'manager-push',
31
- scheduling: scheduleStatus(platform),
32
- },
33
- };
115
+ if (input.action === 'status') return statusReport(platform, input.now);
34
116
  if (!input.authorized)
35
117
  return required('Manager scheduling changes require --apply --yes.');
36
118
  if (input.action === 'pause' || input.action === 'disable')
37
119
  return removeSchedule(input, platform);
38
120
  return enableSchedule(input, run, platform);
39
121
  }
122
+
123
+ function statusReport(platform: Platform, now = new Date()): CommandOutcome {
124
+ const history = readHistory();
125
+ const check = scheduleCheck(history, now);
126
+ return {
127
+ exitCode: 0,
128
+ data: {
129
+ version: 2,
130
+ command: 'fleet-sync',
131
+ execution: 'manager-push',
132
+ scheduling: scheduleStatus(platform),
133
+ intervalSeconds:
134
+ history.schedule?.intervalSeconds ?? SCHEDULE_INTERVAL_SECONDS,
135
+ runs: history.runs
136
+ .filter((entry) => entry.trigger === 'schedule')
137
+ .slice(-3)
138
+ .reverse(),
139
+ ...(check ? { checks: [check] } : {}),
140
+ },
141
+ };
142
+ }
143
+
144
+ function remember(
145
+ input: ScheduleInput,
146
+ enabled: boolean,
147
+ intervalSeconds = SCHEDULE_INTERVAL_SECONDS
148
+ ): void {
149
+ try {
150
+ (input.record ?? recordSchedule)({
151
+ enabled,
152
+ intervalSeconds,
153
+ changedAt: (input.now ?? new Date()).toISOString(),
154
+ });
155
+ } catch {
156
+ // The schedule changed either way; the next run records it again.
157
+ }
158
+ }
159
+
40
160
  function removeSchedule(
41
161
  input: ScheduleInput,
42
162
  platform: Platform
43
163
  ): CommandOutcome {
44
164
  const result = platform.removeScheduledJob(LABEL);
165
+ if (result.ok) remember(input, false);
45
166
  return {
46
167
  exitCode: result.ok ? 0 : 1,
47
168
  data: {
@@ -60,15 +181,12 @@ async function enableSchedule(
60
181
  run: Runner,
61
182
  platform: Platform
62
183
  ): Promise<CommandOutcome> {
63
- if (
64
- typeof PERSONAL_VERSION !== 'string' ||
65
- !/^\d+\.\d+\.\d+$/.test(PERSONAL_VERSION) ||
66
- typeof PERSONAL_SOURCE_REVISION !== 'string'
67
- )
184
+ const published = input.release ?? release();
185
+ if (!publishedRelease(published))
68
186
  return required(
69
187
  'Scheduling requires a published CLI with clean source provenance; dirty review artifacts cannot enable unattended deployment.'
70
188
  );
71
- const root = machineRoot();
189
+ const root = input.root ?? machineRoot();
72
190
  const prefix = join(root, 'runtime');
73
191
  const installed = await run(
74
192
  [
@@ -79,7 +197,7 @@ async function enableSchedule(
79
197
  '--ignore-scripts',
80
198
  '--no-audit',
81
199
  '--no-fund',
82
- `dev-fleet@${PERSONAL_VERSION}`,
200
+ `${published.name}@${published.version}`,
83
201
  ],
84
202
  { timeoutMs: 120_000 }
85
203
  );
@@ -89,23 +207,12 @@ async function enableSchedule(
89
207
  );
90
208
  const result = platform.installScheduledJob({
91
209
  label: LABEL,
92
- intervalSeconds: 1800,
93
- command: [
94
- process.execPath,
95
- join(prefix, 'node_modules/dev-fleet/cli.mjs'),
96
- 'fleet-sync',
97
- '--config',
98
- input.config,
99
- '--revision',
100
- input.revision,
101
- '--apply',
102
- '--yes',
103
- '--non-interactive',
104
- '--json',
105
- ],
210
+ intervalSeconds: SCHEDULE_INTERVAL_SECONDS,
211
+ command: scheduledCommand(published, input.revision, prefix),
106
212
  logDir: join(root, 'logs'),
107
- env: { PATH: process.env['PATH'] ?? '/usr/bin:/bin' },
213
+ env: scheduledEnvironment(),
108
214
  });
215
+ if (result.ok) remember(input, true);
109
216
  return {
110
217
  exitCode: result.ok ? 0 : 1,
111
218
  data: {
@@ -20,13 +20,22 @@ import {
20
20
  } from '../machine/state';
21
21
  import { loadEnrollment } from '../state/enrollment';
22
22
  import { legacyHome } from '../state/paths';
23
+ import {
24
+ appendRun,
25
+ summarizeRun,
26
+ type SyncRun,
27
+ type Trigger,
28
+ } from '../state/history';
29
+ import { capScheduleLogs } from './schedule-logs';
23
30
  import {
24
31
  DIVERGED_REMEDY,
32
+ mutateState,
25
33
  refresh,
26
34
  stateContext,
27
35
  type Standing,
28
36
  } from '../state/sync';
29
37
  import { runRecord } from './fleet-sync-state';
38
+ import { planSources, planText, syncPlan } from './fleet-sync-plan';
30
39
  import { runCommand } from '../runner';
31
40
  import type { Runner } from '../types';
32
41
  import type { Settings } from './settings';
@@ -84,11 +93,43 @@ export async function fleetSync(
84
93
  return offManager(options, settings, run, forward);
85
94
  if (options.schedule === 'status') return scheduleStatus(settings, run);
86
95
  if (options.only === 'accounts') return onlyAccounts(options, settings, run);
87
- return managerSync(options, settings, {
88
- run,
89
- root: machineRoot(),
90
- restart: () => fleetSync(options, settings, run, forward),
91
- });
96
+ const sync = () =>
97
+ managerSync(options, settings, {
98
+ run,
99
+ root: machineRoot(),
100
+ restart: sync,
101
+ });
102
+ if (options.dryRun || options.schedule) return sync();
103
+ const trigger = runTrigger();
104
+ if (trigger === 'schedule') capScheduleLogs(join(machineRoot(), 'logs'));
105
+ const startedAt = new Date();
106
+ const outcome = await sync();
107
+ await recordRun(
108
+ summarizeRun(
109
+ outcome.data,
110
+ { startedAt, finishedAt: new Date() },
111
+ trigger,
112
+ outcome.exitCode
113
+ ),
114
+ run
115
+ );
116
+ return outcome;
117
+ }
118
+
119
+ /** Runs the manager's schedule starts say so in their environment. */
120
+ function runTrigger(env = process.env): Trigger {
121
+ return env['DEVFLEET_TRIGGER'] === 'schedule' ? 'schedule' : 'operator';
122
+ }
123
+
124
+ /** Appends the run to the history in the fleet state and uploads it. A
125
+ * history that cannot be written never changes the run's result.
126
+ */
127
+ async function recordRun(entry: SyncRun, run: Runner): Promise<void> {
128
+ try {
129
+ await mutateState(stateContext(run), () => appendRun(entry));
130
+ } catch {
131
+ // Reported by the next fleet-state or fleet-sync run.
132
+ }
92
133
  }
93
134
 
94
135
  export interface ManagerSync {
@@ -132,9 +173,9 @@ export async function managerSync(
132
173
  revision
133
174
  );
134
175
  if (rehomed !== 'here') return rehomed;
135
- if (options.dryRun) return selected({ ...options, revision }, settings, run);
136
- // The bucket is the source of truth: a run starts from its version of the
137
- // configuration, the roster and the approval, not from a stale local copy.
176
+ // The bucket is the source of truth: a run, and a preview, starts from its
177
+ // version of the configuration, the roster and the approval, not from a
178
+ // stale local copy. Refreshing never uploads.
138
179
  const fresh = await refresh(stateContext(run));
139
180
  if (fresh.standing === 'diverged')
140
181
  return actionRequired('FLEET_STATE_DIVERGED', DIVERGED_REMEDY, revision);
@@ -144,6 +185,13 @@ export async function managerSync(
144
185
  reloadFleet();
145
186
  return restart();
146
187
  }
188
+ if (options.dryRun)
189
+ return preview({ ...options, revision }, settings, {
190
+ run,
191
+ root,
192
+ applied: state?.appliedRevision ?? null,
193
+ standing: fresh.standing,
194
+ });
147
195
  return authorizeAndApply(root, state, options, {
148
196
  settings,
149
197
  run,
@@ -152,6 +200,40 @@ export async function managerSync(
152
200
  });
153
201
  }
154
202
 
203
+ /** The preview: the read-only report of every machine, and the plan of what
204
+ * a sync would change on them.
205
+ */
206
+ async function preview(
207
+ options: FleetSyncOptions & { revision: string },
208
+ settings: Settings,
209
+ context: {
210
+ run: Runner;
211
+ root: string;
212
+ applied: string | null;
213
+ standing: Standing;
214
+ }
215
+ ): Promise<CommandOutcome> {
216
+ const outcome = await selected(options, settings, context.run);
217
+ const report = outcome.data as { nodes?: { node?: unknown }[] };
218
+ const plan = await syncPlan(
219
+ {
220
+ from: context.applied,
221
+ to: options.revision,
222
+ state: context.standing,
223
+ nodes: (report.nodes ?? [])
224
+ .map((node) => node.node)
225
+ .filter((node): node is string => typeof node === 'string'),
226
+ sources: planSources(settings.workspace, context.root),
227
+ },
228
+ context.run
229
+ );
230
+ return {
231
+ ...outcome,
232
+ data: { ...(outcome.data as object), plan },
233
+ text: [outcome.text, planText(plan)].filter(Boolean).join('\n'),
234
+ };
235
+ }
236
+
155
237
  interface ApplyContext {
156
238
  settings: Settings;
157
239
  run: Runner;