@crvouga/dev-fleet 0.2.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 (129) hide show
  1. package/README.md +50 -16
  2. package/ansible/github/git.yml +66 -0
  3. package/ansible/macos/manager.yml +3 -0
  4. package/ansible/macos/policy.json +11 -1
  5. package/ansible/macos/t3-policy.yml +49 -0
  6. package/ansible/monitoring/compose.yml +10 -0
  7. package/ansible/monitoring/container-watchdog.py +118 -0
  8. package/ansible/monitoring/hub-darwin.yml +146 -0
  9. package/ansible/net/remote-probe.sh +5 -0
  10. package/ansible/ubuntu/baseline.yml +2 -0
  11. package/ansible/ubuntu/disk-gc.py +25 -3
  12. package/ansible/ubuntu/disk-guard.py +24 -5
  13. package/ansible/ubuntu/policy.json +51 -1
  14. package/ansible/ubuntu/t3-desktop.yml +19 -0
  15. package/ansible/ubuntu/t3-health.sh +93 -22
  16. package/ansible/ubuntu/t3-idle.py +42 -0
  17. package/ansible/ubuntu/t3-policy.yml +4 -11
  18. package/ansible/ubuntu/tasks/browser.yml +56 -0
  19. package/ansible/ubuntu/tasks/docker.yml +52 -0
  20. package/ansible/ubuntu/tasks/recovery.yml +60 -1
  21. package/ansible/ubuntu/tasks/resources.yml +2 -2
  22. package/ansible/ubuntu/tasks/services.yml +13 -0
  23. package/ansible/ubuntu/tasks/t3-restart.yml +37 -0
  24. package/ansible/ubuntu/wip-guard.py +121 -0
  25. package/bootstrap/doctor.ts +32 -6
  26. package/bootstrap/fleet-remote-bootstrap.sh +31 -4
  27. package/bootstrap/native-t3.ts +22 -11
  28. package/bootstrap/setup.ts +23 -8
  29. package/bootstrap/worker-tools.ts +118 -0
  30. package/cliproxy/README.md +4 -4
  31. package/cliproxy/compose.yaml +4 -0
  32. package/cliproxy/scripts/accounts.py +216 -0
  33. package/cliproxy/scripts/checkpoint.py +30 -2
  34. package/cliproxy/scripts/cli.py +1 -1
  35. package/cliproxy/scripts/clientkeys.py +99 -0
  36. package/cliproxy/scripts/lease.py +80 -0
  37. package/cliproxy/scripts/ops +137 -25
  38. package/cliproxy/scripts/restore-verify +15 -0
  39. package/docs/accounts.md +154 -0
  40. package/docs/authentication.md +90 -0
  41. package/docs/declared.md +5 -1
  42. package/docs/durability.md +26 -0
  43. package/docs/machine-setup.md +26 -15
  44. package/docs/operations.md +87 -31
  45. package/docs/runbooks.md +380 -0
  46. package/docs/state.md +13 -7
  47. package/docs/topology.md +2 -0
  48. package/docs/ubuntu-sync.md +35 -28
  49. package/examples/fleet.json +19 -1
  50. package/llms.txt +3 -0
  51. package/mise.toml +10 -1
  52. package/package.json +2 -2
  53. package/schema/check-ids.json +522 -0
  54. package/schema/fleet.schema.json +244 -1
  55. package/schema/output/check.schema.json +23 -0
  56. package/schema/output/error.schema.json +14 -0
  57. package/schema/output/fleet-report.schema.json +79 -0
  58. package/schema/output/fleet-sync.schema.json +112 -0
  59. package/schema/output/report.schema.json +38 -0
  60. package/src/accounts/checks.ts +220 -0
  61. package/src/accounts/context.ts +76 -0
  62. package/src/accounts/propagate.ts +62 -0
  63. package/src/accounts/roster.ts +85 -0
  64. package/src/accounts/selection.ts +67 -0
  65. package/src/accounts/sessions.ts +120 -0
  66. package/src/agents/account-relay.ts +176 -26
  67. package/src/agents/launch.ts +84 -5
  68. package/src/agents/native-verify.ts +128 -2
  69. package/src/auth-checks.ts +3 -0
  70. package/src/cli/agent-guide.ts +5 -3
  71. package/src/cli/fleet-accounts.ts +253 -0
  72. package/src/cli/fleet-report.ts +126 -0
  73. package/src/cli/fleet-sync-accounts.ts +58 -0
  74. package/src/cli/fleet-sync-manager.ts +31 -4
  75. package/src/cli/fleet-sync-output.ts +3 -2
  76. package/src/cli/fleet-sync-plan.ts +200 -0
  77. package/src/cli/fleet-sync-remote.ts +23 -14
  78. package/src/cli/fleet-sync-schedule.ts +142 -35
  79. package/src/cli/fleet-sync.ts +126 -8
  80. package/src/cli/fleet-watch.ts +198 -0
  81. package/src/cli/fleet.ts +4 -0
  82. package/src/cli/register-fleet.ts +86 -0
  83. package/src/cli/registry.ts +26 -0
  84. package/src/cli/schedule-logs.ts +36 -0
  85. package/src/clock.ts +63 -0
  86. package/src/definition.ts +226 -7
  87. package/src/git-identity.ts +160 -0
  88. package/src/ide/definition.ts +86 -0
  89. package/src/ide/ide.ts +43 -0
  90. package/src/ide/index.ts +5 -0
  91. package/src/ide/orchestrate.ts +50 -0
  92. package/src/ide/t3.ts +111 -0
  93. package/src/machine-health.ts +51 -4
  94. package/src/manager-health.ts +205 -0
  95. package/src/manager-sync.ts +13 -17
  96. package/src/monitoring/hub-backup.ts +63 -17
  97. package/src/monitoring/manager-hub.ts +44 -2
  98. package/src/node-converge-linux.ts +44 -12
  99. package/src/notify.ts +239 -0
  100. package/src/platform/darwin.ts +0 -13
  101. package/src/platform/fallback.ts +0 -8
  102. package/src/platform/linux.ts +0 -10
  103. package/src/platform/t3-policy.ts +33 -6
  104. package/src/platform/t3-service.ts +104 -0
  105. package/src/platform/types.ts +0 -4
  106. package/src/platform/windows.ts +0 -14
  107. package/src/remote-bootstrap.ts +87 -25
  108. package/src/remote-entry.ts +15 -1
  109. package/src/remote-operation.ts +213 -0
  110. package/src/schedule-check.ts +40 -0
  111. package/src/secrets.ts +26 -8
  112. package/src/service-auth.ts +15 -5
  113. package/src/ssh.ts +10 -2
  114. package/src/state/history.ts +172 -0
  115. package/src/state/store.ts +11 -4
  116. package/src/t3/accounts.ts +7 -1
  117. package/src/t3/models.ts +133 -0
  118. package/src/t3/pairing.ts +31 -0
  119. package/src/t3/profile.ts +203 -0
  120. package/src/t3/version.ts +119 -0
  121. package/src/t3-linked-check.ts +1 -1
  122. package/src/tailscale.ts +51 -0
  123. package/src/toolchain-checks.ts +8 -3
  124. package/src/transport.ts +3 -2
  125. package/src/worker-capability-checks.ts +167 -0
  126. package/src/worker-capacity.ts +19 -7
  127. package/src/worker-host-checks.ts +68 -13
  128. package/src/worker-policy.ts +33 -0
  129. package/src/worker-readiness.ts +10 -1
