@shardflux/cli 0.18.1 → 0.19.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +81 -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 +8 -3
- 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/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
package/dist/agent-repository.js
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
2
|
+
import { spawn } from 'node:child_process';
|
|
3
|
+
import { readFile } from 'node:fs/promises';
|
|
4
|
+
import { homedir } from 'node:os';
|
|
5
|
+
import { basename, join, resolve } from 'node:path';
|
|
6
|
+
import { UsageError } from "./errors.js";
|
|
6
7
|
import { AgentStateStore } from "./agent-state.js";
|
|
7
8
|
import { projectIdentity } from "./project-identity.js";
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
9
|
+
import { excludedSyncPath, localEntry, projectGit, projectGitPaths, selectedProjectPaths, validSyncPath } from "./project-sync-manifest.js";
|
|
10
|
+
import { pushLocalFiles, SYNC_CHUNK_BYTES, writeSyncBlob } from "./project-sync-transfer.js";
|
|
11
|
+
import { seedPhase } from "./seed-phases.js";
|
|
10
12
|
export function projectWorkspacePath(project) {
|
|
11
13
|
if (!/^[a-z0-9][a-z0-9._-]{0,95}$/.test(project.slug))
|
|
12
14
|
throw new Error('invalid sync project slug');
|
|
@@ -20,7 +22,28 @@ export function needsGitSeed(ctx, ws) {
|
|
|
20
22
|
const seed = new AgentStateStore(ctx.store.dir).read().git_seeding?.[ws.id];
|
|
21
23
|
return seed !== undefined && seed.api_url === ctx.apiUrl;
|
|
22
24
|
}
|
|
23
|
-
/**
|
|
25
|
+
/**
|
|
26
|
+
* The checks a start can fail on, run on this computer before a VM is allocated (0.19.0+). A project's size, file
|
|
27
|
+
* count or history never refuses a start; only a folder inside another repository does.
|
|
28
|
+
*/
|
|
29
|
+
export async function preflightProjectSeed(folder, signal) {
|
|
30
|
+
let top;
|
|
31
|
+
try {
|
|
32
|
+
top = (await projectGit(folder, ['rev-parse', '--show-toplevel'], signal)).trim();
|
|
33
|
+
}
|
|
34
|
+
catch (error) {
|
|
35
|
+
if (signal?.aborted || error.code !== 128)
|
|
36
|
+
throw error;
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
if (top !== folder)
|
|
40
|
+
throw new UsageError('project sync requires the Git repository root; run shard there');
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* One seed for every start (C5, C11). A Git project's files come from Git in the VM: HEAD is checked out there and only
|
|
44
|
+
* what differs from HEAD on this computer (changes, untracked files, deletions, .env files) is copied (0.19.0+). Its
|
|
45
|
+
* size, file count and history length do not change what is sent.
|
|
46
|
+
*/
|
|
24
47
|
export async function seedAgentRepository(ctx, ws, folder, options = {}) {
|
|
25
48
|
const project = projectIdentity(folder);
|
|
26
49
|
// A cloud-only `like` launch must never seed an unrelated laptop repository.
|
|
@@ -41,8 +64,11 @@ export async function seedAgentRepository(ctx, ws, folder, options = {}) {
|
|
|
41
64
|
initial?.ready();
|
|
42
65
|
state.update(doc => { const seeds = (doc.git_seeding ?? {}); delete seeds[ws.id]; doc.git_seeding = seeds; });
|
|
43
66
|
}
|
|
44
|
-
/**
|
|
45
|
-
|
|
67
|
+
/**
|
|
68
|
+
* Above this (an estimate: the repository's packed and loose objects) the workspace fetches the history from origin as a
|
|
69
|
+
* blobless partial clone, and receives only the commits origin lacks (0.19.0+). Below it, one small bundle is faster.
|
|
70
|
+
*/
|
|
71
|
+
export const ORIGIN_FIRST_BYTES = 32 << 20;
|
|
46
72
|
async function optionalGit(folder, args, signal) {
|
|
47
73
|
try {
|
|
48
74
|
return (await projectGit(folder, args, signal)).trim();
|
|
@@ -54,71 +80,137 @@ async function optionalGit(folder, args, signal) {
|
|
|
54
80
|
}
|
|
55
81
|
}
|
|
56
82
|
const tail = (text) => text.trim().split('\n').slice(-3).join(' ').slice(-400);
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
83
|
+
/** The repository's object store size, an upper bound of any one branch's history bundle. Reads sizes only. */
|
|
84
|
+
export async function historyEstimate(folder, signal) {
|
|
85
|
+
const counts = new Map((await optionalGit(folder, ['count-objects', '-v'], signal)).split('\n').map(line => line.split(': ')));
|
|
86
|
+
return (Number(counts.get('size') ?? 0) + Number(counts.get('size-pack') ?? 0)) * 1024;
|
|
87
|
+
}
|
|
88
|
+
// Fetches one origin branch (or origin's HEAD) into an empty workspace repository as a blobless partial clone (0.19.0+):
|
|
89
|
+
// every commit and tree. Alongside it, a depth-1 fetch of the same ref brings the tip's files in one pack, moved in
|
|
90
|
+
// beside the history, so the checkout reads them locally instead of asking for each blob. Prints the commit. A failed
|
|
91
|
+
// history fetch leaves a plain repository, so the bundle fallback is not a partial clone of an origin it cannot reach.
|
|
92
|
+
export const FETCH_ORIGIN = [
|
|
93
|
+
'set -eu',
|
|
94
|
+
'git init -q "$1"; git -C "$1" config remote.origin.url "$2"',
|
|
95
|
+
'case "$3" in HEAD) dst=refs/shard-sync/origin ;; *) dst="refs/remotes/origin/${3#refs/heads/}" ;; esac',
|
|
96
|
+
'tip="$1/.git/shard-sync-tip"; rm -rf "$tip"; git init -q --bare "$tip"',
|
|
97
|
+
'git -C "$tip" -c core.hooksPath=/dev/null -c credential.interactive=false -c fetch.unpackLimit=1 fetch -q --no-tags --depth=1 "$2" "+$3:refs/tip" >/dev/null 2>&1 &',
|
|
98
|
+
'job=$!',
|
|
99
|
+
'if ! git -C "$1" -c core.hooksPath=/dev/null -c credential.interactive=false fetch -q --no-tags --filter=blob:none origin "+$3:$dst" >&2; then',
|
|
100
|
+
' wait "$job" || true; rm -rf "$tip"',
|
|
101
|
+
' git -C "$1" config --unset remote.origin.promisor || true; git -C "$1" config --unset remote.origin.partialclonefilter || true; exit 1',
|
|
102
|
+
'fi',
|
|
103
|
+
'if wait "$job"; then for pack in "$tip"/objects/pack/pack-*.pack; do [ -f "$pack" ] || continue; base=${pack%.pack}',
|
|
104
|
+
' for ext in idx rev pack; do if [ -f "$base.$ext" ]; then mv "$base.$ext" "$1/.git/objects/pack/"; fi; done; done; fi',
|
|
105
|
+
'rm -rf "$tip"',
|
|
106
|
+
'git -C "$1" rev-parse --verify "$dst^{commit}"',
|
|
107
|
+
].join('\n');
|
|
108
|
+
// Completes a blobless partial clone in the background, at the lowest priority, once the start is done: later pushes to
|
|
109
|
+
// the project hub, `git log -p` and blame then read local objects.
|
|
110
|
+
export const BACKFILL = 'set -eu; cd "$1"; git -c core.hooksPath=/dev/null -c credential.interactive=false fetch -q --no-tags --no-write-fetch-head --refetch --no-filter origin "$2"; ' +
|
|
111
|
+
'git config --unset remote.origin.partialclonefilter || true; git config --unset remote.origin.promisor || true';
|
|
60
112
|
// Applies an optional bundle of HEAD (plus its branch and upstream) and points the workspace HEAD at the laptop's.
|
|
61
113
|
const APPLY_HISTORY = 'set -eu; git init -q "$1"; if [ -n "$2" ]; then git -C "$1" -c core.hooksPath=/dev/null fetch -q --update-head-ok "$2" HEAD:refs/shard-sync/initial ' +
|
|
62
114
|
'"+refs/heads/*:refs/heads/*" "+refs/tags/*:refs/tags/*" "+refs/remotes/*:refs/remotes/*"; fi; ' +
|
|
63
115
|
'if [ -n "$4" ]; then git -C "$1" symbolic-ref HEAD "$4"; else git -C "$1" update-ref --no-deref HEAD "$3"; fi; git -C "$1" update-ref HEAD "$3"; git -C "$1" read-tree HEAD; ' +
|
|
64
116
|
'for ref in refs/shard-sync/initial refs/shard-sync/origin; do if git -C "$1" rev-parse -q --verify "$ref" >/dev/null; then git -C "$1" update-ref -d "$ref"; fi; done';
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
117
|
+
// Writes HEAD's files that are missing from the workspace tree (never replacing one), except those this computer
|
|
118
|
+
// deleted. A partial clone first fetches the missing blobs in one request; Git LFS files are left to the copy.
|
|
119
|
+
export const CHECKOUT_HEAD = String.raw `
|
|
120
|
+
import subprocess,sys
|
|
121
|
+
root,skipfile=sys.argv[1],sys.argv[2]
|
|
122
|
+
def git(*a,data=None): return subprocess.run(['git','-C',root,'-c','core.hooksPath=/dev/null','-c','filter.lfs.smudge=cat','-c','filter.lfs.process=','-c','filter.lfs.required=false',*a],check=True,input=data,stdout=subprocess.PIPE).stdout
|
|
123
|
+
skip=set(open(skipfile,'rb').read().split(b'\0')) if skipfile else set()
|
|
124
|
+
missing=[p for p in git('ls-files','-z','--deleted').split(b'\0') if p and p not in skip]
|
|
125
|
+
if missing and git('config','--bool','--default','false','remote.origin.promisor').strip()==b'true':
|
|
126
|
+
want,wanted=set(),set(missing)
|
|
127
|
+
for rec in git('ls-files','-z','-s').split(b'\0'):
|
|
128
|
+
if rec:
|
|
129
|
+
meta,path=rec.split(b'\t',1)
|
|
130
|
+
if path in wanted: want.add(meta.split()[1])
|
|
131
|
+
absent={l[1:] for l in git('rev-list','--objects','--no-walk','--missing=print','HEAD').split(b'\n') if l.startswith(b'?')}
|
|
132
|
+
want&=absent
|
|
133
|
+
if want: git('-c','fetch.negotiationAlgorithm=noop','fetch','-q','--no-tags','--no-write-fetch-head','--recurse-submodules=no','--filter=blob:none','--stdin','origin',data=b'\n'.join(sorted(want))+b'\n')
|
|
134
|
+
for at in range(0,len(missing),4096): git('checkout-index','-z','--stdin',data=b'\0'.join(missing[at:at+4096])+b'\0')
|
|
135
|
+
print(len(missing))
|
|
136
|
+
`;
|
|
137
|
+
/** `git bundle create` streamed straight into the workspace in chunks: any size, no temporary file, no time limit. */
|
|
138
|
+
async function streamBundle(cell, folder, remote, revs, signal) {
|
|
139
|
+
const child = spawn('git', ['-C', folder, '-c', 'core.hooksPath=/dev/null', 'bundle', 'create', '-q', '-', ...revs], { stdio: ['ignore', 'pipe', 'pipe'], signal, env: { ...process.env, GIT_TERMINAL_PROMPT: '0', GIT_OPTIONAL_LOCKS: '0' } });
|
|
140
|
+
let stderr = '';
|
|
141
|
+
child.stderr.on('data', (chunk) => { stderr = (stderr + chunk.toString()).slice(-4000); });
|
|
142
|
+
const exited = new Promise((resolve, reject) => { child.on('error', reject); child.on('close', code => resolve(code ?? 1)); });
|
|
143
|
+
let pending = [], size = 0, total = 0;
|
|
144
|
+
const flush = async () => { if (!size && total)
|
|
145
|
+
return; await cell.files.write(remote, Buffer.concat(pending, size), { mode: '0600', append: total > 0 }); total += size; pending = []; size = 0; };
|
|
146
|
+
try {
|
|
147
|
+
for await (const chunk of child.stdout) {
|
|
148
|
+
pending.push(chunk);
|
|
149
|
+
size += chunk.length;
|
|
150
|
+
if (size >= SYNC_CHUNK_BYTES)
|
|
151
|
+
await flush();
|
|
152
|
+
}
|
|
153
|
+
await flush();
|
|
154
|
+
}
|
|
155
|
+
catch (error) {
|
|
156
|
+
child.kill();
|
|
157
|
+
await exited.catch(() => undefined);
|
|
158
|
+
throw error;
|
|
159
|
+
}
|
|
160
|
+
const code = await exited;
|
|
161
|
+
if (code !== 0)
|
|
162
|
+
throw new Error(`Git could not bundle the project history: ${tail(stderr)}`);
|
|
163
|
+
return total;
|
|
68
164
|
}
|
|
69
165
|
/**
|
|
70
166
|
* Imports the checked-out branch's history into an empty workspace repository. Only HEAD, its branch and that branch's
|
|
71
|
-
* upstream are
|
|
72
|
-
*
|
|
167
|
+
* upstream are sent: other branches, worktrees and stashes stay on this computer. A history above `originFirst` comes
|
|
168
|
+
* from origin as a blobless partial clone (the workspace's own Git credentials), plus a bundle of the commits origin
|
|
169
|
+
* lacks. Without a reachable origin the whole bundle is streamed in chunks: no history size refuses (0.19.0+).
|
|
73
170
|
*/
|
|
74
|
-
export async function importProjectHistory(cell, folder, root, head, branch, origin, signal,
|
|
171
|
+
export async function importProjectHistory(cell, folder, root, head, branch, origin, signal, originFirst = ORIGIN_FIRST_BYTES, report = () => undefined) {
|
|
75
172
|
const upstream = branch ? await optionalGit(folder, ['rev-parse', '--symbolic-full-name', `${branch.slice(11)}@{upstream}`], signal) : '';
|
|
76
173
|
const tracked = upstream.startsWith('refs/remotes/') ? upstream : '';
|
|
77
174
|
const tips = ['HEAD', ...(branch ? [branch] : []), ...(tracked ? [tracked] : [])];
|
|
78
|
-
const
|
|
79
|
-
const local = join(dir, 'history.bundle'), remote = `/tmp/shard-sync-${randomUUID()}.bundle`;
|
|
175
|
+
const remote = `/tmp/shard-sync-${randomUUID()}.bundle`;
|
|
80
176
|
try {
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
if ((await stat(local)).size > limit) {
|
|
84
|
-
const name = branch ? branch.slice(11) : 'HEAD';
|
|
85
|
-
if (!origin)
|
|
86
|
-
throw tooLarge(`Git history of ${name} exceeds the 256 MiB sync limit and the project has no network origin the workspace can clone`, 'add an origin remote, then run shard again', name, origin);
|
|
177
|
+
let revs = tips, partial = null;
|
|
178
|
+
if (origin && await historyEstimate(folder, signal) > originFirst) {
|
|
87
179
|
const ref = upstream.startsWith('refs/remotes/origin/') ? `refs/heads/${upstream.slice(20)}` : 'HEAD';
|
|
88
180
|
const fetched = await cell.exec.run(['sh', '-c', FETCH_ORIGIN, 'shard-sync', root, origin, ref], { timeoutMs: 600_000, maxOutputBytes: 8192, env: { GIT_TERMINAL_PROMPT: '0' }, signal });
|
|
181
|
+
seedPhase('origin_fetch');
|
|
89
182
|
const base = fetched.stdout.trim();
|
|
90
|
-
if (fetched.exitCode
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
bundle = !onOrigin;
|
|
107
|
-
if (bundle) {
|
|
108
|
-
await rm(local, { force: true });
|
|
109
|
-
await projectGit(folder, ['bundle', 'create', local, ...tips.filter(tip => tip !== tracked), '--not', exclude], signal);
|
|
110
|
-
if ((await stat(local)).size > limit)
|
|
111
|
-
throw tooLarge(`Commits of ${name} that ${origin} does not have exceed the 256 MiB sync limit`, 'push them, then run shard again', name, origin);
|
|
183
|
+
if (fetched.exitCode === 0 && /^[0-9a-f]{40}(?:[0-9a-f]{24})?$/.test(base)) {
|
|
184
|
+
// Commits origin already has are not sent again; a base this computer does not know falls back to its upstream.
|
|
185
|
+
const exclude = await optionalGit(folder, ['cat-file', '-t', base], signal) === 'commit' ? base : tracked;
|
|
186
|
+
let onOrigin = false;
|
|
187
|
+
if (exclude) {
|
|
188
|
+
try {
|
|
189
|
+
await projectGit(folder, ['merge-base', '--is-ancestor', head, exclude], signal);
|
|
190
|
+
onOrigin = true;
|
|
191
|
+
}
|
|
192
|
+
catch (error) {
|
|
193
|
+
if (signal?.aborted || error.code !== 1)
|
|
194
|
+
throw error;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
revs = onOrigin ? null : exclude ? [...tips.filter(tip => tip !== tracked), '--not', exclude] : tips;
|
|
198
|
+
partial = ref;
|
|
112
199
|
}
|
|
200
|
+
else
|
|
201
|
+
report(`Sending the history from this computer: the workspace could not fetch ${origin} (${tail(fetched.stderr)}).`);
|
|
202
|
+
}
|
|
203
|
+
if (revs) {
|
|
204
|
+
await streamBundle(cell, folder, remote, revs, signal);
|
|
205
|
+
seedPhase('bundle');
|
|
113
206
|
}
|
|
114
|
-
|
|
115
|
-
await writeSyncBlob(cell, remote, await readFile(local));
|
|
116
|
-
const result = await cell.exec.run(['sh', '-c', APPLY_HISTORY, 'shard-sync', root, bundle ? remote : '', head, branch], { timeoutMs: 60_000, maxOutputBytes: 4096, signal });
|
|
207
|
+
const result = await cell.exec.run(['sh', '-c', APPLY_HISTORY, 'shard-sync', root, revs ? remote : '', head, branch], { timeoutMs: 600_000, maxOutputBytes: 4096, signal });
|
|
117
208
|
if (result.exitCode !== 0)
|
|
118
209
|
throw new Error(`initial Git bundle failed: ${result.stderr.trim()}`);
|
|
210
|
+
seedPhase('apply_history');
|
|
211
|
+
return { partial };
|
|
119
212
|
}
|
|
120
213
|
finally {
|
|
121
|
-
await rm(dir, { recursive: true, force: true });
|
|
122
214
|
await cell.files.remove(remote).catch(() => undefined);
|
|
123
215
|
}
|
|
124
216
|
}
|
|
@@ -131,7 +223,7 @@ async function initializeProjectGit(cell, folder, root, signal, populate = false
|
|
|
131
223
|
if (signal?.aborted || error.code !== 128)
|
|
132
224
|
throw error;
|
|
133
225
|
if (populate)
|
|
134
|
-
await
|
|
226
|
+
await copyProjectFiles(cell, folder, root, await missingInWorkspace(cell, root, await selectedProjectPaths(folder, signal), signal), [], signal, report, initial, false);
|
|
135
227
|
return;
|
|
136
228
|
}
|
|
137
229
|
if (top !== folder)
|
|
@@ -156,9 +248,10 @@ async function initializeProjectGit(cell, folder, root, signal, populate = false
|
|
|
156
248
|
if (signal?.aborted)
|
|
157
249
|
throw error;
|
|
158
250
|
}
|
|
251
|
+
let partial = null;
|
|
159
252
|
if (head && importHistory) {
|
|
160
253
|
const remote = projectIdentity(folder).remote;
|
|
161
|
-
await importProjectHistory(cell, folder, root, head, branch, remote && !remote.startsWith('file:') ? remote : null, signal);
|
|
254
|
+
({ partial } = await importProjectHistory(cell, folder, root, head, branch, remote && !remote.startsWith('file:') ? remote : null, signal, ORIGIN_FIRST_BYTES, report));
|
|
162
255
|
}
|
|
163
256
|
else if (importHistory) {
|
|
164
257
|
const result = await cell.exec.run(['git', 'init', '-q', ...(branch.startsWith('refs/heads/') ? ['--initial-branch', branch.slice(11)] : []), root], { timeoutMs: 5000, signal });
|
|
@@ -194,11 +287,13 @@ async function initializeProjectGit(cell, folder, root, signal, populate = false
|
|
|
194
287
|
throw new Error('could not copy Git upstream');
|
|
195
288
|
}
|
|
196
289
|
}
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
290
|
+
if (head)
|
|
291
|
+
await seedFromHead(cell, folder, root, populate, signal, report, initial);
|
|
292
|
+
else
|
|
293
|
+
await seedUnborn(cell, folder, root, populate, signal, report, initial);
|
|
294
|
+
// Optional work, after the start's own: the lowest priority, and a failure only leaves the clone partial.
|
|
295
|
+
if (partial)
|
|
296
|
+
await cell.exec.start({ argv: ['nice', '-n', '19', 'sh', '-c', BACKFILL, 'shard-backfill', root, partial], env: { GIT_TERMINAL_PROMPT: '0' }, timeout_ms: 3_600_000 }, signal).catch(() => undefined);
|
|
202
297
|
}
|
|
203
298
|
// Git's info/exclude and laptop-wide excludes are selection metadata, copied
|
|
204
299
|
// into a dedicated Git exclude file without syncing credentials or .git data.
|
|
@@ -226,44 +321,166 @@ async function initializeProjectGit(cell, folder, root, signal, populate = false
|
|
|
226
321
|
const result = await cell.exec.run(['git', '-C', root, 'config', 'core.excludesFile', `${root}/.git/shard-sync-excludes`], { timeoutMs: 5000, signal });
|
|
227
322
|
if (result.exitCode !== 0)
|
|
228
323
|
throw new Error('could not apply project Git exclude rules');
|
|
324
|
+
seedPhase('excludes');
|
|
229
325
|
}
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
326
|
+
const nul = (text) => text.split('\0').filter(Boolean);
|
|
327
|
+
/** Git's NUL-separated output of any size, streamed (no output buffer or time bound). */
|
|
328
|
+
async function gitList(folder, args, signal) {
|
|
329
|
+
const out = [];
|
|
330
|
+
await projectGitPaths(folder, args, path => { out.push(path); }, { signal });
|
|
331
|
+
return out;
|
|
332
|
+
}
|
|
333
|
+
/**
|
|
334
|
+
* What differs between HEAD and this computer's working tree, from Git's own status (0.19.0+): changed, added and
|
|
335
|
+
* untracked files Git does not ignore, the .env files we always carry, Git LFS files (their content lives outside Git),
|
|
336
|
+
* and deletions. Ignored files, nested repositories and submodule contents stay out.
|
|
337
|
+
*/
|
|
338
|
+
export async function localChanges(folder, signal) {
|
|
339
|
+
const [fields, untracked, env, lfs] = await Promise.all([
|
|
340
|
+
gitList(folder, ['diff', '--name-status', '-z', '--no-renames', '--ignore-submodules=all', 'HEAD', '--'], signal),
|
|
341
|
+
gitList(folder, ['ls-files', '-z', '--others', '--exclude-standard', '--', '.'], signal),
|
|
342
|
+
gitList(folder, ['ls-files', '-z', '--others', '--ignored', '--exclude-standard', '--', '.env*', '**/.env*'], signal),
|
|
343
|
+
gitList(folder, ['ls-files', '-z', '--cached', '--', ':(attr:filter=lfs)'], signal).catch((error) => { if (signal?.aborted)
|
|
344
|
+
throw error; return []; }),
|
|
345
|
+
]);
|
|
346
|
+
const selected = new Set([...untracked, ...env.filter(path => basename(path).startsWith('.env')), ...lfs]);
|
|
347
|
+
const removed = new Set();
|
|
348
|
+
for (let at = 0; at + 1 < fields.length; at += 2) {
|
|
349
|
+
const status = fields[at], path = fields[at + 1];
|
|
350
|
+
if (!status || !path)
|
|
351
|
+
continue;
|
|
352
|
+
if (status === 'D' && !selected.has(path))
|
|
353
|
+
removed.add(path);
|
|
354
|
+
else
|
|
355
|
+
selected.add(path);
|
|
356
|
+
}
|
|
357
|
+
const copy = [], remove = [], skipped = [];
|
|
358
|
+
for (const path of [...selected].sort()) {
|
|
359
|
+
if (path.endsWith('/')) {
|
|
360
|
+
skipped.push({ path, reason: 'nested_repository' });
|
|
361
|
+
continue;
|
|
362
|
+
}
|
|
363
|
+
if (excludedSyncPath(path))
|
|
364
|
+
continue;
|
|
365
|
+
if (!validSyncPath(path)) {
|
|
366
|
+
skipped.push({ path, reason: 'unsupported_path' });
|
|
367
|
+
continue;
|
|
368
|
+
}
|
|
369
|
+
copy.push(path);
|
|
370
|
+
}
|
|
371
|
+
for (const path of [...removed].sort())
|
|
372
|
+
if (validSyncPath(path) && !excludedSyncPath(path))
|
|
373
|
+
remove.push(path);
|
|
374
|
+
return { copy, remove, skipped };
|
|
375
|
+
}
|
|
376
|
+
/** HEAD's files come from the workspace's own Git; then only this computer's changes are copied. */
|
|
377
|
+
async function seedFromHead(cell, folder, root, populate, signal, report, initial) {
|
|
378
|
+
if (!populate) {
|
|
379
|
+
// Ignore files must exist before the first remote scan; otherwise a template's dependency outputs would look like
|
|
380
|
+
// agent-created source.
|
|
381
|
+
const ignores = await gitList(folder, ['ls-files', '-z', '--cached', '--others', '--exclude-standard', '--', '.gitignore', '**/.gitignore'], signal);
|
|
382
|
+
await copyProjectFiles(cell, folder, root, await missingInWorkspace(cell, root, ignores, signal), [], signal, report, undefined, false);
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
const changes = await localChanges(folder, signal);
|
|
386
|
+
seedPhase('local_changes');
|
|
387
|
+
const skip = changes.remove.length ? `/tmp/shard-sync-${randomUUID()}.skip` : '';
|
|
388
|
+
try {
|
|
389
|
+
if (skip)
|
|
390
|
+
await writeSyncBlob(cell, skip, Buffer.from(changes.remove.join('\0')));
|
|
391
|
+
const checkout = await cell.exec.run(['python3', '-c', CHECKOUT_HEAD, root, skip], { timeoutMs: 600_000, maxOutputBytes: 8192, env: { GIT_TERMINAL_PROMPT: '0' }, signal });
|
|
392
|
+
if (checkout.exitCode !== 0)
|
|
393
|
+
throw new Error(`the workspace could not check out HEAD: ${tail(checkout.stderr)}`);
|
|
394
|
+
}
|
|
395
|
+
finally {
|
|
396
|
+
if (skip)
|
|
397
|
+
await cell.files.remove(skip).catch(() => undefined);
|
|
398
|
+
}
|
|
399
|
+
seedPhase('checkout_head');
|
|
400
|
+
for (const skipped of changes.skipped)
|
|
401
|
+
report(`Project seed skipped ${skipped.path}: ${skipped.reason}`);
|
|
402
|
+
await copyProjectFiles(cell, folder, root, changes.copy, changes.remove, signal, report, initial);
|
|
403
|
+
}
|
|
404
|
+
/** An unborn repository has no history to check out: its files are copied, and tracked ones are marked intent-to-add. */
|
|
405
|
+
async function seedUnborn(cell, folder, root, populate, signal, report, initial) {
|
|
406
|
+
const tracked = (await gitList(folder, ['ls-files', '-z', '--cached'], signal)).filter(path => validSyncPath(path) && !excludedSyncPath(path));
|
|
407
|
+
const paths = populate ? await selectedProjectPaths(folder, signal) : [...new Set([...tracked, ...await gitList(folder, ['ls-files', '-z', '--others', '--exclude-standard', '--', '.gitignore', '**/.gitignore'], signal)])];
|
|
408
|
+
await copyProjectFiles(cell, folder, root, await missingInWorkspace(cell, root, paths, signal), [], signal, report, initial, false);
|
|
409
|
+
const present = new Set(paths);
|
|
410
|
+
const add = tracked.filter(path => present.has(path));
|
|
411
|
+
if (!add.length)
|
|
412
|
+
return;
|
|
413
|
+
const list = `/tmp/shard-sync-${randomUUID()}.add`;
|
|
414
|
+
try {
|
|
415
|
+
await writeSyncBlob(cell, list, Buffer.from(add.join('\0')));
|
|
416
|
+
const result = await cell.exec.run(['git', '-C', root, '-c', 'core.hooksPath=/dev/null', 'add', '-N', '-f', `--pathspec-from-file=${list}`, '--pathspec-file-nul'], { timeoutMs: 600_000, signal });
|
|
417
|
+
if (result.exitCode !== 0)
|
|
418
|
+
throw new Error(`initial tracked project files failed: ${result.stderr.trim()}`);
|
|
419
|
+
}
|
|
420
|
+
finally {
|
|
421
|
+
await cell.files.remove(list).catch(() => undefined);
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
// Rewrites the list file with only the paths the workspace tree lacks and prints its size: the answer is read back in
|
|
425
|
+
// chunks, so any number of paths fits.
|
|
426
|
+
const MISSING = 'import os,sys;root=sys.argv[1].encode();f=sys.argv[2];paths=[p for p in open(f,"rb").read().split(b"\\0") if p];' +
|
|
427
|
+
'out=b"\\0".join(p for p in paths if not os.path.lexists(os.path.join(root,p)));open(f,"wb").write(out);print(len(out))';
|
|
428
|
+
/** The paths not yet in the workspace tree: a retried or partial seed never replaces what is there. */
|
|
429
|
+
async function missingInWorkspace(cell, root, paths, signal) {
|
|
430
|
+
if (!paths.length)
|
|
431
|
+
return [];
|
|
432
|
+
const list = `/tmp/shard-sync-${randomUUID()}.paths`;
|
|
433
|
+
try {
|
|
434
|
+
await writeSyncBlob(cell, list, Buffer.from(paths.join('\0')));
|
|
435
|
+
const probe = await cell.exec.run(['python3', '-c', MISSING, root, list], { timeoutMs: 600_000, maxOutputBytes: 4096, signal });
|
|
436
|
+
const size = Number(probe.stdout.trim());
|
|
437
|
+
if (probe.exitCode !== 0 || !Number.isSafeInteger(size))
|
|
438
|
+
throw new Error(`could not inspect the workspace project files: ${tail(probe.stderr)}`);
|
|
439
|
+
const parts = [];
|
|
440
|
+
for (let offset = 0; offset < size;) {
|
|
441
|
+
const chunk = await cell.files.read(list, { offset, length: Math.min(SYNC_CHUNK_BYTES, size - offset) });
|
|
442
|
+
if (!chunk.length)
|
|
443
|
+
throw new Error('the workspace file list changed while it was read');
|
|
444
|
+
parts.push(chunk);
|
|
445
|
+
offset += chunk.length;
|
|
446
|
+
}
|
|
447
|
+
return nul(Buffer.concat(parts).toString('utf8'));
|
|
448
|
+
}
|
|
449
|
+
finally {
|
|
450
|
+
await cell.files.remove(list).catch(() => undefined);
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
/**
|
|
454
|
+
* Copies these files from this computer (any number, any size: small ones batched, big ones streamed) and applies the
|
|
455
|
+
* deletions. `replace` (a seed over a fresh checkout) overwrites what the checkout wrote; otherwise only missing paths
|
|
456
|
+
* are passed in. Files a harness reads first (initial) go before the rest.
|
|
457
|
+
*/
|
|
458
|
+
async function copyProjectFiles(cell, folder, root, paths, deletions, signal, report, initial, replace = true) {
|
|
459
|
+
const groups = initial ? [paths.filter(path => initial.paths.has(path)), paths.filter(path => !initial.paths.has(path))] : [paths];
|
|
236
460
|
for (let group = 0; group < groups.length; group++) {
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
const file = await readLocalSyncFile(folder, entry);
|
|
252
|
-
seeds.set(entry.path, file);
|
|
253
|
-
bytes += file.data.length;
|
|
254
|
-
}
|
|
255
|
-
if (seeds.size)
|
|
256
|
-
await applyRemoteSyncBatch(cell, root, seeds, {}, signal);
|
|
257
|
-
if (!head) {
|
|
258
|
-
const add = entries.filter((entry) => tracked.has(entry.path)).map((entry) => entry.path);
|
|
259
|
-
if (add.length) {
|
|
260
|
-
const result = await cell.exec.run(['git', '-C', root, '-c', 'core.hooksPath=/dev/null', 'add', '-N', '-f', '--', ...add], { timeoutMs: 5000, signal });
|
|
261
|
-
if (result.exitCode !== 0)
|
|
262
|
-
throw new Error(`initial tracked project files failed: ${result.stderr.trim()}`);
|
|
461
|
+
const entries = [];
|
|
462
|
+
let next = 0;
|
|
463
|
+
const list = groups[group];
|
|
464
|
+
await Promise.all(Array.from({ length: Math.min(16, list.length) }, async () => {
|
|
465
|
+
for (;;) {
|
|
466
|
+
const path = list[next++];
|
|
467
|
+
if (path === undefined)
|
|
468
|
+
break;
|
|
469
|
+
const entry = await localEntry(folder, path, signal);
|
|
470
|
+
if (entry === undefined)
|
|
471
|
+
continue;
|
|
472
|
+
if (!('sha256' in entry)) {
|
|
473
|
+
report(`Project seed skipped ${entry.path}: ${entry.reason}`);
|
|
474
|
+
continue;
|
|
263
475
|
}
|
|
476
|
+
entries.push(entry);
|
|
264
477
|
}
|
|
265
|
-
}
|
|
478
|
+
}));
|
|
479
|
+
entries.sort((a, b) => a.path.localeCompare(b.path));
|
|
480
|
+
// Deletions go first: a directory this computer replaced with a file must be gone before the file arrives.
|
|
481
|
+
await pushLocalFiles(cell, root, folder, entries, group === 0 ? deletions : [], { signal, overwrite: replace });
|
|
266
482
|
if (group === 0)
|
|
267
483
|
initial?.ready();
|
|
268
484
|
}
|
|
485
|
+
seedPhase('copy_changes');
|
|
269
486
|
}
|
package/dist/agent-state.d.ts
CHANGED
|
@@ -22,6 +22,9 @@ export declare class AgentStateStore {
|
|
|
22
22
|
host: string;
|
|
23
23
|
api_url: string;
|
|
24
24
|
}): void;
|
|
25
|
+
/** The APIs whose account has its T3 Connect sign-in, as the last live check saw it: every project can start in T3. */
|
|
26
|
+
t3Connected(apiUrl: string): boolean;
|
|
27
|
+
saveT3Connected(apiUrl: string, connected: boolean): void;
|
|
25
28
|
saveTarget(project: string, target: string): void;
|
|
26
29
|
update(change: (state: State) => void): void;
|
|
27
30
|
save(project: string, app: 'claude' | 'codex'): void;
|
package/dist/agent-state.js
CHANGED
|
@@ -41,6 +41,19 @@ export class AgentStateStore {
|
|
|
41
41
|
doc.t3 = [...list.filter(e => !(e.host === entry.host && e.api_url === entry.api_url)), entry];
|
|
42
42
|
});
|
|
43
43
|
}
|
|
44
|
+
/** The APIs whose account has its T3 Connect sign-in, as the last live check saw it: every project can start in T3. */
|
|
45
|
+
t3Connected(apiUrl) {
|
|
46
|
+
const list = this.read().t3_connect;
|
|
47
|
+
return Array.isArray(list) && list.includes(apiUrl);
|
|
48
|
+
}
|
|
49
|
+
saveT3Connected(apiUrl, connected) {
|
|
50
|
+
if (this.t3Connected(apiUrl) === connected)
|
|
51
|
+
return;
|
|
52
|
+
this.update(doc => {
|
|
53
|
+
const list = (Array.isArray(doc.t3_connect) ? doc.t3_connect : []).filter((url) => typeof url === 'string' && url !== apiUrl);
|
|
54
|
+
doc.t3_connect = connected ? [...list, apiUrl] : list;
|
|
55
|
+
});
|
|
56
|
+
}
|
|
44
57
|
saveTarget(project, target) {
|
|
45
58
|
this.update(doc => {
|
|
46
59
|
doc.projects ??= {};
|
|
@@ -33,5 +33,7 @@ export declare function openClaudeDesktopSession(ctx: Ctx, ws: Workspace, conver
|
|
|
33
33
|
* server in the VM (SSH mode), and T3 is brought to the front with --json too (`opened: true`).
|
|
34
34
|
*/
|
|
35
35
|
export declare function openDesktopWorkspace(ctx: Ctx, ws: Workspace, target: DesktopTarget, t3Version?: string, prewarmed?: boolean, thread?: T3ThreadRequest, install?: T3Install, conversation?: string | null): Promise<number>;
|
|
36
|
+
/** T3 Code's Connections page (its t3code:// link), where a project linked with T3 Connect is listed. */
|
|
37
|
+
export declare const T3_CONNECTIONS_PAGE = "t3code://app/settings/connections";
|
|
36
38
|
export declare function queueT3Prewarm(ctx: Ctx, ws: Workspace, version: string): Promise<void>;
|
|
37
39
|
export declare const TARGET_HANDLERS: Record<string, Handler>;
|