@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.
- package/README.md +28 -0
- package/ansible/macos/manager.yml +3 -0
- package/ansible/macos/t3-policy.yml +49 -0
- package/ansible/ubuntu/policy.json +1 -0
- package/ansible/ubuntu/t3-desktop.yml +19 -0
- package/ansible/ubuntu/t3-health.sh +93 -22
- package/ansible/ubuntu/t3-idle.py +42 -0
- package/ansible/ubuntu/t3-policy.yml +4 -11
- package/ansible/ubuntu/tasks/recovery.yml +60 -1
- package/ansible/ubuntu/tasks/t3-restart.yml +37 -0
- package/ansible/ubuntu/wip-guard.py +121 -0
- package/bootstrap/native-t3.ts +22 -11
- package/cliproxy/README.md +3 -3
- package/cliproxy/scripts/accounts.py +216 -0
- package/cliproxy/scripts/checkpoint.py +29 -1
- package/cliproxy/scripts/cli.py +1 -1
- package/cliproxy/scripts/clientkeys.py +99 -0
- package/cliproxy/scripts/lease.py +80 -0
- package/cliproxy/scripts/ops +137 -25
- package/cliproxy/scripts/restore-verify +15 -0
- package/docs/accounts.md +154 -0
- package/docs/declared.md +3 -1
- package/docs/durability.md +26 -0
- package/docs/machine-setup.md +4 -3
- package/docs/topology.md +2 -0
- package/docs/ubuntu-sync.md +28 -22
- package/llms.txt +1 -0
- package/mise.toml +3 -1
- package/package.json +2 -2
- package/schema/fleet.schema.json +66 -0
- package/src/accounts/checks.ts +220 -0
- package/src/accounts/context.ts +76 -0
- package/src/accounts/propagate.ts +62 -0
- package/src/accounts/roster.ts +85 -0
- package/src/accounts/selection.ts +67 -0
- package/src/accounts/sessions.ts +120 -0
- package/src/agents/account-relay.ts +176 -26
- package/src/agents/launch.ts +84 -5
- package/src/agents/native-verify.ts +128 -2
- package/src/cli/fleet-accounts.ts +253 -0
- package/src/cli/fleet-sync-accounts.ts +58 -0
- package/src/cli/fleet-sync-manager.ts +20 -4
- package/src/cli/fleet-sync-output.ts +3 -2
- package/src/cli/fleet-sync-remote.ts +20 -13
- package/src/cli/fleet-sync.ts +36 -0
- package/src/cli/register-fleet.ts +35 -0
- package/src/cli/registry.ts +13 -0
- package/src/definition.ts +85 -3
- package/src/ide/definition.ts +86 -0
- package/src/ide/ide.ts +43 -0
- package/src/ide/index.ts +5 -0
- package/src/ide/orchestrate.ts +50 -0
- package/src/ide/t3.ts +111 -0
- package/src/manager-sync.ts +1 -15
- package/src/node-converge-linux.ts +28 -7
- package/src/platform/darwin.ts +0 -13
- package/src/platform/fallback.ts +0 -8
- package/src/platform/linux.ts +0 -10
- package/src/platform/t3-policy.ts +33 -6
- package/src/platform/t3-service.ts +104 -0
- package/src/platform/types.ts +0 -4
- package/src/platform/windows.ts +0 -14
- package/src/remote-entry.ts +15 -1
- package/src/service-auth.ts +15 -5
- package/src/t3/accounts.ts +7 -1
- package/src/t3/models.ts +133 -0
- package/src/t3/pairing.ts +31 -0
- package/src/t3/profile.ts +203 -0
- package/src/t3/version.ts +119 -0
- package/src/t3-linked-check.ts +1 -1
- package/src/transport.ts +3 -2
- 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([
|
package/src/platform/types.ts
CHANGED
|
@@ -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;
|
package/src/platform/windows.ts
CHANGED
|
@@ -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,
|
package/src/remote-entry.ts
CHANGED
|
@@ -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(
|
|
99
|
+
['probe', 'sync', 'repair', 'configure', 'accounts'].includes(
|
|
100
|
+
String(raw['action'])
|
|
101
|
+
)
|
|
88
102
|
);
|
|
89
103
|
}
|
package/src/service-auth.ts
CHANGED
|
@@ -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
|
|
104
|
+
for (const [id, reason] of Object.entries(PROXY_ROUTED))
|
|
93
105
|
checks.push({
|
|
94
106
|
id: `auth:${id}`,
|
|
95
107
|
status: 'skip',
|
|
96
|
-
|
|
97
|
-
|
|
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/t3/accounts.ts
CHANGED
|
@@ -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[
|
|
70
|
+
instances[accountInstanceId(account)] = {
|
|
65
71
|
driver: tool.driver,
|
|
66
72
|
displayName: `${tool.label} ${account.email}`,
|
|
67
73
|
enabled: true,
|
package/src/t3/models.ts
ADDED
|
@@ -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
|
+
}
|