@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.
- package/README.md +50 -16
- package/ansible/github/git.yml +66 -0
- package/ansible/macos/manager.yml +3 -0
- package/ansible/macos/policy.json +11 -1
- package/ansible/macos/t3-policy.yml +49 -0
- package/ansible/monitoring/compose.yml +10 -0
- package/ansible/monitoring/container-watchdog.py +118 -0
- package/ansible/monitoring/hub-darwin.yml +146 -0
- package/ansible/net/remote-probe.sh +5 -0
- package/ansible/ubuntu/baseline.yml +2 -0
- package/ansible/ubuntu/disk-gc.py +25 -3
- package/ansible/ubuntu/disk-guard.py +24 -5
- package/ansible/ubuntu/policy.json +51 -1
- 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/browser.yml +56 -0
- package/ansible/ubuntu/tasks/docker.yml +52 -0
- package/ansible/ubuntu/tasks/recovery.yml +60 -1
- package/ansible/ubuntu/tasks/resources.yml +2 -2
- package/ansible/ubuntu/tasks/services.yml +13 -0
- package/ansible/ubuntu/tasks/t3-restart.yml +37 -0
- package/ansible/ubuntu/wip-guard.py +121 -0
- package/bootstrap/doctor.ts +32 -6
- package/bootstrap/fleet-remote-bootstrap.sh +31 -4
- package/bootstrap/native-t3.ts +22 -11
- package/bootstrap/setup.ts +23 -8
- package/bootstrap/worker-tools.ts +118 -0
- package/cliproxy/README.md +4 -4
- package/cliproxy/compose.yaml +4 -0
- package/cliproxy/scripts/accounts.py +216 -0
- package/cliproxy/scripts/checkpoint.py +30 -2
- 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/authentication.md +90 -0
- package/docs/declared.md +5 -1
- package/docs/durability.md +26 -0
- package/docs/machine-setup.md +26 -15
- package/docs/operations.md +87 -31
- package/docs/runbooks.md +380 -0
- package/docs/state.md +13 -7
- package/docs/topology.md +2 -0
- package/docs/ubuntu-sync.md +35 -28
- package/examples/fleet.json +19 -1
- package/llms.txt +3 -0
- package/mise.toml +10 -1
- package/package.json +2 -2
- package/schema/check-ids.json +522 -0
- package/schema/fleet.schema.json +244 -1
- package/schema/output/check.schema.json +23 -0
- package/schema/output/error.schema.json +14 -0
- package/schema/output/fleet-report.schema.json +79 -0
- package/schema/output/fleet-sync.schema.json +112 -0
- package/schema/output/report.schema.json +38 -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/auth-checks.ts +3 -0
- package/src/cli/agent-guide.ts +5 -3
- package/src/cli/fleet-accounts.ts +253 -0
- package/src/cli/fleet-report.ts +126 -0
- package/src/cli/fleet-sync-accounts.ts +58 -0
- package/src/cli/fleet-sync-manager.ts +31 -4
- package/src/cli/fleet-sync-output.ts +3 -2
- package/src/cli/fleet-sync-plan.ts +200 -0
- package/src/cli/fleet-sync-remote.ts +23 -14
- package/src/cli/fleet-sync-schedule.ts +142 -35
- package/src/cli/fleet-sync.ts +126 -8
- package/src/cli/fleet-watch.ts +198 -0
- package/src/cli/fleet.ts +4 -0
- package/src/cli/register-fleet.ts +86 -0
- package/src/cli/registry.ts +26 -0
- package/src/cli/schedule-logs.ts +36 -0
- package/src/clock.ts +63 -0
- package/src/definition.ts +226 -7
- package/src/git-identity.ts +160 -0
- 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/machine-health.ts +51 -4
- package/src/manager-health.ts +205 -0
- package/src/manager-sync.ts +13 -17
- package/src/monitoring/hub-backup.ts +63 -17
- package/src/monitoring/manager-hub.ts +44 -2
- package/src/node-converge-linux.ts +44 -12
- package/src/notify.ts +239 -0
- 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-bootstrap.ts +87 -25
- package/src/remote-entry.ts +15 -1
- package/src/remote-operation.ts +213 -0
- package/src/schedule-check.ts +40 -0
- package/src/secrets.ts +26 -8
- package/src/service-auth.ts +15 -5
- package/src/ssh.ts +10 -2
- package/src/state/history.ts +172 -0
- package/src/state/store.ts +11 -4
- 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/tailscale.ts +51 -0
- package/src/toolchain-checks.ts +8 -3
- package/src/transport.ts +3 -2
- package/src/worker-capability-checks.ts +167 -0
- package/src/worker-capacity.ts +19 -7
- package/src/worker-host-checks.ts +68 -13
- package/src/worker-policy.ts +33 -0
- package/src/worker-readiness.ts +10 -1
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { record } from '../config';
|
|
2
|
+
import { databasePath, stateRoot } from './paths';
|
|
3
|
+
import { readDocument, writeDocument } from './store';
|
|
4
|
+
|
|
5
|
+
/** Who started a fleet-sync run: a person (or an agent) at the manager, or
|
|
6
|
+
* the manager's own schedule.
|
|
7
|
+
*/
|
|
8
|
+
export type Trigger = 'operator' | 'schedule';
|
|
9
|
+
|
|
10
|
+
/** One fleet-sync run, as the history keeps it: codes and node names only,
|
|
11
|
+
* never a remedy, a path or anything a subprocess printed.
|
|
12
|
+
*/
|
|
13
|
+
export interface SyncRun {
|
|
14
|
+
startedAt: string;
|
|
15
|
+
finishedAt: string;
|
|
16
|
+
durationMs: number;
|
|
17
|
+
trigger: Trigger;
|
|
18
|
+
revision: string | null;
|
|
19
|
+
ok: boolean;
|
|
20
|
+
exitCode: number;
|
|
21
|
+
/** The IDs of the checks that failed, once each. */
|
|
22
|
+
failing: string[];
|
|
23
|
+
/** The machines that were not ready when the run ended. */
|
|
24
|
+
notReady: string[];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** The schedule the manager was last told to keep. */
|
|
28
|
+
export interface ScheduleRecord {
|
|
29
|
+
enabled: boolean;
|
|
30
|
+
intervalSeconds: number;
|
|
31
|
+
changedAt: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface History {
|
|
35
|
+
version: 1;
|
|
36
|
+
schedule?: ScheduleRecord;
|
|
37
|
+
runs: SyncRun[];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The history is capped twice: by age and by count, so the document stays
|
|
41
|
+
* far below the fleet state's limit on one document.
|
|
42
|
+
*/
|
|
43
|
+
export const HISTORY_DAYS = 90;
|
|
44
|
+
export const HISTORY_RUNS = 500;
|
|
45
|
+
|
|
46
|
+
const DAY_MS = 86_400_000;
|
|
47
|
+
|
|
48
|
+
function strings(value: unknown): value is string[] {
|
|
49
|
+
return Array.isArray(value) && value.every((v) => typeof v === 'string');
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function syncRun(value: unknown): value is SyncRun {
|
|
53
|
+
return (
|
|
54
|
+
record(value) &&
|
|
55
|
+
typeof value['startedAt'] === 'string' &&
|
|
56
|
+
typeof value['finishedAt'] === 'string' &&
|
|
57
|
+
typeof value['durationMs'] === 'number' &&
|
|
58
|
+
(value['trigger'] === 'operator' || value['trigger'] === 'schedule') &&
|
|
59
|
+
(value['revision'] === null || typeof value['revision'] === 'string') &&
|
|
60
|
+
typeof value['ok'] === 'boolean' &&
|
|
61
|
+
typeof value['exitCode'] === 'number' &&
|
|
62
|
+
strings(value['failing']) &&
|
|
63
|
+
strings(value['notReady'])
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function scheduleRecord(value: unknown): value is ScheduleRecord {
|
|
68
|
+
return (
|
|
69
|
+
record(value) &&
|
|
70
|
+
typeof value['enabled'] === 'boolean' &&
|
|
71
|
+
typeof value['intervalSeconds'] === 'number' &&
|
|
72
|
+
typeof value['changedAt'] === 'string'
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** A document that cannot be read is an empty history: it only ever
|
|
77
|
+
* describes the past, and the next run starts it again.
|
|
78
|
+
*/
|
|
79
|
+
export function parseHistory(text: string | null): History {
|
|
80
|
+
let value: unknown = null;
|
|
81
|
+
try {
|
|
82
|
+
value = text === null ? null : JSON.parse(text);
|
|
83
|
+
} catch {
|
|
84
|
+
value = null;
|
|
85
|
+
}
|
|
86
|
+
if (!record(value) || value['version'] !== 1) return { version: 1, runs: [] };
|
|
87
|
+
return {
|
|
88
|
+
version: 1,
|
|
89
|
+
...(scheduleRecord(value['schedule'])
|
|
90
|
+
? { schedule: value['schedule'] }
|
|
91
|
+
: {}),
|
|
92
|
+
runs: Array.isArray(value['runs']) ? value['runs'].filter(syncRun) : [],
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function readHistory(env = process.env): History {
|
|
97
|
+
return parseHistory(readDocument(databasePath(stateRoot(env)), 'history'));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function writeHistory(history: History, env: NodeJS.ProcessEnv): void {
|
|
101
|
+
writeDocument(
|
|
102
|
+
databasePath(stateRoot(env)),
|
|
103
|
+
'history',
|
|
104
|
+
JSON.stringify(history) + '\n'
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Keeps the runs of the last HISTORY_DAYS days, and at most HISTORY_RUNS. */
|
|
109
|
+
export function capped(runs: SyncRun[], now: Date): SyncRun[] {
|
|
110
|
+
const oldest = now.getTime() - HISTORY_DAYS * DAY_MS;
|
|
111
|
+
return runs
|
|
112
|
+
.filter((run) => Date.parse(run.finishedAt) >= oldest)
|
|
113
|
+
.slice(-HISTORY_RUNS);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function appendRun(
|
|
117
|
+
run: SyncRun,
|
|
118
|
+
env = process.env,
|
|
119
|
+
now = new Date()
|
|
120
|
+
): History {
|
|
121
|
+
const history = readHistory(env);
|
|
122
|
+
const next = { ...history, runs: capped([...history.runs, run], now) };
|
|
123
|
+
writeHistory(next, env);
|
|
124
|
+
return next;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function recordSchedule(
|
|
128
|
+
schedule: ScheduleRecord,
|
|
129
|
+
env = process.env
|
|
130
|
+
): void {
|
|
131
|
+
writeHistory({ ...readHistory(env), schedule }, env);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** What a fleet-sync report says about one run. */
|
|
135
|
+
export function summarizeRun(
|
|
136
|
+
data: unknown,
|
|
137
|
+
timing: { startedAt: Date; finishedAt: Date },
|
|
138
|
+
trigger: Trigger,
|
|
139
|
+
exitCode: number
|
|
140
|
+
): SyncRun {
|
|
141
|
+
const report = record(data) ? data : {};
|
|
142
|
+
const checks = (value: unknown): Record<string, unknown>[] =>
|
|
143
|
+
Array.isArray(value) ? value.filter(record) : [];
|
|
144
|
+
const nodes = checks(report['nodes']);
|
|
145
|
+
const failing = new Set<string>();
|
|
146
|
+
for (const check of [
|
|
147
|
+
...checks(report['checks']),
|
|
148
|
+
...nodes.flatMap((node) => checks(node['checks'])),
|
|
149
|
+
])
|
|
150
|
+
if (check['status'] === 'fail' && typeof check['id'] === 'string')
|
|
151
|
+
failing.add(check['id']);
|
|
152
|
+
const notReady = nodes
|
|
153
|
+
.filter((node) =>
|
|
154
|
+
checks(node['checks']).some((check) => check['status'] === 'fail')
|
|
155
|
+
)
|
|
156
|
+
.map((node) => node['node'])
|
|
157
|
+
.filter((node): node is string => typeof node === 'string');
|
|
158
|
+
return {
|
|
159
|
+
startedAt: timing.startedAt.toISOString(),
|
|
160
|
+
finishedAt: timing.finishedAt.toISOString(),
|
|
161
|
+
durationMs: timing.finishedAt.getTime() - timing.startedAt.getTime(),
|
|
162
|
+
trigger,
|
|
163
|
+
revision:
|
|
164
|
+
typeof report['desiredRevision'] === 'string'
|
|
165
|
+
? report['desiredRevision']
|
|
166
|
+
: null,
|
|
167
|
+
ok: exitCode === 0 && report['ok'] !== false,
|
|
168
|
+
exitCode,
|
|
169
|
+
failing: [...failing].sort(),
|
|
170
|
+
notReady: [...new Set(notReady)].sort(),
|
|
171
|
+
};
|
|
172
|
+
}
|
package/src/state/store.ts
CHANGED
|
@@ -9,7 +9,14 @@ import { FleetUsageError } from '../errors';
|
|
|
9
9
|
* connection to each worker, `enrollment` the approved and applied revision.
|
|
10
10
|
*/
|
|
11
11
|
export const DOCUMENTS = ['definition', 'roster', 'enrollment'] as const;
|
|
12
|
+
/** Kept when there is something to keep; a fleet without them is complete:
|
|
13
|
+
* `history` is the record of recent fleet-sync runs and of the schedule.
|
|
14
|
+
*/
|
|
15
|
+
export const OPTIONAL_DOCUMENTS = ['history'] as const;
|
|
12
16
|
export type DocumentName = (typeof DOCUMENTS)[number];
|
|
17
|
+
/** Every document the fleet state may hold. */
|
|
18
|
+
export type StoredName = DocumentName | (typeof OPTIONAL_DOCUMENTS)[number];
|
|
19
|
+
const KNOWN: readonly string[] = [...DOCUMENTS, ...OPTIONAL_DOCUMENTS];
|
|
13
20
|
|
|
14
21
|
/** One version of the whole database. Every change replaces it, so two copies
|
|
15
22
|
* hold the same state exactly when their heads are equal.
|
|
@@ -21,7 +28,7 @@ export interface Head {
|
|
|
21
28
|
}
|
|
22
29
|
|
|
23
30
|
export interface DocumentInfo {
|
|
24
|
-
name:
|
|
31
|
+
name: StoredName;
|
|
25
32
|
bytes: number;
|
|
26
33
|
updatedAt: string;
|
|
27
34
|
}
|
|
@@ -100,7 +107,7 @@ function tables(db: Database): boolean {
|
|
|
100
107
|
);
|
|
101
108
|
}
|
|
102
109
|
|
|
103
|
-
export function readDocument(file: string, name:
|
|
110
|
+
export function readDocument(file: string, name: StoredName): string | null {
|
|
104
111
|
return reading(file, (db) => {
|
|
105
112
|
if (!tables(db)) return null;
|
|
106
113
|
const row = db
|
|
@@ -119,7 +126,7 @@ export function listDocuments(file: string): DocumentInfo[] {
|
|
|
119
126
|
'SELECT name, length(CAST(body AS BLOB)) AS bytes, updated_at AS updatedAt FROM documents ORDER BY name'
|
|
120
127
|
)
|
|
121
128
|
.all() as DocumentInfo[];
|
|
122
|
-
return rows.filter((row) =>
|
|
129
|
+
return rows.filter((row) => KNOWN.includes(row.name));
|
|
123
130
|
}) ?? []
|
|
124
131
|
);
|
|
125
132
|
}
|
|
@@ -138,7 +145,7 @@ export function readHead(file: string): Head | null {
|
|
|
138
145
|
*/
|
|
139
146
|
export function writeDocument(
|
|
140
147
|
file: string,
|
|
141
|
-
name:
|
|
148
|
+
name: StoredName,
|
|
142
149
|
body: string
|
|
143
150
|
): Head {
|
|
144
151
|
if (Buffer.byteLength(body) > MAX_DOCUMENT_BYTES)
|
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
|
+
}
|