@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
@@ -6,15 +6,24 @@ import { verifyUbuntuBoot } from './platform/ubuntu-verify';
6
6
  import { verifyResourceSafety } from './platform/resource-verify';
7
7
  import { convergeT3Route, T3_ROUTE } from './platform/t3-route';
8
8
  import { convergeProxy } from './proxy-converge';
9
- import { providerNames } from './t3/names-check';
10
- import { machineProviders } from './t3/providers';
9
+ import { IDES } from './ide';
10
+ import { enabled, settingsChecks } from './ide/orchestrate';
11
+ import { idesOf } from './ide/definition';
12
+ import { fleet } from './fleet-config';
11
13
  import { quotaLimited, verifyFleetProviders } from './agents/fleet-verify';
12
14
  import { verifyNativeAgents } from './agents/native-verify';
15
+ import { accountContext, nodeAccountChecks } from './accounts/context';
13
16
  import { managedT3, managedT3Desktop } from '../bootstrap/native-t3';
14
- import { convergeT3Service, t3StartedAt } from './platform/t3-service';
17
+ import {
18
+ convergeT3Service,
19
+ runningVersionCheck,
20
+ t3StartedAt,
21
+ } from './platform/t3-service';
15
22
  import { userInfo } from 'node:os';
16
23
  import { resolve } from 'node:path';
17
24
  import { auditUbuntu } from './platform/ubuntu-audit';
25
+ import { convergeGitIdentity } from './git-identity';
26
+ import { workerSelection } from '../bootstrap/worker-tools';
18
27
  import {
19
28
  applyAgentProfiles,
20
29
  attempt,
@@ -52,7 +61,7 @@ export async function convergeLinux(
52
61
  await applyTailscale(root, run),
53
62
  ]))
54
63
  );
55
- const { baseline, verified } = await baselineStep(root, run);
64
+ const { baseline, verified } = await baselineStep(root, run, node);
56
65
  checks.push(...baseline, ...verified);
57
66
  checks.push(...(await t3AfterBaseline(root, { node, baseline, run })));
58
67
  checks.push(
@@ -60,6 +69,11 @@ export async function convergeLinux(
60
69
  configureWorkstation(root, run)
61
70
  ))
62
71
  );
