@crvouga/dev-fleet 0.2.0 → 0.3.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 (72) hide show
  1. package/README.md +28 -0
  2. package/ansible/macos/manager.yml +3 -0
  3. package/ansible/macos/t3-policy.yml +49 -0
  4. package/ansible/ubuntu/policy.json +1 -0
  5. package/ansible/ubuntu/t3-desktop.yml +19 -0
  6. package/ansible/ubuntu/t3-health.sh +93 -22
  7. package/ansible/ubuntu/t3-idle.py +42 -0
  8. package/ansible/ubuntu/t3-policy.yml +4 -11
  9. package/ansible/ubuntu/tasks/recovery.yml +60 -1
  10. package/ansible/ubuntu/tasks/t3-restart.yml +37 -0
  11. package/ansible/ubuntu/wip-guard.py +121 -0
  12. package/bootstrap/native-t3.ts +22 -11
  13. package/cliproxy/README.md +3 -3
  14. package/cliproxy/scripts/accounts.py +216 -0
  15. package/cliproxy/scripts/checkpoint.py +29 -1
  16. package/cliproxy/scripts/cli.py +1 -1
  17. package/cliproxy/scripts/clientkeys.py +99 -0
  18. package/cliproxy/scripts/lease.py +80 -0
  19. package/cliproxy/scripts/ops +137 -25
  20. package/cliproxy/scripts/restore-verify +15 -0
  21. package/docs/accounts.md +154 -0
  22. package/docs/declared.md +3 -1
  23. package/docs/durability.md +26 -0
  24. package/docs/machine-setup.md +4 -3
  25. package/docs/topology.md +2 -0
  26. package/docs/ubuntu-sync.md +28 -22
  27. package/llms.txt +1 -0
  28. package/mise.toml +3 -1
  29. package/package.json +2 -2
  30. package/schema/fleet.schema.json +66 -0
  31. package/src/accounts/checks.ts +220 -0
  32. package/src/accounts/context.ts +76 -0
  33. package/src/accounts/propagate.ts +62 -0
  34. package/src/accounts/roster.ts +85 -0
  35. package/src/accounts/selection.ts +67 -0
  36. package/src/accounts/sessions.ts +120 -0
  37. package/src/agents/account-relay.ts +176 -26
  38. package/src/agents/launch.ts +84 -5
  39. package/src/agents/native-verify.ts +128 -2
  40. package/src/cli/fleet-accounts.ts +253 -0
  41. package/src/cli/fleet-sync-accounts.ts +58 -0
  42. package/src/cli/fleet-sync-manager.ts +20 -4
  43. package/src/cli/fleet-sync-output.ts +3 -2
  44. package/src/cli/fleet-sync-remote.ts +20 -13
  45. package/src/cli/fleet-sync.ts +36 -0
  46. package/src/cli/register-fleet.ts +35 -0
  47. package/src/cli/registry.ts +13 -0
  48. package/src/definition.ts +85 -3
  49. package/src/ide/definition.ts +86 -0
  50. package/src/ide/ide.ts +43 -0
  51. package/src/ide/index.ts +5 -0
  52. package/src/ide/orchestrate.ts +50 -0
  53. package/src/ide/t3.ts +111 -0
  54. package/src/manager-sync.ts +1 -15
  55. package/src/node-converge-linux.ts +28 -7
  56. package/src/platform/darwin.ts +0 -13
  57. package/src/platform/fallback.ts +0 -8
  58. package/src/platform/linux.ts +0 -10
  59. package/src/platform/t3-policy.ts +33 -6
  60. package/src/platform/t3-service.ts +104 -0
  61. package/src/platform/types.ts +0 -4
  62. package/src/platform/windows.ts +0 -14
  63. package/src/remote-entry.ts +15 -1
  64. package/src/service-auth.ts +15 -5
  65. package/src/t3/accounts.ts +7 -1
  66. package/src/t3/models.ts +133 -0
  67. package/src/t3/pairing.ts +31 -0
  68. package/src/t3/profile.ts +203 -0
  69. package/src/t3/version.ts +119 -0
  70. package/src/t3-linked-check.ts +1 -1
  71. package/src/transport.ts +3 -2
  72. package/src/worker-host-checks.ts +36 -13
