@shardflux/cli 0.18.0 → 0.19.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 (54) hide show
  1. package/CHANGELOG.md +96 -1
  2. package/README.md +28 -1
  3. package/dist/account-args.js +1 -0
  4. package/dist/agent-commands.d.ts +1 -0
  5. package/dist/agent-commands.js +15 -5
  6. package/dist/agent-config-files.d.ts +16 -2
  7. package/dist/agent-config-files.js +48 -61
  8. package/dist/agent-config-sync.d.ts +12 -1
  9. package/dist/agent-config-sync.js +228 -54
  10. package/dist/agent-repository.d.ts +39 -9
  11. package/dist/agent-repository.js +309 -92
  12. package/dist/agent-state.d.ts +3 -0
  13. package/dist/agent-state.js +13 -0
  14. package/dist/agent-target-commands.d.ts +2 -0
  15. package/dist/agent-target-commands.js +55 -18
  16. package/dist/args.d.ts +1 -1
  17. package/dist/args.js +16 -4
  18. package/dist/command-center-actions.js +31 -5
  19. package/dist/conversation-commands.js +6 -1
  20. package/dist/conversation-router.js +7 -11
  21. package/dist/conversation-stash.js +64 -18
  22. package/dist/conversation-terminal.d.ts +11 -1
  23. package/dist/conversation-terminal.js +28 -7
  24. package/dist/conversations.d.ts +17 -3
  25. package/dist/conversations.js +58 -6
  26. package/dist/errors.d.ts +1 -1
  27. package/dist/errors.js +1 -1
  28. package/dist/files-mirror.d.ts +1 -9
  29. package/dist/files-mirror.js +19 -3
  30. package/dist/format.js +37 -2
  31. package/dist/handlers.js +3 -1
  32. package/dist/local-sessions.d.ts +60 -0
  33. package/dist/local-sessions.js +445 -0
  34. package/dist/main.js +12 -0
  35. package/dist/move.d.ts +44 -0
  36. package/dist/move.js +248 -0
  37. package/dist/moved-sessions.d.ts +33 -0
  38. package/dist/moved-sessions.js +91 -0
  39. package/dist/onboarding.d.ts +18 -3
  40. package/dist/onboarding.js +72 -46
  41. package/dist/project-sync-manifest.d.ts +31 -3
  42. package/dist/project-sync-manifest.js +192 -108
  43. package/dist/project-sync-transfer.d.ts +50 -4
  44. package/dist/project-sync-transfer.js +404 -106
  45. package/dist/project-sync.js +30 -33
  46. package/dist/seed-phases.d.ts +29 -0
  47. package/dist/seed-phases.js +83 -0
  48. package/dist/t3-launch.js +3 -1
  49. package/dist/t3-mode.d.ts +7 -2
  50. package/dist/t3-mode.js +39 -5
  51. package/dist/thread-on-computer.js +4 -0
  52. package/dist/worktree-twin.d.ts +2 -6
  53. package/dist/worktree-twin.js +16 -51
  54. package/package.json +5 -5
@@ -1,22 +1,31 @@
1
1
  import { createHash, randomUUID } from 'node:crypto';
2
2
  import { constants } from 'node:fs';
3
- import { chmod, lstat, mkdir, open, readlink, rename, rm, symlink, utimes } from 'node:fs/promises';
3
+ import { chmod, copyFile, lstat, mkdir, open, readlink, rename, rm, rmdir, symlink, utimes } from 'node:fs/promises';
4
4
  import { dirname, join } from 'node:path';
5
5
  import { tarEnd, tarHeader, tarPadding } from '@shardflux/sdk';
6
- import { contentIdentity, projectFilePath, validSyncPath } from "./project-sync-manifest.js";
6
+ import { contentIdentity, localEntry, projectFilePath, validSyncPath } from "./project-sync-manifest.js";
7
+ /** One request's worth of file data. A file larger than this streams through its own archive, one chunk at a time. */
7
8
  export const SYNC_CHUNK_BYTES = 8 << 20;
9
+ /** Small files per batch archive (and at most SYNC_CHUNK_BYTES of their data). */
10
+ export const SYNC_BATCH_FILES = 128;
11
+ /** A seed (overwrite) checks no previous versions, so one archive carries more small files (still SYNC_CHUNK_BYTES). */
12
+ const SYNC_SEED_BATCH_FILES = 4096;
13
+ /** Deletions per apply run. */
14
+ const SYNC_BATCH_DELETIONS = 4096;
15
+ const changed = (path) => new Error(`${path} changed during sync; compare manifests again`);
8
16
  export function verifySyncBytes(entry, data) {
9
17
  const hash = createHash('sha256').update(entry.symlink ? `symlink\0${entry.link_target}` : data).digest('hex');
10
18
  if (hash !== entry.sha256 || (!entry.symlink && data.byteLength !== entry.size))
11
- throw new Error(`${entry.path} changed during sync; compare manifests again`);
19
+ throw changed(entry.path);
12
20
  }