72
+ checks.push(
73
+ ...(await attempt('git:configuration', () =>
74
+ convergeGitIdentity(root, fleet.git, run)
75
+ ))
76
+ );
63
77
  checks.push(
64
78
  ...(await attempt('configuration:apply', () =>
65
79
  applyAgentProfiles(root, node, revision, run)
@@ -74,12 +88,19 @@ export async function convergeLinux(
74
88
  verifyFleetProviders(node)
75
89
  );
76
90
  checks.push(...providers);
91
+ const accounts = await accountContext();
92
+ checks.push(
93
+ ...(await attempt('accounts:verification', () =>
94
+ nodeAccountChecks(accounts)
95
+ ))
96
+ );
77
97
  checks.push(
78
98
  ...(await attempt('agent:verification', () =>
79
99
  verifyNativeAgents(node, root, run, {
80
100
  outOfQuota: quotaLimited(providers),
81
101
  // Where T3 starts them: the environment of its user service.
82
102
  t3Service: true,
103
+ ...(accounts ? { accounts } : {}),
83
104
  })
84
105
  ))
85
106
  );
@@ -96,7 +117,8 @@ export async function convergeLinux(
96
117
  */
97
118
  async function baselineStep(
98
119
  root: string,
99
- run: Runner
120
+ run: Runner,
121
+ node: string
100
122
  ): Promise<{ baseline: Check[]; verified: Check[] }> {
101
123
  const measure = async () => [
102
124
  ...(await attempt('ubuntu:boot-policy', () =>
@@ -108,14 +130,14 @@ async function baselineStep(
108
130
  ];
109
131
  let cached = false;
110
132
  let baseline = await attempt('ubuntu:baseline', async () => {
111
- const applied = await applyBaseline(root, run, false);
133
+ const applied = await applyBaseline(root, run, false, node);
112
134
  cached = applied.cached;
113
135
  return applied.checks;
114
136
  });
115
137
  let verified = await measure();
116
138
  if (cached && verified.some((check) => check.status === 'fail')) {
117
139
  baseline = await attempt('ubuntu:baseline', async () => {
118
- return (await applyBaseline(root, run, true)).checks;
140
+ return (await applyBaseline(root, run, true, node)).checks;
119
141
  });
120
142
  verified = await measure();
121
143
  }
@@ -125,7 +147,8 @@ async function baselineStep(
125
147
  async function applyBaseline(
126
148
  root: string,
127
149
  run: Runner,
128
- force: boolean
150
+ force: boolean,
151
+ node: string
129
152
  ): Promise<{ cached: boolean; checks: Check[] }> {
130
153
  const user = userInfo();
131
154
  const ubuntu = resolve(root, 'ansible/ubuntu');
@@ -140,6 +163,8 @@ async function applyBaseline(
140
163
  development_user: user.username,
141
164
  development_uid: user.uid,
142
165
  development_home: user.homedir,
166
+ // The web profile brings headless Chromium (tasks/browser.yml).
167
+ worker_tool_profiles: workerSelection(fleet, node).profiles,
143
168
  }),
144
169
  },
145
170
  {
@@ -236,13 +261,20 @@ async function convergeT3(
236
261
  restarted: startedAt !== (await t3StartedAt(run)),
237
262
  })
238
263
  );
239
- // Settings are watched by T3: providers arrive and stale ones leave at once.
240
- checks.push(await machineProviders(node, root, true));
241
- // Names live in the same settings, so they conform without a restart.
242
- checks.push(await providerNames(node, root, true));
264
+ // Settings are watched by T3: providers, names and the profile apply
265
+ // without a restart.
266
+ checks.push(
267
+ ...(await settingsChecks(enabled(idesOf(fleet), IDES), {
268
+ id: node,
269
+ workspace: root,
270
+ apply: true,
271
+ run,
272
+ }))
273
+ );
243
274
  const serviceReady = checks.some(
244
275
  (check) => check.id === 't3:service' && check.status === 'pass'
245
276
  );
277
+ if (serviceReady) checks.push(await runningVersionCheck(run, userInfo().uid));
246
278
  if (serviceReady)
247
279
  checks.push(
248
280
  ...(await convergeT3Route(run, () => publishT3Route(root, run)))
package/src/notify.ts ADDED
@@ -0,0 +1,239 @@
1
+ import { record } from './config';
2
+ import type { SecretName, SecretsProvider } from './secrets';
3
+ import type { Check, NodeReport } from './types';
4
+
5
+ /** Where fleet-watch sends a notification. The address of each target is a
6
+ * secret, read by purpose: a webhook or ntfy URL often carries its own token.
7
+ */
8
+ export const NOTIFY_TARGETS = {
9
+ webhook: 'notify-webhook-url',
10
+ ntfy: 'notify-ntfy-url',
11
+ } as const satisfies Record<string, SecretName>;
12
+ export type NotifyTarget = keyof typeof NOTIFY_TARGETS;
13
+
14
+ export interface NotifyConfig {
15
+ targets: NotifyTarget[];
16
+ /** Notify again about a machine that stays unhealthy this often; 0 never. */
17
+ repeatHours: number;
18
+ }
19
+
20
+ const DEFAULT_REPEAT_HOURS = 24;
21
+
22
+ /** The `notify` block of a fleet configuration, or null when it is invalid. */
23
+ export function parseNotify(value: unknown): NotifyConfig | null {
24
+ if (!record(value)) return null;
25
+ if (
26
+ Object.keys(value).some((key) => !['targets', 'repeatHours'].includes(key))
27
+ )
28
+ return null;
29
+ const { targets, repeatHours = DEFAULT_REPEAT_HOURS } = value;
30
+ if (
31
+ !Array.isArray(targets) ||
32
+ targets.length === 0 ||
33
+ new Set(targets).size !== targets.length ||
34
+ !targets.every(
35
+ (target) =>
36
+ typeof target === 'string' && Object.hasOwn(NOTIFY_TARGETS, target)
37
+ ) ||
38
+ typeof repeatHours !== 'number' ||
39
+ !Number.isInteger(repeatHours) ||
40
+ repeatHours < 0 ||
41
+ repeatHours > 720
42
+ )
43
+ return null;
44
+ return { targets: targets as NotifyTarget[], repeatHours };
45
+ }
46
+
47
+ /** What the watcher remembers of each machine between two runs. */
48
+ export interface WatchMemory {
49
+ version: 1;
50
+ /** When the watcher last ran: the heartbeat an off-machine check reads. */
51
+ heartbeatAt: string;
52
+ nodes: Record<
53
+ string,
54
+ { state: 'ready' | 'degraded'; since: string; notifiedAt: string | null }
55
+ >;
56
+ }
57
+
58
+ /** One machine as fleet-status saw it: the IDs of its failing checks. */
59
+ export interface Observation {
60
+ node: string;
61
+ failing: string[];
62
+ }
63
+
64
+ export interface Notification {
65
+ event: 'degraded' | 'recovered' | 'still-degraded' | 'test';
66
+ node: string;
67
+ checks: string[];
68
+ }
69
+
70
+ /** Only what a report concluded: the required checks that failed. */
71
+ export function observe(nodes: NodeReport[]): Observation[] {
72
+ return nodes.map((node) => ({
73
+ node: node.node,
74
+ failing: node.checks
75
+ .filter((check) => check.status === 'fail' && check.required !== false)
76
+ .map((check) => check.id),
77
+ }));
78
+ }
79
+
80
+ /** Notifications on transitions only (ready to degraded and back), and a
81
+ * reminder for a machine that stays degraded longer than the repeat interval.
82
+ * A machine never seen before counts as ready.
83
+ */
84
+ export function transitions(
85
+ previous: WatchMemory | null,
86
+ observed: Observation[],
87
+ now: Date,
88
+ repeatHours: number
89
+ ): { notifications: Notification[]; memory: WatchMemory } {
90
+ const at = now.toISOString();
91
+ const notifications: Notification[] = [];
92
+ const nodes: WatchMemory['nodes'] = {};
93
+ for (const { node, failing } of observed) {
94
+ const before = previous?.nodes[node];
95
+ const state = failing.length ? 'degraded' : 'ready';
96
+ const checks = failing.filter(safe);
97
+ if (state === (before?.state ?? 'ready')) {
98
+ const due = state === 'degraded' && repeatDue(before, now, repeatHours);
99
+ if (due) notifications.push({ event: 'still-degraded', node, checks });
100
+ nodes[node] = before
101
+ ? { ...before, ...(due ? { notifiedAt: at } : {}) }
102
+ : { state, since: at, notifiedAt: null };
103
+ continue;
104
+ }
105
+ notifications.push({
106
+ event: state === 'degraded' ? 'degraded' : 'recovered',
107
+ node,
108
+ checks,
109
+ });
110
+ nodes[node] = { state, since: at, notifiedAt: at };
111
+ }
112
+ return { notifications, memory: { version: 1, heartbeatAt: at, nodes } };
113
+ }
114
+
115
+ function repeatDue(
116
+ before: WatchMemory['nodes'][string] | undefined,
117
+ now: Date,
118
+ repeatHours: number
119
+ ): boolean {
120
+ if (repeatHours <= 0 || !before?.notifiedAt) return false;
121
+ return (
122
+ now.getTime() - Date.parse(before.notifiedAt) >= repeatHours * 3_600_000
123
+ );
124
+ }
125
+
126
+ /** The memory stored last time, or null when there is none to trust. */
127
+ export function parseMemory(text: string | null): WatchMemory | null {
128
+ if (!text) return null;
129
+ try {
130
+ const value: unknown = JSON.parse(text);
131
+ if (
132
+ !record(value) ||
133
+ value['version'] !== 1 ||
134
+ typeof value['heartbeatAt'] !== 'string' ||
135
+ !record(value['nodes'])
136
+ )
137
+ return null;
138
+ return value as unknown as WatchMemory;
139
+ } catch {
140
+ return null;
141
+ }
142
+ }
143
+
144
+ /** Node and check IDs are the only words a notification carries. */
145
+ const safe = (id: string): boolean => /^[A-Za-z0-9:._@-]{1,128}$/.test(id);
146
+
147
+ const WORDS: Record<Notification['event'], string> = {
148
+ degraded: 'is degraded',
149
+ recovered: 'is ready again',
150
+ 'still-degraded': 'is still degraded',
151
+ test: 'sent a test notification',
152
+ };
153
+
154
+ export function notificationText(notification: Notification): string {
155
+ const node = safe(notification.node) ? notification.node : 'a machine';
156
+ const checks = notification.checks.filter(safe);
157
+ return `dev-fleet: ${node} ${WORDS[notification.event]}${
158
+ checks.length ? `. Failing checks: ${checks.join(', ')}` : ''
159
+ }`;
160
+ }
161
+
162
+ /** Sends one notification to every target. The result names the target and
163
+ * whether it took the notification: never its address or its answer.
164
+ */
165
+ export async function deliver(
166
+ config: NotifyConfig,
167
+ notification: Notification,
168
+ secrets: SecretsProvider,
169
+ send: typeof fetch = fetch
170
+ ): Promise<Check[]> {
171
+ const text = notificationText(notification);
172
+ const checks: Check[] = [];
173
+ for (const target of config.targets) {
174
+ const id = `notify:${target}`;
175
+ const url = await secrets(NOTIFY_TARGETS[target]);
176
+ if (!url || !/^https?:\/\//.test(url)) {
177
+ checks.push({
178
+ id,
179
+ status: 'fail',
180
+ summary: 'NOTIFY_TARGET_UNAVAILABLE',
181
+ remedy: {
182
+ action: `Store the address of the ${target} target as the secret "${NOTIFY_TARGETS[target]}" with the secret provider fleet.json names.`,
183
+ },
184
+ });
185
+ continue;
186
+ }
187
+ const ok = await send(url, request(target, notification, text))
188
+ .then((response) => response.ok)
189
+ .catch(() => false);
190
+ checks.push(
191
+ ok
192
+ ? {
193
+ id,
194
+ status: 'pass',
195
+ summary: `The ${target} target took the notification.`,
196
+ }
197
+ : {
198
+ id,
199
+ status: 'fail',
200
+ summary: 'NOTIFY_DELIVERY_FAILED',
201
+ remedy: {
202
+ action: `Check that the ${target} target answers, then run devfleet fleet-watch --test.`,
203
+ },
204
+ }
205
+ );
206
+ }
207
+ return checks;
208
+ }
209
+
210
+ function request(
211
+ target: NotifyTarget,
212
+ notification: Notification,
213
+ text: string
214
+ ): RequestInit {
215
+ const signal = AbortSignal.timeout(15_000);
216
+ if (target === 'ntfy')
217
+ return {
218
+ method: 'POST',
219
+ body: text,
220
+ headers: {
221
+ Title: `dev-fleet: ${notification.event}`,
222
+ Tags:
223
+ notification.event === 'recovered' ? 'white_check_mark' : 'warning',
224
+ },
225
+ signal,
226
+ };
227
+ return {
228
+ method: 'POST',
229
+ headers: { 'Content-Type': 'application/json' },
230
+ body: JSON.stringify({
231
+ source: 'dev-fleet',
232
+ event: notification.event,
233
+ node: safe(notification.node) ? notification.node : null,
234
+ checks: notification.checks.filter(safe),
235
+ text,
236
+ }),
237
+ signal,
238
+ };
239
+ }
@@ -176,19 +176,6 @@ export class DarwinPlatform implements Platform {
176
176
  );
177
177
  }
178
178
 
179
- vscodeSettingsPath(): string | null {
180
- return join(
181
- homedir(),
182
- 'Library/Application Support/Code/User/settings.json'
183
- );
184
- }
185
-
186
- vscodeCliBinaries(): readonly string[] {
187
- return [
188
- '/Applications/Visual Studio Code.app/Contents/Resources/app/bin/code',
189
- ].filter((p) => existsSync(p));
190
- }
191
-
192
179
  installScheduledJob(job: ScheduledJob): NotifierTestResult {
193
180
  return installLaunchdJob(job);
194
181
  }
@@ -78,14 +78,6 @@ export class FallbackPlatform implements Platform {
78
78
  return [];
79
79
  }
80
80
 
81
- vscodeSettingsPath(): string | null {
82
- return null;
83
- }
84
-
85
- vscodeCliBinaries(): readonly string[] {
86
- return [];
87
- }
88
-
89
81
  installScheduledJob(job: ScheduledJob): NotifierTestResult {
90
82
  return {
91
83
  ok: false,
@@ -115,16 +115,6 @@ export class LinuxPlatform implements Platform {
115
115
  );
116
116
  }
117
117
 
118
- vscodeSettingsPath(): string | null {
119
- const base =
120
- process.env['XDG_CONFIG_HOME']?.trim() || join(homedir(), '.config');
121
- return join(base, 'Code/User/settings.json');
122
- }
123
-
124
- vscodeCliBinaries(): readonly string[] {
125
- return ['/usr/share/code/bin/code'].filter((p) => existsSync(p));
126
- }
127
-
128
118
  installScheduledJob(job: ScheduledJob): NotifierTestResult {
129
119
  return installSystemdJob(job);
130
120
  }
@@ -1,6 +1,8 @@
1
1
  import { runAnsible } from './ansible';
2
2
  import { desired } from '../t3/desired';
3
3
  import policy from '../../ansible/ubuntu/t3/policy.json';
4
+ import ubuntu from '../../ansible/ubuntu/policy.json';
5
+ import { restartDeferral } from './t3-service';
4
6
  import { basename, dirname, join, resolve } from 'node:path';
5
7
  import { rename, rm, symlink } from 'node:fs/promises';
6
8
  import { userInfo } from 'node:os';
@@ -34,6 +36,7 @@ export async function stageT3Policy(
34
36
  t3_policy_document: document,
35
37
  development_user: user.username,
36
38
  development_uid: user.uid,
39
+ development_home: user.homedir,
37
40
  }),
38
41
  timeoutMs: 120_000,
39
42
  });
@@ -84,14 +87,21 @@ export const T3_RESTARTED_SUMMARY =
84
87
  'T3 was restarted to load the new managed policy; the running server confirms it.';
85
88
 
86
89
  /** `t3-policy.yml` restarts a running T3 that has not loaded the staged policy
87
- * (ending its running turns), so every node offers the same providers after
88
- * every sync. This measures the outcome; `restarted` says whether the app
89
- * restarted during this sync and so needs time to reconnect.
90
+ * so every node offers the same providers, but only while it runs no agent
91
+ * turn: a busy T3 keeps its turns and the play records the restart as
92
+ * pending, for personal-worker-recovery to apply at the first idle check.
93
+ * This measures the outcome; `restarted` says whether the app restarted
94
+ * during this sync and so needs time to reconnect.
90
95
  */
91
96
  export async function loadedT3Policy(
92
97
  executable: string,
93
98
  run: Runner,
94
- options: { restarted: boolean; pause?: () => Promise<void> }
99
+ options: {
100
+ restarted: boolean;
101
+ pause?: () => Promise<void>;
102
+ uid?: number;
103
+ now?: number;
104
+ }
95
105
  ): Promise<Check> {
96
106
  // A restarted desktop app takes a few seconds to come back with its server.
97
107
  const check = await verifyT3Policy(
@@ -103,8 +113,25 @@ export async function loadedT3Policy(
103
113
  setTimeout(done, options.restarted ? 5000 : 1000)
104
114
  ))
105
115
  );
106
- if (check.status !== 'pass')
107
- return { ...check, summary: 'T3_POLICY_RESTART_FAILED' };
116
+ if (check.status !== 'pass') {
117
+ const deferral = await restartDeferral(
118
+ run,
119
+ options.uid ?? userInfo().uid,
120
+ options.now
121
+ );
122
+ if (!deferral.pending)
123
+ return { ...check, summary: 'T3_POLICY_RESTART_FAILED' };
124
+ return {
125
+ ...check,
126
+ status: deferral.overdue ? 'fail' : 'warn',
127
+ summary: 'T3_POLICY_RESTART_PENDING',
128
+ remedy: {
129
+ action: deferral.overdue
130
+ ? `Agent turns kept T3 from loading the staged policy for ${ubuntu.t3RestartMaxDeferralHours} hours. Finish or stop them, or restart t3code-desktop.service on the worker (this ends its running turns).`
131
+ : 'Agent turns are running, so T3 keeps them and loads the staged policy at the first check of personal-worker-recovery with no turn running.',
132
+ },
133
+ };
134
+ }
108
135
  return options.restarted
109
136
  ? { ...check, summary: T3_RESTARTED_SUMMARY }
110
137
  : check;
@@ -1,3 +1,4 @@
1
+ import policy from '../../ansible/ubuntu/policy.json';
1
2
  import type { Runner, Check } from '../types';
2
3
 
3
4
  /** The user unit that runs the pinned desktop app (UI and embedded server). */
@@ -44,6 +45,109 @@ export async function convergeT3Service(
44
45
  ];
45
46
  }
46
47
 
48
+ /** The recovery script, installed outside every revision (`tasks/recovery.yml`). */
49
+ export const T3_HEALTH_SCRIPT = '/usr/local/lib/personal-fleet/t3-health.sh';
50
+
51
+ /** The record of the package the desktop app was unpacked from
52
+ * (`t3-desktop.yml`): `T3-Code-<version>-<arch>.deb`. */
53
+ export const T3_DESKTOP_RELEASE =
54
+ '/opt/T3 Code (Nightly)/.personal-fleet-version';
55
+
56
+ /** Where a play records a T3 restart it deferred because agent turns were
57
+ * running (`tasks/t3-restart.yml`): the second it was first deferred.
58
+ * personal-worker-recovery clears it when it restarts the idle app. */
59
+ export function t3RestartMarker(uid: number): string {
60
+ return `/run/user/${uid}/personal-t3-restart-pending`;
61
+ }
62
+
63
+ /** A restart deferred while agent turns run, and whether it has waited longer
64
+ * than the policy allows. */
65
+ export async function restartDeferral(
66
+ run: Runner,
67
+ uid: number,
68
+ now: number = Date.now()
69
+ ): Promise<{ pending: boolean; overdue: boolean }> {
70
+ const marker = await run(['cat', t3RestartMarker(uid)]);
71
+ if (!marker.ok) return { pending: false, overdue: false };
72
+ const since = Number(marker.stdout.trim());
73
+ const limit = policy.t3RestartMaxDeferralHours * 3600;
74
+ return {
75
+ pending: true,
76
+ overdue: Number.isFinite(since) && now / 1000 - since > limit,
77
+ };
78
+ }
79
+
80
+ /** The release the running server reports, compared with the one installed.
81
+ * Asks the server itself, so it needs no `t3` on the search path. A new
82
+ * release is installed beside a running app, which restarts into it only
83
+ * while it runs no agent turn: until then this is T3_RESTART_PENDING, and a
84
+ * failure once the restart has waited longer than the policy allows. */
85
+ export async function runningVersionCheck(
86
+ run: Runner,
87
+ uid: number,
88
+ now: number = Date.now()
89
+ ): Promise<Check> {
90
+ const server = await run(
91
+ [
92
+ 'curl',
93
+ '--fail',
94
+ '--silent',
95
+ '--max-time',
96
+ '10',
97
+ 'http://127.0.0.1:3773/.well-known/t3/environment',
98
+ ],
99
+ { timeoutMs: 15_000 }
100
+ );
101
+ const installed = await run(['cat', T3_DESKTOP_RELEASE]);
102
+ const running = serverVersion(server.ok ? server.stdout : '');
103
+ const release = /^T3-Code-(.+)-[a-z0-9]+\.deb$/.exec(
104
+ installed.ok ? installed.stdout.trim() : ''
105
+ )?.[1];
106
+ const id = 't3:running-version';
107
+ if (!running || !release)
108
+ return {
109
+ id,
110
+ status: 'fail',
111
+ summary: 'T3_RUNNING_VERSION_UNKNOWN',
112
+ remedy: {
113
+ action:
114
+ 'The running server or the installed release could not be read. Check worker:t3-health, then rerun fleet-sync to reinstall the pinned desktop app.',
115
+ },
116
+ };
117
+ if (running === release)
118
+ return {
119
+ id,
120
+ status: 'pass',
121
+ summary: `The running T3 server is the installed release ${release}.`,
122
+ };
123
+ const deferral = await restartDeferral(run, uid, now);
124
+ return {
125
+ id,
126
+ status: deferral.overdue ? 'fail' : 'warn',
127
+ summary: 'T3_RESTART_PENDING',
128
+ remedy: {
129
+ action: deferral.overdue
130
+ ? `T3 still runs ${running} after ${policy.t3RestartMaxDeferralHours} hours because agent turns kept running. Finish or stop them, or restart ${T3_DESKTOP_UNIT} on the worker (this ends its running turns).`
131
+ : `T3 runs ${running}; ${release} is installed. personal-worker-recovery restarts it at the first check with no agent turn running.`,
132
+ },
133
+ };
134
+ }
135
+
136
+ function serverVersion(body: string): string | undefined {
137
+ try {
138
+ const value: unknown = JSON.parse(body);
139
+ const version =
140
+ typeof value === 'object' && value !== null
141
+ ? (value as Record<string, unknown>)['serverVersion']
142
+ : undefined;
143
+ return typeof version === 'string' && /^[\w.+-]+$/.test(version)
144
+ ? version
145
+ : undefined;
146
+ } catch {
147
+ return undefined;
148
+ }
149
+ }
150
+
47
151
  /** When the desktop app last started; changes exactly when it restarts. */
48
152
  export async function t3StartedAt(run: Runner): Promise<string> {
49
153
  const shown = await run([
@@ -110,10 +110,6 @@ export type Platform = {
110
110
 
111
111
  /** App-bundled Tailscale CLI binaries that exist (used when `tailscale` is not on PATH). */
112
112
  tailscaleAppBinaries(): readonly string[];
113
- /** VS Code user `settings.json`, or `null` when unknown on this OS. */
114
- vscodeSettingsPath(): string | null;
115
- /** VS Code `code` CLI binaries shipped inside the installed app. */
116
- vscodeCliBinaries(): readonly string[];
117
113
 
118
114
  /** Install (or replace) a periodic job. Never throws — returns a result. */
119
115
  installScheduledJob(job: ScheduledJob): NotifierTestResult;
@@ -110,20 +110,6 @@ export class WindowsPlatform implements Platform {
110
110
  );
111
111
  }
112
112
 
113
- vscodeSettingsPath(): string | null {
114
- const base =
115
- process.env['APPDATA']?.trim() || join(homedir(), 'AppData/Roaming');
116
- return join(base, 'Code/User/settings.json');
117
- }
118
-
119
- vscodeCliBinaries(): readonly string[] {
120
- const base =
121
- process.env['LOCALAPPDATA']?.trim() || join(homedir(), 'AppData/Local');
122
- return [join(base, 'Programs/Microsoft VS Code/bin/code.cmd')].filter((p) =>
123
- existsSync(p)
124
- );
125
- }
126
-
127
113
  installScheduledJob(job: ScheduledJob): NotifierTestResult {
128
114
  return {
129
115
  ok: false,