@shardflux/cli 0.18.1 → 0.19.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +93 -1
- package/README.md +27 -0
- package/dist/account-args.js +1 -0
- package/dist/agent-commands.d.ts +1 -0
- package/dist/agent-commands.js +15 -5
- package/dist/agent-config-files.d.ts +16 -2
- package/dist/agent-config-files.js +48 -61
- package/dist/agent-config-sync.d.ts +12 -1
- package/dist/agent-config-sync.js +228 -54
- package/dist/agent-repository.d.ts +39 -9
- package/dist/agent-repository.js +309 -92
- package/dist/agent-state.d.ts +3 -0
- package/dist/agent-state.js +13 -0
- package/dist/agent-target-commands.d.ts +2 -0
- package/dist/agent-target-commands.js +45 -15
- package/dist/args.d.ts +1 -1
- package/dist/args.js +14 -2
- package/dist/conversation-router.js +7 -11
- package/dist/conversation-stash.js +64 -18
- package/dist/conversation-terminal.d.ts +1 -1
- package/dist/conversation-terminal.js +9 -10
- package/dist/conversations.d.ts +9 -2
- package/dist/conversations.js +23 -6
- package/dist/errors.d.ts +1 -1
- package/dist/errors.js +1 -1
- package/dist/files-mirror.d.ts +1 -9
- package/dist/files-mirror.js +19 -3
- package/dist/format.js +37 -2
- package/dist/handlers.js +3 -1
- package/dist/initial-prompt.d.ts +15 -2
- package/dist/initial-prompt.js +29 -4
- package/dist/local-sessions.d.ts +60 -0
- package/dist/local-sessions.js +445 -0
- package/dist/main.js +12 -0
- package/dist/move.d.ts +44 -0
- package/dist/move.js +248 -0
- package/dist/moved-sessions.d.ts +33 -0
- package/dist/moved-sessions.js +91 -0
- package/dist/onboarding.d.ts +18 -3
- package/dist/onboarding.js +72 -46
- package/dist/project-sync-manifest.d.ts +31 -3
- package/dist/project-sync-manifest.js +192 -108
- package/dist/project-sync-transfer.d.ts +50 -4
- package/dist/project-sync-transfer.js +404 -106
- package/dist/project-sync.js +30 -33
- package/dist/seed-phases.d.ts +29 -0
- package/dist/seed-phases.js +83 -0
- package/dist/t3-mode.d.ts +7 -2
- package/dist/t3-mode.js +39 -5
- package/dist/thread-on-computer.js +4 -0
- package/dist/worktree-twin.d.ts +2 -6
- package/dist/worktree-twin.js +16 -51
- 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
|
|
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
|
|
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
|
|
38
|
+
throw changed(entry.path);
|
|
30
39
|
offset += bytesRead;
|
|
31
40
|
}
|
|
32
41
|
if ((await file.stat()).size !== entry.size)
|
|
33
|
-
throw
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
-
|
|
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
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
|
113
|
-
//
|
|
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
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
|
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
|
|
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
|
|
135
|
-
|
|
136
|
-
|
|
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
|
|
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
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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)!=
|
|
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'])
|
|
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)!=
|
|
190
|
-
os.replace(tmp,name,src_dir_fd=fd,dst_dir_fd=fd)
|
|
191
|
-
|
|
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
|
-
|
|
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
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
export async function
|
|
207
|
-
const
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
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
|
-
|
|
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
|
-
|
|
233
|
-
|
|
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
|
+
}
|