@@ -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,
@@ -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
  }
@@ -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
  }
@@ -46,6 +46,12 @@ const TOOLS: Record<
46
46
  },
47
47
  };
48
48
 
49
+ /** The T3 instance of one account, and the T3 driver that runs its tool. */
50
+ export const accountInstanceId = (account: Account): string =>
51
+ `personal_${slug(account.prefix)}`.slice(0, 64);
52
+ export const accountDriver = (tool: string): string | undefined =>
53
+ TOOLS[tool]?.driver;
54
+
49
55
  /** One `<Provider> <account email>` instance per signed-in account, identical
50
56
  * on every machine because every machine reads the same proxy.
51
57
  */
@@ -61,7 +67,7 @@ export function accountInstances(
61
67
  ...variables,
62
68
  account: slug(account.email),
63
69
  };
64
- instances[`personal_${slug(account.prefix)}`.slice(0, 64)] = {
70
+ instances[accountInstanceId(account)] = {
65
71
  driver: tool.driver,
66
72
  displayName: `${tool.label} ${account.email}`,
67
73
  enabled: true,
@@ -0,0 +1,133 @@
1
+ import { join } from 'node:path';
2
+ import type { Check } from '../types';
3
+ import { accountDriver, accountInstanceId, type Roster } from './accounts';
4
+ import type { Account } from '../agents/accounts';
5
+ import { object, read } from './files';
6
+
7
+ /** The models T3 offers for one instance: the catalogue T3 keeps for its
8
+ * driver (userdata/model-manifest.json) and any custom models the instance
9
+ * adds. Null when T3 has no list for the driver: it asks the tool itself.
10
+ */
11
+ function offered(
12
+ manifest: Record<string, unknown>,
13
+ instance: unknown,
14
+ driver: string
15
+ ): string[] | null {
16
+ const catalogue = object(
17
+ JSON.stringify(
18
+ object(JSON.stringify(manifest['manifest'] ?? {}))['currentModels'] ?? {}
19
+ )
20
+ )[driver];
21
+ const config =
22
+ instance && typeof instance === 'object'
23
+ ? (instance as { config?: { customModels?: unknown } }).config
24
+ : undefined;
25
+ const custom = Array.isArray(config?.customModels)
26
+ ? config.customModels.flatMap((model: unknown) =>
27
+ typeof model === 'string'
28
+ ? [model]
29
+ : model &&
30
+ typeof model === 'object' &&
31
+ typeof (model as { slug?: unknown }).slug === 'string'
32
+ ? [(model as { slug: string }).slug]
33
+ : []
34
+ )
35
+ : [];
36
+ if (!Array.isArray(catalogue) && !custom.length) return null;
37
+ return [
38
+ ...new Set([
39
+ ...(Array.isArray(catalogue)
40
+ ? catalogue.filter(
41
+ (model): model is string => typeof model === 'string'
42
+ )
43
+ : []),
44
+ ...custom,
45
+ ]),
46
+ ].sort();
47
+ }
48
+
49
+ /** `t3:models`: for every account instance the fleet manages, T3 offers only
50
+ * models the proxy serves for that account, and every required model is both
51
+ * served and offered. A report names tools and models, never an account.
52
+ */
53
+ export async function modelsCheck(options: {
54
+ t3Home: string;
55
+ roster: Roster;
56
+ required?: Record<string, string[]>;
57
+ }): Promise<Check> {
58
+ const remedy = (action: string) => ({ remedy: { action } });
59
+ let accounts;
60
+ let settings;
61
+ let manifest;
62
+ try {
63
+ accounts = await options.roster();
64
+ settings = object(
65
+ await read(join(options.t3Home, 'userdata/settings.json'))
66
+ );
67
+ manifest = object(
68
+ await read(join(options.t3Home, 'userdata/model-manifest.json'))
69
+ );
70
+ } catch {
71
+ return {
72
+ id: 't3:models',
73
+ status: 'warn',
74
+ summary: 'T3_MODELS_UNVERIFIED',
75
+ ...remedy(
76
+ 'The proxy’s model list or T3’s settings could not be read on this machine. Check that the CLI proxy answers and T3 has started once, then run fleet-sync.'
77
+ ),
78
+ };
79
+ }
80
+ const problems = mismatches(
81
+ accounts,
82
+ object(JSON.stringify(settings['providerInstances'] ?? {})),
83
+ manifest,
84
+ options.required ?? {}
85
+ );
86
+ if (problems.size)
87
+ return {
88
+ id: 't3:models',
89
+ status: 'warn',
90
+ summary: 'T3_MODELS_MISMATCH',
91
+ ...remedy(
92
+ `${[...problems].sort().join('; ')}. Serve the model on the CLI proxy, or remove it from t3.requiredModels in fleet.json; a model the proxy no longer serves fails when it is chosen in T3.`
93
+ ),
94
+ };
95
+ return {
96
+ id: 't3:models',
97
+ status: 'pass',
98
+ summary:
99
+ 'Every model T3 offers for a managed account is one the proxy serves for it, and every required model is offered.',
100
+ };
101
+ }
102
+
103
+ /** What is wrong with the models of each managed account instance. */
104
+ function mismatches(
105
+ accounts: Account[],
106
+ instances: Record<string, unknown>,
107
+ manifest: Record<string, unknown>,
108
+ required: Record<string, string[]>
109
+ ): Set<string> {
110
+ const problems = new Set<string>();
111
+ for (const account of accounts) {
112
+ const driver = accountDriver(account.tool);
113
+ const id = accountInstanceId(account);
114
+ if (!driver || !Object.hasOwn(instances, id)) continue;
115
+ const offers = offered(manifest, instances[id], driver);
116
+ for (const model of offers ?? [])
117
+ if (!account.models.includes(model))
118
+ problems.add(
119
+ `${account.tool}: T3 offers ${model}, which the proxy does not serve`
120
+ );
121
+ for (const model of required[account.tool] ?? []) {
122
+ if (!account.models.includes(model))
123
+ problems.add(
124
+ `${account.tool}: the proxy does not serve the required ${model}`
125
+ );
126
+ else if (offers && !offers.includes(model))
127
+ problems.add(
128
+ `${account.tool}: T3 does not offer the required ${model}`
129
+ );
130
+ }
131
+ }
132
+ return problems;
133
+ }
@@ -0,0 +1,31 @@
1
+ import { parseConfig } from '../config';
2
+ import { STATE_ROSTER, readRosterText, writeRosterText } from '../state/roster';
3
+ import { mutateState, stateContext, type StateContext } from '../state/sync';
4
+
5
+ /** Records the T3 environment a worker runs in the roster, the first time a
6
+ * verified link from the manager reaches it. From then on the link check
7
+ * compares against this identity, so a worker whose T3 data was replaced is
8
+ * told apart from one that is only offline. An identity already recorded is
9
+ * never rewritten.
10
+ */
11
+ export async function rememberEnvironment(
12
+ path: string,
13
+ node: string,
14
+ environmentId: string,
15
+ context: () => StateContext = stateContext
16
+ ): Promise<boolean> {
17
+ const save = async (): Promise<boolean> => {
18
+ const text = await readRosterText(path);
19
+ if (text === null) return false;
20
+ const roster = parseConfig(JSON.parse(text) as unknown);
21
+ const entry = roster.nodes.find((candidate) => candidate.id === node);
22
+ if (!entry || entry.t3EnvironmentId) return false;
23
+ entry.t3EnvironmentId = environmentId;
24
+ // Parsing again refuses an identity that is not a T3 environment ID.
25
+ const next = parseConfig(roster);
26
+ await writeRosterText(path, `${JSON.stringify(next, null, 2)}\n`);
27
+ return true;
28
+ };
29
+ if (path !== STATE_ROSTER) return save();
30
+ return (await mutateState(context(), save)).value;
31
+ }
@@ -0,0 +1,203 @@
1
+ import { dirname, join } from 'node:path';
2
+ import type { Check } from '../types';
3
+ import { canonical, object, read, write } from './files';
4
+
5
+ /** What happens to one declared setting. `release` stops managing a key the
6
+ * fleet no longer declares and leaves its value to the machine.
7
+ */
8
+ export type ProfileAction =
9
+ 'create' | 'update' | 'delete' | 'release' | 'unchanged' | 'conflict';
10
+ export type ProfileChange = { key: string; action: ProfileAction };
11
+
12
+ /** The values the fleet last wrote, kept beside T3's settings: the base of
13
+ * the three-way merge between what is declared, what T3 holds and what was
14
+ * applied before.
15
+ */
16
+ export const profileRecord = (settings: string): string =>
17
+ join(dirname(settings), 'personal-profile.json');
18
+
19
+ const same = (a: unknown, b: unknown): boolean => canonical(a) === canonical(b);
20
+
21
+ /** Managed keys converge, keys nobody declared are left alone, and a key
22
+ * changed on the machine since the fleet last wrote it (or that existed
23
+ * before the fleet declared it) is a conflict, never overwritten.
24
+ */
25
+ export function planProfile(
26
+ settings: Record<string, unknown>,
27
+ applied: Record<string, unknown>,
28
+ declared: Record<string, unknown>
29
+ ) {
30
+ const next = { ...settings };
31
+ const record = { ...applied };
32
+ const changes: ProfileChange[] = [];
33
+ const keys = [
34
+ ...new Set([...Object.keys(declared), ...Object.keys(applied)]),
35
+ ].sort();
36
+ for (const key of keys) {
37
+ const present = Object.hasOwn(settings, key);
38
+ const managed = Object.hasOwn(applied, key);
39
+ const action = actionFor(key, declared, {
40
+ present,
41
+ managed,
42
+ current: settings[key],
43
+ base: applied[key],
44
+ });
45
+ changes.push({ key, action });
46
+ if (action === 'create' || action === 'update') {
47
+ next[key] = declared[key];
48
+ record[key] = declared[key];
49
+ } else if (action === 'unchanged') record[key] = declared[key];
50
+ else if (action === 'delete') {
51
+ delete next[key];
52
+ delete record[key];
53
+ } else if (action === 'release') delete record[key];
54
+ }
55
+ return {
56
+ settings: next,
57
+ record,
58
+ changes,
59
+ settingsChanged: !same(next, settings),
60
+ recordChanged: !same(record, applied),
61
+ };
62
+ }
63
+
64
+ function actionFor(
65
+ key: string,
66
+ declared: Record<string, unknown>,
67
+ {
68
+ present,
69
+ managed,
70
+ current,
71
+ base,
72
+ }: { present: boolean; managed: boolean; current: unknown; base: unknown }
73
+ ): ProfileAction {
74
+ if (!Object.hasOwn(declared, key)) {
75
+ if (present && same(current, base)) return 'delete';
76
+ return 'release';
77
+ }
78
+ const wanted = declared[key];
79
+ if (present && same(current, wanted)) return 'unchanged';
80
+ if (!present) return managed ? 'conflict' : 'create';
81
+ return managed && same(current, base) ? 'update' : 'conflict';
82
+ }
83
+
84
+ export interface ProfileOptions {
85
+ apply: boolean;
86
+ /** Tries before a settings file T3 keeps rewriting is reported busy. */
87
+ attempts?: number;
88
+ pause?: () => Promise<void>;
89
+ /** Runs between the read and the write: where T3 may write too. */
90
+ beforeWrite?: () => Promise<void>;
91
+ }
92
+
93
+ /** Plans, and with `apply` writes, the declared profile. T3 writes the same
94
+ * file and offers no compare-and-swap, so a write happens only when the file
95
+ * is still what was read; otherwise the merge is planned again, a bounded
96
+ * number of times.
97
+ */
98
+ export async function reconcileProfile(
99
+ settingsPath: string,
100
+ declared: Record<string, unknown>,
101
+ options: ProfileOptions
102
+ ) {
103
+ const recordPath = profileRecord(settingsPath);
104
+ const attempts = options.attempts ?? 3;
105
+ const pause =
106
+ options.pause ?? (() => new Promise((done) => setTimeout(done, 500)));
107
+ for (let attempt = 1; ; attempt++) {
108
+ const raw = await read(settingsPath);
109
+ const recordRaw = await read(recordPath);
110
+ const plan = planProfile(
111
+ object(raw),
112
+ readRecord(recordRaw, settingsPath),
113
+ declared
114
+ );
115
+ if (!options.apply || (!plan.settingsChanged && !plan.recordChanged))
116
+ return { ...plan, applied: false };
117
+ await options.beforeWrite?.();
118
+ if (!plan.settingsChanged || (await read(settingsPath)) === raw) {
119
+ if (plan.settingsChanged)
120
+ await write(
121
+ settingsPath,
122
+ JSON.stringify(plan.settings, null, 2) + '\n'
123
+ );
124
+ await write(
125
+ recordPath,
126
+ canonical({ version: 1, target: settingsPath, settings: plan.record }) +
127
+ '\n'
128
+ );
129
+ return { ...plan, applied: true };
130
+ }
131
+ if (attempt >= attempts) throw new Error('T3_SETTINGS_BUSY');
132
+ await pause();
133
+ }
134
+ }
135
+
136
+ function readRecord(
137
+ raw: string | null,
138
+ target: string
139
+ ): Record<string, unknown> {
140
+ if (raw === null) return {};
141
+ const record = object(raw);
142
+ if (record['version'] !== 1 || record['target'] !== target)
143
+ throw new Error('T3_PROFILE_RECORD_INVALID');
144
+ return object(JSON.stringify(record['settings'] ?? {}));
145
+ }
146
+
147
+ /** `t3:profile`: the declared settings on this machine. A report names the
148
+ * keys and what happens to them, never a value: a tool server's settings may
149
+ * carry a credential.
150
+ */
151
+ export async function profileCheck(
152
+ settingsPath: string,
153
+ declared: Record<string, unknown>,
154
+ options: ProfileOptions
155
+ ): Promise<Check> {
156
+ const remedy = (action: string) => ({ remedy: { action } });
157
+ try {
158
+ const result = await reconcileProfile(settingsPath, declared, options);
159
+ const conflicts = result.changes.filter(
160
+ (change) => change.action === 'conflict'
161
+ );
162
+ if (conflicts.length)
163
+ return {
164
+ id: 't3:profile',
165
+ status: 'warn',
166
+ summary: 'T3_PROFILE_CONFLICT',
167
+ ...remedy(
168
+ `These T3 settings were changed on this machine since dev-fleet last wrote them, or existed before they were declared, and were left as they are: ${conflicts.map((change) => change.key).join(', ')}. Put the value every machine should hold under t3.profile in fleet.json, or remove the setting on this machine, then run fleet-sync.`
169
+ ),
170
+ };
171
+ const pending = result.changes.filter(
172
+ (change) => !['unchanged', 'conflict'].includes(change.action)
173
+ );
174
+ if (!options.apply && pending.length)
175
+ return {
176
+ id: 't3:profile',
177
+ status: 'warn',
178
+ summary: 'T3_PROFILE_PENDING',
179
+ ...remedy(
180
+ `fleet-sync would change these T3 settings: ${pending.map((change) => `${change.key} (${change.action})`).join(', ')}.`
181
+ ),
182
+ };
183
+ return {
184
+ id: 't3:profile',
185
+ status: 'pass',
186
+ summary: Object.keys(declared).length
187
+ ? 'T3 on this machine holds every setting of the declared profile; settings nobody declared are its own.'
188
+ : 'No T3 profile is declared; every setting stays local.',
189
+ };
190
+ } catch (error) {
191
+ const busy = error instanceof Error && error.message === 'T3_SETTINGS_BUSY';
192
+ return {
193
+ id: 't3:profile',
194
+ status: 'fail',
195
+ summary: busy ? 'T3_SETTINGS_BUSY' : 'T3_PROFILE_UNVERIFIED',
196
+ ...remedy(
197
+ busy
198
+ ? 'T3 kept rewriting its settings while dev-fleet merged the profile. Run fleet-sync again; nothing was overwritten.'
199
+ : 'Repair T3’s settings.json (or restore personal-profile.json from a backup) on this machine, then run fleet-sync.'
200
+ ),
201
+ };
202
+ }
203
+ }