21
+ /** A small file's verified bytes (whole in memory: callers stream anything above SYNC_CHUNK_BYTES). */
13
22
  export async function readLocalSyncFile(folder, entry) {
14
23
  const path = await projectFilePath(folder, entry.path);
15
24
  const st = await lstat(path);
16
25
  let data = new Uint8Array();
17
26
  if (entry.symlink) {
18
27
  if (!st.isSymbolicLink() || await readlink(path) !== entry.link_target)
19
- throw new Error(`${entry.path} changed during sync`);
28
+ throw changed(entry.path);
20
29
  }
21
30
  else {
22
31
  const file = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
@@ -26,11 +35,11 @@ export async function readLocalSyncFile(folder, entry) {
26
35
  while (offset < data.length) {
27
36
  const { bytesRead } = await file.read(data, offset, data.length - offset, offset);
28
37
  if (!bytesRead)
29
- throw new Error(`${entry.path} changed during sync`);
38
+ throw changed(entry.path);
30
39
  offset += bytesRead;
31
40
  }
32
41
  if ((await file.stat()).size !== entry.size)
33
- throw new Error(`${entry.path} changed during sync`);
42
+ throw changed(entry.path);
34
43
  }
35
44
  finally {
36
45
  await file.close();
@@ -41,34 +50,19 @@ export async function readLocalSyncFile(folder, entry) {
41
50
  verifySyncBytes(entry, data);
42
51
  return { entry, data };
43
52
  }
44
- export async function readRemoteSyncFile(cell, root, entry) {
45
- const data = new Uint8Array(entry.symlink ? 0 : entry.size);
46
- for (let offset = 0; offset < data.length;) {
47
- const chunk = await cell.files.read(`${root}/${entry.path}`, { offset, length: Math.min(SYNC_CHUNK_BYTES, data.length - offset) });
48
- if (chunk.length === 0 || chunk.length > data.length - offset)
49
- throw new Error(`${entry.path} changed during sync`);
50
- data.set(chunk, offset);
51
- offset += chunk.length;
52
- }
53
- verifySyncBytes(entry, data);
54
- return { entry, data };
53
+ // ---------------------------------------------------------------------------------------------------------------
54
+ // VM → laptop
55
+ async function localIdentity(folder, path) {
56
+ const found = await localEntry(folder, path);
57
+ if (found && !('sha256' in found))
58
+ throw new Error(`${path} is not a regular file or symbolic link`);
59
+ return contentIdentity(found);
55
60
  }
56
61
  async function checkLocal(folder, path, expected) {
57
- if (expected) {
58
- await readLocalSyncFile(folder, expected);
59
- return;
60
- }
61
- try {
62
- await lstat(await projectFilePath(folder, path));
63
- }
64
- catch (error) {
65
- if (error.code === 'ENOENT')
66
- return;
67
- throw error;
68
- }
69
- throw new Error(`${path} was created during sync`);
62
+ if (await localIdentity(folder, path) !== contentIdentity(expected))
63
+ throw new Error(expected ? `${path} changed during sync` : `${path} was created during sync`);
70
64
  }
71
- export async function applyLocalSyncFile(folder, path, file, expected) {
65
+ async function makeParents(folder, path) {
72
66
  if (!validSyncPath(path))
73
67
  throw new Error(`unsafe sync path: ${path}`);
74
68
  // Create each parent separately and refuse links before descending.
@@ -86,31 +80,30 @@ export async function applyLocalSyncFile(folder, path, file, expected) {
86
80
  if (!st.isDirectory() || st.isSymbolicLink())
87
81
  throw new Error(`sync parent is not a directory: ${parent}`);
88
82
  }
89
- const target = await projectFilePath(folder, path);
90
- await checkLocal(folder, path, expected);
91
- if (!file) {
92
- await rm(target, { force: true });
93
- return;
83
+ }
84
+ /** Directories a deletion left empty go too (as Git does), so a path can turn from a directory into a file. */
85
+ async function pruneLocalParents(folder, path) {
86
+ for (let dir = dirname(path); dir !== '.'; dir = dirname(dir)) {
87
+ try {
88
+ await rmdir(join(folder, dir));
89
+ }
90
+ catch {
91
+ return;
92
+ }
94
93
  }
95
- verifySyncBytes(file.entry, file.data);
94
+ }
95
+ /** Writes `tmp` with `write(fd)`, then re-checks the destination and renames over it; `tmp` never outlives the call. */
96
+ async function replaceLocal(folder, path, entry, expected, write) {
97
+ const target = await projectFilePath(folder, path);
96
98
  const tmp = `${target}.shard-tmp-${randomUUID()}`;
97
99
  try {
98
- if (file.entry.symlink)
99
- await symlink(file.entry.link_target, tmp);
100
- else {
101
- const fd = await open(tmp, 'wx', file.entry.mode);
102
- try {
103
- await fd.writeFile(file.data);
104
- await fd.sync();
105
- }
106
- finally {
107
- await fd.close();
108
- }
109
- await chmod(tmp, file.entry.mode);
110
- await utimes(tmp, file.entry.mtime_unix_ms / 1000, file.entry.mtime_unix_ms / 1000);
100
+ await write(tmp);
101
+ if (!entry.symlink) {
102
+ await chmod(tmp, entry.mode);
103
+ await utimes(tmp, entry.mtime_unix_ms / 1000, entry.mtime_unix_ms / 1000);
111
104
  }
112
- // The read above is deliberately just before replacement; concurrent
113
- // changes detected here fail the pass instead of advancing its baseline.
105
+ // The check is deliberately just before replacement; concurrent changes
106
+ // detected here fail the pass instead of advancing its baseline.
114
107
  await checkLocal(folder, path, expected);
115
108
  await rename(tmp, target);
116
109
  }
@@ -118,25 +111,114 @@ export async function applyLocalSyncFile(folder, path, file, expected) {
118
111
  await rm(tmp, { force: true });
119
112
  }
120
113
  }
121
- // Extract our existing SDK tar format with dirfd + O_NOFOLLOW, so an agent's
122
- // parent symlink cannot redirect a batch out of the project. Compare previous
123
- // hashes at the mutation boundary, retain mtimes, and fsync every replacement.
114
+ async function writeFileAt(tmp, mode, fill) {
115
+ const fd = await open(tmp, 'wx', mode);
116
+ try {
117
+ await fill(fd);
118
+ await fd.sync();
119
+ }
120
+ finally {
121
+ await fd.close();
122
+ }
123
+ }
124
+ /** Applies a file already in memory (or deletes `path` when `file` is undefined). `expected` is the local version it replaces. */
125
+ export async function applyLocalSyncFile(folder, path, file, expected) {
126
+ await makeParents(folder, path);
127
+ await checkLocal(folder, path, expected);
128
+ if (!file) {
129
+ await rm(await projectFilePath(folder, path), { force: true });
130
+ await pruneLocalParents(folder, path);
131
+ return;
132
+ }
133
+ verifySyncBytes(file.entry, file.data);
134
+ await replaceLocal(folder, path, file.entry, expected, async (tmp) => {
135
+ if (file.entry.symlink)
136
+ await symlink(file.entry.link_target, tmp);
137
+ else
138
+ await writeFileAt(tmp, file.entry.mode, async (fd) => { await fd.writeFile(file.data); });
139
+ });
140
+ }
141
+ /**
142
+ * Applies the VM's version of one file here (or deletes it when `entry` is undefined), streaming it chunk by chunk
143
+ * into a temporary file next to the target: any size, never whole in memory. The bytes must hash to `entry`.
144
+ * `from` reads another VM path (a conflict copy); `expected` is the local version it replaces.
145
+ */
146
+ export async function pullRemoteFile(cell, root, folder, path, entry, options = {}) {
147
+ const { expected, signal } = options;
148
+ await makeParents(folder, path);
149
+ await checkLocal(folder, path, expected);
150
+ if (!entry) {
151
+ await rm(await projectFilePath(folder, path), { force: true });
152
+ await pruneLocalParents(folder, path);
153
+ return;
154
+ }
155
+ if (entry.symlink) {
156
+ verifySyncBytes(entry, new Uint8Array());
157
+ await replaceLocal(folder, path, entry, expected, (tmp) => symlink(entry.link_target, tmp));
158
+ return;
159
+ }
160
+ const source = `${root}/${options.from ?? path}`;
161
+ await replaceLocal(folder, path, entry, expected, (tmp) => writeFileAt(tmp, entry.mode, async (fd) => {
162
+ const hash = createHash('sha256');
163
+ for (let offset = 0; offset < entry.size;) {
164
+ signal?.throwIfAborted();
165
+ const chunk = await cell.files.read(source, { offset, length: Math.min(SYNC_CHUNK_BYTES, entry.size - offset) });
166
+ if (chunk.length === 0 || chunk.length > entry.size - offset)
167
+ throw changed(path);
168
+ hash.update(chunk);
169
+ for (let at = 0; at < chunk.length;)
170
+ at += (await fd.write(chunk, at, chunk.length - at, offset + at)).bytesWritten;
171
+ offset += chunk.length;
172
+ }
173
+ if (hash.digest('hex') !== entry.sha256)
174
+ throw changed(path);
175
+ }));
176
+ }
177
+ /** Copies a local file to another project path (a conflict copy), verified against `entry`; any size. */
178
+ export async function copyLocalSyncFile(folder, entry, to) {
179
+ await makeParents(folder, to);
180
+ await checkLocal(folder, to, undefined);
181
+ const source = await projectFilePath(folder, entry.path);
182
+ await replaceLocal(folder, to, entry, undefined, async (tmp) => {
183
+ if (entry.symlink) {
184
+ if (await readlink(source) !== entry.link_target)
185
+ throw changed(entry.path);
186
+ await symlink(entry.link_target, tmp);
187
+ return;
188
+ }
189
+ await copyFile(source, tmp, constants.COPYFILE_EXCL);
190
+ const copied = await localEntry(dirname(tmp), tmp.slice(dirname(tmp).length + 1));
191
+ if (!copied || !('sha256' in copied) || copied.sha256 !== entry.sha256 || copied.size !== entry.size)
192
+ throw changed(entry.path);
193
+ });
194
+ }
195
+ // ---------------------------------------------------------------------------------------------------------------
196
+ // Laptop → VM
197
+ // Extract our SDK tar format with dirfd + O_NOFOLLOW, so an agent's parent
198
+ // symlink cannot redirect a batch out of the project. The first member is the
199
+ // plan: [path, previous identity (null with overwrite), next entry (null deletes)].
200
+ // Compare previous hashes at the mutation boundary, retain mtimes, fsync every
201
+ // replacement and each touched directory before exiting, except with overwrite (a seed into a fresh checkout, which a
202
+ // retry repeats; an fsync per file cost 5.7 ms in a VM, 3.4 s for a 600-file seed). No size limits: members
203
+ // stream through a 1 MiB buffer. A deletion removes the directories it empties.
124
204
  export const APPLY_SYNC_TAR = String.raw `
125
205
  import os,sys,json,hashlib,tarfile,uuid,stat
126
- root,archive,changes=sys.argv[1:]
127
- changes=json.loads(changes)
206
+ root,archive=sys.argv[1:]
128
207
  rootfd=os.open(root,os.O_RDONLY|os.O_DIRECTORY|os.O_NOFOLLOW)
129
- def parent(p):
208
+ def split(p):
130
209
  parts=p.split('/')
131
210
  if not p or any(x in ('','..','.','.git','.shard') for x in parts) or '\\' in p: raise ValueError('unsafe project path')
211
+ return parts
212
+ def walk(parts,make):
132
213
  fd=os.dup(rootfd)
133
214
  try:
134
- for x in parts[:-1]:
135
- try: os.mkdir(x,dir_fd=fd)
136
- except FileExistsError: pass
215
+ for x in parts:
216
+ if make:
217
+ try: os.mkdir(x,dir_fd=fd)
218
+ except FileExistsError: pass
137
219
  child=os.open(x,os.O_RDONLY|os.O_DIRECTORY|os.O_NOFOLLOW,dir_fd=fd)
138
220
  os.close(fd);fd=child
139
- return fd,parts[-1]
221
+ return fd
140
222
  except: os.close(fd);raise
141
223
  def identity(fd,name):
142
224
  try: st=os.stat(name,dir_fd=fd,follow_symlinks=False)
@@ -144,26 +226,40 @@ def identity(fd,name):
144
226
  if stat.S_ISLNK(st.st_mode):
145
227
  digest=hashlib.sha256(('symlink\0'+os.readlink(name,dir_fd=fd)).encode()).hexdigest();kind='link'
146
228
  elif stat.S_ISREG(st.st_mode):
147
- if st.st_size>67108864: raise ValueError('destination grew beyond sync limit')
148
229
  f=os.open(name,os.O_RDONLY|os.O_NOFOLLOW,dir_fd=fd)
149
230
  with os.fdopen(f,'rb') as reader: digest=hashlib.file_digest(reader,'sha256').hexdigest()
150
231
  kind='file'
151
232
  else: raise ValueError('destination is not a file')
152
233
  return digest+':'+str(stat.S_IMODE(st.st_mode)&511)+':'+kind
234
+ dirty=set()
235
+ def prune(parts):
236
+ for n in range(len(parts)-1,0,-1):
237
+ try: fd=walk(parts[:n-1],False)
238
+ except OSError: return
239
+ try: os.rmdir(parts[n-1],dir_fd=fd)
240
+ except OSError: return
241
+ finally: os.close(fd)
242
+ dirty.add('/'.join(parts[:n-1]))
243
+ tar=tarfile.open(archive,'r:')
153
244
  try:
245
+ ms=tar.getmembers()
246
+ if not ms or ms[0].name!='.shard-plan' or not ms[0].isfile(): raise ValueError('invalid sync archive')
247
+ plan=json.loads(tar.extractfile(ms[0]).read())
248
+ overwrite=plan.get('overwrite') is True
249
+ wanted={c[0] for c in plan['changes'] if c[2] is not None}
154
250
  members={}
155
- tar=None
156
- if archive:
157
- tar=tarfile.open(archive,'r:')
158
- for member in tar:
159
- if member.name in members or member.name not in changes or not(member.isfile() or member.issym()) or member.size>67108864: raise ValueError('invalid sync archive')
160
- members[member.name]=member
161
- for p,change in changes.items():
162
- fd,name=parent(p)
251
+ for member in ms[1:]:
252
+ if member.name in members or member.name not in wanted or not(member.isfile() or member.issym()): raise ValueError('invalid sync archive')
253
+ members[member.name]=member
254
+ for p,previous,entry in plan['changes']:
255
+ parts=split(p);name=parts[-1]
256
+ try: fd=walk(parts[:-1],entry is not None)
257
+ except (FileNotFoundError,NotADirectoryError):
258
+ if overwrite or previous=='absent': continue
259
+ raise ValueError(p+' changed during sync')
163
260
  tmp=name+'.shard-tmp-'+uuid.uuid4().hex
164
261
  try:
165
- if identity(fd,name)!=change['previous']: raise ValueError(p+' changed during sync')
166
- entry=change['next']
262
+ if not overwrite and identity(fd,name)!=previous: raise ValueError(p+' changed during sync')
167
263
  if entry is None:
168
264
  try: os.unlink(name,dir_fd=fd)
169
265
  except FileNotFoundError: pass
@@ -182,55 +278,141 @@ try:
182
278
  b=reader.read(1048576)
183
279
  if not b: break
184
280
  digest.update(b);writer.write(b)
185
- writer.flush();os.fchmod(writer.fileno(),entry['mode']);os.fsync(writer.fileno())
281
+ writer.flush();os.fchmod(writer.fileno(),entry['mode'])
282
+ if not overwrite: os.fsync(writer.fileno())
186
283
  digest=digest.hexdigest()
187
284
  os.utime(tmp,ns=(int(entry['mtime_unix_ms']*1000000),)*2,dir_fd=fd,follow_symlinks=False)
188
285
  if digest!=entry['sha256']: raise ValueError('content mismatch')
189
- if identity(fd,name)!=change['previous']: raise ValueError(p+' changed during sync')
190
- os.replace(tmp,name,src_dir_fd=fd,dst_dir_fd=fd)
191
- os.fsync(fd)
286
+ if not overwrite and identity(fd,name)!=previous: raise ValueError(p+' changed during sync')
287
+ try: os.replace(tmp,name,src_dir_fd=fd,dst_dir_fd=fd)
288
+ except IsADirectoryError:
289
+ if not overwrite: raise
290
+ os.rmdir(name,dir_fd=fd);os.replace(tmp,name,src_dir_fd=fd,dst_dir_fd=fd)
291
+ dirty.add('/'.join(parts[:-1]))
192
292
  finally:
193
293
  try: os.unlink(tmp,dir_fd=fd)
194
294
  except FileNotFoundError: pass
195
295
  os.close(fd)
296
+ if entry is None: prune(parts)
297
+ for d in ([] if overwrite else dirty):
298
+ try: fd=walk(d.split('/') if d else [],False)
299
+ except OSError: continue
300
+ try: os.fsync(fd)
301
+ finally: os.close(fd)
196
302
  finally:
197
- if tar: tar.close()
303
+ tar.close()
198
304
  os.close(rootfd)
199
305
  `;
306
+ /** Appends to a VM file one SYNC_CHUNK_BYTES request at a time, holding at most one chunk. */
307
+ class SyncBlobWriter {
308
+ #chunk = new Uint8Array(SYNC_CHUNK_BYTES);
309
+ #held = 0;
310
+ #written = 0;
311
+ #cell;
312
+ #path;
313
+ #signal;
314
+ constructor(cell, path, signal) { this.#cell = cell; this.#path = path; this.#signal = signal; }
315
+ get size() { return this.#written + this.#held; }
316
+ async push(data) {
317
+ for (let at = 0; at < data.length;) {
318
+ const take = Math.min(SYNC_CHUNK_BYTES - this.#held, data.length - at);
319
+ this.#chunk.set(data.subarray(at, at + take), this.#held);
320
+ this.#held += take;
321
+ at += take;
322
+ if (this.#held === SYNC_CHUNK_BYTES)
323
+ await this.#flush();
324
+ }
325
+ }
326
+ /** Copies `size` bytes of `file` from `position`, hashing them; a short file fails as changed. */
327
+ async pushFrom(file, size, hash, path, position = 0) {
328
+ for (let left = size; left > 0;) {
329
+ this.#signal?.throwIfAborted();
330
+ const { bytesRead } = await file.read(this.#chunk, this.#held, Math.min(SYNC_CHUNK_BYTES - this.#held, left), position);
331
+ if (!bytesRead)
332
+ throw changed(path);
333
+ hash?.update(this.#chunk.subarray(this.#held, this.#held + bytesRead));
334
+ this.#held += bytesRead;
335
+ position += bytesRead;
336
+ left -= bytesRead;
337
+ if (this.#held === SYNC_CHUNK_BYTES)
338
+ await this.#flush();
339
+ }
340
+ }
341
+ async close() { if (this.#held > 0 || this.#written === 0)
342
+ await this.#flush(); }
343
+ async #flush() {
344
+ this.#signal?.throwIfAborted();
345
+ await this.#cell.files.write(this.#path, this.#chunk.subarray(0, this.#held), { mode: '0600', append: this.#written > 0 });
346
+ this.#written += this.#held;
347
+ this.#held = 0;
348
+ }
349
+ }
200
350
  export async function writeSyncBlob(cell, path, data) {
201
- if (data.byteLength === 0)
202
- await cell.files.write(path, data, { mode: '0600' });
203
- for (let offset = 0; offset < data.byteLength; offset += SYNC_CHUNK_BYTES)
204
- await cell.files.write(path, data.subarray(offset, offset + SYNC_CHUNK_BYTES), { mode: '0600', append: offset > 0 });
205
- }
206
- export async function applyRemoteSyncBatch(cell, root, files, expected, signal) {
207
- const parts = [];
208
- const changes = Object.create(null);
209
- let size = 0;
210
- for (const [path, file] of files) {
211
- if (!validSyncPath(path))
212
- throw new Error(`unsafe sync path: ${path}`);
213
- changes[path] = { previous: contentIdentity(expected[path]), next: file?.entry ?? null };
214
- if (file) {
215
- verifySyncBytes(file.entry, file.data);
216
- const header = tarHeader({ path, type: file.entry.symlink ? 'symlink' : 'file', size: file.data.length, mode: file.entry.mode, linkname: file.entry.link_target, mtime: Math.floor(file.entry.mtime_unix_ms / 1000) });
217
- const padding = tarPadding(file.data.length);
218
- parts.push(header, file.data, padding);
219
- size += header.length + file.data.length + padding.length;
351
+ const writer = new SyncBlobWriter(cell, path);
352
+ await writer.push(data);
353
+ await writer.close();
354
+ }
355
+ /** Uploads a local file of any size to a VM path, SYNC_CHUNK_BYTES at a time straight from disk. */
356
+ export async function writeSyncFileFrom(cell, path, local, signal) {
357
+ const file = await open(local, constants.O_RDONLY);
358
+ try {
359
+ const writer = new SyncBlobWriter(cell, path, signal);
360
+ await writer.pushFrom(file, (await file.stat()).size, undefined, local);
361
+ await writer.close();
362
+ }
363
+ finally {
364
+ await file.close();
365
+ }
366
+ }
367
+ /** Downloads a VM file of `size` bytes to a new local file, SYNC_CHUNK_BYTES at a time. */
368
+ export async function readSyncFileTo(cell, path, size, local, signal) {
369
+ await writeFileAt(local, 0o600, async (fd) => {
370
+ for (let offset = 0; offset < size;) {
371
+ signal?.throwIfAborted();
372
+ const chunk = await cell.files.read(path, { offset, length: Math.min(SYNC_CHUNK_BYTES, size - offset) });
373
+ if (chunk.length === 0 || chunk.length > size - offset)
374
+ throw new Error(`${path} changed while it was read`);
375
+ for (let at = 0; at < chunk.length;)
376
+ at += (await fd.write(chunk, at, chunk.length - at, offset + at)).bytesWritten;
377
+ offset += chunk.length;
220
378
  }
379
+ });
380
+ }
381
+ /** Copies a file of `size` bytes from one VM to another, SYNC_CHUNK_BYTES at a time. */
382
+ export async function copySyncBlob(from, path, size, to, target, signal) {
383
+ if (size === 0) {
384
+ await to.files.write(target, new Uint8Array(), { mode: '0600' });
385
+ return;
221
386
  }
222
- const end = tarEnd(size);
223
- const archive = new Uint8Array(size + end.length);
224
- let at = 0;
225
- for (const part of parts) {
226
- archive.set(part, at);
227
- at += part.length;
387
+ for (let offset = 0; offset < size;) {
388
+ signal?.throwIfAborted();
389
+ const chunk = await from.files.read(path, { offset, length: Math.min(SYNC_CHUNK_BYTES, size - offset) });
390
+ if (chunk.length === 0 || chunk.length > size - offset)
391
+ throw new Error(`${path} changed while it was copied`);
392
+ await to.files.write(target, chunk, { mode: '0600', append: offset > 0 });
393
+ offset += chunk.length;
228
394
  }
229
- archive.set(end, at);
395
+ }
396
+ const noMembers = () => Promise.resolve();
397
+ const fileHeader = (entry) => tarHeader({ path: entry.path, type: entry.symlink ? 'symlink' : 'file', size: entry.symlink ? 0 : entry.size, mode: entry.mode, linkname: entry.link_target, mtime: Math.floor(entry.mtime_unix_ms / 1000) });
398
+ /** Writes one archive (plan first, then `body` adds the members) to the VM and applies it there. */
399
+ async function applyArchive(cell, root, overwrite, changes, signal, body) {
400
+ for (const [path] of changes)
401
+ if (!validSyncPath(path))
402
+ throw new Error(`unsafe sync path: ${path}`);
230
403
  const temp = `/tmp/shard-sync-${randomUUID()}.tar`;
404
+ const writer = new SyncBlobWriter(cell, temp, signal);
231
405
  try {
232
- await writeSyncBlob(cell, temp, archive);
233
- const result = await cell.exec.run(['python3', '-c', APPLY_SYNC_TAR, root, temp, JSON.stringify(changes)], { timeoutMs: 60_000, maxOutputBytes: 4096, signal });
406
+ const plan = new TextEncoder().encode(JSON.stringify({ overwrite, changes }));
407
+ await writer.push(tarHeader({ path: '.shard-plan', type: 'file', size: plan.length, mode: 0o600 }));
408
+ await writer.push(plan);
409
+ await writer.push(tarPadding(plan.length));
410
+ await body(writer);
411
+ await writer.push(tarEnd(writer.size));
412
+ await writer.close();
413
+ // Time grows with the work: 60 s, plus 50 ms a change, plus a second per 16 MiB.
414
+ const timeoutMs = 60_000 + changes.length * 50 + Math.ceil(writer.size / (16 << 20)) * 1000;
415
+ const result = await cell.exec.run(['python3', '-c', APPLY_SYNC_TAR, root, temp], { timeoutMs, maxOutputBytes: 4096, signal });
234
416
  if (result.exitCode !== 0)
235
417
  throw new Error(`project sync batch failed: ${result.stderr.trim()}`);
236
418
  }
@@ -238,3 +420,119 @@ export async function applyRemoteSyncBatch(cell, root, files, expected, signal)
238
420
  await cell.files.remove(temp).catch(() => undefined);
239
421
  }
240
422
  }
423
+ async function pushSmallFiles(cell, root, overwrite, files, previous, signal) {
424
+ for (const file of files)
425
+ verifySyncBytes(file.entry, file.data);
426
+ await applyArchive(cell, root, overwrite, files.map((file) => [file.entry.path, previous(file.entry.path), file.entry]), signal, async (writer) => {
427
+ for (const file of files) {
428
+ await writer.push(fileHeader(file.entry));
429
+ await writer.push(file.data);
430
+ await writer.push(tarPadding(file.data.length));
431
+ }
432
+ });
433
+ }
434
+ /** One file of any size, from disk to the VM through its own archive, hashed on the way; differing bytes fail the pass. */
435
+ async function pushLargeFile(cell, root, folder, entry, overwrite, previous, signal) {
436
+ const path = await projectFilePath(folder, entry.path);
437
+ const file = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
438
+ try {
439
+ const st = await file.stat();
440
+ if (!st.isFile() || st.size !== entry.size)
441
+ throw changed(entry.path);
442
+ if ((st.mode & 0o777) !== entry.mode)
443
+ throw new Error(`${entry.path} mode changed during sync`);
444
+ await applyArchive(cell, root, overwrite, [[entry.path, previous, entry]], signal, async (writer) => {
445
+ await writer.push(fileHeader(entry));
446
+ const hash = createHash('sha256');
447
+ await writer.pushFrom(file, entry.size, hash, entry.path);
448
+ if (hash.digest('hex') !== entry.sha256 || (await file.stat()).size !== entry.size)
449
+ throw changed(entry.path);
450
+ await writer.push(tarPadding(entry.size));
451
+ });
452
+ }
453
+ finally {
454
+ await file.close();
455
+ }
456
+ }
457
+ /**
458
+ * Writes local files into the VM project at `root`: `deletions` first, then `files` (manifest entries of `folder`),
459
+ * small ones batched (at most SYNC_BATCH_FILES and SYNC_CHUNK_BYTES per archive), larger ones streamed from disk one
460
+ * chunk at a time. Every file is verified against its entry. Without `overwrite`, each VM path must still be the
461
+ * `expected` version (absent when missing) or the pass fails as changed; `overwrite: true` replaces whatever is there.
462
+ * A deletion removes the directories it empties, so a directory can become a file.
463
+ */
464
+ export async function pushLocalFiles(cell, root, folder, files, deletions, options = {}) {
465
+ const { signal, overwrite = false } = options;
466
+ const expected = options.expected ?? {};
467
+ const previous = (path) => overwrite ? null : contentIdentity(Object.hasOwn(expected, path) ? expected[path] : undefined);
468
+ for (let at = 0; at < deletions.length; at += SYNC_BATCH_DELETIONS) {
469
+ await applyArchive(cell, root, overwrite, deletions.slice(at, at + SYNC_BATCH_DELETIONS).map((path) => [path, previous(path), null]), signal, noMembers);
470
+ }
471
+ let batch = [], bytes = 0;
472
+ const flush = async () => { if (batch.length)
473
+ await pushSmallFiles(cell, root, overwrite, batch, previous, signal); batch = []; bytes = 0; };
474
+ for (const entry of files) {
475
+ signal?.throwIfAborted();
476
+ if (!validSyncPath(entry.path))
477
+ throw new Error(`unsafe sync path: ${entry.path}`);
478
+ if (!entry.symlink && entry.size > SYNC_CHUNK_BYTES) {
479
+ await pushLargeFile(cell, root, folder, entry, overwrite, previous(entry.path), signal);
480
+ continue;
481
+ }
482
+ if (batch.length >= (overwrite ? SYNC_SEED_BATCH_FILES : SYNC_BATCH_FILES) || bytes + entry.size > SYNC_CHUNK_BYTES)
483
+ await flush();
484
+ const file = await readLocalSyncFile(folder, entry);
485
+ batch.push(file);
486
+ bytes += file.data.length;
487
+ }
488
+ await flush();
489
+ }
490
+ // ---------------------------------------------------------------------------------------------------------------
491
+ // VM manifest
492
+ // Hashes VM files a guest left out of its manifest for size (guests before unbounded manifests stop at 64 MiB).
493
+ const HASH_SYNC_FILES = String.raw `
494
+ import os,sys,json,hashlib,stat
495
+ rootfd=os.open(sys.argv[1],os.O_RDONLY|os.O_DIRECTORY|os.O_NOFOLLOW)
496
+ out=[]
497
+ for p in json.loads(sys.argv[2]):
498
+ parts=p.split('/')
499
+ fd=os.dup(rootfd)
500
+ try:
501
+ try:
502
+ for x in parts[:-1]:
503
+ c=os.open(x,os.O_RDONLY|os.O_DIRECTORY|os.O_NOFOLLOW,dir_fd=fd);os.close(fd);fd=c
504
+ st=os.stat(parts[-1],dir_fd=fd,follow_symlinks=False)
505
+ except (FileNotFoundError,NotADirectoryError): continue
506
+ if not stat.S_ISREG(st.st_mode): continue
507
+ f=os.open(parts[-1],os.O_RDONLY|os.O_NOFOLLOW,dir_fd=fd)
508
+ with os.fdopen(f,'rb') as r:
509
+ d=hashlib.file_digest(r,'sha256').hexdigest();after=os.fstat(r.fileno())
510
+ if after.st_size!=st.st_size or after.st_mtime_ns!=st.st_mtime_ns or after.st_ino!=st.st_ino: raise ValueError(p+' changed while hashing; retry project sync')
511
+ out.append({'path':p,'sha256':d,'size':st.st_size,'mode':stat.S_IMODE(st.st_mode)&511,'mtime_unix_ms':st.st_mtime_ns//1000000,'symlink':False,'link_target':''})
512
+ finally: os.close(fd)
513
+ print(json.dumps(out))
514
+ `;
515
+ /**
516
+ * The VM project's manifest with every file hashed, whatever its size: no `maxFileBytes` is sent, and files an
517
+ * older guest still skips for size (`max_file_bytes`) are hashed in the VM and returned as entries.
518
+ */
519
+ export async function remoteManifest(cell, root, knownPaths, signal) {
520
+ const manifest = await cell.files.manifest(root, { ...(knownPaths ? { knownPaths } : {}), ...(signal ? { signal } : {}) });
521
+ const oversized = Array.isArray(manifest?.skipped) ? manifest.skipped.filter((skip) => skip?.reason === 'max_file_bytes' && validSyncPath(skip.path)).map((skip) => skip.path) : [];
522
+ if (oversized.length === 0)
523
+ return manifest;
524
+ for (let at = 0; at < oversized.length; at += 16) {
525
+ const paths = oversized.slice(at, at + 16);
526
+ // Hashing reads each file once in the VM: allow ten minutes per 16 files.
527
+ const result = await cell.exec.run(['python3', '-c', HASH_SYNC_FILES, root, JSON.stringify(paths)], { timeoutMs: 600_000, maxOutputBytes: 1 << 20, ...(signal ? { signal } : {}) });
528
+ if (result.exitCode !== 0)
529
+ throw new Error(`project sync could not hash VM files: ${result.stderr.trim()}`);
530
+ for (const entry of JSON.parse(result.stdout))
531
+ if (paths.includes(entry.path))
532
+ manifest.entries.push(entry);
533
+ }
534
+ // A file that is gone or no longer regular is simply absent from this pass.
535
+ const hashed = new Set(oversized);
536
+ manifest.skipped = manifest.skipped.filter((skip) => !(skip.reason === 'max_file_bytes' && hashed.has(skip.path)));
537
+ return manifest;
538
+ }