@crvouga/dev-fleet 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (110) hide show
  1. package/README.md +237 -37
  2. package/agents/setup.md +42 -12
  3. package/ansible/macos/manager.yml +7 -7
  4. package/ansible/macos/policy.json +2 -10
  5. package/ansible/monitoring/README.md +86 -25
  6. package/ansible/monitoring/agent.yml +40 -2
  7. package/ansible/monitoring/hub-route-darwin.yml +51 -0
  8. package/ansible/monitoring/lock.json +5 -0
  9. package/ansible/pyproject.toml +12 -0
  10. package/ansible/ubuntu/baseline.yml +1 -1
  11. package/ansible/ubuntu/disk-gc.py +336 -0
  12. package/ansible/ubuntu/policy.json +12 -3
  13. package/ansible/ubuntu/tailscale.yml +8 -5
  14. package/ansible/ubuntu/tasks/resources.yml +61 -1
  15. package/ansible/uv.lock +190 -0
  16. package/bootstrap/bootstrap.sh +25 -18
  17. package/bootstrap/doctor.ts +46 -1
  18. package/bootstrap/fleet-manager-run.sh +42 -15
  19. package/bootstrap/fleet-remote-bootstrap.sh +85 -12
  20. package/bootstrap/global-config.ts +64 -6
  21. package/bootstrap/install-launcher.sh +3 -0
  22. package/bootstrap/npm.ts +10 -2
  23. package/bootstrap/release-version.ts +157 -0
  24. package/docs/consumer-setup.md +408 -0
  25. package/docs/declared.md +89 -0
  26. package/docs/machine-setup.md +399 -0
  27. package/docs/operations.md +276 -0
  28. package/docs/releasing.md +103 -0
  29. package/docs/state.md +254 -0
  30. package/docs/topology.md +67 -0
  31. package/docs/ubuntu-sync.md +351 -0
  32. package/docs/worker-contract.md +67 -0
  33. package/examples/fleet.json +5 -0
  34. package/llms.txt +35 -0
  35. package/mise.lock +29 -0
  36. package/mise.toml +12 -1
  37. package/package.json +11 -2
  38. package/schema/fleet.schema.json +71 -15
  39. package/src/agents/fleet-verify.ts +9 -0
  40. package/src/agents/native-verify.ts +167 -22
  41. package/src/agents/provider-errors.ts +9 -2
  42. package/src/agents/registry.ts +7 -1
  43. package/src/cli/agent-guide.ts +39 -5
  44. package/src/cli/app.ts +11 -4
  45. package/src/cli/cli.ts +7 -0
  46. package/src/cli/command-index.ts +7 -0
  47. package/src/cli/fleet-state.ts +499 -0
  48. package/src/cli/fleet-sync-checkout.ts +56 -17
  49. package/src/cli/fleet-sync-enrollment.ts +19 -23
  50. package/src/cli/fleet-sync-forward.ts +1 -1
  51. package/src/cli/fleet-sync-manager.ts +64 -8
  52. package/src/cli/fleet-sync-monitoring.ts +5 -5
  53. package/src/cli/fleet-sync-output.ts +4 -1
  54. package/src/cli/fleet-sync-remote.ts +11 -5
  55. package/src/cli/fleet-sync-state.ts +86 -0
  56. package/src/cli/fleet-sync.ts +123 -57
  57. package/src/cli/fleet.ts +4 -2
  58. package/src/cli/managed-health.ts +4 -4
  59. package/src/cli/node-sync.ts +7 -0
  60. package/src/cli/program.ts +20 -4
  61. package/src/cli/project.ts +51 -0
  62. package/src/cli/register-fleet.ts +51 -1
  63. package/src/cli/registry.ts +28 -1
  64. package/src/cli/roster.ts +46 -30
  65. package/src/cli/settings.ts +15 -12
  66. package/src/cli/source-operations.ts +27 -3
  67. package/src/cli/tailnet.ts +2 -2
  68. package/src/definition.ts +31 -1
  69. package/src/diagnostics.ts +1 -1
  70. package/src/fleet-config.ts +105 -19
  71. package/src/machine/source.ts +3 -3
  72. package/src/machine/state.ts +15 -27
  73. package/src/manager-sync.ts +8 -3
  74. package/src/monitoring/agent-evidence.ts +146 -0
  75. package/src/monitoring/apply.ts +15 -3
  76. package/src/monitoring/beszel.ts +11 -4
  77. package/src/monitoring/enroll.ts +2 -1
  78. package/src/monitoring/hub-access.ts +4 -3
  79. package/src/monitoring/hub-backup.ts +121 -0
  80. package/src/monitoring/hub-viewer.ts +8 -8
  81. package/src/monitoring/hub.ts +3 -3
  82. package/src/monitoring/manager-hub.ts +30 -14
  83. package/src/node-converge-linux.ts +2 -0
  84. package/src/package-info.ts +45 -0
  85. package/src/platform/ansible.ts +19 -15
  86. package/src/platform/resource-verify.ts +8 -4
  87. package/src/platform/ubuntu-audit.ts +5 -2
  88. package/src/remote-bootstrap.ts +17 -19
  89. package/src/repair-catalog.ts +1 -1
  90. package/src/runner.ts +1 -1
  91. package/src/state/bucket.ts +208 -0
  92. package/src/state/check.ts +74 -0
  93. package/src/state/connection.ts +92 -0
  94. package/src/state/enrollment.ts +45 -0
  95. package/src/state/legacy.ts +89 -0
  96. package/src/state/litestream.ts +143 -0
  97. package/src/state/operator.ts +20 -0
  98. package/src/state/paths.ts +40 -0
  99. package/src/state/roster.ts +62 -0
  100. package/src/state/store.ts +169 -0
  101. package/src/state/sync.ts +352 -0
  102. package/src/t3-endpoint.ts +64 -8
  103. package/src/t3-linked-check.ts +23 -1
  104. package/src/toolchain-checks.ts +25 -0
  105. package/src/types.ts +4 -0
  106. package/src/worker-access-checks.ts +56 -3
  107. package/src/worker-host-checks.ts +5 -0
  108. package/src/workstation-checks.ts +1 -1
  109. package/src/index.ts +0 -6
  110. package/src/test-fixtures.ts +0 -36
