@shardflux/cli 0.20.0 → 0.22.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/CHANGELOG.md +214 -1
  2. package/README.md +215 -75
  3. package/dist/account-args.js +7 -2
  4. package/dist/account-commands.js +56 -106
  5. package/dist/account-pool.d.ts +19 -15
  6. package/dist/account-pool.js +47 -96
  7. package/dist/account-scope.d.ts +65 -0
  8. package/dist/account-scope.js +246 -0
  9. package/dist/acp-login.d.ts +4 -3
  10. package/dist/acp-login.js +7 -5
  11. package/dist/acp-models.js +20 -15
  12. package/dist/acp.js +66 -13
  13. package/dist/agent-commands.d.ts +4 -1
  14. package/dist/agent-commands.js +86 -32
  15. package/dist/agent-config-return.d.ts +46 -2
  16. package/dist/agent-config-return.js +110 -4
  17. package/dist/agent-credentials.js +7 -3
  18. package/dist/agent-repository.d.ts +16 -3
  19. package/dist/agent-repository.js +149 -74
  20. package/dist/agent-setup.d.ts +14 -3
  21. package/dist/agent-setup.js +78 -36
  22. package/dist/agent-target-commands.js +24 -13
  23. package/dist/args.d.ts +1 -1
  24. package/dist/args.js +94 -54
  25. package/dist/bin.js +5 -1
  26. package/dist/browser.d.ts +13 -2
  27. package/dist/browser.js +30 -14
  28. package/dist/claude-cloud-login.d.ts +24 -2
  29. package/dist/claude-cloud-login.js +151 -11
  30. package/dist/cli-proxy-forward.d.ts +2 -0
  31. package/dist/cli-proxy-forward.js +9 -2
  32. package/dist/cli-proxy-hub.d.ts +69 -25
  33. package/dist/cli-proxy-hub.js +231 -54
  34. package/dist/cli-proxy.d.ts +70 -1
  35. package/dist/cli-proxy.js +298 -32
  36. package/dist/client-settings.d.ts +39 -0
  37. package/dist/client-settings.js +91 -0
  38. package/dist/codex-cloud.d.ts +5 -0
  39. package/dist/codex-cloud.js +88 -17
  40. package/dist/command-center-actions.js +5 -1
  41. package/dist/commands.js +171 -22
  42. package/dist/conversation-commands.d.ts +5 -0
  43. package/dist/conversation-commands.js +8 -5
  44. package/dist/conversation-terminal.d.ts +10 -2
  45. package/dist/conversation-terminal.js +77 -13
  46. package/dist/conversations.d.ts +73 -1
  47. package/dist/conversations.js +257 -23
  48. package/dist/device-bridge.d.ts +1 -1
  49. package/dist/device-bridge.js +6 -4
  50. package/dist/device-helper.js +43 -22
  51. package/dist/errors.d.ts +27 -1
  52. package/dist/errors.js +45 -5
  53. package/dist/filesystem-commands.d.ts +16 -9
  54. package/dist/filesystem-commands.js +108 -54
  55. package/dist/format.d.ts +2 -1
  56. package/dist/format.js +10 -1
  57. package/dist/handlers.js +3 -1
  58. package/dist/initial-prompt.d.ts +8 -0
  59. package/dist/initial-prompt.js +43 -1
  60. package/dist/large-media.d.ts +14 -0
  61. package/dist/large-media.js +100 -0
  62. package/dist/linux-terminal.d.ts +5 -3
  63. package/dist/linux-terminal.js +6 -20
  64. package/dist/local-sessions.d.ts +26 -2
  65. package/dist/local-sessions.js +113 -24
  66. package/dist/machine-setup.d.ts +11 -1
  67. package/dist/machine-setup.js +69 -22
  68. package/dist/machine-status.d.ts +67 -0
  69. package/dist/machine-status.js +140 -0
  70. package/dist/main.js +11 -13
  71. package/dist/move-worktrees-restore.py +144 -0
  72. package/dist/move-worktrees.d.ts +63 -0
  73. package/dist/move-worktrees.js +201 -0
  74. package/dist/move.d.ts +10 -1
  75. package/dist/move.js +116 -16
  76. package/dist/onboarding.d.ts +11 -7
  77. package/dist/onboarding.js +59 -27
  78. package/dist/prepared-repository.d.ts +202 -0
  79. package/dist/prepared-repository.js +860 -0
  80. package/dist/prepared-repository.py +1306 -0
  81. package/dist/project-preparation.d.ts +48 -0
  82. package/dist/project-preparation.js +304 -0
  83. package/dist/project-sync-manifest.d.ts +4 -0
  84. package/dist/project-sync-manifest.js +5 -1
  85. package/dist/project-sync-transfer.js +1 -1
  86. package/dist/project-sync.d.ts +19 -4
  87. package/dist/project-sync.js +31 -8
  88. package/dist/project-template-setup.d.ts +33 -1
  89. package/dist/project-template-setup.js +59 -2
  90. package/dist/secret-ask.d.ts +72 -0
  91. package/dist/secret-ask.js +351 -0
  92. package/dist/seed-phases.d.ts +8 -1
  93. package/dist/seed-phases.js +17 -2
  94. package/dist/setup-cache.d.ts +7 -0
  95. package/dist/setup-cache.js +32 -0
  96. package/dist/t3-launch.js +1 -4
  97. package/dist/t3-mode.d.ts +8 -2
  98. package/dist/t3-mode.js +19 -4
  99. package/dist/t3-phone.d.ts +4 -2
  100. package/dist/t3-phone.js +12 -4
  101. package/dist/terminal-apps.d.ts +157 -0
  102. package/dist/terminal-apps.js +561 -0
  103. package/dist/terminal-onboarding.js +6 -1
  104. package/dist/this-computer.d.ts +11 -0
  105. package/dist/this-computer.js +36 -0
  106. package/dist/thread-on-computer.js +4 -1
  107. package/dist/update.js +11 -3
  108. package/dist/worktree-twin.d.ts +10 -2
  109. package/dist/worktree-twin.js +27 -9
  110. package/package.json +5 -5
