@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,761 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { execFileSync } from 'node:child_process';
|
|
3
|
+
import { closeSync, existsSync, lstatSync, mkdirSync, openSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync, writeSync, chmodSync, unlinkSync, rmdirSync } from 'node:fs';
|
|
4
|
+
import { homedir } from 'node:os';
|
|
5
|
+
import { basename, dirname, join, posix } from 'node:path';
|
|
6
|
+
import { parse as parseYaml } from 'yaml';
|
|
7
|
+
import { conversationsUnavailable, localConversations } from "./conversations.js";
|
|
8
|
+
import { conversationClient } from "./conversation-commands.js";
|
|
9
|
+
import { projectIdentity } from "./project-identity.js";
|
|
10
|
+
import { withTargetLock } from "./target-lock.js";
|
|
11
|
+
import { UsageError } from "./errors.js";
|
|
12
|
+
/**
|
|
13
|
+
* Files across conversations on the Mac (docs/progress/cross-conversation-files.md): a read-only folder per
|
|
14
|
+
* conversation, `~/Shardflux/<repository>/<base branch>/<conversation>/`, holding what that conversation committed
|
|
15
|
+
* relative to its fork point. Git (the project hub) is the only channel; the user's own checkouts are never written.
|
|
16
|
+
* Files above the per-file limit, or past the conversation's total, are placeholders until `shard files pull`.
|
|
17
|
+
*/
|
|
18
|
+
export const HUB_PATH = '/shardflux/project/git/hub.git';
|
|
19
|
+
export const MANIFEST = '.shardflux-manifest.json';
|
|
20
|
+
export const PLACEHOLDER = '.placeholder.txt';
|
|
21
|
+
export const FILES_CONFIG = '.shardflux/files.yaml';
|
|
22
|
+
export const DEFAULT_MAX_FILE = 50 * 2 ** 20;
|
|
23
|
+
export const DEFAULT_MAX_CONVERSATION = 2 * 2 ** 30;
|
|
24
|
+
export function filesRoot(env) {
|
|
25
|
+
return env.SHARDFLUX_FILES_DIR || join(env.HOME || homedir(), 'Shardflux');
|
|
26
|
+
}
|
|
27
|
+
// ---- pure rules, shared with the guest's sibling view (services/guest-agent/internal/conversations/siblings.go) ----
|
|
28
|
+
/** A base branch as one path component: slashes and other separators become "-". */
|
|
29
|
+
export function branchFolder(branch) {
|
|
30
|
+
if (!branch)
|
|
31
|
+
return 'detached';
|
|
32
|
+
const name = branch.replaceAll('/', '-').replace(/[^A-Za-z0-9._-]+/g, '-').replace(/^[.-]+|[.-]+$/g, '').slice(0, 100);
|
|
33
|
+
return name || 'branch';
|
|
34
|
+
}
|
|
35
|
+
/** A human name as one Finder path component. */
|
|
36
|
+
export function humanFolder(text, fallback) {
|
|
37
|
+
const printable = [...text.normalize('NFC')].map(ch => (ch.charCodeAt(0) < 32 || ch.charCodeAt(0) === 127 ? ' ' : ch)).join('');
|
|
38
|
+
const name = printable.replace(/[/:\\]+/g, ' ').replace(/\s+/g, ' ').trim().replace(/^\.+/, '');
|
|
39
|
+
if (!name)
|
|
40
|
+
return fallback;
|
|
41
|
+
if (name.length <= 60)
|
|
42
|
+
return name;
|
|
43
|
+
const cut = name.slice(0, 60), space = cut.lastIndexOf(' ');
|
|
44
|
+
return (space > 30 ? cut.slice(0, space) : cut).trim();
|
|
45
|
+
}
|
|
46
|
+
export function repositoryName(identity) {
|
|
47
|
+
const tail = identity.replace(/\/+$/, '').split(/[/:]/).at(-1)?.replace(/\.git$/, '') ?? '';
|
|
48
|
+
return humanFolder(tail, 'project');
|
|
49
|
+
}
|
|
50
|
+
export function parseFilesConfig(text) {
|
|
51
|
+
const limits = { maxFile: DEFAULT_MAX_FILE, maxConversation: DEFAULT_MAX_CONVERSATION };
|
|
52
|
+
if (!text)
|
|
53
|
+
return limits;
|
|
54
|
+
let doc;
|
|
55
|
+
try {
|
|
56
|
+
doc = parseYaml(text);
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
return limits;
|
|
60
|
+
}
|
|
61
|
+
if (!doc || typeof doc !== 'object')
|
|
62
|
+
return limits;
|
|
63
|
+
const { max_file_mb: file, max_conversation_mb: total } = doc;
|
|
64
|
+
if (Number.isInteger(file) && file >= 1 && file <= 10240)
|
|
65
|
+
limits.maxFile = file * 2 ** 20;
|
|
66
|
+
if (Number.isInteger(total) && total >= 1 && total <= 2 ** 20)
|
|
67
|
+
limits.maxConversation = total * 2 ** 20;
|
|
68
|
+
return limits;
|
|
69
|
+
}
|
|
70
|
+
export function parseDiffTree(bytes) {
|
|
71
|
+
const fields = bytes.toString('utf8').split('\0'), out = [];
|
|
72
|
+
for (let i = 0; i + 1 < fields.length; i += 2) {
|
|
73
|
+
const head = fields[i];
|
|
74
|
+
if (!head)
|
|
75
|
+
break;
|
|
76
|
+
const parts = head.replace(/^:/, '').split(' ');
|
|
77
|
+
if (parts.length !== 5)
|
|
78
|
+
throw new Error(`unexpected diff-tree record ${JSON.stringify(head)}`);
|
|
79
|
+
out.push({ status: parts[4].slice(0, 1), mode: parts[1], oid: parts[3], path: fields[i + 1] });
|
|
80
|
+
}
|
|
81
|
+
return out;
|
|
82
|
+
}
|
|
83
|
+
export function safeRelative(path) {
|
|
84
|
+
if (!path || path.startsWith('/') || /[\0\\]/.test(path) || posix.normalize(path) !== path || path === MANIFEST)
|
|
85
|
+
return false;
|
|
86
|
+
return path.split('/').every(part => part !== '..' && part !== '.' && part.toLowerCase() !== '.git');
|
|
87
|
+
}
|
|
88
|
+
/** Smallest first while the conversation's total stays within its limit; fetched files stay while unchanged. */
|
|
89
|
+
export function planFiles(files, limits, prev) {
|
|
90
|
+
files.sort((a, b) => a.size - b.size || (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
|
|
91
|
+
let total = 0;
|
|
92
|
+
for (const f of files) {
|
|
93
|
+
const before = prev.get(f.path);
|
|
94
|
+
delete f.reason;
|
|
95
|
+
delete f.fetched;
|
|
96
|
+
if (f.mode === '160000')
|
|
97
|
+
f.state = 'submodule';
|
|
98
|
+
else if (f.mode === '120000')
|
|
99
|
+
f.state = 'symlink';
|
|
100
|
+
else if (before?.fetched && before.oid === f.oid) {
|
|
101
|
+
f.state = 'present';
|
|
102
|
+
f.fetched = true;
|
|
103
|
+
}
|
|
104
|
+
else if (f.size > limits.maxFile) {
|
|
105
|
+
f.state = 'placeholder';
|
|
106
|
+
f.reason = 'file_limit';
|
|
107
|
+
}
|
|
108
|
+
else if (total + f.size > limits.maxConversation) {
|
|
109
|
+
f.state = 'placeholder';
|
|
110
|
+
f.reason = 'conversation_limit';
|
|
111
|
+
}
|
|
112
|
+
else {
|
|
113
|
+
f.state = 'present';
|
|
114
|
+
total += f.size;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return files.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
|
|
118
|
+
}
|
|
119
|
+
export function humanBytes(n) {
|
|
120
|
+
if (n >= 2 ** 30)
|
|
121
|
+
return `${(n / 2 ** 30).toFixed(1)} GB`;
|
|
122
|
+
if (n >= 2 ** 20)
|
|
123
|
+
return `${(n / 2 ** 20).toFixed(1)} MB`;
|
|
124
|
+
if (n >= 2 ** 10)
|
|
125
|
+
return `${(n / 2 ** 10).toFixed(1)} KB`;
|
|
126
|
+
return `${n} bytes`;
|
|
127
|
+
}
|
|
128
|
+
const shellWord = (s) => /^[A-Za-z0-9._/@%+=:,-]+$/.test(s) ? s : `'${s.replaceAll("'", `'\\''`)}'`;
|
|
129
|
+
export function placeholderText(m, f) {
|
|
130
|
+
const why = f.reason === 'conversation_limit' ? `this conversation's changed files are above the ${humanBytes(m.max_conversation_bytes)} total limit` : `it is above the ${humanBytes(m.max_file_bytes)} per-file limit`;
|
|
131
|
+
return `${f.path} (${humanBytes(f.size)}) is listed here but not downloaded, because ${why}.\nConversation ${m.name}, commit ${m.commit.slice(0, 12)}.\n` +
|
|
132
|
+
`Download it from the Shardflux menu bar (Download), or run: shard files pull ${m.name} ${shellWord(f.path)}\n`;
|
|
133
|
+
}
|
|
134
|
+
function runGit(args, options = {}) {
|
|
135
|
+
return new Promise((resolve, reject) => {
|
|
136
|
+
const child = spawn('git', args, { env: { ...(options.env ?? process.env), GIT_TERMINAL_PROMPT: '0' }, stdio: ['pipe', 'pipe', 'pipe'], ...(options.signal ? { signal: options.signal } : {}) });
|
|
137
|
+
const out = [], err = [];
|
|
138
|
+
child.stdout.on('data', (b) => out.push(b));
|
|
139
|
+
child.stderr.on('data', (b) => err.push(b));
|
|
140
|
+
child.on('error', reject);
|
|
141
|
+
child.on('close', code => resolve({ code: code ?? 1, stdout: Buffer.concat(out), stderr: Buffer.concat(err).toString('utf8').trim() }));
|
|
142
|
+
child.stdin.on('error', () => undefined);
|
|
143
|
+
child.stdin.end(options.stdin ?? '');
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
/** A hub on this machine (tests). */
|
|
147
|
+
export function localHub(path) {
|
|
148
|
+
return {
|
|
149
|
+
git: (args, stdin) => runGit([`--git-dir=${path}`, ...args], { ...(stdin !== undefined ? { stdin } : {}) }),
|
|
150
|
+
blobs: (oids, onData) => new Promise((resolve, reject) => {
|
|
151
|
+
const child = spawn('git', [`--git-dir=${path}`, 'cat-file', '--batch'], { stdio: ['pipe', 'pipe', 'pipe'] });
|
|
152
|
+
const err = [];
|
|
153
|
+
child.stdout.on('data', (b) => onData(b));
|
|
154
|
+
child.stderr.on('data', (b) => err.push(b));
|
|
155
|
+
child.on('error', reject);
|
|
156
|
+
child.on('close', code => code === 0 ? resolve() : reject(new Error(`git cat-file failed: ${Buffer.concat(err).toString('utf8').trim()}`)));
|
|
157
|
+
child.stdin.end(oids.join('\n') + '\n');
|
|
158
|
+
}),
|
|
159
|
+
close() { },
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
/** The project hub through a ready VM of the project: git runs there, file contents stream back over the same exec API. */
|
|
163
|
+
export function cellHub(ws, signal) {
|
|
164
|
+
const cell = ws.cell({ wake: null, agentLabel: 'shard-files' });
|
|
165
|
+
return {
|
|
166
|
+
async git(args, stdin) {
|
|
167
|
+
const chunks = [];
|
|
168
|
+
const r = await cell.exec.run(['git', `--git-dir=${HUB_PATH}`, ...args], { ...(stdin !== undefined ? { stdin } : {}), timeoutMs: 120_000, maxOutputBytes: 1 << 20, signal, onOutput: (stream, chunk) => { if (stream === 'stdout')
|
|
169
|
+
chunks.push(chunk); } });
|
|
170
|
+
return { code: r.exitCode ?? 1, stdout: Buffer.concat(chunks), stderr: r.stderr.trim() };
|
|
171
|
+
},
|
|
172
|
+
async blobs(oids, onData) {
|
|
173
|
+
// The batch goes to a file on the VM's disk and comes back through the files API, which carries any bytes and
|
|
174
|
+
// reads a VM's disk even if it suspends meanwhile.
|
|
175
|
+
const made = await cell.exec.run(['sh', '-c', 'set -e; d="$HOME/.cache/shard-files"; mkdir -p "$d"; f=$(mktemp "$d/batch-XXXXXX"); git --git-dir="$1" cat-file --batch > "$f"; wc -c < "$f"; printf "%s\\n" "$f"', 'shard-files', HUB_PATH], { stdin: oids.join('\n') + '\n', timeoutMs: 1_800_000, maxOutputBytes: 4096, signal });
|
|
176
|
+
const [size, path] = made.stdout.trim().split('\n').map(s => s.trim());
|
|
177
|
+
if (made.exitCode !== 0 || !path?.startsWith('/') || !/^\d+$/.test(size ?? ''))
|
|
178
|
+
throw new Error(`git cat-file failed in the project's VM: ${made.stderr.trim() || `exit ${made.exitCode}`}`);
|
|
179
|
+
try {
|
|
180
|
+
for (let at = 0, total = Number(size); at < total;) {
|
|
181
|
+
signal.throwIfAborted();
|
|
182
|
+
const chunk = await cell.files.read(path, { offset: at, length: Math.min(8 << 20, total - at) });
|
|
183
|
+
if (!chunk.length)
|
|
184
|
+
throw new Error('the project hub stream ended early');
|
|
185
|
+
onData(chunk);
|
|
186
|
+
at += chunk.length;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
finally {
|
|
190
|
+
await cell.files.remove(path).catch(() => undefined);
|
|
191
|
+
}
|
|
192
|
+
},
|
|
193
|
+
close() { cell.close(); },
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
/** Writes `git cat-file --batch` output into place as it arrives: read-only files, verified sizes, atomic renames. */
|
|
197
|
+
class BatchWriter {
|
|
198
|
+
#queue;
|
|
199
|
+
#root;
|
|
200
|
+
#header = Buffer.alloc(0);
|
|
201
|
+
#current;
|
|
202
|
+
#newline = false;
|
|
203
|
+
error;
|
|
204
|
+
constructor(root, files) { this.#root = root; this.#queue = [...files]; }
|
|
205
|
+
feed(chunk) {
|
|
206
|
+
if (this.error)
|
|
207
|
+
return;
|
|
208
|
+
try {
|
|
209
|
+
this.#feed(Buffer.from(chunk.buffer, chunk.byteOffset, chunk.byteLength));
|
|
210
|
+
}
|
|
211
|
+
catch (e) {
|
|
212
|
+
this.error = e instanceof Error ? e : new Error(String(e));
|
|
213
|
+
this.#abort();
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
#feed(chunk) {
|
|
217
|
+
let at = 0;
|
|
218
|
+
while (at < chunk.length) {
|
|
219
|
+
if (this.#current) {
|
|
220
|
+
const n = Math.min(this.#current.left, chunk.length - at);
|
|
221
|
+
writeSync(this.#current.fd, chunk, at, n);
|
|
222
|
+
this.#current.left -= n;
|
|
223
|
+
at += n;
|
|
224
|
+
if (this.#current.left === 0)
|
|
225
|
+
this.#finish();
|
|
226
|
+
}
|
|
227
|
+
else if (this.#newline) {
|
|
228
|
+
if (chunk[at] !== 0x0a)
|
|
229
|
+
throw new Error('git cat-file output is out of step');
|
|
230
|
+
this.#newline = false;
|
|
231
|
+
at++;
|
|
232
|
+
}
|
|
233
|
+
else {
|
|
234
|
+
const end = chunk.indexOf(0x0a, at);
|
|
235
|
+
if (end < 0) {
|
|
236
|
+
this.#header = Buffer.concat([this.#header, chunk.subarray(at)]);
|
|
237
|
+
at = chunk.length;
|
|
238
|
+
continue;
|
|
239
|
+
}
|
|
240
|
+
const header = Buffer.concat([this.#header, chunk.subarray(at, end)]).toString('utf8');
|
|
241
|
+
this.#header = Buffer.alloc(0);
|
|
242
|
+
at = end + 1;
|
|
243
|
+
this.#start(header);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
#start(header) {
|
|
248
|
+
const file = this.#queue.shift(), [oid, type, size] = header.split(' ');
|
|
249
|
+
if (!file || oid !== file.oid || type !== 'blob' || Number(size) !== file.size)
|
|
250
|
+
throw new Error(`the project hub does not have ${file?.path ?? 'the file'} (${header})`);
|
|
251
|
+
ensureDirs(this.#root, posix.dirname(file.path));
|
|
252
|
+
const target = join(this.#root, file.path), tmp = join(dirname(target), `.sf-copy-${process.pid}-${Math.random().toString(36).slice(2)}`);
|
|
253
|
+
this.#current = { file, fd: openSync(tmp, 'wx', 0o600), tmp, left: file.size };
|
|
254
|
+
if (file.size === 0)
|
|
255
|
+
this.#finish();
|
|
256
|
+
}
|
|
257
|
+
#finish() {
|
|
258
|
+
const { file, fd, tmp } = this.#current;
|
|
259
|
+
this.#current = undefined;
|
|
260
|
+
this.#newline = true;
|
|
261
|
+
closeSync(fd);
|
|
262
|
+
chmodSync(tmp, file.mode === '100755' ? 0o555 : 0o444);
|
|
263
|
+
const target = join(this.#root, file.path), st = lstatSync(target, { throwIfNoEntry: false });
|
|
264
|
+
if (st?.isDirectory())
|
|
265
|
+
rmSync(target, { recursive: true, force: true });
|
|
266
|
+
renameSync(tmp, target);
|
|
267
|
+
}
|
|
268
|
+
#abort() { if (this.#current) {
|
|
269
|
+
try {
|
|
270
|
+
closeSync(this.#current.fd);
|
|
271
|
+
}
|
|
272
|
+
catch { /* closed */ }
|
|
273
|
+
rmSync(this.#current.tmp, { force: true });
|
|
274
|
+
this.#current = undefined;
|
|
275
|
+
} }
|
|
276
|
+
end() {
|
|
277
|
+
if (this.error)
|
|
278
|
+
throw this.error;
|
|
279
|
+
if (this.#current || this.#queue.length || this.#header.length) {
|
|
280
|
+
this.#abort();
|
|
281
|
+
throw new Error('the project hub stopped before every file arrived');
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
/** Copy files' contents from the hub into root, in one stream. */
|
|
286
|
+
export async function copyFromHub(hub, root, files) {
|
|
287
|
+
if (!files.length)
|
|
288
|
+
return;
|
|
289
|
+
const writer = new BatchWriter(root, files);
|
|
290
|
+
await hub.blobs(files.map(f => f.oid), chunk => writer.feed(chunk));
|
|
291
|
+
writer.end();
|
|
292
|
+
}
|
|
293
|
+
async function must(hub, args, stdin) {
|
|
294
|
+
const r = await hub.git(args, stdin);
|
|
295
|
+
if (r.code !== 0)
|
|
296
|
+
throw new Error(`git ${args[0]} failed: ${r.stderr}`);
|
|
297
|
+
return r.stdout;
|
|
298
|
+
}
|
|
299
|
+
async function hubRefs(hub) {
|
|
300
|
+
const refs = new Map();
|
|
301
|
+
for (const line of (await must(hub, ['for-each-ref', '--format=%(objectname) %(refname)', 'refs/heads/', 'refs/shard/base/', 'refs/shard/seed/'])).toString('utf8').split('\n')) {
|
|
302
|
+
const [sha, ref] = line.split(' ');
|
|
303
|
+
if (sha && ref)
|
|
304
|
+
refs.set(ref, sha);
|
|
305
|
+
}
|
|
306
|
+
return refs;
|
|
307
|
+
}
|
|
308
|
+
async function forkPoint(hub, tip, candidates) {
|
|
309
|
+
for (const set of [candidates, candidates.slice(0, -1)]) {
|
|
310
|
+
if (!set.length)
|
|
311
|
+
continue;
|
|
312
|
+
const r = await hub.git(['merge-base', tip, ...set]);
|
|
313
|
+
if (r.code === 0)
|
|
314
|
+
return r.stdout.toString('utf8').trim().split('\n')[0];
|
|
315
|
+
}
|
|
316
|
+
return '';
|
|
317
|
+
}
|
|
318
|
+
/** Create rel under base one component at a time, replacing (never following) a file or symlink in the way. */
|
|
319
|
+
function ensureDirs(base, rel) {
|
|
320
|
+
let at = base;
|
|
321
|
+
for (const part of rel.split('/').filter(p => p && p !== '.')) {
|
|
322
|
+
at = join(at, part);
|
|
323
|
+
const st = lstatSync(at, { throwIfNoEntry: false });
|
|
324
|
+
if (st?.isDirectory())
|
|
325
|
+
continue;
|
|
326
|
+
if (st)
|
|
327
|
+
unlinkSync(at);
|
|
328
|
+
mkdirSync(at, { mode: 0o755 });
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
function removeLeaf(root, rel) {
|
|
332
|
+
const target = join(root, rel);
|
|
333
|
+
const st = lstatSync(target, { throwIfNoEntry: false });
|
|
334
|
+
if (st && !st.isDirectory()) {
|
|
335
|
+
try {
|
|
336
|
+
unlinkSync(target);
|
|
337
|
+
}
|
|
338
|
+
catch { /* already gone */ }
|
|
339
|
+
}
|
|
340
|
+
for (let dir = dirname(target); dir !== root && dir.startsWith(root + '/'); dir = dirname(dir)) {
|
|
341
|
+
try {
|
|
342
|
+
rmdirSync(dir);
|
|
343
|
+
}
|
|
344
|
+
catch {
|
|
345
|
+
break;
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
function writeAtomic(target, bytes, mode) {
|
|
350
|
+
const tmp = join(dirname(target), `.sf-write-${process.pid}-${Math.random().toString(36).slice(2)}`);
|
|
351
|
+
writeFileSync(tmp, bytes, { mode });
|
|
352
|
+
chmodSync(tmp, mode);
|
|
353
|
+
const st = lstatSync(target, { throwIfNoEntry: false });
|
|
354
|
+
if (st?.isDirectory())
|
|
355
|
+
rmSync(target, { recursive: true, force: true });
|
|
356
|
+
renameSync(tmp, target);
|
|
357
|
+
}
|
|
358
|
+
export class FilesMirrorState {
|
|
359
|
+
revisions = new Map();
|
|
360
|
+
retryAt = 0;
|
|
361
|
+
failures = 0;
|
|
362
|
+
listedAt = 0;
|
|
363
|
+
reported = new Set();
|
|
364
|
+
}
|
|
365
|
+
function readJson(file, fallback) { try {
|
|
366
|
+
return JSON.parse(readFileSync(file, 'utf8'));
|
|
367
|
+
}
|
|
368
|
+
catch {
|
|
369
|
+
return fallback;
|
|
370
|
+
} }
|
|
371
|
+
function manifests(root) {
|
|
372
|
+
const found = new Map();
|
|
373
|
+
const dirs = (p) => { try {
|
|
374
|
+
return readdirSync(p, { withFileTypes: true }).filter(d => d.isDirectory() && !d.name.startsWith('.')).map(d => d.name);
|
|
375
|
+
}
|
|
376
|
+
catch {
|
|
377
|
+
return [];
|
|
378
|
+
} };
|
|
379
|
+
for (const project of dirs(root))
|
|
380
|
+
for (const group of dirs(join(root, project)))
|
|
381
|
+
for (const conv of dirs(join(root, project, group))) {
|
|
382
|
+
const rel = join(project, group, conv), manifest = readJson(join(root, rel, MANIFEST), null);
|
|
383
|
+
if (manifest && /^conv_[a-z0-9]{26}$/.test(manifest.conversation_id ?? ''))
|
|
384
|
+
found.set(manifest.conversation_id, { rel, manifest });
|
|
385
|
+
}
|
|
386
|
+
return found;
|
|
387
|
+
}
|
|
388
|
+
const nameOf = (c) => c.branch.replace(/^shard\//, '');
|
|
389
|
+
/** The repository a conversation belongs to: its normalized remote; worktrees of an origin-less repository share its Git common directory. */
|
|
390
|
+
export function repositoryIdentity(c, local) {
|
|
391
|
+
let remote = c.repo_remote || undefined;
|
|
392
|
+
if (!remote && local) {
|
|
393
|
+
try {
|
|
394
|
+
const p = projectIdentity(local.folder);
|
|
395
|
+
remote = p.remote ?? `file://${p.folder}`;
|
|
396
|
+
}
|
|
397
|
+
catch { /* the folder moved */ }
|
|
398
|
+
}
|
|
399
|
+
if (remote?.startsWith('file://')) {
|
|
400
|
+
const path = remote.slice('file://'.length);
|
|
401
|
+
try {
|
|
402
|
+
const common = execFileSync('git', ['-C', path, 'rev-parse', '--path-format=absolute', '--git-common-dir'], { encoding: 'utf8', timeout: 5000, stdio: ['ignore', 'pipe', 'ignore'] }).trim();
|
|
403
|
+
remote = `file://${basename(common) === '.git' ? dirname(common) : common.replace(/\.git$/, '')}`;
|
|
404
|
+
}
|
|
405
|
+
catch { /* not on this Mac */ }
|
|
406
|
+
}
|
|
407
|
+
if (remote)
|
|
408
|
+
return { id: remote, name: repositoryName(remote) };
|
|
409
|
+
return { id: `project:${c.project_id}`, name: humanFolder(c.key.split('/')[0] ?? '', 'project') };
|
|
410
|
+
}
|
|
411
|
+
function assign(taken, candidates) {
|
|
412
|
+
for (const c of candidates)
|
|
413
|
+
if (!taken.has(c.toLowerCase()))
|
|
414
|
+
return c;
|
|
415
|
+
for (let n = 2;; n++) {
|
|
416
|
+
const c = `${candidates[0]}-${n}`;
|
|
417
|
+
if (!taken.has(c.toLowerCase()))
|
|
418
|
+
return c;
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
export class FilesMirror {
|
|
422
|
+
root;
|
|
423
|
+
ctx;
|
|
424
|
+
connect;
|
|
425
|
+
constructor(ctx, connect, root = filesRoot(ctx.io.env)) {
|
|
426
|
+
this.ctx = ctx;
|
|
427
|
+
this.connect = connect;
|
|
428
|
+
this.root = root;
|
|
429
|
+
}
|
|
430
|
+
get stateFile() { return join(this.root, '.shardflux', 'state.json'); }
|
|
431
|
+
get indexFile() { return join(this.ctx.store.dir, 'files.json'); }
|
|
432
|
+
/** One pass: only conversations whose API record moved touch the hub. */
|
|
433
|
+
async sync(all, state = new FilesMirrorState()) {
|
|
434
|
+
const locals = localConversations({ store: this.ctx.store, apiUrl: this.ctx.apiUrl });
|
|
435
|
+
const revision = (c) => JSON.stringify([c.state, c.last_activity_at, c.title, c.base_branch ?? null, c.landed ?? null, c.repo_remote ?? null, c.workspace_id]);
|
|
436
|
+
const listed = new Set(all.map(c => c.id));
|
|
437
|
+
const existing = manifests(this.root);
|
|
438
|
+
const changed = all.filter(c => state.revisions.get(c.id) !== revision(c));
|
|
439
|
+
const gone = [...existing.keys()].filter(id => !listed.has(id) || all.find(c => c.id === id)?.landed?.how === 'discarded');
|
|
440
|
+
if (!changed.length && !gone.length)
|
|
441
|
+
return;
|
|
442
|
+
await withTargetLock(join(this.ctx.store.dir, 'files-lock'), async () => {
|
|
443
|
+
mkdirSync(join(this.root, '.shardflux'), { recursive: true, mode: 0o755 });
|
|
444
|
+
const a = readJson(this.stateFile, { projects: {}, groups: {}, conversations: {} });
|
|
445
|
+
const current = manifests(this.root);
|
|
446
|
+
for (const id of gone) {
|
|
447
|
+
const d = current.get(id);
|
|
448
|
+
if (d)
|
|
449
|
+
this.#remove(d.rel);
|
|
450
|
+
delete a.conversations[id];
|
|
451
|
+
current.delete(id);
|
|
452
|
+
}
|
|
453
|
+
const errors = [];
|
|
454
|
+
const byProject = new Map();
|
|
455
|
+
for (const c of changed) {
|
|
456
|
+
if (c.landed?.how === 'discarded' || c.join_mode === 'reviewer') {
|
|
457
|
+
state.revisions.set(c.id, revision(c));
|
|
458
|
+
continue;
|
|
459
|
+
}
|
|
460
|
+
byProject.set(c.project_id, [...(byProject.get(c.project_id) ?? []), c]);
|
|
461
|
+
}
|
|
462
|
+
for (const [projectId, list] of byProject) {
|
|
463
|
+
let hub;
|
|
464
|
+
try {
|
|
465
|
+
hub = await this.connect(projectId, all);
|
|
466
|
+
const refs = await hubRefs(hub);
|
|
467
|
+
for (const c of list) {
|
|
468
|
+
try {
|
|
469
|
+
if (refs.has(`refs/heads/${c.branch}`))
|
|
470
|
+
await this.#refresh(hub, refs, c, locals[c.id], a, current);
|
|
471
|
+
state.revisions.set(c.id, revision(c));
|
|
472
|
+
}
|
|
473
|
+
catch (e) {
|
|
474
|
+
errors.push(`${c.branch}: ${e instanceof Error ? e.message : String(e)}`);
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
catch (e) {
|
|
479
|
+
errors.push(e instanceof Error ? e.message : String(e));
|
|
480
|
+
}
|
|
481
|
+
finally {
|
|
482
|
+
hub?.close();
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
writeFileSync(this.stateFile, JSON.stringify(a, null, 2) + '\n');
|
|
486
|
+
this.#index();
|
|
487
|
+
if (errors.length)
|
|
488
|
+
throw new Error(`Files mirror: ${errors.join('; ')}`);
|
|
489
|
+
}, this.ctx.signal);
|
|
490
|
+
}
|
|
491
|
+
#remove(rel) {
|
|
492
|
+
rmSync(join(this.root, rel), { recursive: true, force: true });
|
|
493
|
+
for (let dir = dirname(join(this.root, rel)); dir !== this.root && dir.startsWith(this.root + '/'); dir = dirname(dir)) {
|
|
494
|
+
try {
|
|
495
|
+
rmdirSync(dir);
|
|
496
|
+
}
|
|
497
|
+
catch {
|
|
498
|
+
break;
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
/** Where a conversation's folder lives; assignments persist so a new repository never renames an existing one. */
|
|
503
|
+
#placeOf(c, local, a) {
|
|
504
|
+
const repo = repositoryIdentity(c, local);
|
|
505
|
+
const usedProjects = new Set(Object.entries(a.projects).filter(([id]) => id !== repo.id).map(([, n]) => n.toLowerCase()));
|
|
506
|
+
a.projects[repo.id] ??= assign(usedProjects, [repo.name]);
|
|
507
|
+
const project = a.projects[repo.id];
|
|
508
|
+
const base = c.base_branch ?? local?.base_branch ?? null, groupKey = `${repo.id}\n${base ?? ''}`;
|
|
509
|
+
const usedGroups = new Set(Object.entries(a.groups).filter(([k]) => k !== groupKey && k.startsWith(`${repo.id}\n`)).map(([, n]) => n.toLowerCase()));
|
|
510
|
+
a.groups[groupKey] ??= assign(usedGroups, [branchFolder(base)]);
|
|
511
|
+
const group = a.groups[groupKey];
|
|
512
|
+
// Titles start as the first prompt; attachment markers are not a name.
|
|
513
|
+
const name = nameOf(c), title = humanFolder((c.title && c.title !== name ? c.title : name).replace(/\[Image #\d+\]\s*/g, ''), name);
|
|
514
|
+
const candidates = [title, `${title} (${name})`, `${title} (${c.id})`];
|
|
515
|
+
const usedHere = new Set(Object.entries(a.conversations).filter(([id, rel]) => id !== c.id && dirname(rel) === join(project, group)).map(([, rel]) => basename(rel).toLowerCase()));
|
|
516
|
+
const prior = a.conversations[c.id];
|
|
517
|
+
const keep = prior && dirname(prior) === join(project, group) && candidates.includes(basename(prior)) && !usedHere.has(basename(prior).toLowerCase());
|
|
518
|
+
a.conversations[c.id] = keep ? prior : join(project, group, assign(usedHere, candidates));
|
|
519
|
+
return a.conversations[c.id];
|
|
520
|
+
}
|
|
521
|
+
async #refresh(hub, refs, c, local, a, current) {
|
|
522
|
+
const prior = current.get(c.id), rel = this.#placeOf(c, local, a), root = join(this.root, rel);
|
|
523
|
+
if (prior && prior.rel !== rel) {
|
|
524
|
+
ensureDirs(this.root, dirname(rel));
|
|
525
|
+
rmSync(root, { recursive: true, force: true });
|
|
526
|
+
renameSync(join(this.root, prior.rel), root);
|
|
527
|
+
this.#remove(prior.rel);
|
|
528
|
+
}
|
|
529
|
+
const old = prior?.manifest, name = nameOf(c), tip = refs.get(`refs/heads/${c.branch}`);
|
|
530
|
+
const base = c.base_branch ?? local?.base_branch ?? null;
|
|
531
|
+
if (old?.landed) {
|
|
532
|
+
if (old.title !== c.title) {
|
|
533
|
+
old.title = c.title;
|
|
534
|
+
this.#writeManifest(root, old);
|
|
535
|
+
}
|
|
536
|
+
return;
|
|
537
|
+
}
|
|
538
|
+
const candidates = [refs.get(`refs/shard/base/${name}`), base && base !== c.branch ? refs.get(`refs/heads/${base}`) : undefined, c.base_commit].filter((s) => !!s);
|
|
539
|
+
let fork = await forkPoint(hub, tip, candidates);
|
|
540
|
+
if (!fork)
|
|
541
|
+
throw new Error('no fork point in the project hub');
|
|
542
|
+
const landed = c.landed?.how === 'merged' || (fork === tip && tip !== c.base_commit);
|
|
543
|
+
const note = landed ? { how: 'merged', ref: c.landed?.ref || base || '', at: c.landed?.at ?? '', note: 'Landed: these files now live in the base branch. This folder no longer updates.' } : undefined;
|
|
544
|
+
if (landed && old) {
|
|
545
|
+
old.landed = note;
|
|
546
|
+
this.#writeManifest(root, old);
|
|
547
|
+
return;
|
|
548
|
+
}
|
|
549
|
+
if (landed && fork === tip)
|
|
550
|
+
fork = (await forkPoint(hub, tip, [c.base_commit])) || fork;
|
|
551
|
+
const seed = refs.get(`refs/shard/seed/${name}`);
|
|
552
|
+
if (!landed && old && old.commit === tip && old.fork_point === fork && old.seed === seed) {
|
|
553
|
+
if (old.title !== c.title || old.base_branch !== base) {
|
|
554
|
+
old.title = c.title;
|
|
555
|
+
old.base_branch = base;
|
|
556
|
+
this.#writeManifest(root, old);
|
|
557
|
+
}
|
|
558
|
+
return;
|
|
559
|
+
}
|
|
560
|
+
const config = await hub.git(['cat-file', 'blob', `${tip}:${FILES_CONFIG}`]);
|
|
561
|
+
const limits = parseFilesConfig(config.code === 0 ? config.stdout.toString('utf8') : undefined);
|
|
562
|
+
let changes = parseDiffTree(await must(hub, ['diff-tree', '-r', '-z', '--raw', '--no-renames', '--no-abbrev', fork, tip]));
|
|
563
|
+
if (seed && seed !== fork) {
|
|
564
|
+
// The working tree it started from (uncommitted laptop edits) is not its change: a path counts only when it
|
|
565
|
+
// also differs from the seed snapshot.
|
|
566
|
+
const differs = new Set(parseDiffTree(await must(hub, ['diff-tree', '-r', '-z', '--raw', '--no-renames', '--no-abbrev', seed, tip])).map(ch => ch.path));
|
|
567
|
+
changes = changes.filter(ch => differs.has(ch.path));
|
|
568
|
+
}
|
|
569
|
+
const m = { version: 1, conversation_id: c.id, project_id: c.project_id, name, title: c.title, repository: repositoryIdentity(c, local).id, branch: c.branch, base_branch: base,
|
|
570
|
+
commit: tip, fork_point: fork, ...(seed ? { seed } : {}), max_file_bytes: limits.maxFile, max_conversation_bytes: limits.maxConversation, files: [], deleted: [], updated_at: new Date().toISOString() };
|
|
571
|
+
for (const ch of changes) {
|
|
572
|
+
if (!safeRelative(ch.path))
|
|
573
|
+
continue;
|
|
574
|
+
if (ch.status === 'D')
|
|
575
|
+
m.deleted.push(ch.path);
|
|
576
|
+
else
|
|
577
|
+
m.files.push({ path: ch.path, oid: ch.oid, size: 0, mode: ch.mode, state: 'present' });
|
|
578
|
+
}
|
|
579
|
+
const oids = m.files.filter(f => f.mode !== '160000').map(f => f.oid);
|
|
580
|
+
if (oids.length) {
|
|
581
|
+
for (const line of (await must(hub, ['cat-file', '--batch-check=%(objectname) %(objectsize)'], oids.join('\n') + '\n')).toString('utf8').split('\n')) {
|
|
582
|
+
const [oid, size] = line.split(' ');
|
|
583
|
+
for (const f of m.files)
|
|
584
|
+
if (f.oid === oid && size)
|
|
585
|
+
f.size = Number(size);
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
const prev = new Map((old?.files ?? []).map(f => [f.path, f]));
|
|
589
|
+
planFiles(m.files, limits, prev);
|
|
590
|
+
if (note)
|
|
591
|
+
m.landed = note;
|
|
592
|
+
await this.#apply(hub, root, m, prev);
|
|
593
|
+
this.#writeManifest(root, m);
|
|
594
|
+
}
|
|
595
|
+
async #apply(hub, root, m, prev) {
|
|
596
|
+
mkdirSync(root, { recursive: true, mode: 0o755 });
|
|
597
|
+
const next = new Map(m.files.map(f => [f.path, f]));
|
|
598
|
+
for (const [path, f] of prev) {
|
|
599
|
+
const n = next.get(path);
|
|
600
|
+
if (f.state === 'present' && n?.state !== 'present')
|
|
601
|
+
removeLeaf(root, path);
|
|
602
|
+
if (f.state === 'placeholder' && n?.state !== 'placeholder')
|
|
603
|
+
removeLeaf(root, path + PLACEHOLDER);
|
|
604
|
+
}
|
|
605
|
+
const want = [];
|
|
606
|
+
for (const f of m.files) {
|
|
607
|
+
const p = prev.get(f.path);
|
|
608
|
+
if (f.state === 'present') {
|
|
609
|
+
if (!(p?.state === 'present' && p.oid === f.oid && lstatSync(join(root, f.path), { throwIfNoEntry: false })))
|
|
610
|
+
want.push(f);
|
|
611
|
+
}
|
|
612
|
+
else if (f.state === 'placeholder' && !next.has(f.path + PLACEHOLDER)) {
|
|
613
|
+
ensureDirs(root, posix.dirname(f.path));
|
|
614
|
+
writeAtomic(join(root, f.path + PLACEHOLDER), placeholderText(m, f), 0o444);
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
await copyFromHub(hub, root, want);
|
|
618
|
+
}
|
|
619
|
+
#writeManifest(root, m) { writeAtomic(join(root, MANIFEST), JSON.stringify(m, null, 2) + '\n', 0o444); }
|
|
620
|
+
/** files.json in the config directory: the folder and its placeholders, for the menu bar's Open files and Download. */
|
|
621
|
+
#index() {
|
|
622
|
+
const placeholders = [];
|
|
623
|
+
for (const [, { rel, manifest }] of manifests(this.root))
|
|
624
|
+
for (const f of manifest.files)
|
|
625
|
+
if (f.state === 'placeholder') {
|
|
626
|
+
placeholders.push({ conversation_id: manifest.conversation_id, conversation: manifest.name, title: manifest.title, folder: join(this.root, rel), path: f.path, size: f.size, local_path: join(this.root, rel, f.path) });
|
|
627
|
+
}
|
|
628
|
+
mkdirSync(this.ctx.store.dir, { recursive: true, mode: 0o700 });
|
|
629
|
+
writeAtomic(this.indexFile, JSON.stringify({ root: this.root, placeholders, updated_at: new Date().toISOString() }, null, 2) + '\n', 0o600);
|
|
630
|
+
}
|
|
631
|
+
/** Find a mirrored conversation by id, name or folder name. */
|
|
632
|
+
find(ref) {
|
|
633
|
+
const all = [...manifests(this.root).values()];
|
|
634
|
+
const hits = all.filter(d => d.manifest.conversation_id === ref || d.manifest.name === ref || basename(d.rel) === ref);
|
|
635
|
+
if (hits.length === 1)
|
|
636
|
+
return hits[0];
|
|
637
|
+
if (!hits.length)
|
|
638
|
+
throw new UsageError(`No conversation ${JSON.stringify(ref)} in ${this.root}. Its folder appears once it has committed files.`);
|
|
639
|
+
throw new UsageError(`More than one conversation is called ${JSON.stringify(ref)}; use its id: ${hits.map(h => h.manifest.conversation_id).join(', ')}.`);
|
|
640
|
+
}
|
|
641
|
+
/** Download one listed file, whatever its size. */
|
|
642
|
+
async pull(ref, path, all) {
|
|
643
|
+
const clean = posix.normalize(path.trim()).replace(/^\.?\//, '');
|
|
644
|
+
if (!safeRelative(clean))
|
|
645
|
+
throw new UsageError('Give the file path inside the conversation’s repository, e.g. out/hype.mp4.');
|
|
646
|
+
return withTargetLock(join(this.ctx.store.dir, 'files-lock'), async () => {
|
|
647
|
+
const { rel, manifest } = this.find(ref), root = join(this.root, rel);
|
|
648
|
+
const f = manifest.files.find(x => x.path === clean);
|
|
649
|
+
if (!f)
|
|
650
|
+
throw new UsageError(`${clean} is not one of the files ${manifest.name} changed; ${join(root, MANIFEST)} lists them.`);
|
|
651
|
+
const local = join(root, f.path);
|
|
652
|
+
if (f.state === 'present' && existsSync(local))
|
|
653
|
+
return { conversation: manifest.name, path: f.path, local_path: local, bytes: f.size, already: true };
|
|
654
|
+
if (f.state === 'submodule' || f.state === 'symlink')
|
|
655
|
+
throw new UsageError(`${clean} is a ${f.state}, not a file to download.`);
|
|
656
|
+
const hub = await this.connect(manifest.project_id, await all());
|
|
657
|
+
try {
|
|
658
|
+
await copyFromHub(hub, root, [f]);
|
|
659
|
+
}
|
|
660
|
+
finally {
|
|
661
|
+
hub.close();
|
|
662
|
+
}
|
|
663
|
+
removeLeaf(root, f.path + PLACEHOLDER);
|
|
664
|
+
Object.assign(f, { state: 'present', fetched: true });
|
|
665
|
+
delete f.reason;
|
|
666
|
+
this.#writeManifest(root, manifest);
|
|
667
|
+
this.#index();
|
|
668
|
+
return { conversation: manifest.name, path: f.path, local_path: local, bytes: f.size, already: false };
|
|
669
|
+
}, this.ctx.signal);
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
/**
|
|
673
|
+
* The seed snapshot (sh; $1 repository, $2 conversation name, $3 hub): HEAD plus the working tree the conversation was
|
|
674
|
+
* seeded with, committed in the conversation's VM with a temporary index (never on this computer), kept per worktree
|
|
675
|
+
* as refs/worktree/shard/seed and in the hub as refs/shard/seed/<name>. Mirrors leave out what it already contains,
|
|
676
|
+
* so the user's own uncommitted edits never show as the conversation's change. Threads on one computer are worktrees
|
|
677
|
+
* of one repository (0.16.0+), so the seed is per worktree; the shared refs/shard/seed of earlier seeds is read, and
|
|
678
|
+
* still written, only in the repository's main worktree.
|
|
679
|
+
*/
|
|
680
|
+
export const SEED_SNAPSHOT = `set -eu
|
|
681
|
+
cd "$1"
|
|
682
|
+
main=0; [ "$(git rev-parse --absolute-git-dir)" = "$(git rev-parse --path-format=absolute --git-common-dir)" ] && main=1
|
|
683
|
+
if git rev-parse -q --verify refs/worktree/shard/seed >/dev/null; then seed=$(git rev-parse refs/worktree/shard/seed)
|
|
684
|
+
elif [ "$main" = 1 ] && git rev-parse -q --verify refs/shard/seed >/dev/null; then seed=$(git rev-parse refs/shard/seed); else
|
|
685
|
+
idx=$(mktemp); trap 'rm -f "$idx"' EXIT
|
|
686
|
+
cp "$(git rev-parse --path-format=absolute --git-path index)" "$idx" 2>/dev/null || { rm -f "$idx"; GIT_INDEX_FILE="$idx" git read-tree HEAD; }
|
|
687
|
+
GIT_INDEX_FILE="$idx" git add -A
|
|
688
|
+
tree=$(GIT_INDEX_FILE="$idx" git write-tree)
|
|
689
|
+
if [ "$tree" = "$(git rev-parse 'HEAD^{tree}')" ]; then seed=$(git rev-parse HEAD); else
|
|
690
|
+
seed=$(GIT_AUTHOR_NAME=Shardflux GIT_AUTHOR_EMAIL=seed@shardflux.dev GIT_COMMITTER_NAME=Shardflux GIT_COMMITTER_EMAIL=seed@shardflux.dev git commit-tree "$tree" -p HEAD -m "Shardflux: the working tree this conversation started from"); fi
|
|
691
|
+
git update-ref refs/worktree/shard/seed "$seed"
|
|
692
|
+
if [ "$main" = 1 ]; then git update-ref refs/shard/seed "$seed"; fi
|
|
693
|
+
fi
|
|
694
|
+
if [ -d "$3" ]; then git -c core.hooksPath=/dev/null push -q "$3" "+$seed:refs/shard/seed/$2" >/dev/null 2>&1 || true; fi
|
|
695
|
+
printf '%s\n' "$seed"
|
|
696
|
+
`;
|
|
697
|
+
/** Best effort: without a snapshot the folder also lists the start state's own edits. */
|
|
698
|
+
export async function recordSeedSnapshot(ws, c, signal) {
|
|
699
|
+
try {
|
|
700
|
+
const r = await ws.cell().exec.run(['sh', '-c', SEED_SNAPSHOT, 'shard-seed', c.worktree_path, c.branch.replace(/^shard\//, ''), HUB_PATH], { timeoutMs: 120_000, maxOutputBytes: 4096, ...(signal ? { signal } : {}) });
|
|
701
|
+
return r.exitCode === 0 ? r.stdout.trim() : null;
|
|
702
|
+
}
|
|
703
|
+
catch (error) {
|
|
704
|
+
if (signal?.aborted)
|
|
705
|
+
throw error;
|
|
706
|
+
return null;
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
/** A ready VM of the project (never woken), else the project's space reader, woken when needed. */
|
|
710
|
+
export function workspaceHub(ctx) {
|
|
711
|
+
return async (projectId, all) => {
|
|
712
|
+
const client = conversationClient(ctx);
|
|
713
|
+
const candidates = all.filter(c => c.project_id === projectId).sort((a, b) => (b.last_activity_at ?? '').localeCompare(a.last_activity_at ?? ''));
|
|
714
|
+
for (const c of candidates) {
|
|
715
|
+
try {
|
|
716
|
+
const ws = await client.workspaces.get(c.workspace_id);
|
|
717
|
+
if (!ws.data.deleted_at && ws.ready)
|
|
718
|
+
return cellHub(ws, ctx.signal);
|
|
719
|
+
}
|
|
720
|
+
catch { /* another binding may be ready */ }
|
|
721
|
+
}
|
|
722
|
+
const reader = await client.conversations.spaceReader(projectId, { signal: ctx.signal });
|
|
723
|
+
const ws = await client.workspaces.get(reader.workspace.id);
|
|
724
|
+
if (!ws.ready)
|
|
725
|
+
await ws.wake({ timeoutMs: ctx.wakeTimeoutMs, signal: ctx.signal });
|
|
726
|
+
return cellHub(ws, ctx.signal);
|
|
727
|
+
};
|
|
728
|
+
}
|
|
729
|
+
export async function listMirrorConversations(ctx) {
|
|
730
|
+
const all = [];
|
|
731
|
+
let cursor;
|
|
732
|
+
do {
|
|
733
|
+
const page = await conversationClient(ctx).conversations.list({ limit: 200, ...(cursor ? { cursor } : {}) });
|
|
734
|
+
all.push(...page.data.filter(c => /^conv_[a-z0-9]{26}$/.test(c.id) && /^shard\/[a-z0-9][a-z0-9-]{0,47}$/.test(c.branch ?? '')));
|
|
735
|
+
cursor = page.nextCursor ?? undefined;
|
|
736
|
+
} while (cursor);
|
|
737
|
+
return all;
|
|
738
|
+
}
|
|
739
|
+
/** The device helper's pass: list every few seconds; touch the hub only for conversations whose record moved. */
|
|
740
|
+
export async function mirrorFilesOnce(ctx, state, intervalMs = 5000) {
|
|
741
|
+
const now = Date.now();
|
|
742
|
+
if (now < state.retryAt || now - state.listedAt < intervalMs)
|
|
743
|
+
return;
|
|
744
|
+
state.listedAt = now;
|
|
745
|
+
try {
|
|
746
|
+
await new FilesMirror(ctx, workspaceHub(ctx)).sync(await listMirrorConversations(ctx), state);
|
|
747
|
+
state.failures = 0;
|
|
748
|
+
state.retryAt = 0;
|
|
749
|
+
}
|
|
750
|
+
catch (e) {
|
|
751
|
+
if (ctx.signal.aborted || conversationsUnavailable(e))
|
|
752
|
+
return;
|
|
753
|
+
const wait = Math.min(300_000, 5000 * 2 ** Math.min(state.failures++, 6));
|
|
754
|
+
state.retryAt = Date.now() + wait;
|
|
755
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
756
|
+
if (!state.reported.has(message)) {
|
|
757
|
+
state.reported.add(message);
|
|
758
|
+
ctx.io.stderr.write(`${message} (retrying in ${wait / 1000}s).\n`);
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
}
|