@@ -0,0 +1,40 @@
1
+ import { homedir } from 'node:os';
2
+ import { join } from 'node:path';
3
+
4
+ /** The one directory that holds the manager's fleet state. Everything in it is
5
+ * a local copy: the storage bucket is the source of truth, so the directory
6
+ * can be deleted and restored with `fleet-state --restore`. `DEVFLEET_STATE`
7
+ * names another directory (a test fixture, a second fleet).
8
+ */
9
+ export function stateRoot(env = process.env, home = homedir()): string {
10
+ return env['DEVFLEET_STATE'] || join(home, '.local/share/dev-fleet/state');
11
+ }
12
+
13
+ /** The fleet state database Litestream replicates. */
14
+ export const databasePath = (root: string): string => join(root, 'fleet.db');
15
+
16
+ /** Records the bucket's head this copy last matched (see `standing`). */
17
+ export const replicatedPath = (root: string): string =>
18
+ join(root, 'replicated.json');
19
+
20
+ /** The definition as a file, for the subprocesses that take a path. Rewritten
21
+ * from the database; never edited.
22
+ */
23
+ export const definitionPath = (root: string): string =>
24
+ join(root, 'definition.json');
25
+
26
+ /** The bucket this manager replicates to, and its credentials. The only local
27
+ * file that is not a copy of something else. `DEVFLEET_BUCKET` names another.
28
+ */
29
+ export function bucketPath(env = process.env, home = homedir()): string {
30
+ return env['DEVFLEET_BUCKET'] || join(home, '.config/dev-fleet/bucket.json');
31
+ }
32
+
33
+ /** The home directory whose earlier files (kept before the fleet state
34
+ * existed) may still be read, or null. A state directory named with
35
+ * `DEVFLEET_STATE` is another fleet's: this machine's earlier files are not
36
+ * part of it.
37
+ */
38
+ export function legacyHome(env = process.env, home = homedir()): string | null {
39
+ return env['DEVFLEET_STATE'] ? null : home;
40
+ }
@@ -0,0 +1,62 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { lstat, mkdir, open, readFile, rename, unlink } from 'node:fs/promises';
3
+ import { homedir } from 'node:os';
4
+ import { dirname } from 'node:path';
5
+ import { FleetUsageError } from '../errors';
6
+ import { adoptEarlier, readLegacy } from './legacy';
7
+ import { databasePath, stateRoot } from './paths';
8
+ import { readDocument, writeDocument } from './store';
9
+
10
+ /** The roster kept in the fleet state: what `--config` means when no file is
11
+ * named. A named file (`--config PATH`, `.fleet.local.json`) stays a file.
12
+ */
13
+ export const STATE_ROSTER = 'state:roster';
14
+
15
+ /** The roster's text, or null when there is none. */
16
+ export async function readRosterText(
17
+ path: string,
18
+ env = process.env,
19
+ home = homedir()
20
+ ): Promise<string | null> {
21
+ if (path === STATE_ROSTER)
22
+ return (
23
+ readDocument(databasePath(stateRoot(env, home)), 'roster') ??
24
+ readLegacy('roster', env, home)
25
+ );
26
+ try {
27
+ const info = await lstat(path);
28
+ if (!info.isFile() || info.size > 1024 * 1024)
29
+ throw new FleetUsageError(
30
+ 'Roster must be a regular file smaller than 1 MiB; symlinks are not supported.'
31
+ );
32
+ return await readFile(path, 'utf8');
33
+ } catch (error) {
34
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null;
35
+ throw error;
36
+ }
37
+ }
38
+
39
+ export async function writeRosterText(
40
+ path: string,
41
+ text: string,
42
+ env = process.env,
43
+ home = homedir()
44
+ ): Promise<void> {
45
+ if (path === STATE_ROSTER) {
46
+ adoptEarlier(env, home);
47
+ writeDocument(databasePath(stateRoot(env, home)), 'roster', text);
48
+ return;
49
+ }
50
+ await mkdir(dirname(path), { recursive: true, mode: 0o700 });
51
+ const temporary = `${path}.${randomUUID()}.tmp`;
52
+ const handle = await open(temporary, 'wx', 0o600);
53
+ try {
54
+ await handle.writeFile(text);
55
+ await handle.sync();
56
+ await handle.close();
57
+ await rename(temporary, path);
58
+ } finally {
59
+ await handle.close();
60
+ await unlink(temporary).catch(() => undefined);
61
+ }
62
+ }
@@ -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
+ }