@shardflux/cli 0.15.1 → 0.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +83 -0
- package/README.md +50 -3
- package/dist/acp-models.js +5 -1
- package/dist/agent-commands.d.ts +8 -2
- package/dist/agent-commands.js +23 -11
- package/dist/agent-target-commands.d.ts +7 -2
- package/dist/agent-target-commands.js +12 -7
- package/dist/agent-targets.d.ts +3 -0
- package/dist/agent-targets.js +4 -1
- package/dist/agent-terminal.d.ts +8 -0
- package/dist/agent-terminal.js +35 -2
- package/dist/args.d.ts +1 -1
- package/dist/args.js +29 -3
- package/dist/codex-cloud.d.ts +2 -0
- package/dist/codex-cloud.js +2 -0
- package/dist/command-center-actions.js +9 -0
- package/dist/commands.d.ts +23 -1
- package/dist/commands.js +54 -14
- package/dist/conversation-commands.js +6 -0
- package/dist/conversation-terminal.js +10 -1
- package/dist/conversations.d.ts +13 -1
- package/dist/conversations.js +41 -5
- package/dist/device-helper.js +9 -2
- package/dist/errors.d.ts +5 -0
- package/dist/errors.js +11 -1
- package/dist/files-mirror.d.ts +164 -0
- package/dist/files-mirror.js +761 -0
- package/dist/handlers.js +3 -1
- package/dist/main.js +39 -14
- package/dist/menubar.d.ts +57 -0
- package/dist/menubar.js +264 -45
- package/dist/registry.d.ts +8 -0
- package/dist/registry.js +18 -0
- package/dist/thread-on-computer.d.ts +35 -0
- package/dist/thread-on-computer.js +200 -0
- package/dist/update-check.d.ts +9 -1
- package/dist/update-check.js +55 -17
- package/dist/update.d.ts +2 -0
- package/dist/update.js +26 -8
- package/dist/wait-status.d.ts +22 -0
- package/dist/wait-status.js +59 -0
- package/dist/worktree-twin-engine.d.ts +95 -0
- package/dist/worktree-twin-engine.js +402 -0
- package/dist/worktree-twin-land.py +331 -0
- package/dist/worktree-twin.d.ts +296 -0
- package/dist/worktree-twin.js +718 -0
- package/package.json +3 -3
|
@@ -0,0 +1,718 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The live twin (0.16.0+): finished cloud conversations land in the user's local worktree with no input.
|
|
3
|
+
* Design: docs/progress/cross-conversation-files.md, "Design note: building the live twin".
|
|
4
|
+
*
|
|
5
|
+
* - A twin is one snapshot commit per local worktree (its working state as `git add -A` sees it, first parent the
|
|
6
|
+
* checked-out branch tip), written with a private index; the user's index, HEAD and branches are not touched.
|
|
7
|
+
* - The twin is snapshotted when the cloud needs it and carried inside an operation that already has a VM awake
|
|
8
|
+
* (a landing in the finished conversation's VM, a seed in a new conversation's VM): no timer keeps a VM awake.
|
|
9
|
+
* - worktree-twin-land.py runs in the conversation's VM and returns the new branch tip and the result tree; the
|
|
10
|
+
* device helper applies them here (quiet period, compare-and-swap, backups, atomic writes, index refresh).
|
|
11
|
+
*/
|
|
12
|
+
import { spawn } from 'node:child_process';
|
|
13
|
+
import { createHash, randomBytes, randomUUID } from 'node:crypto';
|
|
14
|
+
import { copyFileSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, readlinkSync, realpathSync, renameSync, rmSync, rmdirSync, statSync, symlinkSync, utimesSync, writeFileSync } from 'node:fs';
|
|
15
|
+
import { open, readFile, rm, writeFile } from 'node:fs/promises';
|
|
16
|
+
import { tmpdir } from 'node:os';
|
|
17
|
+
import { basename, dirname, join, relative, resolve, sep } from 'node:path';
|
|
18
|
+
import { setTimeout as delay } from 'node:timers/promises';
|
|
19
|
+
import { AgentStateStore } from "./agent-state.js";
|
|
20
|
+
import { MAX_SYNC_FILE_BYTES } from "./project-sync-manifest.js";
|
|
21
|
+
import { SYNC_CHUNK_BYTES } from "./project-sync-transfer.js";
|
|
22
|
+
/** Files above this stay at HEAD's version in a twin (the bound the seed copy already uses). */
|
|
23
|
+
export const TWIN_MAX_FILE_BYTES = MAX_SYNC_FILE_BYTES;
|
|
24
|
+
/** A file the landing rewrites must be untouched this long first. */
|
|
25
|
+
export const QUIET_MS = 2000;
|
|
26
|
+
export const HUB_PATH = '/shardflux/project/git/hub.git';
|
|
27
|
+
const TRANSFER_LIMIT = 256 << 20;
|
|
28
|
+
const INLINE_BYTES = 512 << 10;
|
|
29
|
+
const BACKUP_DAYS = 14;
|
|
30
|
+
const BACKUP_BYTES = 1 << 30;
|
|
31
|
+
const SNAPSHOT_TRAILER = 'Shardflux-Snapshot';
|
|
32
|
+
const ZERO = /^0+$/;
|
|
33
|
+
export class TwinGitError extends Error {
|
|
34
|
+
code;
|
|
35
|
+
stderr;
|
|
36
|
+
constructor(args, result) {
|
|
37
|
+
super(`git ${args.find(a => !a.startsWith('-') && !a.includes('=')) ?? ''} failed (${result.code}): ${result.stderr.trim().slice(-400)}`);
|
|
38
|
+
this.code = result.code;
|
|
39
|
+
this.stderr = result.stderr;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
/** Git with no hooks, no fsmonitor and no optional locks; binary-safe output. */
|
|
43
|
+
export function git(cwd, args, options = {}) {
|
|
44
|
+
const full = ['-c', 'core.hooksPath=/dev/null', '-c', 'core.fsmonitor=false', ...args];
|
|
45
|
+
return new Promise((resolveResult, reject) => {
|
|
46
|
+
const child = spawn('git', full, { cwd, stdio: ['pipe', 'pipe', 'pipe'], signal: options.signal,
|
|
47
|
+
env: { ...process.env, GIT_TERMINAL_PROMPT: '0', GIT_OPTIONAL_LOCKS: '0', LC_ALL: 'C', ...options.env } });
|
|
48
|
+
const out = [], err = [];
|
|
49
|
+
child.stdout.on('data', (b) => out.push(b));
|
|
50
|
+
child.stderr.on('data', (b) => err.push(b));
|
|
51
|
+
child.stdin.on('error', () => undefined);
|
|
52
|
+
child.once('error', reject);
|
|
53
|
+
child.once('close', code => {
|
|
54
|
+
const result = { code: code ?? 1, stdout: Buffer.concat(out), stderr: Buffer.concat(err).toString('utf8') };
|
|
55
|
+
if ((options.ok ?? [0]).includes(result.code))
|
|
56
|
+
resolveResult(result);
|
|
57
|
+
else
|
|
58
|
+
reject(new TwinGitError(args, result));
|
|
59
|
+
});
|
|
60
|
+
child.stdin.end(options.input ?? '');
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
export async function gitText(cwd, args, options = {}) {
|
|
64
|
+
return (await git(cwd, args, options)).stdout.toString('utf8').trim();
|
|
65
|
+
}
|
|
66
|
+
async function verifyCommit(cwd, rev) {
|
|
67
|
+
const r = await git(cwd, ['rev-parse', '-q', '--verify', `${rev}^{commit}`], { ok: [0, 1, 128] });
|
|
68
|
+
return r.code === 0 ? r.stdout.toString().trim() : '';
|
|
69
|
+
}
|
|
70
|
+
export async function inspectWorktree(root) {
|
|
71
|
+
const [gitDir, commonDir, top] = (await gitText(root, ['rev-parse', '--path-format=absolute', '--git-dir', '--git-common-dir', '--show-toplevel'])).split('\n');
|
|
72
|
+
const head = await verifyCommit(root, 'HEAD');
|
|
73
|
+
const branch = (await git(root, ['symbolic-ref', '-q', 'HEAD'], { ok: [0, 1] })).stdout.toString().trim();
|
|
74
|
+
return { root: realpathSync(top), gitDir: gitDir, commonDir: commonDir, head: head || null, branch: branch || null };
|
|
75
|
+
}
|
|
76
|
+
/** The user's own Git operation in progress: the twin waits for it. */
|
|
77
|
+
export function pauseReason(info) {
|
|
78
|
+
for (const name of ['index.lock', 'MERGE_HEAD', 'CHERRY_PICK_HEAD', 'REVERT_HEAD', 'BISECT_LOG', 'rebase-merge', 'rebase-apply', 'sequencer', 'HEAD.lock']) {
|
|
79
|
+
if (existsSync(join(info.gitDir, name)))
|
|
80
|
+
return name;
|
|
81
|
+
}
|
|
82
|
+
return existsSync(join(info.commonDir, 'sequencer')) ? 'sequencer' : null;
|
|
83
|
+
}
|
|
84
|
+
export async function listWorktrees(anyRoot) {
|
|
85
|
+
const raw = (await git(anyRoot, ['worktree', 'list', '--porcelain', '-z'])).stdout.toString('utf8');
|
|
86
|
+
const out = [];
|
|
87
|
+
let cur = null, bare = false;
|
|
88
|
+
for (const line of raw.split('\0')) {
|
|
89
|
+
if (line.startsWith('worktree ')) {
|
|
90
|
+
cur = { root: line.slice(9), head: null, branch: null };
|
|
91
|
+
bare = false;
|
|
92
|
+
}
|
|
93
|
+
else if (line.startsWith('HEAD ') && cur)
|
|
94
|
+
cur.head = line.slice(5);
|
|
95
|
+
else if (line.startsWith('branch ') && cur)
|
|
96
|
+
cur.branch = line.slice(7);
|
|
97
|
+
else if (line === 'bare')
|
|
98
|
+
bare = true;
|
|
99
|
+
else if (line === '' && cur) {
|
|
100
|
+
if (!bare && existsSync(cur.root))
|
|
101
|
+
out.push(cur);
|
|
102
|
+
cur = null;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
if (cur && !bare && existsSync(cur.root))
|
|
106
|
+
out.push(cur);
|
|
107
|
+
return out;
|
|
108
|
+
}
|
|
109
|
+
export const worktreeId = (root) => createHash('sha256').update(realpathSync(root)).digest('hex').slice(0, 16);
|
|
110
|
+
export const twinRef = (id) => `refs/shard/twin/${id}`;
|
|
111
|
+
export const hubTwinRef = (device, id) => `refs/shard/twin/${device}/${id}`;
|
|
112
|
+
const identity = (now) => ({ GIT_AUTHOR_NAME: 'Shardflux', GIT_AUTHOR_EMAIL: 'twin@shardflux.dev', GIT_COMMITTER_NAME: 'Shardflux', GIT_COMMITTER_EMAIL: 'twin@shardflux.dev',
|
|
113
|
+
GIT_AUTHOR_DATE: `@${Math.floor(now.getTime() / 1000)} +0000`, GIT_COMMITTER_DATE: `@${Math.floor(now.getTime() / 1000)} +0000` });
|
|
114
|
+
const excludedPath = (path) => path.split('/').some(part => part === '.git' || part === '.shard' || part.includes('.shard-tmp-'));
|
|
115
|
+
/**
|
|
116
|
+
* One snapshot commit of the worktree's working state (what `git add -A` sees; `.shard/` and files above the size gate
|
|
117
|
+
* left at HEAD's version), first parent HEAD. Kept as refs/shard/twin/<worktree id>; an unchanged state reuses it.
|
|
118
|
+
*/
|
|
119
|
+
export async function snapshotWorktree(info, options) {
|
|
120
|
+
if (!info.head)
|
|
121
|
+
throw new Error('the worktree has no commit yet');
|
|
122
|
+
const id = worktreeId(info.root), max = options.maxFileBytes ?? TWIN_MAX_FILE_BYTES, signal = options.signal;
|
|
123
|
+
const dir = join(options.stateDir, 'twin');
|
|
124
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
125
|
+
const index = join(dir, `${id}.${process.pid}.${randomBytes(4).toString('hex')}.index`);
|
|
126
|
+
const env = { GIT_INDEX_FILE: index };
|
|
127
|
+
try {
|
|
128
|
+
// A copy of the user's index carries its stat cache, so only changed files are hashed. The original is never written.
|
|
129
|
+
if (existsSync(join(info.gitDir, 'index'))) {
|
|
130
|
+
// The copy keeps the index's own mtime: Git re-reads a file whose stat equals its entry but which changed in the
|
|
131
|
+
// same second as the index was written ("racily clean"); a newer copy would make such an edit invisible.
|
|
132
|
+
const original = statSync(join(info.gitDir, 'index'));
|
|
133
|
+
copyFileSync(join(info.gitDir, 'index'), index);
|
|
134
|
+
utimesSync(index, original.atime, original.mtime);
|
|
135
|
+
}
|
|
136
|
+
else
|
|
137
|
+
await git(info.root, ['read-tree', info.head], { env, signal });
|
|
138
|
+
const candidates = (await git(info.root, ['ls-files', '-z', '-m', '-o', '--exclude-standard'], { signal })).stdout.toString('utf8').split('\0').filter(Boolean);
|
|
139
|
+
const held = [];
|
|
140
|
+
for (const path of candidates) {
|
|
141
|
+
if (excludedPath(path))
|
|
142
|
+
continue;
|
|
143
|
+
const st = lstatSync(join(info.root, path), { throwIfNoEntry: false });
|
|
144
|
+
if (st?.isFile() && st.size > max)
|
|
145
|
+
held.push(path);
|
|
146
|
+
}
|
|
147
|
+
const specs = ['.', ':(exclude).shard', ':(exclude,glob)**/.shard/**', ':(exclude,glob)**/*.shard-tmp-*', ...held.map(p => `:(exclude,literal)${p}`)];
|
|
148
|
+
await git(info.root, ['add', '-A', '--ignore-errors', '--pathspec-from-file=-', '--pathspec-file-nul'], { env, input: specs.join('\0') + '\0', signal });
|
|
149
|
+
if (held.length) {
|
|
150
|
+
const atHead = (await git(info.root, ['ls-tree', '-z', '--full-tree', info.head, '--', ...held], { signal })).stdout.toString('utf8').split('\0').filter(Boolean);
|
|
151
|
+
const known = new Set(atHead.map(line => line.slice(line.indexOf('\t') + 1)));
|
|
152
|
+
const lines = [...atHead, ...held.filter(p => !known.has(p)).map(p => `0 ${'0'.repeat(40)}\t${p}`)];
|
|
153
|
+
await git(info.root, ['update-index', '-z', '--index-info'], { env, input: lines.join('\0') + '\0', signal });
|
|
154
|
+
}
|
|
155
|
+
const tree = await gitText(info.root, ['write-tree'], { env, signal });
|
|
156
|
+
const headTree = await gitText(info.root, ['rev-parse', `${info.head}^{tree}`], { signal });
|
|
157
|
+
const previous = await verifyCommit(info.root, twinRef(id));
|
|
158
|
+
let commit = '';
|
|
159
|
+
if (previous) {
|
|
160
|
+
const [prevTree, prevParent] = (await gitText(info.root, ['show', '-s', '--format=%T %P', previous], { signal })).split(' ');
|
|
161
|
+
if (prevTree === tree && prevParent === info.head)
|
|
162
|
+
commit = previous;
|
|
163
|
+
}
|
|
164
|
+
if (!commit) {
|
|
165
|
+
const message = `Shardflux twin of ${basename(info.root)}${info.branch ? ` (${info.branch.replace(/^refs\/heads\//, '')})` : ''}\n\n${SNAPSHOT_TRAILER}: ${options.device}/${id}\nShardflux-Branch: ${info.branch ?? 'HEAD'}\n`;
|
|
166
|
+
commit = await gitText(info.root, ['commit-tree', tree, '-p', info.head, '-F', '-'], { env: identity(options.now ?? new Date()), input: message, signal });
|
|
167
|
+
await git(info.root, ['update-ref', twinRef(id), commit], { signal });
|
|
168
|
+
}
|
|
169
|
+
return { commit, tree, base: info.head, branch: info.branch, root: info.root, id, held, headTree };
|
|
170
|
+
}
|
|
171
|
+
finally {
|
|
172
|
+
rmSync(index, { force: true });
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
/** A twin for a branch checked out nowhere: its own tree, first parent its tip. */
|
|
176
|
+
export async function refTwin(repo, branch, device, now = new Date()) {
|
|
177
|
+
const base = await verifyCommit(repo, branch);
|
|
178
|
+
if (!base)
|
|
179
|
+
throw new Error(`${branch} does not exist`);
|
|
180
|
+
const tree = await gitText(repo, ['rev-parse', `${base}^{tree}`]);
|
|
181
|
+
const id = createHash('sha256').update(`${realpathSync(repo)}\0${branch}`).digest('hex').slice(0, 16);
|
|
182
|
+
const commit = await gitText(repo, ['commit-tree', tree, '-p', base, '-F', '-'], { env: identity(now), input: `Shardflux twin of ${branch}\n\n${SNAPSHOT_TRAILER}: ${device}/${id}\nShardflux-Branch: ${branch}\n` });
|
|
183
|
+
await git(repo, ['update-ref', twinRef(id), commit]);
|
|
184
|
+
return { commit, tree, base, branch, root: realpathSync(repo), id, held: [], headTree: tree };
|
|
185
|
+
}
|
|
186
|
+
async function rawDiff(cwd, a, b) {
|
|
187
|
+
const parts = (await git(cwd, ['diff-tree', '-r', '-z', '--raw', '--no-renames', '--no-abbrev', a, b])).stdout.toString('utf8').split('\0');
|
|
188
|
+
const out = [];
|
|
189
|
+
for (let i = 0; i + 1 < parts.length; i += 2) {
|
|
190
|
+
const meta = parts[i];
|
|
191
|
+
if (!meta.startsWith(':')) {
|
|
192
|
+
i--;
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
195
|
+
const [oldMode, newMode, oldSha, newSha] = meta.slice(1).split(' ');
|
|
196
|
+
out.push({ oldMode: oldMode, newMode: newMode, oldSha: oldSha, newSha: newSha, path: parts[i + 1] });
|
|
197
|
+
}
|
|
198
|
+
return out;
|
|
199
|
+
}
|
|
200
|
+
const absent = (mode) => ZERO.test(mode);
|
|
201
|
+
const gitlink = (mode) => mode === '160000';
|
|
202
|
+
function inside(root, path) {
|
|
203
|
+
const target = resolve(root, path);
|
|
204
|
+
if (target !== root && !target.startsWith(root + sep))
|
|
205
|
+
throw new Error(`path escapes the worktree: ${path}`);
|
|
206
|
+
// No symlinked parent: the landing writes only inside the worktree it inspected.
|
|
207
|
+
let at = dirname(target);
|
|
208
|
+
while (at !== root && at.startsWith(root + sep)) {
|
|
209
|
+
const st = lstatSync(at, { throwIfNoEntry: false });
|
|
210
|
+
if (st?.isSymbolicLink())
|
|
211
|
+
throw new Error(`path traverses a symbolic link: ${path}`);
|
|
212
|
+
at = dirname(at);
|
|
213
|
+
}
|
|
214
|
+
return target;
|
|
215
|
+
}
|
|
216
|
+
/** The blob id Git would store for this path now (filters applied), or null when it is absent; 'other' for a directory. */
|
|
217
|
+
async function diskBlob(root, path) {
|
|
218
|
+
const file = inside(root, path), st = lstatSync(file, { throwIfNoEntry: false });
|
|
219
|
+
if (!st)
|
|
220
|
+
return { sha: null, mtime: 0 };
|
|
221
|
+
const mtime = Math.max(st.mtimeMs, st.ctimeMs);
|
|
222
|
+
if (st.isSymbolicLink())
|
|
223
|
+
return { sha: await gitText(root, ['hash-object', '--stdin'], { input: readlinkSync(file) }), mtime };
|
|
224
|
+
if (!st.isFile())
|
|
225
|
+
return 'other';
|
|
226
|
+
return { sha: await gitText(root, ['hash-object', '--path', path, '--', file]), mtime };
|
|
227
|
+
}
|
|
228
|
+
async function blob(cwd, sha, path) {
|
|
229
|
+
return (await git(cwd, path ? ['cat-file', '--filters', `--path=${path}`, sha] : ['cat-file', 'blob', sha])).stdout;
|
|
230
|
+
}
|
|
231
|
+
/** Temp file next to the target, then rename: readers see the old or the new file, never a partial one. */
|
|
232
|
+
async function writeAtomic(root, path, mode, content) {
|
|
233
|
+
const file = inside(root, path);
|
|
234
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
235
|
+
const tmp = join(dirname(file), `.${basename(file)}.shard-tmp-${randomBytes(4).toString('hex')}`);
|
|
236
|
+
try {
|
|
237
|
+
if (mode === '120000')
|
|
238
|
+
symlinkSync(content.toString('utf8'), tmp);
|
|
239
|
+
else {
|
|
240
|
+
// Keep the file's own permissions; Git's mode only decides the execute bits.
|
|
241
|
+
const umask = process.umask(), current = lstatSync(file, { throwIfNoEntry: false });
|
|
242
|
+
const bits = current?.isFile() ? current.mode & 0o777 : 0o666 & ~umask;
|
|
243
|
+
const perms = mode === '100755' ? bits | ((bits & 0o444) >> 2) : bits & ~0o111;
|
|
244
|
+
const handle = await open(tmp, 'wx', perms);
|
|
245
|
+
try {
|
|
246
|
+
await handle.writeFile(content);
|
|
247
|
+
await handle.chmod(perms);
|
|
248
|
+
await handle.sync();
|
|
249
|
+
}
|
|
250
|
+
finally {
|
|
251
|
+
await handle.close();
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
renameSync(tmp, file);
|
|
255
|
+
}
|
|
256
|
+
finally {
|
|
257
|
+
rmSync(tmp, { force: true });
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
function removeEmptyParents(root, file) {
|
|
261
|
+
for (let dir = dirname(file); dir !== root && dir.startsWith(root + sep); dir = dirname(dir)) {
|
|
262
|
+
try {
|
|
263
|
+
rmdirSync(dir);
|
|
264
|
+
}
|
|
265
|
+
catch {
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
async function mergeFile(cwd, base, ours, theirs) {
|
|
271
|
+
const dir = join(tmpdir(), `shard-merge-${randomUUID()}`);
|
|
272
|
+
mkdirSync(dir, { mode: 0o700 });
|
|
273
|
+
try {
|
|
274
|
+
for (const [name, data] of [['ours', ours], ['base', base], ['theirs', theirs]])
|
|
275
|
+
writeFileSync(join(dir, name), data);
|
|
276
|
+
const r = await git(cwd, ['merge-file', '-p', join(dir, 'ours'), join(dir, 'base'), join(dir, 'theirs')], { ok: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10] });
|
|
277
|
+
return r.code === 0 ? r.stdout : null;
|
|
278
|
+
}
|
|
279
|
+
finally {
|
|
280
|
+
rmSync(dir, { recursive: true, force: true });
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
/** Prune twin backups older than 14 days, then the oldest while the total exceeds 1 GiB. */
|
|
284
|
+
export function pruneBackups(root, now = Date.now(), maxDays = BACKUP_DAYS, maxBytes = BACKUP_BYTES) {
|
|
285
|
+
if (!existsSync(root))
|
|
286
|
+
return [];
|
|
287
|
+
const size = (p) => { const st = lstatSync(p); return st.isDirectory() ? readdirSync(p).reduce((n, e) => n + size(join(p, e)), 0) : st.size; };
|
|
288
|
+
const entries = readdirSync(root).map(name => ({ name, path: join(root, name), at: statSync(join(root, name)).mtimeMs })).sort((a, b) => a.at - b.at);
|
|
289
|
+
const removed = [];
|
|
290
|
+
let total = entries.reduce((n, e) => n + size(e.path), 0);
|
|
291
|
+
for (const e of entries) {
|
|
292
|
+
if (now - e.at > maxDays * 86_400_000 || total > maxBytes) {
|
|
293
|
+
total -= size(e.path);
|
|
294
|
+
rmSync(e.path, { recursive: true, force: true });
|
|
295
|
+
removed.push(e.name);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
return removed;
|
|
299
|
+
}
|
|
300
|
+
/** Writes each backed-up file back (the current content is backed up first); files the landing created are removed. */
|
|
301
|
+
export async function restoreBackup(backup, options) {
|
|
302
|
+
const manifest = JSON.parse(readFileSync(join(backup, 'manifest.json'), 'utf8'));
|
|
303
|
+
const root = realpathSync(manifest.worktree), stamp = new Date((options.now ?? Date.now)()).toISOString().replace(/[:.]/g, '-');
|
|
304
|
+
const safety = join(options.backupRoot, `${stamp}-restore-${manifest.name}-${randomBytes(3).toString('hex')}`);
|
|
305
|
+
mkdirSync(safety, { recursive: true, mode: 0o700 });
|
|
306
|
+
const restored = [];
|
|
307
|
+
for (const f of manifest.files) {
|
|
308
|
+
const target = inside(root, f.path), current = lstatSync(target, { throwIfNoEntry: false });
|
|
309
|
+
if (current?.isFile() || current?.isSymbolicLink()) {
|
|
310
|
+
mkdirSync(dirname(join(safety, 'files', f.path)), { recursive: true });
|
|
311
|
+
copyFileSync(target, join(safety, 'files', f.path));
|
|
312
|
+
}
|
|
313
|
+
if (f.saved) {
|
|
314
|
+
const source = join(backup, 'files', f.path), st = lstatSync(source);
|
|
315
|
+
await writeAtomic(root, f.path, st.isSymbolicLink() ? '120000' : (st.mode & 0o111 ? '100755' : '100644'), st.isSymbolicLink() ? Buffer.from(readlinkSync(source)) : await readFile(source));
|
|
316
|
+
restored.push(f.path);
|
|
317
|
+
}
|
|
318
|
+
else if (f.action === 'create' && current) {
|
|
319
|
+
rmSync(target, { force: true });
|
|
320
|
+
removeEmptyParents(root, target);
|
|
321
|
+
restored.push(f.path);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
writeFileSync(join(safety, 'manifest.json'), JSON.stringify({ ...manifest, restored_from: basename(backup) }, null, 1) + '\n', { mode: 0o600 });
|
|
325
|
+
return { restored, backup: safety };
|
|
326
|
+
}
|
|
327
|
+
/**
|
|
328
|
+
* Moves the branch from plan.base to plan.tip and the worktree from plan.twin's tree to plan.result's tree, only where
|
|
329
|
+
* the disk still holds what the twin captured and has been quiet. Never drops a user's edit: a changed file means
|
|
330
|
+
* 'moved' (redo the round), a running Git operation 'paused', a recent write 'busy'.
|
|
331
|
+
*/
|
|
332
|
+
export async function applyLanding(plan, options) {
|
|
333
|
+
const now = options.now ?? Date.now, quiet = options.quietMs ?? QUIET_MS, wt = plan.worktree;
|
|
334
|
+
if (wt) {
|
|
335
|
+
const reason = pauseReason(wt);
|
|
336
|
+
if (reason)
|
|
337
|
+
return { state: 'paused', reason };
|
|
338
|
+
const head = (await git(wt.root, ['symbolic-ref', '-q', 'HEAD'], { ok: [0, 1] })).stdout.toString().trim();
|
|
339
|
+
if (head !== plan.branch)
|
|
340
|
+
return { state: 'moved', reason: 'branch_switched' };
|
|
341
|
+
}
|
|
342
|
+
if (await verifyCommit(plan.repo, plan.branch) !== plan.base)
|
|
343
|
+
return { state: 'moved', reason: 'branch_moved' };
|
|
344
|
+
if (!wt) {
|
|
345
|
+
const r = await git(plan.repo, ['update-ref', '-m', `shardflux: landed ${options.label.name}`, plan.branch, plan.tip, plan.base], { ok: [0, 1, 128] });
|
|
346
|
+
return r.code === 0 ? { state: 'applied', files: [], raced: [], backup: null, at: now() } : { state: 'moved', reason: 'branch_moved' };
|
|
347
|
+
}
|
|
348
|
+
const root = wt.root, changes = await rawDiff(root, plan.twin, plan.result);
|
|
349
|
+
const edited = [];
|
|
350
|
+
let wait = 0;
|
|
351
|
+
for (const c of changes) {
|
|
352
|
+
if (gitlink(c.oldMode) || gitlink(c.newMode))
|
|
353
|
+
continue;
|
|
354
|
+
const disk = await diskBlob(root, c.path);
|
|
355
|
+
if (disk === 'other') {
|
|
356
|
+
edited.push(c.path);
|
|
357
|
+
continue;
|
|
358
|
+
}
|
|
359
|
+
const expected = absent(c.oldMode) ? null : c.oldSha;
|
|
360
|
+
if (disk.sha !== expected) {
|
|
361
|
+
edited.push(c.path);
|
|
362
|
+
continue;
|
|
363
|
+
}
|
|
364
|
+
if (disk.sha !== null)
|
|
365
|
+
wait = Math.max(wait, quiet - (now() - disk.mtime));
|
|
366
|
+
}
|
|
367
|
+
if (edited.length)
|
|
368
|
+
return { state: 'moved', reason: 'edited', paths: edited };
|
|
369
|
+
if (wait > 0)
|
|
370
|
+
return { state: 'busy', waitMs: Math.ceil(wait) };
|
|
371
|
+
if (pauseReason(wt))
|
|
372
|
+
return { state: 'paused', reason: pauseReason(wt) };
|
|
373
|
+
// Backups first: every file this landing rewrites or removes.
|
|
374
|
+
const stamp = new Date(now()).toISOString().replace(/[:.]/g, '-');
|
|
375
|
+
const backup = join(options.backupRoot, `${stamp}-${options.label.name}-${randomBytes(3).toString('hex')}`);
|
|
376
|
+
const manifest = { version: 1, created_at: new Date(now()).toISOString(), worktree: root, branch: plan.branch, conversation_id: options.label.conversation_id,
|
|
377
|
+
name: options.label.name, title: options.label.title, from: plan.base, to: plan.tip, twin: plan.twin, result: plan.result, files: [] };
|
|
378
|
+
const writes = changes.filter(c => !gitlink(c.oldMode) && !gitlink(c.newMode));
|
|
379
|
+
if (writes.length) {
|
|
380
|
+
mkdirSync(join(backup, 'files'), { recursive: true, mode: 0o700 });
|
|
381
|
+
for (const c of writes) {
|
|
382
|
+
const action = absent(c.oldMode) ? 'create' : absent(c.newMode) ? 'delete' : 'modify';
|
|
383
|
+
let saved = false;
|
|
384
|
+
if (action !== 'create') {
|
|
385
|
+
const target = join(backup, 'files', c.path);
|
|
386
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
387
|
+
if (c.oldMode === '120000')
|
|
388
|
+
symlinkSync(readlinkSync(inside(root, c.path)), target);
|
|
389
|
+
else
|
|
390
|
+
copyFileSync(inside(root, c.path), target);
|
|
391
|
+
saved = true;
|
|
392
|
+
}
|
|
393
|
+
manifest.files.push({ path: c.path, action, before: action === 'create' ? null : c.oldSha, after: action === 'delete' ? null : c.newSha, saved });
|
|
394
|
+
}
|
|
395
|
+
writeFileSync(join(backup, 'manifest.json'), JSON.stringify(manifest, null, 1) + '\n', { mode: 0o600 });
|
|
396
|
+
pruneBackups(options.backupRoot, now());
|
|
397
|
+
}
|
|
398
|
+
// Deletions before writes (a file can become a directory). Each file is re-checked just before it is replaced.
|
|
399
|
+
const written = [], raced = [];
|
|
400
|
+
for (const c of [...writes.filter(w => absent(w.newMode)), ...writes.filter(w => !absent(w.newMode))]) {
|
|
401
|
+
options.beforeWrite?.(c.path);
|
|
402
|
+
const disk = await diskBlob(root, c.path), expected = absent(c.oldMode) ? null : c.oldSha;
|
|
403
|
+
const current = disk === 'other' ? 'other' : disk.sha;
|
|
404
|
+
if (current !== expected) {
|
|
405
|
+
// The user saved this file between the check and now: merge three ways; a conflict keeps the user's file.
|
|
406
|
+
if (current === 'other' || current === null || absent(c.newMode) || absent(c.oldMode) || c.newMode === '120000') {
|
|
407
|
+
raced.push(c.path);
|
|
408
|
+
continue;
|
|
409
|
+
}
|
|
410
|
+
const merged = await mergeFile(root, await blob(root, c.oldSha, c.path), await readFile(inside(root, c.path)), await blob(root, c.newSha, c.path));
|
|
411
|
+
if (!merged) {
|
|
412
|
+
raced.push(c.path);
|
|
413
|
+
continue;
|
|
414
|
+
}
|
|
415
|
+
await writeAtomic(root, c.path, c.newMode, merged);
|
|
416
|
+
written.push(c.path);
|
|
417
|
+
raced.push(c.path);
|
|
418
|
+
continue;
|
|
419
|
+
}
|
|
420
|
+
if (absent(c.newMode)) {
|
|
421
|
+
const file = inside(root, c.path);
|
|
422
|
+
rmSync(file, { force: true });
|
|
423
|
+
removeEmptyParents(root, file);
|
|
424
|
+
written.push(c.path);
|
|
425
|
+
continue;
|
|
426
|
+
}
|
|
427
|
+
const content = c.newMode === '120000' ? await blob(root, c.newSha) : await blob(root, c.newSha, c.path);
|
|
428
|
+
await writeAtomic(root, c.path, c.newMode, content);
|
|
429
|
+
written.push(c.path);
|
|
430
|
+
}
|
|
431
|
+
const moved = await git(plan.repo, ['update-ref', '-m', `shardflux: landed ${options.label.name}`, plan.branch, plan.tip, plan.base], { ok: [0, 1, 128] });
|
|
432
|
+
// Lost the branch to a commit made in this instant: the files are the user's now, and the next round folds them in.
|
|
433
|
+
if (moved.code !== 0)
|
|
434
|
+
return { state: 'moved', reason: 'branch_moved' };
|
|
435
|
+
await refreshIndex(root, plan.base, plan.tip);
|
|
436
|
+
return { state: 'applied', files: written, raced, backup: writes.length ? backup : null, at: now() };
|
|
437
|
+
}
|
|
438
|
+
/** Index entries that equal the old tip move to the new tip; entries the user staged differently are merged three ways. */
|
|
439
|
+
export async function refreshIndex(root, base, tip, attempts = 50) {
|
|
440
|
+
const changes = (await rawDiff(root, base, tip)).filter(c => !gitlink(c.oldMode) && !gitlink(c.newMode));
|
|
441
|
+
for (let attempt = 0;; attempt++) {
|
|
442
|
+
try {
|
|
443
|
+
if (changes.length) {
|
|
444
|
+
const staged = new Map();
|
|
445
|
+
for (let at = 0; at < changes.length; at += 500) {
|
|
446
|
+
const listing = (await git(root, ['ls-files', '-s', '-z', '--', ...changes.slice(at, at + 500).map(c => `:(literal)${c.path}`)])).stdout.toString('utf8');
|
|
447
|
+
for (const line of listing.split('\0').filter(Boolean)) {
|
|
448
|
+
const [meta, path] = [line.slice(0, line.indexOf('\t')), line.slice(line.indexOf('\t') + 1)];
|
|
449
|
+
const [mode, sha, stage] = meta.split(' ');
|
|
450
|
+
if (stage !== '0' || !staged.has(path))
|
|
451
|
+
staged.set(path, { mode: mode, sha: sha, stage: stage });
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
const lines = [];
|
|
455
|
+
for (const c of changes) {
|
|
456
|
+
const entry = staged.get(c.path);
|
|
457
|
+
if (entry && entry.stage !== '0')
|
|
458
|
+
continue; // the user's own unresolved merge state
|
|
459
|
+
const matchesBase = entry ? !absent(c.oldMode) && entry.sha === c.oldSha && entry.mode === c.oldMode : absent(c.oldMode);
|
|
460
|
+
if (matchesBase) {
|
|
461
|
+
lines.push(absent(c.newMode) ? `0 ${'0'.repeat(c.newSha.length)}\t${c.path}` : `${c.newMode} ${c.newSha}\t${c.path}`);
|
|
462
|
+
continue;
|
|
463
|
+
}
|
|
464
|
+
if (!entry || absent(c.oldMode) || absent(c.newMode))
|
|
465
|
+
continue;
|
|
466
|
+
const merged = await mergeFile(root, await blob(root, c.oldSha), await blob(root, entry.sha), await blob(root, c.newSha));
|
|
467
|
+
if (merged)
|
|
468
|
+
lines.push(`${entry.mode} ${await gitText(root, ['hash-object', '-w', '--stdin'], { input: merged })}\t${c.path}`);
|
|
469
|
+
}
|
|
470
|
+
if (lines.length)
|
|
471
|
+
await git(root, ['update-index', '-z', '--index-info'], { input: lines.join('\0') + '\0' });
|
|
472
|
+
}
|
|
473
|
+
await git(root, ['update-index', '-q', '--refresh'], { ok: [0, 1] });
|
|
474
|
+
return;
|
|
475
|
+
}
|
|
476
|
+
catch (error) {
|
|
477
|
+
if (!(error instanceof TwinGitError) || !/index\.lock/.test(error.stderr) || attempt >= attempts)
|
|
478
|
+
throw error;
|
|
479
|
+
await delay(100);
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
let script;
|
|
484
|
+
/** The landing script, shipped next to this module (src in the monorepo, dist when published). */
|
|
485
|
+
export function landingScript() { return script ??= readFileSync(new URL('./worktree-twin-land.py', import.meta.url), 'utf8'); }
|
|
486
|
+
export async function runLandingScript(cell, input, signal) {
|
|
487
|
+
const r = await cell.exec.run(['python3', '-c', landingScript()], { stdin: JSON.stringify(input), timeoutMs: 300_000, maxOutputBytes: 4 << 20, ...(signal ? { signal } : {}) });
|
|
488
|
+
const line = r.stdout.trim().split('\n').at(-1) ?? '';
|
|
489
|
+
if (r.exitCode !== 0 || !line.startsWith('{'))
|
|
490
|
+
return { state: 'error', reason: 'script', message: `landing script exited ${r.exitCode}: ${r.stderr.trim().slice(-400)}` };
|
|
491
|
+
return JSON.parse(line);
|
|
492
|
+
}
|
|
493
|
+
/** A temporary repository in the cell with the conversation's branch (and its earlier landing refs) from the hub. */
|
|
494
|
+
export const HUB_CHECKOUT = `set -eu
|
|
495
|
+
d=$(mktemp -d "\${TMPDIR:-/tmp}/shard-land-XXXXXX")
|
|
496
|
+
git init -q "$d"
|
|
497
|
+
cd "$d"
|
|
498
|
+
git fetch -q --no-tags "$1" "+refs/heads/$2:refs/heads/$2"
|
|
499
|
+
git fetch -q --no-tags "$1" "+refs/shard/land/$3/upto:refs/shard/land/upto" 2>/dev/null || true
|
|
500
|
+
git symbolic-ref HEAD "refs/heads/$2"
|
|
501
|
+
if [ -n "$4" ]; then git remote add origin "$4"; fi
|
|
502
|
+
printf '%s\\n' "$d"
|
|
503
|
+
`;
|
|
504
|
+
export async function landingRound(cell, input) {
|
|
505
|
+
if (!input.fromHub)
|
|
506
|
+
return landingRoundIn(cell, input);
|
|
507
|
+
const hub = input.hub ?? HUB_PATH, c = input.conversation;
|
|
508
|
+
const made = await cell.exec.run(['sh', '-c', HUB_CHECKOUT, 'shard-land', hub, c.branch, c.name, input.fromHub.repoRemote ?? ''], { timeoutMs: 300_000, maxOutputBytes: 4096, ...(input.signal ? { signal: input.signal } : {}) });
|
|
509
|
+
const dir = made.stdout.trim().split('\n').at(-1) ?? '';
|
|
510
|
+
if (made.exitCode !== 0 || !dir.startsWith('/'))
|
|
511
|
+
return { state: 'error', land: { state: 'error', reason: 'hub', message: `the conversation's branch could not be read from the project hub: ${made.stderr.trim().slice(-400)}` }, timings: {} };
|
|
512
|
+
try {
|
|
513
|
+
return await landingRoundIn(cell, { ...input, conversation: { ...c, worktree_path: dir }, hub: '' });
|
|
514
|
+
}
|
|
515
|
+
finally {
|
|
516
|
+
await cell.exec.run(['rm', '-rf', '--', dir], { timeoutMs: 60_000 }).catch(() => undefined);
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
async function landingRoundIn(cell, input) {
|
|
520
|
+
const now = input.now ?? Date.now, t0 = now(), timings = {};
|
|
521
|
+
const mark = (name) => { timings[name] = now() - t0; };
|
|
522
|
+
const c = input.conversation, branch = `refs/heads/${input.into}`, hub = input.hub ?? HUB_PATH, vmTmp = input.vmTmp ?? '/tmp';
|
|
523
|
+
if (input.worktree) {
|
|
524
|
+
const reason = pauseReason(input.worktree);
|
|
525
|
+
if (reason)
|
|
526
|
+
return { state: 'paused', reason, timings };
|
|
527
|
+
}
|
|
528
|
+
const snap = input.worktree ? await snapshotWorktree(input.worktree, { device: input.device, stateDir: input.stateDir, signal: input.signal }) : await refTwin(input.repo, branch, input.device);
|
|
529
|
+
if (input.worktree && snap.base !== await verifyCommit(input.repo, branch))
|
|
530
|
+
return { state: 'moved', reason: 'branch_switched', timings };
|
|
531
|
+
mark('snapshot');
|
|
532
|
+
const out = `${vmTmp}/shard-land-${randomUUID()}.bundle`;
|
|
533
|
+
const send = async (known) => {
|
|
534
|
+
// The VM has the conversation's base and what earlier landings made there; the bundle carries the rest.
|
|
535
|
+
const exclude = [];
|
|
536
|
+
for (const rev of [c.base_commit, ...known])
|
|
537
|
+
if (rev && await verifyCommit(input.repo, rev))
|
|
538
|
+
exclude.push(rev);
|
|
539
|
+
const local = join(tmpdir(), `shard-twin-${randomUUID()}.bundle`), remote = `${vmTmp}/shard-twin-${randomUUID()}.bundle`, ref = twinRef(snap.id);
|
|
540
|
+
try {
|
|
541
|
+
const bundled = await git(input.repo, ['bundle', 'create', '-q', local, ref, ...(exclude.length ? ['--not', ...exclude] : [])], { ok: [0, 128] });
|
|
542
|
+
// "Refusing to create empty bundle": the VM already has every commit the twin needs.
|
|
543
|
+
if (bundled.code !== 0 && !/empty bundle/.test(bundled.stderr))
|
|
544
|
+
throw new TwinGitError(['bundle'], bundled);
|
|
545
|
+
const hasBundle = bundled.code === 0;
|
|
546
|
+
let inline, uploaded = false;
|
|
547
|
+
if (hasBundle) {
|
|
548
|
+
const bytes = await readFile(local);
|
|
549
|
+
if (bytes.length > TRANSFER_LIMIT)
|
|
550
|
+
throw new Error('the twin exceeds the 256 MiB transfer bound');
|
|
551
|
+
// A small bundle rides in the script's input: no file round trips.
|
|
552
|
+
if (bytes.length <= INLINE_BYTES)
|
|
553
|
+
inline = bytes.toString('base64');
|
|
554
|
+
else {
|
|
555
|
+
for (let at = 0; at < bytes.length; at += SYNC_CHUNK_BYTES)
|
|
556
|
+
await cell.files.write(remote, bytes.subarray(at, at + SYNC_CHUNK_BYTES), { mode: '0600', append: at > 0 });
|
|
557
|
+
uploaded = true;
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
mark('upload');
|
|
561
|
+
try {
|
|
562
|
+
return await runLandingScript(cell, { op: 'land', repo: c.worktree_path, hub, bundle: uploaded ? remote : null, bundle_b64: inline ?? null, twin_ref: ref, twin: snap.commit,
|
|
563
|
+
hub_twin_ref: hubTwinRef(input.device, snap.id), name: c.name, branch: c.branch, base: c.base_commit, upto: input.upto ?? '', into: input.into,
|
|
564
|
+
mode: input.mode === 'pr' ? 'pr' : 'land', title: c.title, summary: c.summary, conversation_id: c.id, out }, input.signal);
|
|
565
|
+
}
|
|
566
|
+
finally {
|
|
567
|
+
if (uploaded)
|
|
568
|
+
await cell.files.remove(remote).catch(() => undefined);
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
finally {
|
|
572
|
+
await rm(local, { force: true });
|
|
573
|
+
}
|
|
574
|
+
};
|
|
575
|
+
let land = await send(input.known ?? []);
|
|
576
|
+
// A VM that lacks an earlier landing (the conversation moved) gets everything since the conversation's base.
|
|
577
|
+
if (land.state === 'error' && (land.reason === 'prerequisites' || land.reason === 'twin_missing'))
|
|
578
|
+
land = await send([]);
|
|
579
|
+
mark('merge');
|
|
580
|
+
if (land.state === 'noop' && land.upto) {
|
|
581
|
+
// Nothing to write here, but the conversation's work is in the branch: record it as landed in its VM too.
|
|
582
|
+
await runLandingScript(cell, { op: 'confirm', repo: c.worktree_path, hub, name: c.name, upto: land.upto, into: input.into }, input.signal).catch(() => undefined);
|
|
583
|
+
}
|
|
584
|
+
if (land.state !== 'ready')
|
|
585
|
+
return { state: land.state === 'error' ? 'error' : land.state, land, timings };
|
|
586
|
+
const fetched = join(tmpdir(), `shard-land-${randomUUID()}.bundle`);
|
|
587
|
+
const tipRef = `refs/shard/land/${c.name}/tip`, resultRef = `refs/shard/land/${c.name}/result`;
|
|
588
|
+
try {
|
|
589
|
+
let bytes;
|
|
590
|
+
if (land.bundle_b64)
|
|
591
|
+
bytes = Buffer.from(land.bundle_b64, 'base64');
|
|
592
|
+
else {
|
|
593
|
+
const info = await cell.files.stat(out);
|
|
594
|
+
if (info.size > TRANSFER_LIMIT)
|
|
595
|
+
throw new Error('the landing exceeds the 256 MiB transfer bound');
|
|
596
|
+
bytes = await cell.files.read(out, { length: info.size + 1 });
|
|
597
|
+
if (bytes.length !== info.size)
|
|
598
|
+
throw new Error('the landing bundle changed while it was read');
|
|
599
|
+
}
|
|
600
|
+
await writeFile(fetched, bytes, { mode: 0o600 });
|
|
601
|
+
await git(input.repo, ['fetch', '-q', '--no-tags', fetched, `+refs/shard/land/tip:${tipRef}`, `+refs/shard/land/result:${resultRef}`]);
|
|
602
|
+
if (await verifyCommit(input.repo, tipRef) !== land.tip || await verifyCommit(input.repo, resultRef) !== land.result)
|
|
603
|
+
throw new Error('the landing bundle does not match the landing');
|
|
604
|
+
}
|
|
605
|
+
finally {
|
|
606
|
+
await rm(fetched, { force: true });
|
|
607
|
+
if (!land.bundle_b64)
|
|
608
|
+
await cell.files.remove(out).catch(() => undefined);
|
|
609
|
+
}
|
|
610
|
+
mark('download');
|
|
611
|
+
const outcome = await applyLanding({ worktree: input.worktree, repo: input.repo, branch, base: land.base, twin: land.twin, tip: land.tip, result: land.result }, { backupRoot: input.backupRoot, quietMs: input.quietMs, now, label: { conversation_id: c.id, name: c.name, title: c.title } });
|
|
612
|
+
mark('apply');
|
|
613
|
+
await git(input.repo, ['update-ref', '-d', resultRef], { ok: [0, 1] });
|
|
614
|
+
await git(input.repo, ['update-ref', '-d', tipRef], { ok: [0, 1] });
|
|
615
|
+
if (outcome.state !== 'applied')
|
|
616
|
+
return { ...outcome, reason: 'reason' in outcome ? outcome.reason : 'busy', land, timings };
|
|
617
|
+
// The files are on disk. Recording the landing in the VM and copying it to the project hub (shared storage, seconds)
|
|
618
|
+
// runs after, without holding up the next landing.
|
|
619
|
+
const confirmed = runLandingScript(cell, { op: 'confirm', repo: c.worktree_path, hub, name: c.name, upto: land.upto, into: input.into,
|
|
620
|
+
twin: land.twin, hub_twin_ref: hubTwinRef(input.device, snap.id), tip: land.tip, result: land.result }, input.signal).then(r => {
|
|
621
|
+
if (r.state === 'error')
|
|
622
|
+
throw new Error(`landing confirmation failed: ${r.message ?? r.reason}`);
|
|
623
|
+
timings.confirm = now() - t0;
|
|
624
|
+
});
|
|
625
|
+
confirmed.catch(() => undefined);
|
|
626
|
+
return { state: 'landed', land, applied: outcome, timings, confirmed };
|
|
627
|
+
}
|
|
628
|
+
export class TwinLedger {
|
|
629
|
+
store;
|
|
630
|
+
constructor(dir) { this.store = new AgentStateStore(dir); }
|
|
631
|
+
read() { return this.store.read().worktree_twin ?? {}; }
|
|
632
|
+
mode(project) { return this.read().landing?.[project] ?? 'auto'; }
|
|
633
|
+
setMode(project, mode) { this.update(doc => { doc.landing = { ...doc.landing, [project]: mode }; }); }
|
|
634
|
+
conversation(id) { return this.read().conversations?.[id] ?? {}; }
|
|
635
|
+
patch(id, change) {
|
|
636
|
+
this.update(doc => { doc.conversations = { ...doc.conversations, [id]: { ...doc.conversations?.[id], ...change } }; });
|
|
637
|
+
}
|
|
638
|
+
forget(id) { this.update(doc => { if (doc.conversations)
|
|
639
|
+
delete doc.conversations[id]; }); }
|
|
640
|
+
update(change) {
|
|
641
|
+
this.store.update(state => { const doc = state.worktree_twin ?? {}; change(doc); state.worktree_twin = doc; });
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
export const backupRoot = (stateDir) => join(stateDir, 'twin-backups');
|
|
645
|
+
export function relativeTo(root, path) { return relative(root, path) || '.'; }
|
|
646
|
+
// ---------------------------------------------------------------------------------------------------------------
|
|
647
|
+
// Seeding a new conversation from the twin
|
|
648
|
+
/** This computer's device id (the device helper's), created on first use. */
|
|
649
|
+
export function deviceId(stateDir) {
|
|
650
|
+
const file = join(stateDir, 'device-id');
|
|
651
|
+
try {
|
|
652
|
+
const id = readFileSync(file, 'utf8').trim();
|
|
653
|
+
if (/^[a-zA-Z0-9-]{8,64}$/.test(id))
|
|
654
|
+
return id;
|
|
655
|
+
}
|
|
656
|
+
catch { /* first use */ }
|
|
657
|
+
const id = randomUUID();
|
|
658
|
+
mkdirSync(stateDir, { recursive: true, mode: 0o700 });
|
|
659
|
+
try {
|
|
660
|
+
writeFileSync(file, id + '\n', { mode: 0o600, flag: 'wx' });
|
|
661
|
+
}
|
|
662
|
+
catch (error) {
|
|
663
|
+
if (error.code !== 'EEXIST')
|
|
664
|
+
throw error;
|
|
665
|
+
}
|
|
666
|
+
return readFileSync(file, 'utf8').trim();
|
|
667
|
+
}
|
|
668
|
+
/**
|
|
669
|
+
* The commit a new conversation starts from: HEAD when the worktree is clean (or landing is off, or the user's Git
|
|
670
|
+
* operation is running), else the twin snapshot S (first parent HEAD). The conversation's own work is then S..T, and
|
|
671
|
+
* the user's edits stay theirs.
|
|
672
|
+
*/
|
|
673
|
+
export async function twinSeed(stateDir, folder, head, project) {
|
|
674
|
+
if (project && new TwinLedger(stateDir).mode(project) === 'off')
|
|
675
|
+
return { commit: head, snapshot: false };
|
|
676
|
+
const info = await inspectWorktree(folder);
|
|
677
|
+
if (info.head !== head || pauseReason(info))
|
|
678
|
+
return { commit: head, snapshot: false };
|
|
679
|
+
const snap = await snapshotWorktree(info, { device: deviceId(stateDir), stateDir });
|
|
680
|
+
return snap.tree === snap.headTree ? { commit: head, snapshot: false } : { commit: snap.commit, snapshot: true };
|
|
681
|
+
}
|
|
682
|
+
export function rememberTwinSeed(stateDir, conversation, folder, seed, branch) {
|
|
683
|
+
new TwinLedger(stateDir).patch(conversation, { seed: seed.commit, seed_snapshot: seed.snapshot, folder, ...(branch ? { base_branch: branch } : {}) });
|
|
684
|
+
}
|
|
685
|
+
/** Carries a snapshot seed into the conversation's VM repository (one small bundle: the snapshot over HEAD). */
|
|
686
|
+
export async function sendTwinSeed(stateDir, cell, conversation, folder, signal) {
|
|
687
|
+
const state = new TwinLedger(stateDir).conversation(conversation.id);
|
|
688
|
+
if (!state.seed_snapshot || state.seed !== conversation.base_commit)
|
|
689
|
+
return;
|
|
690
|
+
const ref = `refs/shard/seed-tmp/${randomUUID()}`, local = join(tmpdir(), `shard-seed-${randomUUID()}.bundle`), remote = `/tmp/shard-seed-${randomUUID()}.bundle`;
|
|
691
|
+
await git(folder, ['update-ref', ref, conversation.base_commit]);
|
|
692
|
+
try {
|
|
693
|
+
await git(folder, ['bundle', 'create', '-q', local, ref, '--not', `${conversation.base_commit}^1`]);
|
|
694
|
+
const bytes = await readFile(local);
|
|
695
|
+
if (bytes.length > TRANSFER_LIMIT)
|
|
696
|
+
throw new Error('the uncommitted changes exceed the 256 MiB transfer bound');
|
|
697
|
+
for (let at = 0; at < bytes.length; at += SYNC_CHUNK_BYTES)
|
|
698
|
+
await cell.files.write(remote, bytes.subarray(at, at + SYNC_CHUNK_BYTES), { mode: '0600', append: at > 0 });
|
|
699
|
+
const r = await cell.exec.run(['git', '-C', conversation.worktree_path, '-c', 'core.hooksPath=/dev/null', 'fetch', '-q', '--no-tags', remote, `+${ref}:refs/shard/twin-seed`], { timeoutMs: 120_000, ...(signal ? { signal } : {}) });
|
|
700
|
+
if (r.exitCode !== 0)
|
|
701
|
+
throw new Error(`the conversation could not receive its starting snapshot: ${r.stderr.trim()}`);
|
|
702
|
+
}
|
|
703
|
+
finally {
|
|
704
|
+
await git(folder, ['update-ref', '-d', ref], { ok: [0, 1] });
|
|
705
|
+
await rm(local, { force: true });
|
|
706
|
+
await cell.files.remove(remote).catch(() => undefined);
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
/**
|
|
710
|
+
* The conversation branch at its base. A snapshot base already matches the copied files: the branch starts at HEAD
|
|
711
|
+
* and moves to the snapshot without touching them (`checkout` would refuse the "local changes").
|
|
712
|
+
*/
|
|
713
|
+
export function twinCheckoutArgv(stateDir, conversation) {
|
|
714
|
+
const state = new TwinLedger(stateDir).conversation(conversation.id);
|
|
715
|
+
if (!state.seed_snapshot || state.seed !== conversation.base_commit)
|
|
716
|
+
return ['git', '-C', conversation.worktree_path, 'checkout', '-B', conversation.branch, conversation.base_commit];
|
|
717
|
+
return ['sh', '-c', 'set -e; git -C "$1" checkout -q -B "$2" HEAD; git -C "$1" reset -q --mixed "$3"', 'shard-seed', conversation.worktree_path, conversation.branch, conversation.base_commit];
|
|
718
|
+
}
|