@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
@@ -4,7 +4,22 @@ import { join } from 'node:path';
4
4
  import { prepareSource } from './machine/source';
5
5
  import { isRevision } from './machine/state';
6
6
  import { sshArgs, shellQuote, sshFailure } from './ssh';
7
- import type { FleetNode, NodeReport, Runner, Check } from './types';
7
+ import type {
8
+ FleetNode,
9
+ NodeReport,
10
+ Runner,
11
+ Check,
12
+ CommandResult,
13
+ } from './types';
14
+ import {
15
+ followOperation,
16
+ realClock,
17
+ retrying,
18
+ RETRY_PAUSES_MS,
19
+ transient,
20
+ type Clock,
21
+ type Follow,
22
+ } from './remote-operation';
8
23
  import { record } from './config';
9
24
  import { installedDefinition, type FleetDefinition } from './definition';
10
25
  import { cleanCheck, validCheck } from './protocol';
@@ -94,9 +109,11 @@ async function bundleRevision(
94
109
  export async function bootstrapRemote(
95
110
  node: FleetNode,
96
111
  payload: BootstrapPayload,
97
- run: Runner
112
+ run: Runner,
113
+ follow: Follow = {}
98
114
  ): Promise<NodeReport> {
99
- const cached = await cachedRevision(node, payload.revision, run);
115
+ const clock = follow.clock ?? realClock;
116
+ const cached = await cachedRevision(node, payload.revision, run, clock);
100
117
  if (typeof cached !== 'boolean') return { node: node.id, checks: [cached] };
101
118
  const transfer = cached
102
119
  ? { mode: 'cached', bundle: '' }
@@ -113,27 +130,68 @@ export async function bootstrapRemote(
113
130
  ]
114
131
  .map(shellQuote)
115
132
  .join(' ');
116
- const result = await run([...sshArgs(node), command], {
117
- input: bootstrapInput(payload.config, transfer.bundle),
118
- timeoutMs: 5_400_000,
119
- });
120
- const evidence = parseEvidence(result);
121
- if (evidence) return { node: node.id, checks: evidence };
122
- if (!result.ok) return { node: node.id, checks: [sshFailure(result)] };
123
- return {
124
- node: node.id,
125
- checks: [
126
- {
127
- id: 'bootstrap:protocol',
128
- status: 'fail',
129
- summary: 'REMOTE_BOOTSTRAP_EVIDENCE_INVALID',
130
- remedy: {
131
- action:
132
- 'Inspect the source-owned remote operation and lock. Do not assume a manager disconnect stopped installation.',
133
- },
133
+ const checks = await bootstrapSessions(
134
+ node,
135
+ payload,
136
+ { command, input: bootstrapInput(payload.config, transfer.bundle) },
137
+ { run, follow: { ...follow, clock } }
138
+ );
139
+ return { node: node.id, checks };
140
+ }
141
+
142
+ /** Opens the bootstrap session, and again after a lost one unless the
143
+ * worker's own operation can be followed to its end instead.
144
+ */
145
+ async function bootstrapSessions(
146
+ node: FleetNode,
147
+ payload: BootstrapPayload,
148
+ session: { command: string; input: string },
149
+ context: { run: Runner; follow: Follow & { clock: Clock } }
150
+ ): Promise<Check[]> {
151
+ const { run, follow } = context;
152
+ let result: CommandResult | undefined;
153
+ for (const pause of [0, ...RETRY_PAUSES_MS]) {
154
+ if (result) {
155
+ // The session was lost, not the operation: the worker keeps running
156
+ // it, so the manager follows it rather than starting another.
157
+ await follow.clock.pause(pause);
158
+ const followed = await followOperation(
159
+ node,
160
+ payload.revision,
161
+ run,
162
+ follow
163
+ );
164
+ if (followed) return followed;
165
+ }
166
+ result = await run([...sshArgs(node), session.command], {
167
+ input: session.input,
168
+ timeoutMs: 5_400_000,
169
+ });
170
+ const evidence = parseEvidence(result);
171
+ if (evidence && locked(evidence))
172
+ return (
173
+ (await followOperation(node, payload.revision, run, follow)) ?? evidence
174
+ );
175
+ if (evidence) return evidence;
176
+ if (result.reason !== 'exit' || !transient(result)) break;
177
+ }
178
+ if (!result!.ok) return [sshFailure(result!)];
179
+ return [
180
+ {
181
+ id: 'bootstrap:protocol',
182
+ status: 'fail',
183
+ summary: 'REMOTE_BOOTSTRAP_EVIDENCE_INVALID',
184
+ remedy: {
185
+ action:
186
+ 'Inspect the source-owned remote operation and lock. Do not assume a manager disconnect stopped installation.',
134
187
  },
135
- ],
136
- };
188
+ },
189
+ ];
190
+ }
191
+
192
+ /** Another operation holds the worker: its lock was the first thing tried. */
193
+ function locked(checks: Check[]): boolean {
194
+ return checks.some((check) => check.summary === 'FLEET_REMOTE_LOCKED');
137
195
  }
138
196
 
139
197
  /** What the bootstrap reads on stdin: one line with the fleet configuration
@@ -151,10 +209,14 @@ export function bootstrapInput(
151
209
  async function cachedRevision(
152
210
  node: FleetNode,
153
211
  revision: string,
154
- run: Runner
212
+ run: Runner,
213
+ clock: Clock
155
214
  ): Promise<boolean | Check> {
156
215
  const command = `d="$HOME/.local/share/personal/fleet/revisions/${revision}"; test ! -L "$d" && test -d "$d/.git" && test "$(git -C "$d" rev-parse HEAD 2>/dev/null)" = '${revision}' && test "$(git -C "$d" rev-parse --show-toplevel 2>/dev/null)" = "$d" && clean=$(git -C "$d" status --porcelain 2>/dev/null) && test -z "$clean" && printf ready`;
157
- const result = await run([...sshArgs(node), command], { timeoutMs: 20_000 });
216
+ const result = await retrying(
217
+ () => run([...sshArgs(node), command], { timeoutMs: 20_000 }),
218
+ clock
219
+ );
158
220
  if (!result.ok) {
159
221
  const failure = sshFailure(result);
160
222
  if (result.reason !== 'exit' || failure.id !== 'ssh:probe') return failure;
@@ -6,6 +6,8 @@ import { syncWorkspace } from './sync';
6
6
  import { repairWorkspace, validRepairs } from './repair-runner';
7
7
  import { validSnapshot } from './protocol';
8
8
  import type { Fingerprint } from './types';
9
+ import { machineProviders } from './t3/providers';
10
+ import { accountContext, nodeAccountChecks } from './accounts/context';
9
11
 
10
12
  async function main(): Promise<void> {
11
13
  const raw: unknown = JSON.parse(
@@ -36,6 +38,16 @@ async function main(): Promise<void> {
36
38
  raw['expected'] as Fingerprint
37
39
  );
38
40
  process.stdout.write(JSON.stringify({ version: 1, checks }));
41
+ } else if (raw['action'] === 'accounts') {
42
+ // The fast account path: only the account-derived T3 instances. T3
43
+ // watches its settings, so no restart and no running turn ends.
44
+ if (raw['apply'] !== true)
45
+ throw new Error('Account propagation requires explicit authorization.');
46
+ const checks = [
47
+ await machineProviders(node.id, node.workspace, true),
48
+ ...(await nodeAccountChecks(await accountContext())),
49
+ ];
50
+ process.stdout.write(JSON.stringify({ version: 1, checks }));
39
51
  } else if (raw['action'] === 'sync') {
40
52
  if (raw['apply'] !== true)
41
53
  throw new Error('Sync requires explicit authorization.');
@@ -84,6 +96,8 @@ async function configureRequest(
84
96
  function validRequest(raw: unknown): raw is Record<string, unknown> {
85
97
  return (
86
98
  record(raw) &&
87
- ['probe', 'sync', 'repair', 'configure'].includes(String(raw['action']))
99
+ ['probe', 'sync', 'repair', 'configure', 'accounts'].includes(
100
+ String(raw['action'])
101
+ )
88
102
  );
89
103
  }
@@ -0,0 +1,213 @@
1
+ import { record } from './config';
2
+ import { cleanCheck, validCheck } from './protocol';
3
+ import { sshArgs, sshFailure } from './ssh';
4
+ import type { Check, CommandResult, FleetNode, Runner } from './types';
5
+
6
+ /** How the manager waits: a pause, and the time now. Tests answer both. */
7
+ export interface Clock {
8
+ pause(ms: number): Promise<void>;
9
+ now(): number;
10
+ }
11
+
12
+ export const realClock: Clock = {
13
+ pause: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
14
+ now: () => Date.now(),
15
+ };
16
+
17
+ /** A session that could not be opened, or was lost, is tried again after
18
+ * these pauses: three attempts over thirty seconds.
19
+ */
20
+ export const RETRY_PAUSES_MS = [10_000, 20_000];
21
+ /** How often an operation the manager follows is looked at again. */
22
+ export const FOLLOW_EVERY_MS = 15_000;
23
+ /** As long as a bootstrap may run. */
24
+ export const FOLLOW_AT_MOST_MS = 5_400_000;
25
+
26
+ /** Failures of the connection, not of the worker: worth another attempt. A
27
+ * changed host key, a refused login or an unknown name never are.
28
+ */
29
+ export function transient(result: CommandResult): boolean {
30
+ if (result.ok) return false;
31
+ const { id } = sshFailure(result);
32
+ return ['ssh:disconnected', 'ssh:network', 'ssh:timeout'].includes(id);
33
+ }
34
+
35
+ /** Runs a short SSH session, again after a pause while its connection fails. */
36
+ export async function retrying(
37
+ attempt: () => Promise<CommandResult>,
38
+ clock: Clock
39
+ ): Promise<CommandResult> {
40
+ let result = await attempt();
41
+ for (const pause of RETRY_PAUSES_MS) {
42
+ if (!transient(result)) return result;
43
+ await clock.pause(pause);
44
+ result = await attempt();
45
+ }
46
+ return result;
47
+ }
48
+
49
+ /** What a worker says about the fleet operation on it. */
50
+ export interface Operation {
51
+ /** The operation lock is held by a running process. */
52
+ held: boolean;
53
+ /** The process that recorded the operation is still running. */
54
+ alive: boolean;
55
+ revision: string | null;
56
+ phase: string | null;
57
+ /** Seconds since the epoch, on the worker's clock. */
58
+ started: number | null;
59
+ finished: boolean;
60
+ /** What node convergence reported, once the operation finished. */
61
+ result: Check[] | null;
62
+ }
63
+
64
+ /** Read-only. Prints whether the lock is held, whether the recorded process
65
+ * runs, the operation record and its result, one per line. Holding the lock
66
+ * for a moment to test it is not possible while another holds it, and takes
67
+ * nothing from anyone when nobody does.
68
+ */
69
+ export const OPERATION_PROBE = [
70
+ 'r="$HOME/.local/share/personal/fleet"',
71
+ 'l="$r/remote-operation.lock"',
72
+ 'held=no',
73
+ 'if [ -f "$l" ] && [ ! -L "$l" ]; then if command -v flock >/dev/null 2>&1; then flock -n "$l" true || held=yes; else python3 -c \'import fcntl, sys; fcntl.flock(open(sys.argv[1], "a"), fcntl.LOCK_EX | fcntl.LOCK_NB)\' "$l" 2>/dev/null || held=yes; fi; fi',
74
+ 'op=$(head -c 4096 "$r/remote-operation.json" 2>/dev/null | head -n 1)',
75
+ 'pid=$(printf %s "$op" | sed -n \'s/.*"pid":\\([0-9][0-9]*\\).*/\\1/p\')',
76
+ 'alive=no',
77
+ 'if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then alive=yes; fi',
78
+ 'printf \'held=%s\\nalive=%s\\noperation=%s\\nresult=\' "$held" "$alive" "$op"',
79
+ 'head -c 1048576 "$r/remote-operation-result.json" 2>/dev/null | head -n 1',
80
+ "printf '\\n'",
81
+ ].join('\n');
82
+
83
+ function field(stdout: string, name: string): string | null {
84
+ const line = stdout.split('\n').find((entry) => entry.startsWith(`${name}=`));
85
+ return line === undefined ? null : line.slice(name.length + 1);
86
+ }
87
+
88
+ function json(text: string | null): unknown {
89
+ try {
90
+ return text ? JSON.parse(text) : null;
91
+ } catch {
92
+ return null;
93
+ }
94
+ }
95
+
96
+ function evidence(value: unknown): Check[] | null {
97
+ return record(value) &&
98
+ value['version'] === 1 &&
99
+ Array.isArray(value['checks']) &&
100
+ value['checks'].length > 0 &&
101
+ value['checks'].length <= 200 &&
102
+ value['checks'].every(validCheck)
103
+ ? value['checks'].map(cleanCheck)
104
+ : null;
105
+ }
106
+
107
+ export function parseOperation(stdout: string): Operation | null {
108
+ const held = field(stdout, 'held');
109
+ const alive = field(stdout, 'alive');
110
+ if (held === null || alive === null) return null;
111
+ const operation = json(field(stdout, 'operation'));
112
+ const op = record(operation) ? operation : {};
113
+ const text = (value: unknown, pattern: RegExp): string | null =>
114
+ typeof value === 'string' && pattern.test(value) ? value : null;
115
+ return {
116
+ held: held === 'yes',
117
+ alive: alive === 'yes',
118
+ revision: text(op['revision'], /^[a-f0-9]{40}$/),
119
+ phase: text(op['phase'], /^[a-z0-9-]{1,40}$/),
120
+ started: typeof op['started'] === 'number' ? op['started'] : null,
121
+ finished: typeof op['finished'] === 'number',
122
+ result: evidence(json(field(stdout, 'result'))),
123
+ };
124
+ }
125
+
126
+ export async function inspectOperation(
127
+ node: FleetNode,
128
+ run: Runner,
129
+ clock: Clock
130
+ ): Promise<Operation | null> {
131
+ const result = await retrying(
132
+ () => run([...sshArgs(node), OPERATION_PROBE], { timeoutMs: 20_000 }),
133
+ clock
134
+ );
135
+ return result.ok ? parseOperation(result.stdout) : null;
136
+ }
137
+
138
+ /** An operation of this revision whose process still runs: this is the run
139
+ * the manager lost, or one another manager run started; either way it is
140
+ * the run to follow, not one to start beside it.
141
+ */
142
+ function followable(operation: Operation, revision: string): boolean {
143
+ return (
144
+ operation.held &&
145
+ operation.alive &&
146
+ !operation.finished &&
147
+ operation.revision === revision
148
+ );
149
+ }
150
+
151
+ function finishedWith(operation: Operation, revision: string): Check[] | null {
152
+ return !operation.held &&
153
+ operation.finished &&
154
+ operation.revision === revision
155
+ ? operation.result
156
+ : null;
157
+ }
158
+
159
+ export interface Follow {
160
+ clock?: Clock;
161
+ /** Told the phase and how long the operation has run, at every look. */
162
+ progress?: (phase: string) => void;
163
+ }
164
+
165
+ /** After a lost session or a held lock: the result of the operation of this
166
+ * revision on the worker, followed to its end when it still runs. Null when
167
+ * there is none to follow: another revision, another holder, or nothing.
168
+ */
169
+ export async function followOperation(
170
+ node: FleetNode,
171
+ revision: string,
172
+ run: Runner,
173
+ options: Follow = {}
174
+ ): Promise<Check[] | null> {
175
+ const clock = options.clock ?? realClock;
176
+ const deadline = clock.now() + FOLLOW_AT_MOST_MS;
177
+ let operation = await inspectOperation(node, run, clock);
178
+ if (!operation) return null;
179
+ const done = finishedWith(operation, revision);
180
+ if (done) return [attached(operation, 'finished'), ...done];
181
+ if (!followable(operation, revision)) return null;
182
+ let last: Operation = operation;
183
+ while (clock.now() < deadline) {
184
+ options.progress?.(describe(last, clock));
185
+ await clock.pause(FOLLOW_EVERY_MS);
186
+ operation = await inspectOperation(node, run, clock);
187
+ if (!operation) continue;
188
+ last = operation;
189
+ const result = finishedWith(operation, revision);
190
+ if (result) return [attached(operation, 'followed'), ...result];
191
+ if (!followable(operation, revision)) return null;
192
+ }
193
+ return null;
194
+ }
195
+
196
+ function describe(operation: Operation, clock: Clock): string {
197
+ const elapsed =
198
+ operation.started === null
199
+ ? ''
200
+ : `, ${Math.max(0, Math.round(clock.now() / 1000 - operation.started))} s`;
201
+ return `Following the running operation (${operation.phase ?? 'unknown phase'}${elapsed})`;
202
+ }
203
+
204
+ function attached(_operation: Operation, how: 'finished' | 'followed'): Check {
205
+ return {
206
+ id: 'bootstrap:attached',
207
+ status: 'pass',
208
+ summary:
209
+ how === 'followed'
210
+ ? 'The manager lost or could not take this machine’s operation of the same revision, followed it to its end and reports its result.'
211
+ : 'This machine finished the operation of the same revision the manager lost; its result is reported.',
212
+ };
213
+ }
@@ -0,0 +1,40 @@
1
+ import type { History } from './state/history';
2
+ import type { Check } from './types';
3
+
4
+ /** How often the manager's schedule runs fleet-sync. */
5
+ export const SCHEDULE_INTERVAL_SECONDS = 1800;
6
+
7
+ /** `manager:schedule`: a schedule that is on must have succeeded within two
8
+ * of its intervals. A schedule that was never turned on is no check at all.
9
+ */
10
+ export function scheduleCheck(history: History, now: Date): Check | null {
11
+ const { schedule } = history;
12
+ if (!schedule?.enabled) return null;
13
+ const window = 2 * schedule.intervalSeconds * 1000;
14
+ const lastSuccess = history.runs
15
+ .filter((run) => run.trigger === 'schedule' && run.ok)
16
+ .map((run) => Date.parse(run.finishedAt))
17
+ .reduce((latest, at) => Math.max(latest, at), Number.NEGATIVE_INFINITY);
18
+ // A schedule turned on a moment ago has had no chance to run yet.
19
+ const since = Math.max(lastSuccess, Date.parse(schedule.changedAt));
20
+ const minutes = Math.round(schedule.intervalSeconds / 60);
21
+ if (now.getTime() - since <= window)
22
+ return {
23
+ id: 'manager:schedule',
24
+ status: 'pass',
25
+ summary: Number.isFinite(lastSuccess)
26
+ ? `The schedule last converged the fleet at ${new Date(lastSuccess).toISOString()}.`
27
+ : 'The schedule was turned on and has not had to run yet.',
28
+ };
29
+ return {
30
+ id: 'manager:schedule',
31
+ status: 'fail',
32
+ summary: Number.isFinite(lastSuccess)
33
+ ? `The schedule has not converged the fleet since ${new Date(lastSuccess).toISOString()}, more than two intervals of ${minutes} minutes.`
34
+ : `The schedule has not converged the fleet once since it was turned on, more than two intervals of ${minutes} minutes ago.`,
35
+ remedy: {
36
+ action:
37
+ 'Run devfleet fleet-sync --schedule status on the manager to see the last scheduled runs, then devfleet fleet-report for the failing checks. Re-enable it with devfleet fleet-sync --schedule enable --apply --yes when the manager was replaced.',
38
+ },
39
+ };
40
+ }
package/src/secrets.ts CHANGED
@@ -12,7 +12,20 @@ export const SECRET_NAMES = [
12
12
  'monitoring-tunnel-token',
13
13
  'proxy-tunnel-token',
14
14
  ] as const;
15
- export type SecretName = (typeof SECRET_NAMES)[number];
15
+ /** Secrets only a feature the fleet declares needs: a Vault location may
16
+ * leave them out, and they are then unavailable.
17
+ */
18
+ export const OPTIONAL_SECRET_NAMES = [
19
+ // The off-machine storage of the monitoring hub's backups
20
+ // (services.monitoring.backups).
21
+ 'monitoring-backup-access-key',
22
+ 'monitoring-backup-secret-key',
23
+ // Where fleet-watch notifies (notify.targets).
24
+ 'notify-webhook-url',
25
+ 'notify-ntfy-url',
26
+ ] as const;
27
+ export type SecretName =
28
+ (typeof SECRET_NAMES)[number] | (typeof OPTIONAL_SECRET_NAMES)[number];
16
29
 
17
30
  /** A secret's location in a Vault KV v2 mount. */
18
31
  export type VaultLocation = { path: string; field: string };
@@ -24,8 +37,13 @@ export type SecretsConfig =
24
37
  provider: 'vault';
25
38
  address: string;
26
39
  mount: string;
27
- locations: Record<SecretName, VaultLocation>;
40
+ locations: VaultLocations;
28
41
  };
42
+ export type VaultLocations = Record<
43
+ (typeof SECRET_NAMES)[number],
44
+ VaultLocation
45
+ > &
46
+ Partial<Record<(typeof OPTIONAL_SECRET_NAMES)[number], VaultLocation>>;
29
47
 
30
48
  /** Runs a command and returns its stdout, or null when it fails. */
31
49
  export type SecretCommand = (
@@ -79,6 +97,7 @@ export function secretsProvider(
79
97
  );
80
98
  return async (name) => {
81
99
  const location = config.locations[name];
100
+ if (!location) return null;
82
101
  return present(
83
102
  await run([
84
103
  'vault',
@@ -142,13 +161,12 @@ const PARSERS: Record<string, (value: Fields) => SecretsConfig | null> = {
142
161
  },
143
162
  };
144
163
 
145
- function vaultLocations(
146
- value: unknown
147
- ): Record<SecretName, VaultLocation> | null {
164
+ function vaultLocations(value: unknown): VaultLocations | null {
148
165
  if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
149
166
  const fields = value as Fields;
150
- if (!only(fields, [...SECRET_NAMES])) return null;
151
- const valid = SECRET_NAMES.every((name) => {
167
+ if (!only(fields, [...SECRET_NAMES, ...OPTIONAL_SECRET_NAMES])) return null;
168
+ const optional = OPTIONAL_SECRET_NAMES.filter((name) => name in fields);
169
+ const valid = [...SECRET_NAMES, ...optional].every((name) => {
152
170
  const location = fields[name] as Fields | undefined;
153
171
  return (
154
172
  location !== null &&
@@ -158,7 +176,7 @@ function vaultLocations(
158
176
  text(location['field'])
159
177
  );
160
178
  });
161
- return valid ? (fields as Record<SecretName, VaultLocation>) : null;
179
+ return valid ? (fields as VaultLocations) : null;
162
180
  }
163
181
 
164
182
  /** The `secrets` block of a fleet configuration, or null when it is invalid. */
@@ -42,6 +42,18 @@ export async function codingAuth(
42
42
  ];
43
43
  }
44
44
 
45
+ /** CLIs with no sign-in of their own to check, and why. Each says where
46
+ * its access is proven instead; none is a silent skip.
47
+ */
48
+ export const PROXY_ROUTED: Record<string, string> = {
49
+ gemini:
50
+ 'gemini signs in through the CLI proxy and has no T3 launcher; provider:gemini:inference proves its route from this node.',
51
+ copilot:
52
+ 'copilot is not routed through the CLI proxy and dev-fleet does not manage its GitHub sign-in; no provider account is checked.',
53
+ opencode:
54
+ 'opencode signs in through the CLI proxy; agent:muse:session and the per-account agent:opencode sessions prove it from this node.',
55
+ };
56
+
45
57
  export async function serviceAuth(run: Runner): Promise<Check[]> {
46
58
  const services: Service[] = [
47
59
  {
@@ -89,14 +101,12 @@ export async function serviceAuth(run: Runner): Promise<Check[]> {
89
101
  ];
90
102
  const checks: Check[] = [];
91
103
  for (const service of services) checks.push(await serviceCheck(service, run));
92
- for (const id of ['gemini', 'copilot', 'opencode'])
104
+ for (const [id, reason] of Object.entries(PROXY_ROUTED))
93
105
  checks.push({
94
106
  id: `auth:${id}`,
95
107
  status: 'skip',
96
- summary: `${id} provider authorization has no verified noninteractive status adapter in this CLI. Installed binaries or saved keys are not proof of access.`,
97
- remedy: {
98
- action: `Verify ${id} provider access locally using its supported account diagnostics. No prompt or billable request was made.`,
99
- },
108
+ required: false,
109
+ summary: `AUTH_NOT_SUPPORTED_ON_ROUTE: ${reason}`,
100
110
  });
101
111
  return checks;
102
112
  }
package/src/ssh.ts CHANGED
@@ -20,9 +20,9 @@ export function sshArgs(node: FleetNode): string[] {
20
20
  '-o',
21
21
  'ConnectionAttempts=1',
22
22
  '-o',
23
- 'ServerAliveInterval=5',
23
+ 'ServerAliveInterval=15',
24
24
  '-o',
25
- 'ServerAliveCountMax=2',
25
+ 'ServerAliveCountMax=4',
26
26
  '-o',
27
27
  'ClearAllForwardings=yes',
28
28
  '-o',
@@ -66,6 +66,14 @@ const FAILURES = [
66
66
  action:
67
67
  'Use ssh-add -l and ssh -vvv ALIAS locally; check User, IdentityFile, IdentitiesOnly, agent/keychain access and authorized_keys. Do not share debug logs without review.',
68
68
  },
69
+ {
70
+ pattern:
71
+ /Connection (?:to \S+ )?closed|Connection reset|Broken pipe|Timeout, server \S+ not responding|client_loop: send disconnect/i,
72
+ id: 'ssh:disconnected',
73
+ summary: 'The SSH session was lost while the operation ran.',
74
+ action:
75
+ 'The worker keeps running what it started. Rerun fleet-sync: it follows the operation of the same revision to its end. Check the network between the manager and the worker if it repeats.',
76
+ },
69
77
  {
70
78
  pattern: /Connection refused|No route to host|Network is unreachable/i,
71
79
  id: 'ssh:network',