@@ -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
+ }
@@ -1,7 +1,8 @@
1
- import { verifyT3Linked } from '../t3-linked-check';
2
- import { T3_RESTARTED_SUMMARY } from '../platform/t3-policy';
3
1
  import { createHash } from 'node:crypto';
4
- import { verifyT3Endpoint } from '../t3-endpoint';
2
+ import { IDES } from '../ide';
3
+ import { enabled, workerChecks } from '../ide/orchestrate';
4
+ import { idesOf } from '../ide/definition';
5
+ import { rememberEnvironment } from '../t3/pairing';
5
6
  import { authorizePrivilege } from './fleet-sync-privilege';
6
7
  import { authorizeVault } from './fleet-sync-vault';
7
8
  import { managerFailure } from '../manager-failure';
@@ -92,7 +93,11 @@ export async function selected(
92
93
  ),
93
94
  },
94
95
  run,
95
- deployWithMonitoring(payload, canonical, run, ui)
96
+ deployWithMonitoring(payload, canonical, run, {
97
+ ...ui,
98
+ workspace: settings.workspace,
99
+ roster: settings.config,
100
+ })
96
101
  ).finally(() => ui.progress.stop());
97
102
  if (settle) report.checks.push(await settle(report));
98
103
  return outcome(report, planFingerprint, started);
@@ -157,6 +162,9 @@ function deployWithMonitoring(
157
162
  ui: {
158
163
  progress: SyncProgress;
159
164
  interactiveAuthorization: boolean;
165
+ /** The manager's checkout, and the roster an identity is recorded in. */
166
+ workspace: string;
167
+ roster: string;
160
168
  }
161
169
  ): DeployNode {
162
170
  return async (node, revision) => {
@@ -185,7 +193,9 @@ function deployWithMonitoring(
185
193
  if (ui.interactiveAuthorization) ui.progress.stop();
186
194
  const before = await authorizeVault(node, vault, run);
187
195
  ui.progress.step(node.id, 'Converging (remote lock, install, verify)');
188
- 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
+ });
189
199
  // A fresh node gets vault from the bootstrap; log it in now. Its provider
190
200
  // checks in this run predate the login, so the next sync verifies them.
191
201
  result.checks.push(
@@ -194,16 +204,15 @@ function deployWithMonitoring(
194
204
  // Workers are Linux by definition.
195
205
  ui.progress.step(node.id, 'Verifying SSH, OS and T3');
196
206
  result.checks.push(...(await inspectBootstrap(node, run, 'linux')));
197
- result.checks.push(...(await verifyT3Endpoint(node, run)));
198
- // A worker T3 restarted by this sync (idle policy heal) needs a minute or
199
- // two before the manager's link reconnects.
200
- const restarted = result.checks.some(
201
- (check) => check.summary === T3_RESTARTED_SUMMARY
202
- );
203
207
  result.checks.push(
204
- await verifyT3Linked(node, run, {
205
- reconnectMs: restarted ? 180_000 : 0,
206
- })
208
+ ...(await workerChecks(enabled(idesOf(canonical), IDES), node, {
209
+ run,
210
+ workspace: ui.workspace,
211
+ earlier: result.checks,
212
+ remember: async (id, identity) => {
213
+ await rememberEnvironment(ui.roster, id, identity);
214
+ },
215
+ }))
207
216
  );
208
217
  if (
209
218
  result.checks.some(
@@ -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: {