@@ -0,0 +1,860 @@
1
+ /**
2
+ * Prepared projects: a project's working state reaches a VM as Git packs, once (0.22.0+).
3
+ *
4
+ * A start snapshots the worktree as the twin commit S (worktree-twin.ts: HEAD plus the dirty and untracked files).
5
+ * Everything S needs that the project cache lacks travels as Git packs: a thin pack of history and small files (normal
6
+ * Git deltas and compression) and the large blobs apart. Large untracked media stay out of S: their bytes travel once,
7
+ * as stored Git blobs, and land in the worktree as files its Git ignores (as the user's Git does), possibly after the
8
+ * first prompt. What Git must not hold (.env files, filter output) travels as a small tar. The cache
9
+ * (/shardflux/project/prepared/<remote hash>, shared by the project's VMs) keeps all of it, so a later start sends only
10
+ * what changed since a revision it holds, and a start from a prepared project sends nothing.
11
+ *
12
+ * Packs travel as 8 MiB binary parts, several at once, each named by its SHA-256 and acknowledged with the VM's own
13
+ * hash of what it stored: an interrupted upload resumes with the missing parts.
14
+ *
15
+ * The VM half is prepared-repository.py: one exec per step (probe, origin, import, apply, materialize, prune).
16
+ */
17
+ import { spawn } from 'node:child_process';
18
+ import { createHash, randomUUID } from 'node:crypto';
19
+ import { constants, readFileSync } from 'node:fs';
20
+ import { lstat, mkdir, open, readFile, readdir, readlink, rename, rm, stat, writeFile } from 'node:fs/promises';
21
+ import { homedir } from 'node:os';
22
+ import { join, resolve } from 'node:path';
23
+ import { createDeflate, deflateRawSync } from 'node:zlib';
24
+ import { tarEnd, tarHeader, tarPadding } from '@shardflux/sdk';
25
+ import { localChanges } from "./agent-repository.js";
26
+ import { localEntry, projectFilePath, projectGit } from "./project-sync-manifest.js";
27
+ import { SYNC_CHUNK_BYTES, writeSyncBlob } from "./project-sync-transfer.js";
28
+ import { inspectWorktree, worktreeId } from "./worktree-twin.js";
29
+ import { dependencyHash } from "./project-template-setup.js";
30
+ import { withTargetLock } from "./target-lock.js";
31
+ import { blobId, largeUntrackedMedia } from "./large-media.js";
32
+ /** One upload part. */
33
+ export const PART_BYTES = SYNC_CHUNK_BYTES;
34
+ /** Blobs of S from this size on leave the thin pack: stored when incompressible, compressed apart otherwise. */
35
+ export const LARGE_BLOB_BYTES = 1 << 20;
36
+ /** Large media above this follow the first prompt when the caller allows it. */
37
+ export const DEFER_BYTES = 32 << 20;
38
+ /** Parts in flight at once, and their bytes. */
39
+ export const UPLOAD_WINDOW = 4;
40
+ export const UPLOAD_BUDGET_BYTES = 32 << 20;
41
+ /** The history the laptop sends before the cache fetches it from origin instead. */
42
+ export const ORIGIN_FIRST_BYTES = 32 << 20;
43
+ /** The history the laptop sends before the cache takes the VM's own fetch of the branch (its boot prefetch) instead. */
44
+ export const PREFETCH_ADOPT_BYTES = 4 << 20;
45
+ /** The local spool of generated packs (for resuming), pruned past this or a day. */
46
+ export const SPOOL_BYTES = 4 << 30;
47
+ const STDIN_BYTES = 768 << 10;
48
+ const sha256 = (bytes) => createHash('sha256').update(bytes).digest('hex');
49
+ let helperSource;
50
+ const helper = () => helperSource ??= readFileSync(new URL('./prepared-repository.py', import.meta.url), 'utf8');
51
+ export function preparedCachePath(remote) { return `/shardflux/project/prepared/${sha256(remote)}`; }
52
+ export const preparedDirectory = (stateDir, folder) => join(stateDir, 'prepared', worktreeId(folder));
53
+ /** The manifest's identity bytes: sorted keys, no spaces, UTF-8 (prepared-repository.py `canonical`). */
54
+ export function canonicalJson(value) {
55
+ const sort = (v) => Array.isArray(v) ? v.map(sort)
56
+ : v && typeof v === 'object' ? Object.fromEntries(Object.keys(v).sort().filter(k => v[k] !== undefined).map(k => [k, sort(v[k])])) : v;
57
+ return JSON.stringify(sort(value));
58
+ }
59
+ export const manifestRevision = (m) => sha256(canonicalJson(m));
60
+ /** A refusal the VM half reports by reason (missing or corrupt parts, an owned worktree, ...). */
61
+ export class PreparedRefusal extends Error {
62
+ reason;
63
+ chunks;
64
+ constructor(reason, message, chunks = []) { super(message); this.reason = reason; this.chunks = chunks; }
65
+ }
66
+ // ---------------------------------------------------------------------------------------------------------------
67
+ // Capture
68
+ async function optional(folder, args, signal) {
69
+ try {
70
+ return (await projectGit(folder, args, signal)).trim();
71
+ }
72
+ catch (error) {
73
+ if (signal?.aborted)
74
+ throw error;
75
+ return '';
76
+ }
77
+ }
78
+ /** Git's output of any size (projectGit buffers at most 4 MiB). */
79
+ function gitStream(folder, args, signal, input) {
80
+ return new Promise((resolveOut, reject) => {
81
+ const child = spawn('git', ['-C', folder, '-c', 'core.hooksPath=/dev/null', '-c', 'core.fsmonitor=false', ...args], { stdio: ['pipe', 'pipe', 'pipe'], signal, env: { ...process.env, GIT_OPTIONAL_LOCKS: '0', GIT_TERMINAL_PROMPT: '0', LC_ALL: 'C' } });
82
+ const out = [];
83
+ let err = '';
84
+ child.stdout.on('data', (b) => out.push(b));
85
+ child.stderr.on('data', (b) => { err = (err + String(b)).slice(-2000); });
86
+ child.stdin.on('error', () => undefined);
87
+ child.once('error', reject);
88
+ child.once('close', code => code === 0 ? resolveOut(Buffer.concat(out)) : reject(new Error(`git ${args[0]} failed: ${err.trim()}`)));
89
+ child.stdin.end(input ?? '');
90
+ });
91
+ }
92
+ async function treeEntries(folder, commit, signal) {
93
+ const map = new Map();
94
+ for (const rec of (await gitStream(folder, ['ls-tree', '-r', '-z', '--full-tree', commit], signal)).toString('utf8').split('\0')) {
95
+ if (!rec)
96
+ continue;
97
+ const tab = rec.indexOf('\t'), [mode, , oid] = rec.slice(0, tab).split(' ');
98
+ map.set(rec.slice(tab + 1), { mode: mode, oid: oid });
99
+ }
100
+ return map;
101
+ }
102
+ async function rawBlobId(folder, path, format) {
103
+ const target = await projectFilePath(folder, path), st = await lstat(target);
104
+ if (st.isSymbolicLink()) {
105
+ const link = Buffer.from(await readlink(target));
106
+ return createHash(format).update(`blob ${link.length}\0`).update(link).digest('hex');
107
+ }
108
+ return blobId(target, format, st.size);
109
+ }
110
+ /**
111
+ * The changed paths whose bytes S does not carry exactly: not in S (.env files, which Git ignores and we always bring;
112
+ * every change when S is HEAD), or in S through a filter or line-ending conversion that changes them (Git LFS, CRLF).
113
+ */
114
+ async function extraPaths(folder, tree, copy, seedIsHead, format, signal) {
115
+ if (!copy.length)
116
+ return [];
117
+ const autocrlf = ['true', 'input'].includes((await optional(folder, ['config', '--get', 'core.autocrlf'], signal)).toLowerCase());
118
+ const convert = new Set(), inTree = copy.filter(path => tree.has(path));
119
+ for (let at = 0; at < inTree.length; at += 4096) {
120
+ const out = (await gitStream(folder, ['check-attr', '-z', '--stdin', 'filter', 'text', 'eol', 'ident', 'working-tree-encoding'], signal, inTree.slice(at, at + 4096).join('\0') + '\0')).toString('utf8').split('\0');
121
+ for (let i = 0; i + 2 < out.length; i += 3)
122
+ if (!['unspecified', 'unset'].includes(out[i + 2]))
123
+ convert.add(out[i]);
124
+ }
125
+ const extras = [];
126
+ for (const path of copy) {
127
+ const entry = tree.get(path);
128
+ if (!entry) {
129
+ extras.push(path);
130
+ continue;
131
+ }
132
+ if (entry.mode === '160000' || (!seedIsHead && !autocrlf && !convert.has(path)))
133
+ continue;
134
+ try {
135
+ if (await rawBlobId(folder, path, format) !== entry.oid)
136
+ extras.push(path);
137
+ }
138
+ catch (error) {
139
+ if (error.code !== 'ENOENT')
140
+ throw error;
141
+ }
142
+ }
143
+ return extras;
144
+ }
145
+ async function writeExact(file, data) {
146
+ for (let at = 0; at < data.length;)
147
+ at += (await file.write(data, at, data.length - at)).bytesWritten;
148
+ }
149
+ /** A deterministic tar of the extras (sorted, zero times and owners): its SHA-256 and the entries it holds. */
150
+ async function extrasArchive(folder, directory, paths, signal) {
151
+ const entries = [];
152
+ if (!paths.length)
153
+ return { entries };
154
+ const temp = join(directory, `extras-${randomUUID()}.tmp`), hash = createHash('sha256');
155
+ const file = await open(temp, 'wx', 0o600);
156
+ let offset = 0;
157
+ const put = async (data) => { hash.update(data); await writeExact(file, data); offset += data.length; };
158
+ try {
159
+ for (const path of [...paths].sort()) {
160
+ signal?.throwIfAborted();
161
+ const entry = await localEntry(folder, path, signal);
162
+ if (!entry || !('sha256' in entry))
163
+ continue;
164
+ if (entry.symlink) {
165
+ await put(tarHeader({ path, type: 'symlink', mode: 0o777, size: 0, linkname: entry.link_target }));
166
+ }
167
+ else {
168
+ const source = await open(await projectFilePath(folder, path), constants.O_RDONLY | constants.O_NOFOLLOW);
169
+ try {
170
+ await put(tarHeader({ path, type: 'file', mode: entry.mode, size: entry.size }));
171
+ const check = createHash('sha256'), buffer = Buffer.allocUnsafe(1 << 20);
172
+ for (let read = 0; read < entry.size;) {
173
+ const { bytesRead } = await source.read(buffer, 0, Math.min(buffer.length, entry.size - read), read);
174
+ if (!bytesRead)
175
+ throw new Error(`${path} changed while it was captured; start again.`);
176
+ check.update(buffer.subarray(0, bytesRead));
177
+ await put(buffer.subarray(0, bytesRead));
178
+ read += bytesRead;
179
+ }
180
+ if (check.digest('hex') !== entry.sha256)
181
+ throw new Error(`${path} changed while it was captured; start again.`);
182
+ await put(tarPadding(entry.size));
183
+ }
184
+ finally {
185
+ await source.close();
186
+ }
187
+ }
188
+ entries.push({ path, sha256: entry.sha256, size: entry.symlink ? 0 : entry.size, mode: entry.mode, symlink: entry.symlink, link_target: entry.link_target });
189
+ }
190
+ await put(tarEnd(offset));
191
+ await file.sync();
192
+ }
193
+ catch (error) {
194
+ await file.close();
195
+ await rm(temp, { force: true });
196
+ throw error;
197
+ }
198
+ await file.close();
199
+ const key = hash.digest('hex');
200
+ await rename(temp, join(directory, `extras-${key}.tar`));
201
+ return { key, entries };
202
+ }
203
+ /** Each media file's Git blob id, hashed once per version (size, times, inode). */
204
+ async function mediaFiles(folder, directory, outside, format) {
205
+ const cachePath = join(directory, `blob-ids-${format}.json`);
206
+ let ids = {};
207
+ try {
208
+ ids = JSON.parse(await readFile(cachePath, 'utf8'));
209
+ }
210
+ catch { /* first capture */ }
211
+ const next = {}, files = new Array(outside.length);
212
+ let at = 0;
213
+ await Promise.all(Array.from({ length: Math.min(8, outside.length) }, async () => {
214
+ for (let i = at++; i < outside.length; i = at++) {
215
+ const f = outside[i], oid = ids[f.path]?.meta === f.meta ? ids[f.path].oid : await blobId(join(folder, f.path), format, f.size);
216
+ next[f.path] = { meta: f.meta, oid };
217
+ files[i] = { path: f.path, oid, size: f.size, mode: f.mode };
218
+ }
219
+ }));
220
+ const temp = `${cachePath}.${process.pid}.tmp`;
221
+ await writeFile(temp, JSON.stringify(next), { mode: 0o600 });
222
+ await rename(temp, cachePath);
223
+ return files;
224
+ }
225
+ /**
226
+ * The prepared revision of a worktree's state: S (the twin seed, already made), its large media and its extras. S's
227
+ * objects stay in the local repository under refs/shard/prepared/<revision> until the start that uses it is done, so a
228
+ * start accepted before a deadline resumes later with exactly the state it began from, whatever the user edits.
229
+ */
230
+ export async function capturePreparedSnapshot(stateDir, folder, seed, origin, signal, options = {}) {
231
+ // A remote on this computer's disk is out of the VM's reach; the history then comes from here.
232
+ const reachable = options.reachable ?? ((url) => !url.startsWith('file:') && !url.startsWith('/'));
233
+ const directory = preparedDirectory(stateDir, folder);
234
+ await mkdir(join(directory, 'spool'), { recursive: true, mode: 0o700 });
235
+ const info = await inspectWorktree(folder);
236
+ if (!info.head)
237
+ throw new Error('The project has no commit to prepare.');
238
+ const format = (await projectGit(folder, ['rev-parse', '--show-object-format'], signal)).trim() === 'sha256' ? 'sha256' : 'sha1';
239
+ let upstream;
240
+ if (info.branch) {
241
+ const ref = await optional(folder, ['rev-parse', '--symbolic-full-name', `${info.branch.slice(11)}@{upstream}`], signal);
242
+ const oid = ref.startsWith('refs/remotes/') ? await optional(folder, ['rev-parse', '--verify', '-q', `${ref}^{commit}`], signal) : '';
243
+ if (oid)
244
+ upstream = { ref, oid };
245
+ }
246
+ const outside = seed.outside ?? await largeUntrackedMedia(info.root, stateDir, worktreeId(folder), signal);
247
+ let shallow = [];
248
+ if (await optional(folder, ['rev-parse', '--is-shallow-repository'], signal) === 'true') {
249
+ const file = await optional(folder, ['rev-parse', '--git-path', 'shallow'], signal);
250
+ shallow = file ? (await readFile(resolve(folder, file), 'utf8').catch(() => '')).split('\n').filter(line => /^[a-f0-9]{40}(?:[a-f0-9]{24})?$/.test(line)).sort() : [];
251
+ }
252
+ const outsidePaths = new Set(outside.map(f => f.path));
253
+ const [changes, tree] = await Promise.all([localChanges(folder, signal), treeEntries(folder, seed.commit, signal)]);
254
+ const copy = changes.copy.filter(path => !outsidePaths.has(path));
255
+ const [extras, files] = await Promise.all([
256
+ extraPaths(folder, tree, copy, seed.commit === info.head, format, signal).then(paths => extrasArchive(folder, directory, paths, signal)),
257
+ mediaFiles(folder, directory, outside, format),
258
+ ]);
259
+ const manifest = { version: 2, format, head: info.head, seed: seed.commit, ...(info.branch ? { branch: info.branch } : {}), ...(upstream ? { upstream } : {}),
260
+ origin: origin && reachable(origin) ? origin : null, repository: origin ?? `file://${folder}`, worktree: worktreeId(folder),
261
+ deps_hash: options.depsHash ?? dependencyHash(folder), extras: extras.entries, ...(extras.key ? { extras_key: extras.key } : {}), files,
262
+ remove: changes.remove.filter(path => tree.has(path)), ...(shallow.length ? { shallow } : {}) };
263
+ const revision = manifestRevision(manifest);
264
+ await projectGit(folder, ['update-ref', `refs/shard/prepared/${revision}`, seed.commit], signal);
265
+ await writeFile(join(directory, `manifest-${revision}.json`), canonicalJson(manifest), { mode: 0o600 });
266
+ // Which checkout pins these revisions (refs/shard/prepared/*): local pruning releases a revision it drops.
267
+ await writeFile(join(directory, 'folder'), folder, { mode: 0o600 });
268
+ return { revision, manifest, directory };
269
+ }
270
+ /** The local pin of a revision's objects, once its start no longer needs them. */
271
+ export async function releaseLocalSnapshot(folder, revision) {
272
+ if (!/^[a-f0-9]{64}$/.test(revision))
273
+ return;
274
+ await projectGit(folder, ['update-ref', '-d', `refs/shard/prepared/${revision}`]).catch(() => undefined);
275
+ }
276
+ export async function readPreparedSnapshot(stateDir, folder, revision) {
277
+ const directory = preparedDirectory(stateDir, folder);
278
+ const manifest = JSON.parse(await readFile(join(directory, `manifest-${revision}.json`), 'utf8'));
279
+ if (manifestRevision(manifest) !== revision)
280
+ throw new Error('The saved project revision changed on disk.');
281
+ return { revision, manifest, directory };
282
+ }
283
+ const noStats = () => ({ puts: 0, execs: 0, uploaded: 0, reused: 0, inFlight: 0, maxInFlight: 0 });
284
+ async function invoke(cell, action, cache, req, stats, signal) {
285
+ let body = JSON.stringify(req);
286
+ if (Buffer.byteLength(body) > STDIN_BYTES) {
287
+ const file = `${cache}/requests/${randomUUID()}.json`;
288
+ await cell.files.mkdir(`${cache}/requests`, { parents: true });
289
+ await writeSyncBlob(cell, file, Buffer.from(body));
290
+ stats.puts++;
291
+ body = JSON.stringify({ request_file: file });
292
+ }
293
+ stats.execs++;
294
+ const r = await cell.exec.run(['python3', '-c', helper(), action, cache], { stdin: body, timeoutMs: 1_800_000, maxOutputBytes: 8 << 20, ...(signal ? { signal } : {}) });
295
+ const line = r.stdout.trim().split('\n').at(-1) ?? '';
296
+ if (r.exitCode !== 0 || !line.startsWith('{'))
297
+ throw new Error(`Project ${action} failed: ${(r.stderr || r.stdout).trim().slice(-1500)}`);
298
+ const result = JSON.parse(line);
299
+ if (result.error)
300
+ throw new PreparedRefusal(result.error, result.message ?? result.error, result.chunks ?? []);
301
+ return result;
302
+ }
303
+ /** Bounded concurrent part uploads: a window of parts and of bytes, every part acknowledged by the VM's own SHA-256. */
304
+ class Uploader {
305
+ #running = new Set();
306
+ #bytes = 0;
307
+ #failure;
308
+ cell;
309
+ parts;
310
+ stats;
311
+ signal;
312
+ constructor(cell, parts, stats, signal) { this.cell = cell; this.parts = parts; this.stats = stats; this.signal = signal; }
313
+ async put(key, data) {
314
+ while (this.#running.size >= UPLOAD_WINDOW || (this.#running.size && this.#bytes + data.length > UPLOAD_BUDGET_BYTES))
315
+ await Promise.race(this.#running);
316
+ if (this.#failure)
317
+ throw this.#failure;
318
+ this.signal?.throwIfAborted();
319
+ this.#bytes += data.length;
320
+ this.stats.inFlight += data.length;
321
+ this.stats.maxInFlight = Math.max(this.stats.maxInFlight, this.stats.inFlight);
322
+ const task = (async () => {
323
+ try {
324
+ const written = await this.cell.files.write(`${this.parts}/${key}`, data, { mode: '0600', createParents: true });
325
+ this.stats.puts++;
326
+ if (written && typeof written === 'object' && 'sha256' in written && written.sha256 !== key)
327
+ throw new Error('An uploaded part was stored with different bytes.');
328
+ this.stats.uploaded += data.length;
329
+ }
330
+ catch (error) {
331
+ this.#failure ??= error instanceof Error ? error : new Error(String(error));
332
+ }
333
+ finally {
334
+ this.#bytes -= data.length;
335
+ this.stats.inFlight -= data.length;
336
+ }
337
+ })();
338
+ this.#running.add(task);
339
+ void task.finally(() => this.#running.delete(task));
340
+ }
341
+ async drain() {
342
+ await Promise.all(this.#running);
343
+ if (this.#failure)
344
+ throw this.#failure;
345
+ }
346
+ }
347
+ /** Which of these commits this repository also has: what the packs may leave out. */
348
+ async function localCommits(folder, oids, signal) {
349
+ if (!oids.length)
350
+ return [];
351
+ const out = (await gitStream(folder, ['cat-file', '--batch-check=%(objectname) %(objecttype)'], signal, oids.join('\n') + '\n')).toString('utf8').split('\n');
352
+ return out.filter(line => line.endsWith(' commit')).map(line => line.split(' ')[0]);
353
+ }
354
+ /** Large blobs whose first 64 KiB barely compress are stored as they are: zlib on media costs seconds and saves nothing. */
355
+ async function incompressible(folder, path) {
356
+ let file;
357
+ try {
358
+ file = await open(await projectFilePath(folder, path), constants.O_RDONLY | constants.O_NOFOLLOW);
359
+ const sample = Buffer.allocUnsafe(64 << 10), { bytesRead } = await file.read(sample, 0, sample.length, 0);
360
+ return bytesRead > 0 && deflateRawSync(sample.subarray(0, bytesRead), { level: 1 }).length >= bytesRead * 0.92;
361
+ }
362
+ catch {
363
+ return false;
364
+ }
365
+ finally {
366
+ await file?.close();
367
+ }
368
+ }
369
+ async function planTransfer(folder, snapshot, known, missingFiles, history, signal) {
370
+ const m = snapshot.manifest, tops = [m.seed, m.head, ...(m.upstream ? [m.upstream.oid] : [])];
371
+ const negatives = [...new Set(known)].sort().map(oid => `^${oid}`), revs = [...tops, ...negatives].join('\n') + '\n';
372
+ const identity = (kind, content) => sha256(`${kind}\0${snapshot.revision}\0${negatives.join(',')}\0${content}`);
373
+ const packs = [];
374
+ if (history) {
375
+ // What the cache lacks, and the same walk without large blobs: the difference is the large blobs to send. (A
376
+ // filtered walk's omitted list also names blobs the cache already has.) Aggressive edges: the cache's snapshots are
377
+ // siblings of S, not ancestors, and a plain walk would send again everything their trees hold.
378
+ const list = async (filter) => new Set((await gitStream(folder, ['rev-list', '--objects-edge-aggressive', '--no-object-names', ...filter, '--stdin'], signal, revs))
379
+ .toString('utf8').split('\n').filter(line => line && !line.startsWith('-')));
380
+ const needed = await list([]), small = await list([`--filter=blob:limit=${LARGE_BLOB_BYTES}`]);
381
+ const large = [...needed].filter(oid => !small.has(oid));
382
+ // A large blob of the working state (a tracked asset edited) has a path to sample; history's large blobs keep Git's
383
+ // compression, usually reused from its packs as they are.
384
+ const paths = new Map();
385
+ if (large.length) {
386
+ const wanted = new Set(large);
387
+ for (const [path, entry] of await treeEntries(folder, m.seed, signal))
388
+ if (wanted.has(entry.oid) && !paths.has(entry.oid))
389
+ paths.set(entry.oid, path);
390
+ }
391
+ const stored = [], compressed = [];
392
+ for (const oid of large) {
393
+ const path = paths.get(oid);
394
+ (path && await incompressible(folder, path) ? stored : compressed).push(oid);
395
+ }
396
+ if (small.size)
397
+ packs.push({ key: identity('history', revs), kind: 'history', thin: true, stdin: revs,
398
+ // --shallow: aggressive edges for the same reason (a one-line edit sent a 300-file folder again without it).
399
+ args: ['pack-objects', '--revs', '--thin', '--shallow', '--stdout', '--delta-base-offset', '-q', `--filter=blob:limit=${LARGE_BLOB_BYTES}`] });
400
+ if (compressed.length) {
401
+ const stdin = compressed.sort().join('\n') + '\n';
402
+ packs.push({ key: identity('content', stdin), kind: 'content', thin: false, stdin, args: ['pack-objects', '--stdout', '--delta-base-offset', '-q'] });
403
+ }
404
+ if (stored.length) {
405
+ const stdin = stored.sort().join('\n') + '\n';
406
+ packs.push({ key: identity('large', stdin), kind: 'large', thin: false, stdin, args: ['-c', 'pack.compression=0', 'pack-objects', '--stdout', '--window=0', '-q'] });
407
+ }
408
+ }
409
+ const wanted = new Set(missingFiles), seen = new Set(), files = [];
410
+ for (const f of m.files)
411
+ if (wanted.has(f.oid) && !seen.has(f.oid)) {
412
+ seen.add(f.oid);
413
+ files.push(f);
414
+ }
415
+ if (files.length)
416
+ packs.push({ key: identity('media', files.map(f => f.oid).join('\n')), kind: 'media', thin: false, files });
417
+ return { revision: snapshot.revision, packs };
418
+ }
419
+ /** A pack's object header: type and size, 4 then 7 bits at a time. */
420
+ function objectHeader(type, size) {
421
+ const bytes = [];
422
+ let c = (type << 4) | (size % 16), rest = Math.floor(size / 16);
423
+ while (rest > 0) {
424
+ bytes.push(c | 0x80);
425
+ c = rest % 128;
426
+ rest = Math.floor(rest / 128);
427
+ }
428
+ bytes.push(c);
429
+ return Buffer.from(bytes);
430
+ }
431
+ /**
432
+ * The media pack, written here from the files themselves: each blob stored (zlib level 0), no copy in the local
433
+ * repository. A file that changed since the capture still yields one object of its captured size, so the pack stays
434
+ * valid; its captured blob id never arrives and the file is reported in `changed`.
435
+ */
436
+ async function* mediaPack(folder, files, format, changed) {
437
+ const trailer = createHash(format);
438
+ const emit = (b) => { trailer.update(b); return b; };
439
+ const header = Buffer.alloc(12);
440
+ header.write('PACK', 0, 'latin1');
441
+ header.writeUInt32BE(2, 4);
442
+ header.writeUInt32BE(files.length, 8);
443
+ yield emit(header);
444
+ for (const f of files) {
445
+ yield emit(objectHeader(3, f.size));
446
+ const id = createHash(format).update(`blob ${f.size}\0`), deflate = createDeflate({ level: 0 });
447
+ let file;
448
+ try {
449
+ file = await open(await projectFilePath(folder, f.path), constants.O_RDONLY | constants.O_NOFOLLOW);
450
+ }
451
+ catch { /* gone: zeros below */ }
452
+ const feed = (async () => {
453
+ const buffer = Buffer.allocUnsafe(1 << 20);
454
+ for (let at = 0; at < f.size;) {
455
+ const want = Math.min(buffer.length, f.size - at);
456
+ let got = file ? (await file.read(buffer, 0, want, at)).bytesRead : 0;
457
+ if (!got) {
458
+ buffer.fill(0, 0, want);
459
+ got = want;
460
+ }
461
+ const piece = Buffer.from(buffer.subarray(0, got));
462
+ id.update(piece);
463
+ at += got;
464
+ if (!deflate.write(piece))
465
+ await new Promise(r => deflate.once('drain', r));
466
+ }
467
+ deflate.end();
468
+ })();
469
+ try {
470
+ for await (const z of deflate)
471
+ yield emit(z);
472
+ await feed;
473
+ }
474
+ finally {
475
+ await file?.close();
476
+ }
477
+ if (id.digest('hex') !== f.oid)
478
+ changed.push(f.path);
479
+ }
480
+ yield trailer.digest();
481
+ }
482
+ function packSource(folder, pack, format, changed, signal) {
483
+ if (pack.files)
484
+ return { chunks: mediaPack(folder, pack.files, format, changed), done: () => Promise.resolve() };
485
+ const child = spawn('git', ['-C', folder, '-c', 'core.hooksPath=/dev/null', ...pack.args], { signal, stdio: ['pipe', 'pipe', 'pipe'],
486
+ env: { ...process.env, GIT_OPTIONAL_LOCKS: '0', GIT_TERMINAL_PROMPT: '0' } });
487
+ let stderr = '';
488
+ child.stderr.on('data', (b) => { stderr = (stderr + String(b)).slice(-4000); });
489
+ const exited = new Promise((resolveExit, reject) => { child.once('error', reject); child.once('close', code => resolveExit(code ?? 1)); });
490
+ child.stdin.on('error', () => undefined);
491
+ child.stdin.end(pack.stdin);
492
+ return { chunks: child.stdout, done: async () => { if (await exited !== 0)
493
+ throw new Error(`Git could not pack the project: ${stderr.trim()}`); } };
494
+ }
495
+ /**
496
+ * Streams a pack into 8 MiB parts while the uploader sends them. Git's packs are spooled for a resumed transfer (their
497
+ * bytes may differ when generated again); the media pack is not: the same files always give the same bytes, so a
498
+ * resumed transfer generates it again and skips the parts the VM already holds.
499
+ */
500
+ async function sendPack(folder, snapshot, pack, present, upload, stats, signal) {
501
+ const spool = join(snapshot.directory, 'spool'), recordPath = join(spool, `${pack.key}.json`), spooled = !pack.files;
502
+ let record;
503
+ try {
504
+ record = JSON.parse(await readFile(recordPath, 'utf8'));
505
+ }
506
+ catch { /* not generated yet */ }
507
+ if (record && (await Promise.all(record.chunks.map(key => stat(join(spool, key)).then(() => true, () => false)))).every(Boolean)) {
508
+ for (const key of record.chunks) {
509
+ if (present.has(key)) {
510
+ stats.reused += (await stat(join(spool, key))).size;
511
+ continue;
512
+ }
513
+ await upload.put(key, await readFile(join(spool, key)));
514
+ present.add(key);
515
+ }
516
+ return record;
517
+ }
518
+ const changed = [], source = packSource(folder, pack, snapshot.manifest.format, changed, signal);
519
+ const chunks = [];
520
+ let size = 0, held = [], heldBytes = 0;
521
+ const emit = async (data) => {
522
+ const key = sha256(data), target = join(spool, key);
523
+ if (spooled)
524
+ try {
525
+ await stat(target);
526
+ }
527
+ catch {
528
+ const temp = `${target}.${randomUUID()}.tmp`;
529
+ await writeFile(temp, data, { mode: 0o600, flag: 'wx' });
530
+ await rename(temp, target);
531
+ }
532
+ chunks.push(key);
533
+ size += data.length;
534
+ if (present.has(key))
535
+ stats.reused += data.length;
536
+ else {
537
+ await upload.put(key, data);
538
+ present.add(key);
539
+ }
540
+ };
541
+ for await (const raw of source.chunks) {
542
+ held.push(raw);
543
+ heldBytes += raw.length;
544
+ while (heldBytes >= PART_BYTES) {
545
+ const all = Buffer.concat(held, heldBytes);
546
+ await emit(all.subarray(0, PART_BYTES));
547
+ held = [all.subarray(PART_BYTES)];
548
+ heldBytes = all.length - PART_BYTES;
549
+ }
550
+ }
551
+ if (heldBytes)
552
+ await emit(Buffer.concat(held, heldBytes));
553
+ await source.done();
554
+ record = { chunks, size, ...(changed.length ? { changed } : {}) };
555
+ if (spooled)
556
+ await writeFile(recordPath, JSON.stringify(record), { mode: 0o600 });
557
+ // Media edited while they were sent: a later call learns of them even after the cache took this pack.
558
+ else if (changed.length)
559
+ await writeFile(changedPath(snapshot.directory, pack.key), JSON.stringify(changed), { mode: 0o600 });
560
+ return record;
561
+ }
562
+ const changedPath = (directory, key) => join(directory, 'spool', `${key}.changed.json`);
563
+ async function sendFile(path, present, upload, stats) {
564
+ const file = await open(path, constants.O_RDONLY), keys = [];
565
+ try {
566
+ const size = (await file.stat()).size;
567
+ for (let at = 0; at < size;) {
568
+ const data = Buffer.allocUnsafe(Math.min(PART_BYTES, size - at));
569
+ for (let read = 0; read < data.length;) {
570
+ const { bytesRead } = await file.read(data, read, data.length - read, at + read);
571
+ if (!bytesRead)
572
+ throw new Error('The project files archive changed.');
573
+ read += bytesRead;
574
+ }
575
+ const key = sha256(data);
576
+ keys.push(key);
577
+ if (present.has(key))
578
+ stats.reused += data.length;
579
+ else {
580
+ await upload.put(key, data);
581
+ present.add(key);
582
+ }
583
+ at += data.length;
584
+ }
585
+ }
586
+ finally {
587
+ await file.close();
588
+ }
589
+ return keys;
590
+ }
591
+ /** Where a conversation start's parts go: the VM's own disk, indexed there; the cache receives them after the reply. */
592
+ export const PARTS_ROOT = '/home/user/.cache/shardflux/prepared-parts';
593
+ /**
594
+ * Sends the revision, only what the project cache lacks. Without `apply` it goes into the cache (a preparation). With
595
+ * `apply` (a conversation start) it is checked out at that target: in the probe's own exec when the cache already
596
+ * holds the revision (a warm start is one exec), otherwise from parts uploaded to the VM's disk and indexed there, with
597
+ * no Git work on the shared cache before the reply; the cache receives them after it. With `defer`, media beyond
598
+ * DEFER_BYTES wait for a later call: the result names them in `pending`, and the worktree starts without them.
599
+ */
600
+ export async function transferPreparedSnapshot(cell, folder, cache, snapshot, options = {}) {
601
+ const { signal } = options, m = snapshot.manifest, started = performance.now(), timings = {};
602
+ const markTime = (name) => { timings[name] = Math.round(performance.now() - started); };
603
+ const stats = noStats(), sizes = {}, changed = [];
604
+ let originFetched = false;
605
+ const result = (missing) => {
606
+ const lost = new Set(changed), absent = new Set(missing), pending = m.files.filter(f => absent.has(f.oid) && !lost.has(f.path)).map(f => f.path);
607
+ return { revision: snapshot.revision, complete: !pending.length, pending, history_bytes: sizes.history ?? 0, large_bytes: (sizes.large ?? 0) + (sizes.content ?? 0),
608
+ media_bytes: sizes.media ?? 0, extras_bytes: sizes.extras ?? 0, uploaded_bytes: stats.uploaded, reused_bytes: stats.reused, puts: stats.puts, execs: stats.execs,
609
+ max_in_flight_bytes: stats.maxInFlight, origin_fetched: originFetched, changed, timings };
610
+ };
611
+ const staging = options.apply !== undefined, partsDir = staging ? options.partsDir ?? `${PARTS_ROOT}/${sha256(cache).slice(0, 16)}` : `${cache}/chunks`;
612
+ let probe = await invoke(cell, 'probe', cache, { manifest: m, ...(options.pin ? { pin: options.pin } : {}),
613
+ ...(options.apply ? { apply: applyRequest(options.apply) } : {}), ...(staging ? { parts_dir: partsDir } : {}) }, stats, signal);
614
+ markTime('probe');
615
+ if (probe.revision !== snapshot.revision)
616
+ throw new Error('The VM computed a different project revision.');
617
+ let applied = probe.applied;
618
+ if (probe.ready)
619
+ return { ...result([]), ...(applied ? { applied } : {}) };
620
+ const planPath = join(snapshot.directory, `plan-${snapshot.revision}.json`);
621
+ let plan;
622
+ try {
623
+ plan = JSON.parse(await readFile(planPath, 'utf8'));
624
+ }
625
+ catch { /* first attempt */ }
626
+ // A saved plan counts on the commits the cache held then; one the cache no longer holds (pruned, another project
627
+ // space) is negotiated again before anything is sent.
628
+ // A plan made while the cache had the revision carries no history: it holds only while the cache still does.
629
+ if (plan && (!plan.base || plan.base.some(oid => !probe.tips.includes(oid)) || (plan.published && !probe.published))) {
630
+ await cleanSpool(snapshot.directory, plan);
631
+ plan = undefined;
632
+ }
633
+ if (!plan) {
634
+ let known = await localCommits(folder, probe.tips, signal);
635
+ // History origin has, beyond what the cache holds, comes from origin inside the cloud rather than over this link:
636
+ // the VM's own fetch of the branch at boot when it made one (a cold cache takes it whatever its size; a cache
637
+ // behind by little gets the few commits from here), else a fetch from the cache.
638
+ if (!probe.published && m.origin && m.upstream?.ref.startsWith('refs/remotes/origin/') && !known.includes(m.upstream.oid)) {
639
+ const prefetch = staging ? probe.prefetch : undefined;
640
+ const estimate = prefetch && !known.length ? Infinity
641
+ : Number(await optional(folder, ['rev-list', '--objects', '--disk-usage', m.upstream.oid, ...known.map(oid => `^${oid}`)], signal)) || 0;
642
+ // A prefetch still running is waited for only when the history is worth a fetch of its own.
643
+ const adopt = prefetch === 'ready' ? estimate > PREFETCH_ADOPT_BYTES : prefetch === 'running' && estimate > (options.originFirstBytes ?? ORIGIN_FIRST_BYTES);
644
+ if (adopt || estimate > (options.originFirstBytes ?? ORIGIN_FIRST_BYTES)) {
645
+ try {
646
+ const fetched = await invoke(cell, 'origin', cache, { url: m.origin,
647
+ ref: `refs/heads/${m.upstream.ref.slice(20)}`, name: sha256(m.upstream.ref).slice(0, 16), format: m.format, expect: m.upstream.oid,
648
+ ...(adopt ? { prefetch: options.apply.root } : {}) }, stats, signal);
649
+ probe = { ...probe, tips: fetched.tips };
650
+ // This computer's upstream commit counts as held only when the cache now has it (origin may have rewritten it).
651
+ known = await localCommits(folder, [...fetched.tips, ...(fetched.has_expect !== false ? [m.upstream.oid] : [])], signal);
652
+ originFetched = true;
653
+ if (fetched.adopted)
654
+ markTime('prefetch_adopted');
655
+ }
656
+ catch (error) {
657
+ if (signal?.aborted || !(error instanceof PreparedRefusal))
658
+ throw error;
659
+ }
660
+ markTime('origin');
661
+ }
662
+ }
663
+ plan = { ...await planTransfer(folder, snapshot, known, probe.missing_files, !probe.published, signal), base: known.filter(oid => probe.tips.includes(oid)),
664
+ published: probe.published };
665
+ await writeFile(planPath, JSON.stringify(plan), { mode: 0o600 });
666
+ }
667
+ markTime('plan');
668
+ // What the cache imported is its own record; packs staged into another VM count only once the cache has them.
669
+ const present = new Set(probe.chunks), done = new Set(probe.imported);
670
+ const send = async (packs, extras) => {
671
+ for (let attempt = 0;; attempt++) {
672
+ const upload = new Uploader(cell, partsDir, stats, signal), sent = [];
673
+ const staged = [];
674
+ let extrasChunks;
675
+ for (const pack of packs) {
676
+ if (done.has(pack.key)) {
677
+ const edited = await readFile(changedPath(snapshot.directory, pack.key), 'utf8').then(text => JSON.parse(text), () => []);
678
+ for (const path of edited)
679
+ if (!changed.includes(path))
680
+ changed.push(path);
681
+ continue;
682
+ }
683
+ const record = await sendPack(folder, snapshot, pack, present, upload, stats, signal);
684
+ markTime(`packed_${pack.kind}`);
685
+ sizes[pack.kind] = record.size;
686
+ sent.push({ key: pack.key, chunks: record.chunks, thin: pack.thin });
687
+ if (pack.files)
688
+ staged.push(...pack.files.map(f => f.oid).filter(oid => !record.changed?.length || !m.files.some(f => f.oid === oid && record.changed.includes(f.path))));
689
+ for (const path of record.changed ?? [])
690
+ if (!changed.includes(path))
691
+ changed.push(path);
692
+ }
693
+ if (extras && m.extras_key && !probe.extras_present) {
694
+ const archive = join(snapshot.directory, `extras-${m.extras_key}.tar`);
695
+ sizes.extras = (await stat(archive)).size;
696
+ extrasChunks = await sendFile(archive, present, upload, stats);
697
+ }
698
+ await upload.drain();
699
+ markTime('uploaded');
700
+ try {
701
+ let missing;
702
+ if (staging) {
703
+ const out = await invoke(cell, 'stage', cache, { manifest: m, packs: sent, ...(extrasChunks ? { extras_chunks: extrasChunks } : {}),
704
+ parts_dir: partsDir, apply: applyRequest(options.apply), media_staged: staged, ...(options.pin ? { pin: options.pin } : {}) }, stats, signal);
705
+ applied ??= out.applied;
706
+ const arrived = new Set(staged);
707
+ missing = probe.missing_files.filter(oid => !arrived.has(oid));
708
+ probe = { ...probe, missing_files: missing };
709
+ }
710
+ else {
711
+ missing = (await invoke(cell, 'import', cache, { manifest: m, packs: sent, ...(extrasChunks ? { extras_chunks: extrasChunks } : {}) }, stats, signal)).missing_files;
712
+ }
713
+ markTime(staging ? 'staged' : 'imported');
714
+ for (const pack of sent)
715
+ done.add(pack.key);
716
+ return missing;
717
+ }
718
+ catch (error) {
719
+ if (error instanceof PreparedRefusal && ['missing', 'corrupt'].includes(error.reason) && attempt < 2) {
720
+ for (const key of error.chunks)
721
+ present.delete(key);
722
+ continue;
723
+ }
724
+ // Anything else (a delta base the cache no longer holds, a snapshot it cannot complete): the next attempt
725
+ // negotiates again from what the cache holds then, instead of repeating this plan.
726
+ if (!signal?.aborted)
727
+ await cleanSpool(snapshot.directory, plan);
728
+ throw error;
729
+ }
730
+ }
731
+ };
732
+ // A revision another start published meanwhile needs only its media.
733
+ const packs = probe.published ? plan.packs.filter(p => p.kind === 'media') : plan.packs;
734
+ const media = packs.find(p => p.kind === 'media' && !done.has(p.key)), later = options.defer === true && (media?.files ?? []).reduce((n, f) => n + f.size, 0) > DEFER_BYTES;
735
+ // Already checked out (the probe did, or an earlier stage): only what is still missing goes, and nothing to stage
736
+ // when nothing is left.
737
+ const remaining = packs.filter(p => !done.has(p.key) && (!later || p.kind !== 'media'));
738
+ const missing = !probe.published || remaining.length || (m.extras_key && !probe.extras_present) || (staging && !applied) ? await send(remaining, true) : probe.missing_files;
739
+ markTime(later ? 'start_ready' : 'sent');
740
+ const out = result(missing);
741
+ if (!later)
742
+ await cleanSpool(snapshot.directory, plan);
743
+ return { ...out, ...(applied ? { applied } : {}) };
744
+ }
745
+ async function cleanSpool(directory, plan) {
746
+ for (const pack of plan.packs) {
747
+ const recordPath = join(directory, 'spool', `${pack.key}.json`);
748
+ try {
749
+ const record = JSON.parse(await readFile(recordPath, 'utf8'));
750
+ for (const key of record.chunks)
751
+ await rm(join(directory, 'spool', key), { force: true });
752
+ }
753
+ catch { /* already gone */ }
754
+ await rm(recordPath, { force: true });
755
+ await rm(changedPath(directory, pack.key), { force: true });
756
+ }
757
+ await rm(join(directory, `plan-${plan.revision}.json`), { force: true });
758
+ }
759
+ /** Git settings the VM repository copies from this one: origin, identity, the branch's upstream, exclude rules. */
760
+ export async function preparedGitConfig(folder, manifest, signal) {
761
+ const user = {};
762
+ for (const key of ['name', 'email']) {
763
+ const value = await optional(folder, ['config', '--get', `user.${key}`], signal);
764
+ if (value)
765
+ user[key] = value;
766
+ }
767
+ const readOptional = async (path) => { try {
768
+ return await readFile(path, 'utf8');
769
+ }
770
+ catch (error) {
771
+ if (error.code === 'ENOENT')
772
+ return '';
773
+ throw error;
774
+ } };
775
+ const info = await optional(folder, ['rev-parse', '--git-path', 'info/exclude'], signal);
776
+ const global = await optional(folder, ['config', '--path', '--get', 'core.excludesFile'], signal);
777
+ const excludes = (info ? await readOptional(resolve(folder, info)) : '') + '\n' + await readOptional(global ? resolve(folder, global) : join(process.env.XDG_CONFIG_HOME ?? join(homedir(), '.config'), 'git', 'ignore'));
778
+ const tracking = manifest.branch && manifest.upstream?.ref.startsWith('refs/remotes/origin/') ? { branch: manifest.branch.slice(11), merge: `refs/heads/${manifest.upstream.ref.slice(20)}` } : undefined;
779
+ return { ...(manifest.origin ? { origin_url: manifest.origin } : {}), ...(Object.keys(user).length ? { user } : {}), ...(tracking ? { tracking } : {}), excludes };
780
+ }
781
+ const applyRequest = ({ root, branch, options = {} }) => ({ root, branch, ...options.git,
782
+ ...(options.preparation ? { preparation: true } : {}), ...(options.replacePreparation ? { replace_preparation: true } : {}), ...(options.adopt ? { adopt: true } : {}),
783
+ ...(options.dependencySource ? { dependency_source: options.dependencySource } : {}),
784
+ ...(options.dissociate !== undefined && options.dissociate !== true ? { dissociate: options.dissociate } : {}),
785
+ ...(options.background === false ? { background: false } : {}), ...(options.releasePin ? { release_pin: options.releasePin } : {}),
786
+ ...(options.after ? { after: options.after } : {}),
787
+ ...(options.setupIntent ? { setup_intent: { conversation: options.setupIntent.conversation, deps_hash: options.setupIntent.depsHash } } : {}),
788
+ ...(options.workspace ? { workspace: options.workspace } : {}) });
789
+ /**
790
+ * Checks out the revision as `branch` at `root`, in one exec. Media not in the cache yet stay pending
791
+ * (landDeferredFiles). A root holding the same revision is left as it is; one holding another is refused, unless it is
792
+ * a project template's checkout or `replacePreparation`.
793
+ */
794
+ export async function applyPreparedSnapshot(cell, cache, revision, root, branch, options = {}) {
795
+ return invoke(cell, 'apply', cache, { revision, ...applyRequest({ root, branch, options }) }, noStats(), options.signal);
796
+ }
797
+ /** Lands media that arrived after the start (where nothing is yet: the agent's own file stands); `drop` gives up on blobs that changed. */
798
+ export async function landDeferredFiles(cell, cache, revision, root, options = {}) {
799
+ return invoke(cell, 'materialize', cache, { revision, root, drop: options.drop ?? [] }, noStats(), options.signal);
800
+ }
801
+ export async function prunePreparedCache(cell, cache, signal) {
802
+ await invoke(cell, 'prune', cache, {}, noStats(), signal);
803
+ }
804
+ /**
805
+ * The local spool stays bounded: a finished transfer's parts go at once; other parts and plans after a day or past
806
+ * the budget, oldest first; a revision's manifest and files archive (what a pending start resumes from) after a week.
807
+ */
808
+ export async function pruneLocalPrepared(stateDir, limit = SPOOL_BYTES, every = 0) {
809
+ const root = join(stateDir, 'prepared');
810
+ let worktrees;
811
+ try {
812
+ worktrees = await readdir(root);
813
+ }
814
+ catch {
815
+ return;
816
+ }
817
+ // A start prunes at most every `every` ms (the time is in the file, written before the pass).
818
+ const marker = join(root, '.pruned-at'), last = Number(await readFile(marker, 'utf8').catch(() => '0')) || 0;
819
+ if (every && Date.now() - last < every)
820
+ return;
821
+ await writeFile(marker, String(Date.now()), { mode: 0o600 });
822
+ await withTargetLock(join(root, '.prune-lock'), async () => {
823
+ const files = [];
824
+ for (const id of worktrees) {
825
+ if (!/^[a-f0-9]{16}$/.test(id))
826
+ continue;
827
+ for (const dir of [join(root, id), join(root, id, 'spool')]) {
828
+ let names;
829
+ try {
830
+ names = await readdir(dir);
831
+ }
832
+ catch {
833
+ continue;
834
+ }
835
+ for (const name of names) {
836
+ if (name === 'spool')
837
+ continue;
838
+ const path = join(dir, name), st = await stat(path).catch(() => undefined);
839
+ if (st?.isFile())
840
+ files.push({ path, size: st.size, at: st.mtimeMs });
841
+ }
842
+ }
843
+ }
844
+ let used = files.reduce((n, f) => n + f.size, 0);
845
+ for (const f of files.sort((a, b) => a.at - b.at)) {
846
+ const age = Date.now() - f.at, kept = /\/(?:manifest-[a-f0-9]{64}\.json|extras-[a-f0-9]{64}\.tar|blob-ids-sha(?:1|256)\.json|folder)$/.test(f.path);
847
+ if (age < 60_000)
848
+ continue; // a transfer in progress is writing it
849
+ if (kept ? age < 7 * 86_400_000 : used <= limit && age < 86_400_000)
850
+ continue;
851
+ await rm(f.path, { force: true });
852
+ used -= f.size;
853
+ // A dropped revision's snapshot no longer pins objects in the user's repository.
854
+ const revision = /\/manifest-([a-f0-9]{64})\.json$/.exec(f.path)?.[1];
855
+ const folder = revision ? await readFile(join(f.path, '..', 'folder'), 'utf8').catch(() => '') : '';
856
+ if (revision && folder)
857
+ await releaseLocalSnapshot(folder, revision);
858
+ }
859
+ });
860
+ }