@crvouga/dev-fleet 0.1.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 +265 -37
- package/agents/setup.md +42 -12
- package/ansible/macos/manager.yml +10 -7
- package/ansible/macos/policy.json +2 -10
- package/ansible/macos/t3-policy.yml +49 -0
- package/ansible/monitoring/README.md +86 -25
- package/ansible/monitoring/agent.yml +40 -2
- package/ansible/monitoring/hub-route-darwin.yml +51 -0
- package/ansible/monitoring/lock.json +5 -0
- package/ansible/pyproject.toml +12 -0
- package/ansible/ubuntu/baseline.yml +1 -1
- package/ansible/ubuntu/disk-gc.py +336 -0
- package/ansible/ubuntu/policy.json +13 -3
- 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/tailscale.yml +8 -5
- package/ansible/ubuntu/tasks/recovery.yml +60 -1
- package/ansible/ubuntu/tasks/resources.yml +61 -1
- package/ansible/ubuntu/tasks/t3-restart.yml +37 -0
- package/ansible/ubuntu/wip-guard.py +121 -0
- package/ansible/uv.lock +190 -0
- package/bootstrap/bootstrap.sh +25 -18
- package/bootstrap/doctor.ts +46 -1
- package/bootstrap/fleet-manager-run.sh +42 -15
- package/bootstrap/fleet-remote-bootstrap.sh +85 -12
- package/bootstrap/global-config.ts +64 -6
- package/bootstrap/install-launcher.sh +3 -0
- package/bootstrap/native-t3.ts +22 -11
- package/bootstrap/npm.ts +10 -2
- package/bootstrap/release-version.ts +157 -0
- 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/consumer-setup.md +408 -0
- package/docs/declared.md +91 -0
- package/docs/durability.md +26 -0
- package/docs/machine-setup.md +400 -0
- package/docs/operations.md +276 -0
- package/docs/releasing.md +103 -0
- package/docs/state.md +254 -0
- package/docs/topology.md +69 -0
- package/docs/ubuntu-sync.md +357 -0
- package/docs/worker-contract.md +67 -0
- package/examples/fleet.json +5 -0
- package/llms.txt +36 -0
- package/mise.lock +29 -0
- package/mise.toml +15 -2
- package/package.json +11 -2
- package/schema/fleet.schema.json +137 -15
- 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/fleet-verify.ts +9 -0
- package/src/agents/launch.ts +84 -5
- package/src/agents/native-verify.ts +293 -22
- package/src/agents/provider-errors.ts +9 -2
- package/src/agents/registry.ts +7 -1
- package/src/cli/agent-guide.ts +39 -5
- package/src/cli/app.ts +11 -4
- package/src/cli/cli.ts +7 -0
- package/src/cli/command-index.ts +7 -0
- package/src/cli/fleet-accounts.ts +253 -0
- package/src/cli/fleet-state.ts +499 -0
- package/src/cli/fleet-sync-accounts.ts +58 -0
- package/src/cli/fleet-sync-checkout.ts +56 -17
- package/src/cli/fleet-sync-enrollment.ts +19 -23
- package/src/cli/fleet-sync-forward.ts +1 -1
- package/src/cli/fleet-sync-manager.ts +84 -12
- package/src/cli/fleet-sync-monitoring.ts +5 -5
- package/src/cli/fleet-sync-output.ts +7 -3
- package/src/cli/fleet-sync-remote.ts +31 -18
- package/src/cli/fleet-sync-state.ts +86 -0
- package/src/cli/fleet-sync.ts +159 -57
- package/src/cli/fleet.ts +4 -2
- package/src/cli/managed-health.ts +4 -4
- package/src/cli/node-sync.ts +7 -0
- package/src/cli/program.ts +20 -4
- package/src/cli/project.ts +51 -0
- package/src/cli/register-fleet.ts +86 -1
- package/src/cli/registry.ts +41 -1
- package/src/cli/roster.ts +46 -30
- package/src/cli/settings.ts +15 -12
- package/src/cli/source-operations.ts +27 -3
- package/src/cli/tailnet.ts +2 -2
- package/src/definition.ts +116 -4
- package/src/diagnostics.ts +1 -1
- package/src/fleet-config.ts +105 -19
- 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/source.ts +3 -3
- package/src/machine/state.ts +15 -27
- package/src/manager-sync.ts +9 -18
- package/src/monitoring/agent-evidence.ts +146 -0
- package/src/monitoring/apply.ts +15 -3
- package/src/monitoring/beszel.ts +11 -4
- package/src/monitoring/enroll.ts +2 -1
- package/src/monitoring/hub-access.ts +4 -3
- package/src/monitoring/hub-backup.ts +121 -0
- package/src/monitoring/hub-viewer.ts +8 -8
- package/src/monitoring/hub.ts +3 -3
- package/src/monitoring/manager-hub.ts +30 -14
- package/src/node-converge-linux.ts +30 -7
- package/src/package-info.ts +45 -0
- package/src/platform/ansible.ts +19 -15
- package/src/platform/darwin.ts +0 -13
- package/src/platform/fallback.ts +0 -8
- package/src/platform/linux.ts +0 -10
- package/src/platform/resource-verify.ts +8 -4
- 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/ubuntu-audit.ts +5 -2
- package/src/platform/windows.ts +0 -14
- package/src/remote-bootstrap.ts +17 -19
- package/src/remote-entry.ts +15 -1
- package/src/repair-catalog.ts +1 -1
- package/src/runner.ts +1 -1
- package/src/service-auth.ts +15 -5
- package/src/state/bucket.ts +208 -0
- package/src/state/check.ts +74 -0
- package/src/state/connection.ts +92 -0
- package/src/state/enrollment.ts +45 -0
- package/src/state/legacy.ts +89 -0
- package/src/state/litestream.ts +143 -0
- package/src/state/operator.ts +20 -0
- package/src/state/paths.ts +40 -0
- package/src/state/roster.ts +62 -0
- package/src/state/store.ts +169 -0
- package/src/state/sync.ts +352 -0
- 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-endpoint.ts +64 -8
- package/src/t3-linked-check.ts +24 -2
- package/src/toolchain-checks.ts +25 -0
- package/src/transport.ts +3 -2
- package/src/types.ts +4 -0
- package/src/worker-access-checks.ts +56 -3
- package/src/worker-host-checks.ts +41 -13
- package/src/workstation-checks.ts +1 -1
- package/src/index.ts +0 -6
- package/src/test-fixtures.ts +0 -36
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { Database } from 'bun:sqlite';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { chmodSync, existsSync, mkdirSync } from 'node:fs';
|
|
4
|
+
import { dirname } from 'node:path';
|
|
5
|
+
import { FleetUsageError } from '../errors';
|
|
6
|
+
|
|
7
|
+
/** Everything the manager must not lose, as one JSON text each:
|
|
8
|
+
* `definition` is the fleet configuration (fleet.json), `roster` the enrolled
|
|
9
|
+
* connection to each worker, `enrollment` the approved and applied revision.
|
|
10
|
+
*/
|
|
11
|
+
export const DOCUMENTS = ['definition', 'roster', 'enrollment'] as const;
|
|
12
|
+
export type DocumentName = (typeof DOCUMENTS)[number];
|
|
13
|
+
|
|
14
|
+
/** One version of the whole database. Every change replaces it, so two copies
|
|
15
|
+
* hold the same state exactly when their heads are equal.
|
|
16
|
+
*/
|
|
17
|
+
export interface Head {
|
|
18
|
+
id: string;
|
|
19
|
+
sequence: number;
|
|
20
|
+
writtenAt: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface DocumentInfo {
|
|
24
|
+
name: DocumentName;
|
|
25
|
+
bytes: number;
|
|
26
|
+
updatedAt: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const MAX_DOCUMENT_BYTES = 1024 * 1024;
|
|
30
|
+
|
|
31
|
+
const SCHEMA = `
|
|
32
|
+
CREATE TABLE IF NOT EXISTS documents (
|
|
33
|
+
name TEXT PRIMARY KEY,
|
|
34
|
+
body TEXT NOT NULL,
|
|
35
|
+
updated_at TEXT NOT NULL
|
|
36
|
+
) STRICT;
|
|
37
|
+
CREATE TABLE IF NOT EXISTS head (
|
|
38
|
+
one INTEGER PRIMARY KEY CHECK (one = 1),
|
|
39
|
+
id TEXT NOT NULL,
|
|
40
|
+
sequence INTEGER NOT NULL,
|
|
41
|
+
written_at TEXT NOT NULL
|
|
42
|
+
) STRICT;
|
|
43
|
+
`;
|
|
44
|
+
|
|
45
|
+
function unreadable(): FleetUsageError {
|
|
46
|
+
return new FleetUsageError(
|
|
47
|
+
'The fleet state database cannot be read. It is a local copy: run devfleet fleet-state --restore to replace it from the bucket.'
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Reads never create the database: a machine without one has no state.
|
|
52
|
+
* The connection is still opened for writing, because a WAL database that
|
|
53
|
+
* was just downloaded has no shared-memory file yet and a read-only
|
|
54
|
+
* connection may not create one.
|
|
55
|
+
*/
|
|
56
|
+
function reading<T>(file: string, read: (db: Database) => T): T | null {
|
|
57
|
+
if (!existsSync(file)) return null;
|
|
58
|
+
let db: Database | undefined;
|
|
59
|
+
try {
|
|
60
|
+
db = new Database(file, { readwrite: true, create: false });
|
|
61
|
+
return read(db);
|
|
62
|
+
} catch {
|
|
63
|
+
throw unreadable();
|
|
64
|
+
} finally {
|
|
65
|
+
db?.close();
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Litestream replicates the write-ahead log, so the database stays in WAL
|
|
70
|
+
* mode; a second devfleet process waits for the first instead of failing.
|
|
71
|
+
*/
|
|
72
|
+
function writing<T>(file: string, write: (db: Database) => T): T {
|
|
73
|
+
mkdirSync(dirname(file), { recursive: true, mode: 0o700 });
|
|
74
|
+
let db: Database | undefined;
|
|
75
|
+
try {
|
|
76
|
+
db = new Database(file, { create: true, readwrite: true });
|
|
77
|
+
chmodSync(file, 0o600);
|
|
78
|
+
db.exec('PRAGMA journal_mode = WAL; PRAGMA busy_timeout = 5000;');
|
|
79
|
+
db.exec(SCHEMA);
|
|
80
|
+
return db.transaction(write).immediate(db);
|
|
81
|
+
} catch (error) {
|
|
82
|
+
if (error instanceof FleetUsageError) throw error;
|
|
83
|
+
throw new FleetUsageError(
|
|
84
|
+
'The fleet state database cannot be written. Check the state directory permissions and free disk space.'
|
|
85
|
+
);
|
|
86
|
+
} finally {
|
|
87
|
+
db?.close();
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function tables(db: Database): boolean {
|
|
92
|
+
return (
|
|
93
|
+
(
|
|
94
|
+
db
|
|
95
|
+
.query(
|
|
96
|
+
"SELECT count(*) AS found FROM sqlite_master WHERE type = 'table' AND name IN ('documents', 'head')"
|
|
97
|
+
)
|
|
98
|
+
.get() as { found: number }
|
|
99
|
+
).found === 2
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function readDocument(file: string, name: DocumentName): string | null {
|
|
104
|
+
return reading(file, (db) => {
|
|
105
|
+
if (!tables(db)) return null;
|
|
106
|
+
const row = db
|
|
107
|
+
.query('SELECT body FROM documents WHERE name = ?1')
|
|
108
|
+
.get(name) as { body: string } | null;
|
|
109
|
+
return row?.body ?? null;
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function listDocuments(file: string): DocumentInfo[] {
|
|
114
|
+
return (
|
|
115
|
+
reading(file, (db) => {
|
|
116
|
+
if (!tables(db)) return [];
|
|
117
|
+
const rows = db
|
|
118
|
+
.query(
|
|
119
|
+
'SELECT name, length(CAST(body AS BLOB)) AS bytes, updated_at AS updatedAt FROM documents ORDER BY name'
|
|
120
|
+
)
|
|
121
|
+
.all() as DocumentInfo[];
|
|
122
|
+
return rows.filter((row) => DOCUMENTS.includes(row.name));
|
|
123
|
+
}) ?? []
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function readHead(file: string): Head | null {
|
|
128
|
+
return reading(file, (db) => {
|
|
129
|
+
if (!tables(db)) return null;
|
|
130
|
+
return db
|
|
131
|
+
.query('SELECT id, sequence, written_at AS writtenAt FROM head')
|
|
132
|
+
.get() as Head | null;
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Stores one document and advances the head. Writing the text already
|
|
137
|
+
* stored changes nothing, so repeating a command never looks like new state.
|
|
138
|
+
*/
|
|
139
|
+
export function writeDocument(
|
|
140
|
+
file: string,
|
|
141
|
+
name: DocumentName,
|
|
142
|
+
body: string
|
|
143
|
+
): Head {
|
|
144
|
+
if (Buffer.byteLength(body) > MAX_DOCUMENT_BYTES)
|
|
145
|
+
throw new FleetUsageError(
|
|
146
|
+
'A fleet state document must be smaller than 1 MiB.'
|
|
147
|
+
);
|
|
148
|
+
return writing(file, (db) => {
|
|
149
|
+
const current = db
|
|
150
|
+
.query('SELECT body FROM documents WHERE name = ?1')
|
|
151
|
+
.get(name) as { body: string } | null;
|
|
152
|
+
const head = db
|
|
153
|
+
.query('SELECT id, sequence, written_at AS writtenAt FROM head')
|
|
154
|
+
.get() as Head | null;
|
|
155
|
+
if (head && current?.body === body) return head;
|
|
156
|
+
const next: Head = {
|
|
157
|
+
id: randomUUID(),
|
|
158
|
+
sequence: (head?.sequence ?? 0) + 1,
|
|
159
|
+
writtenAt: new Date().toISOString(),
|
|
160
|
+
};
|
|
161
|
+
db.query(
|
|
162
|
+
'INSERT INTO documents (name, body, updated_at) VALUES (?1, ?2, ?3) ON CONFLICT (name) DO UPDATE SET body = ?2, updated_at = ?3'
|
|
163
|
+
).run(name, body, next.writtenAt);
|
|
164
|
+
db.query(
|
|
165
|
+
'INSERT INTO head (one, id, sequence, written_at) VALUES (1, ?1, ?2, ?3) ON CONFLICT (one) DO UPDATE SET id = ?1, sequence = ?2, written_at = ?3'
|
|
166
|
+
).run(next.id, next.sequence, next.writtenAt);
|
|
167
|
+
return next;
|
|
168
|
+
});
|
|
169
|
+
}
|
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import {
|
|
3
|
+
chmodSync,
|
|
4
|
+
readFileSync,
|
|
5
|
+
readdirSync,
|
|
6
|
+
renameSync,
|
|
7
|
+
rmSync,
|
|
8
|
+
writeFileSync,
|
|
9
|
+
} from 'node:fs';
|
|
10
|
+
import { basename, dirname, join } from 'node:path';
|
|
11
|
+
import { record } from '../config';
|
|
12
|
+
import { FleetUsageError } from '../errors';
|
|
13
|
+
import { FleetLockError } from '../machine/lock-error';
|
|
14
|
+
import { withMachineLock } from '../machine/state';
|
|
15
|
+
import { runCommand } from '../runner';
|
|
16
|
+
import type { Runner } from '../types';
|
|
17
|
+
import { bucketIdentity, type Bucket } from './bucket';
|
|
18
|
+
import { connection } from './connection';
|
|
19
|
+
import { replicate, restore } from './litestream';
|
|
20
|
+
import { databasePath, replicatedPath, stateRoot } from './paths';
|
|
21
|
+
import { readHead, type Head } from './store';
|
|
22
|
+
|
|
23
|
+
export interface StateContext {
|
|
24
|
+
root: string;
|
|
25
|
+
bucket: Bucket | null;
|
|
26
|
+
run: Runner;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function stateContext(
|
|
30
|
+
run: Runner = runCommand,
|
|
31
|
+
env = process.env
|
|
32
|
+
): StateContext {
|
|
33
|
+
return {
|
|
34
|
+
root: stateRoot(env),
|
|
35
|
+
bucket: connection(env).bucket,
|
|
36
|
+
run,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** How this machine's copy compares with the bucket.
|
|
41
|
+
* - `local-only`: no bucket is configured; this copy is the only one.
|
|
42
|
+
* - `unreachable`: the bucket did not answer, or did not take an upload.
|
|
43
|
+
* - `empty`: neither side holds any state.
|
|
44
|
+
* - `unpublished`: the bucket holds nothing yet; this copy can seed it.
|
|
45
|
+
* - `in-sync`: both hold the same version.
|
|
46
|
+
* - `local-ahead`: this copy has changes the bucket has not received.
|
|
47
|
+
* - `bucket-ahead`: another manager changed the bucket; this copy is stale.
|
|
48
|
+
* - `diverged`: both changed. The bucket is the source of truth.
|
|
49
|
+
*/
|
|
50
|
+
export type Standing =
|
|
51
|
+
| 'local-only'
|
|
52
|
+
| 'unreachable'
|
|
53
|
+
| 'empty'
|
|
54
|
+
| 'unpublished'
|
|
55
|
+
| 'in-sync'
|
|
56
|
+
| 'local-ahead'
|
|
57
|
+
| 'bucket-ahead'
|
|
58
|
+
| 'diverged';
|
|
59
|
+
|
|
60
|
+
interface Replicated {
|
|
61
|
+
bucket: string;
|
|
62
|
+
head: string;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The bucket's head as of the last time this copy matched it. A copy whose
|
|
66
|
+
* head still equals it has no changes of its own and may be replaced.
|
|
67
|
+
*/
|
|
68
|
+
function readReplicated(root: string, bucket: Bucket): Replicated | null {
|
|
69
|
+
try {
|
|
70
|
+
const value: unknown = JSON.parse(
|
|
71
|
+
readFileSync(replicatedPath(root), 'utf8')
|
|
72
|
+
);
|
|
73
|
+
return record(value) &&
|
|
74
|
+
value['bucket'] === bucketIdentity(bucket) &&
|
|
75
|
+
typeof value['head'] === 'string'
|
|
76
|
+
? { bucket: value['bucket'], head: value['head'] }
|
|
77
|
+
: null;
|
|
78
|
+
} catch {
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function saveReplicated(root: string, bucket: Bucket, head: Head): void {
|
|
84
|
+
const path = replicatedPath(root);
|
|
85
|
+
const temporary = `${path}.${randomUUID()}.tmp`;
|
|
86
|
+
writeFileSync(
|
|
87
|
+
temporary,
|
|
88
|
+
JSON.stringify({
|
|
89
|
+
version: 1,
|
|
90
|
+
bucket: bucketIdentity(bucket),
|
|
91
|
+
head: head.id,
|
|
92
|
+
}) + '\n',
|
|
93
|
+
{ mode: 0o600 }
|
|
94
|
+
);
|
|
95
|
+
renameSync(temporary, path);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Litestream's own record of what it has uploaded, next to the database. */
|
|
99
|
+
const bookkeeping = (database: string): string =>
|
|
100
|
+
join(dirname(database), `.${basename(database)}-litestream`);
|
|
101
|
+
|
|
102
|
+
function discard(database: string): void {
|
|
103
|
+
for (const path of [database, `${database}-wal`, `${database}-shm`])
|
|
104
|
+
rmSync(path, { force: true });
|
|
105
|
+
rmSync(bookkeeping(database), { recursive: true, force: true });
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const INCOMING = /^incoming-[0-9a-f-]{36}\.db/;
|
|
109
|
+
|
|
110
|
+
type Remote =
|
|
111
|
+
| { status: 'unreachable' }
|
|
112
|
+
| { status: 'absent' }
|
|
113
|
+
| { status: 'present'; file: string; head: Head };
|
|
114
|
+
|
|
115
|
+
/** Downloads the bucket's current version beside the local one. */
|
|
116
|
+
async function fetchRemote(
|
|
117
|
+
context: StateContext & { bucket: Bucket },
|
|
118
|
+
install = true
|
|
119
|
+
): Promise<Remote> {
|
|
120
|
+
const database = databasePath(context.root);
|
|
121
|
+
const file = join(context.root, `incoming-${randomUUID()}.db`);
|
|
122
|
+
const outcome = await restore(
|
|
123
|
+
{ database, bucket: context.bucket, run: context.run, install },
|
|
124
|
+
file
|
|
125
|
+
);
|
|
126
|
+
if (outcome === 'absent') return { status: 'absent' };
|
|
127
|
+
try {
|
|
128
|
+
const head = outcome === 'restored' ? readHead(file) : null;
|
|
129
|
+
if (head) return { status: 'present', file, head };
|
|
130
|
+
discard(file);
|
|
131
|
+
// A replica that holds no version yet is the same as no replica.
|
|
132
|
+
return { status: outcome === 'restored' ? 'absent' : 'unreachable' };
|
|
133
|
+
} catch {
|
|
134
|
+
// A download that cannot be read says nothing about the bucket.
|
|
135
|
+
discard(file);
|
|
136
|
+
return { status: 'unreachable' };
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function compare(
|
|
141
|
+
local: Head | null,
|
|
142
|
+
remote: Head,
|
|
143
|
+
replicated: Replicated | null
|
|
144
|
+
): Standing {
|
|
145
|
+
if (!local) return 'bucket-ahead';
|
|
146
|
+
if (remote.id === local.id) return 'in-sync';
|
|
147
|
+
if (replicated?.head === local.id) return 'bucket-ahead';
|
|
148
|
+
if (replicated?.head === remote.id) return 'local-ahead';
|
|
149
|
+
return 'diverged';
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
interface Observation {
|
|
153
|
+
standing: Standing;
|
|
154
|
+
local: Head | null;
|
|
155
|
+
remote: Remote | null;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async function observe(
|
|
159
|
+
context: StateContext,
|
|
160
|
+
install = true
|
|
161
|
+
): Promise<Observation> {
|
|
162
|
+
const local = readHead(databasePath(context.root));
|
|
163
|
+
const { bucket } = context;
|
|
164
|
+
if (!bucket) return { standing: 'local-only', local, remote: null };
|
|
165
|
+
const remote = await fetchRemote({ ...context, bucket }, install);
|
|
166
|
+
if (remote.status === 'unreachable')
|
|
167
|
+
return { standing: 'unreachable', local, remote };
|
|
168
|
+
if (remote.status === 'absent')
|
|
169
|
+
return { standing: local ? 'unpublished' : 'empty', local, remote };
|
|
170
|
+
const replicated = readReplicated(context.root, bucket);
|
|
171
|
+
return { standing: compare(local, remote.head, replicated), local, remote };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** One devfleet command at a time compares or changes the state. Downloads a
|
|
175
|
+
* killed command left behind are removed while nothing else can be using one.
|
|
176
|
+
*/
|
|
177
|
+
async function locked<T>(
|
|
178
|
+
root: string,
|
|
179
|
+
operation: () => Promise<T>
|
|
180
|
+
): Promise<T> {
|
|
181
|
+
try {
|
|
182
|
+
return await withMachineLock(root, async () => {
|
|
183
|
+
for (const name of readdirSync(root))
|
|
184
|
+
if (INCOMING.test(name)) rmSync(join(root, name), { force: true });
|
|
185
|
+
return operation();
|
|
186
|
+
});
|
|
187
|
+
} catch (error) {
|
|
188
|
+
if (error instanceof FleetLockError)
|
|
189
|
+
throw new FleetUsageError(
|
|
190
|
+
'Another devfleet command is using the fleet state. Wait for it to finish, then retry.'
|
|
191
|
+
);
|
|
192
|
+
throw error;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Replaces a stale copy with the bucket's version and records a copy that
|
|
197
|
+
* already matches. `force` replaces a copy that has changes of its own too.
|
|
198
|
+
*/
|
|
199
|
+
function adopt(
|
|
200
|
+
context: StateContext,
|
|
201
|
+
observation: Observation,
|
|
202
|
+
force: boolean
|
|
203
|
+
): Standing {
|
|
204
|
+
const { remote, local, standing } = observation;
|
|
205
|
+
if (!context.bucket || remote?.status !== 'present') return standing;
|
|
206
|
+
if (standing === 'in-sync' && local)
|
|
207
|
+
saveReplicated(context.root, context.bucket, local);
|
|
208
|
+
if (standing === 'in-sync' || (standing !== 'bucket-ahead' && !force))
|
|
209
|
+
return standing;
|
|
210
|
+
const database = databasePath(context.root);
|
|
211
|
+
discard(database);
|
|
212
|
+
renameSync(remote.file, database);
|
|
213
|
+
chmodSync(database, 0o600);
|
|
214
|
+
saveReplicated(context.root, context.bucket, remote.head);
|
|
215
|
+
return 'in-sync';
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
async function observed(
|
|
219
|
+
context: StateContext,
|
|
220
|
+
force: boolean
|
|
221
|
+
): Promise<Standing> {
|
|
222
|
+
const observation = await observe(context);
|
|
223
|
+
try {
|
|
224
|
+
return adopt(context, observation, force);
|
|
225
|
+
} finally {
|
|
226
|
+
if (observation.remote?.status === 'present')
|
|
227
|
+
discard(observation.remote.file);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** Uploads this copy, then downloads it again: the bucket holds this version
|
|
232
|
+
* only once a restore from it returns this version.
|
|
233
|
+
*/
|
|
234
|
+
async function upload(
|
|
235
|
+
context: StateContext & { bucket: Bucket },
|
|
236
|
+
seed: boolean
|
|
237
|
+
): Promise<Standing> {
|
|
238
|
+
const database = databasePath(context.root);
|
|
239
|
+
const head = readHead(database);
|
|
240
|
+
if (!head) return 'empty';
|
|
241
|
+
// A bucket with no history starts from a full copy, not from where the
|
|
242
|
+
// uploads to another bucket stopped.
|
|
243
|
+
if (seed) rmSync(bookkeeping(database), { recursive: true, force: true });
|
|
244
|
+
const uploaded = await replicate({
|
|
245
|
+
database,
|
|
246
|
+
bucket: context.bucket,
|
|
247
|
+
run: context.run,
|
|
248
|
+
});
|
|
249
|
+
const remote: Remote = uploaded
|
|
250
|
+
? await fetchRemote(context)
|
|
251
|
+
: { status: 'unreachable' };
|
|
252
|
+
if (remote.status === 'present') discard(remote.file);
|
|
253
|
+
if (remote.status !== 'present' || remote.head.id !== head.id)
|
|
254
|
+
return 'unreachable';
|
|
255
|
+
saveReplicated(context.root, context.bucket, head);
|
|
256
|
+
return 'in-sync';
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** After a change made on top of `before`: uploads it when there is a bucket
|
|
260
|
+
* to upload to and this copy differs from what the bucket last matched.
|
|
261
|
+
*/
|
|
262
|
+
function settle(context: StateContext, before: Standing): Promise<Standing> {
|
|
263
|
+
const { bucket } = context;
|
|
264
|
+
if (!bucket || before === 'unreachable') return Promise.resolve(before);
|
|
265
|
+
const head = readHead(databasePath(context.root));
|
|
266
|
+
if (
|
|
267
|
+
before === 'in-sync' &&
|
|
268
|
+
readReplicated(context.root, bucket)?.head === head?.id
|
|
269
|
+
)
|
|
270
|
+
return Promise.resolve('in-sync');
|
|
271
|
+
return upload(
|
|
272
|
+
{ ...context, bucket },
|
|
273
|
+
before === 'empty' || before === 'unpublished'
|
|
274
|
+
);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** Read-only: compares this copy with the bucket and changes neither. */
|
|
278
|
+
export function standing(context: StateContext): Promise<Standing> {
|
|
279
|
+
return locked(context.root, async () => {
|
|
280
|
+
const observation = await observe(context, false);
|
|
281
|
+
if (observation.remote?.status === 'present')
|
|
282
|
+
discard(observation.remote.file);
|
|
283
|
+
return observation.standing;
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
export interface Refreshed {
|
|
288
|
+
standing: Standing;
|
|
289
|
+
/** This copy was stale and is now the bucket's version. */
|
|
290
|
+
replaced: boolean;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** Brings this copy up to the bucket before it is read or changed. Never
|
|
294
|
+
* uploads, and never replaces a copy that has changes of its own.
|
|
295
|
+
*/
|
|
296
|
+
export function refresh(context: StateContext): Promise<Refreshed> {
|
|
297
|
+
return locked(context.root, async () => {
|
|
298
|
+
const database = databasePath(context.root);
|
|
299
|
+
const before = readHead(database)?.id;
|
|
300
|
+
const standing = await observed(context, false);
|
|
301
|
+
return { standing, replaced: readHead(database)?.id !== before };
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/** True when this copy has changes the bucket is not known to hold. Reads
|
|
306
|
+
* local files only.
|
|
307
|
+
*/
|
|
308
|
+
export function pending(context: StateContext): boolean {
|
|
309
|
+
const head = readHead(databasePath(context.root));
|
|
310
|
+
return Boolean(
|
|
311
|
+
context.bucket &&
|
|
312
|
+
head &&
|
|
313
|
+
readReplicated(context.root, context.bucket)?.head !== head.id
|
|
314
|
+
);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/** Replaces this copy with the bucket's version, whatever this copy holds. */
|
|
318
|
+
export function pull(context: StateContext): Promise<Standing> {
|
|
319
|
+
return locked(context.root, () => observed(context, true));
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/** Uploads this copy's changes, only when the bucket has not moved since
|
|
323
|
+
* this copy last matched it. A copy with no changes of its own is brought up
|
|
324
|
+
* to the bucket instead; a diverged one is left alone and reported.
|
|
325
|
+
*/
|
|
326
|
+
export function push(context: StateContext): Promise<Standing> {
|
|
327
|
+
return locked(context.root, async () => {
|
|
328
|
+
const before = await observed(context, false);
|
|
329
|
+
return ['unpublished', 'local-ahead'].includes(before)
|
|
330
|
+
? settle(context, before)
|
|
331
|
+
: before;
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
export const DIVERGED_REMEDY =
|
|
336
|
+
'This machine and the bucket both changed the fleet state. The bucket is the source of truth: keep a copy of anything you still need (devfleet fleet-state --export), then run devfleet fleet-state --restore --yes.';
|
|
337
|
+
|
|
338
|
+
/** Makes one change on top of the bucket's current version and uploads it.
|
|
339
|
+
* A bucket that cannot be reached does not stop the change: this copy stays
|
|
340
|
+
* ahead and the next change, or `fleet-state --push`, uploads it.
|
|
341
|
+
*/
|
|
342
|
+
export function mutateState<T>(
|
|
343
|
+
context: StateContext,
|
|
344
|
+
change: (before: Standing) => T | Promise<T>
|
|
345
|
+
): Promise<{ value: T; standing: Standing }> {
|
|
346
|
+
return locked(context.root, async () => {
|
|
347
|
+
const before = await observed(context, false);
|
|
348
|
+
if (before === 'diverged') throw new FleetUsageError(DIVERGED_REMEDY);
|
|
349
|
+
const value = await change(before);
|
|
350
|
+
return { value, standing: await settle(context, before) };
|
|
351
|
+
});
|
|
352
|
+
}
|
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
|
+
}
|