@turns/dev 0.0.9 → 0.0.11
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/README.md +8 -1
- package/dist/file-browsing.js +72 -4
- package/dist/git-status.js +440 -0
- package/dist/path-resolution.js +212 -6
- package/dist/queue-worker.js +109 -2
- package/dist/upload-sessions.js +10 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -73,10 +73,17 @@ bounds a deliberately broad matcher: prose that merely looks like a path
|
|
|
73
73
|
resolves to nothing and is never mentioned to the server, and no directory is
|
|
74
74
|
ever scanned.
|
|
75
75
|
|
|
76
|
-
An explicit file browse may upload up to
|
|
76
|
+
An explicit file browse may upload up to 2 GiB to the same private R2 prefix.
|
|
77
|
+
Files over 100 MiB use retryable 32 MiB multipart requests and report upload
|
|
78
|
+
progress to the requesting browser over the control WebSocket.
|
|
77
79
|
The daemon streams the file from disk and the Worker hands that request stream
|
|
78
80
|
straight to R2, so neither process retains the complete file in memory.
|
|
79
81
|
|
|
82
|
+
Range-based viewers can bypass that upload. A capable daemon advertises live
|
|
83
|
+
file ranges, validates the path through the same allowlist, and streams a
|
|
84
|
+
bounded window as ordered 32 KiB WebSocket chunks. The browser hex view pages
|
|
85
|
+
64 KiB viewport windows, including arbitrary offset jumps.
|
|
86
|
+
|
|
80
87
|
The systemd unit uses `KillMode=process`, and the macOS LaunchAgent uses
|
|
81
88
|
`AbandonProcessGroup`: restarting the uploader leaves its detached agent-anchor
|
|
82
89
|
processes running so in-flight harness turns continue.
|
package/dist/file-browsing.js
CHANGED
|
@@ -17,10 +17,19 @@ import { uploadFileStream } from "./path-resolution.js";
|
|
|
17
17
|
*/
|
|
18
18
|
/** Kept in step with `MAX_DIRECTORY_ENTRIES` in the Worker's shared listing. */
|
|
19
19
|
export const MAX_DIRECTORY_ENTRIES = 2_000;
|
|
20
|
-
/**
|
|
21
|
-
|
|
20
|
+
/** Kept in step with `MAX_FILE_BYTES` on the server. Multipart transport keeps
|
|
21
|
+
* each individual Worker request below the platform request-body ceiling. */
|
|
22
|
+
export const MAX_BROWSE_FILE_BYTES = 2 * 1024 * 1024 * 1024;
|
|
22
23
|
/** The derived label used in reader-facing errors. */
|
|
23
|
-
export const BROWSE_FILE_LIMIT_LABEL = `${
|
|
24
|
+
export const BROWSE_FILE_LIMIT_LABEL = `${MAX_BROWSE_FILE_BYTES / (1024 * 1024 * 1024)} GB`;
|
|
25
|
+
/** Frequent filesystem chunks are useful for accurate accounting but noisy on
|
|
26
|
+
* a control socket. Four frames per second plus phase boundaries stays smooth. */
|
|
27
|
+
export const BROWSE_PROGRESS_INTERVAL_MS = 250;
|
|
28
|
+
/** One browser range request is deliberately small enough to remain cheap to
|
|
29
|
+
* retry or abandon when a reader scrolls quickly through a large file. */
|
|
30
|
+
export const MAX_BROWSE_RANGE_BYTES = 256 * 1024;
|
|
31
|
+
/** Raw bytes per WebSocket payload. Base64 expands this to roughly 43 KiB. */
|
|
32
|
+
export const BROWSE_RANGE_CHUNK_BYTES = 32 * 1024;
|
|
24
33
|
/** Enough data to distinguish source and prose from arbitrary binary bytes
|
|
25
34
|
* without reading a large artifact into the daemon's memory. */
|
|
26
35
|
export const FILE_TYPE_SAMPLE_BYTES = 64 * 1024;
|
|
@@ -173,7 +182,50 @@ export function readDirectory(directory) {
|
|
|
173
182
|
// clock inside the body would make every read produce different bytes.
|
|
174
183
|
return { path: directory, entries, truncated: names.length > MAX_DIRECTORY_ENTRIES };
|
|
175
184
|
}
|
|
176
|
-
|
|
185
|
+
/** Stream one bounded file window without creating or updating its R2 object. */
|
|
186
|
+
export async function streamMachineFileRange({ requested, offset, length, workingDirectory = '', allowedDirectories = [], onReady, onChunk, }) {
|
|
187
|
+
if (!Number.isSafeInteger(offset) || offset < 0)
|
|
188
|
+
throw new Error('Invalid file range offset.');
|
|
189
|
+
if (!Number.isSafeInteger(length) || length <= 0 || length > MAX_BROWSE_RANGE_BYTES) {
|
|
190
|
+
throw new Error('Invalid file range length.');
|
|
191
|
+
}
|
|
192
|
+
const resolved = resolveBrowsePath(requested, { workingDirectory, allowedDirectories });
|
|
193
|
+
if (!resolved.stat.isFile())
|
|
194
|
+
throw new Error(`Not a readable file: ${resolved.path}`);
|
|
195
|
+
if (resolved.stat.size > MAX_BROWSE_FILE_BYTES) {
|
|
196
|
+
throw new Error(`File is larger than the ${BROWSE_FILE_LIMIT_LABEL} browse limit.`);
|
|
197
|
+
}
|
|
198
|
+
if (offset > resolved.stat.size)
|
|
199
|
+
throw new Error('File range starts beyond the end of the file.');
|
|
200
|
+
const boundedLength = Math.min(length, resolved.stat.size - offset);
|
|
201
|
+
const sample = readFileTypeSample(resolved.path, resolved.stat.size);
|
|
202
|
+
const range = {
|
|
203
|
+
path: resolved.path,
|
|
204
|
+
offset,
|
|
205
|
+
length: boundedLength,
|
|
206
|
+
totalBytes: resolved.stat.size,
|
|
207
|
+
contentType: browseContentType(resolved.path, sample, sample.byteLength === resolved.stat.size),
|
|
208
|
+
modifiedAt: isoTime(resolved.stat.mtimeMs),
|
|
209
|
+
};
|
|
210
|
+
onReady(range);
|
|
211
|
+
if (!boundedLength)
|
|
212
|
+
return range;
|
|
213
|
+
const stream = fs.createReadStream(resolved.path, {
|
|
214
|
+
start: offset,
|
|
215
|
+
end: offset + boundedLength - 1,
|
|
216
|
+
highWaterMark: BROWSE_RANGE_CHUNK_BYTES,
|
|
217
|
+
});
|
|
218
|
+
let chunkOffset = offset;
|
|
219
|
+
for await (const value of stream) {
|
|
220
|
+
const chunk = value;
|
|
221
|
+
onChunk(chunkOffset, chunk);
|
|
222
|
+
chunkOffset += chunk.byteLength;
|
|
223
|
+
}
|
|
224
|
+
if (chunkOffset !== offset + boundedLength)
|
|
225
|
+
throw new Error('The file changed while its byte range was being read.');
|
|
226
|
+
return range;
|
|
227
|
+
}
|
|
228
|
+
export async function browseMachinePath({ requested, machine, baseUrl, options, workingDirectory = '', contentEtag = '', onUploadProgress, fetchImpl = fetch, }) {
|
|
177
229
|
const resolved = resolveBrowsePath(requested, {
|
|
178
230
|
workingDirectory,
|
|
179
231
|
allowedDirectories: options.allowedDirectories || [],
|
|
@@ -201,6 +253,20 @@ export async function browseMachinePath({ requested, machine, baseUrl, options,
|
|
|
201
253
|
const modifiedAt = isoTime(resolved.stat.mtimeMs);
|
|
202
254
|
if (resolved.stat.size === 0)
|
|
203
255
|
return { path: resolved.path, kind: 'file', bytes: 0, contentType, modifiedAt };
|
|
256
|
+
let lastProgressAt = 0;
|
|
257
|
+
let lastUploadedBytes = -1;
|
|
258
|
+
const reportProgress = (phase, uploadedBytes, force = false) => {
|
|
259
|
+
if (!onUploadProgress)
|
|
260
|
+
return;
|
|
261
|
+
const bounded = Math.max(lastUploadedBytes, Math.min(resolved.stat.size, Math.max(0, uploadedBytes)));
|
|
262
|
+
const now = Date.now();
|
|
263
|
+
if (!force && now - lastProgressAt < BROWSE_PROGRESS_INTERVAL_MS && bounded < resolved.stat.size)
|
|
264
|
+
return;
|
|
265
|
+
lastProgressAt = now;
|
|
266
|
+
lastUploadedBytes = bounded;
|
|
267
|
+
onUploadProgress({ path: resolved.path, phase, uploadedBytes: bounded, totalBytes: resolved.stat.size });
|
|
268
|
+
};
|
|
269
|
+
reportProgress('starting', 0, true);
|
|
204
270
|
await uploadFileStream({
|
|
205
271
|
baseUrl,
|
|
206
272
|
options,
|
|
@@ -210,6 +276,8 @@ export async function browseMachinePath({ requested, machine, baseUrl, options,
|
|
|
210
276
|
bytes: resolved.stat.size,
|
|
211
277
|
modifiedAt,
|
|
212
278
|
...(contentEtag ? { contentEtag } : {}),
|
|
279
|
+
onProgress: (uploadedBytes) => reportProgress('uploading', uploadedBytes),
|
|
280
|
+
onFinalizing: () => reportProgress('finalizing', resolved.stat.size, true),
|
|
213
281
|
fetchImpl,
|
|
214
282
|
});
|
|
215
283
|
return { path: resolved.path, kind: 'file', bytes: resolved.stat.size, contentType, modifiedAt };
|
|
@@ -0,0 +1,440 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { resolveBrowsePath } from "./file-browsing.js";
|
|
5
|
+
/**
|
|
6
|
+
* Asking this machine's git what the tree a reader is browsing looks like.
|
|
7
|
+
*
|
|
8
|
+
* The browser cannot run git, and the daemon will not hand it a shell, so the
|
|
9
|
+
* questions worth asking are enumerated here and answered with fixed argument
|
|
10
|
+
* vectors: a repository path never becomes an option, a pattern, or a shell
|
|
11
|
+
* fragment. Every invocation is bounded in time and in output, because a work
|
|
12
|
+
* tree can be arbitrarily large and a browse must not be able to pin a daemon.
|
|
13
|
+
*
|
|
14
|
+
* The base revision is picked the way a reviewer would. On the default branch
|
|
15
|
+
* there is nothing to have diverged from, so the base is `HEAD` and the answer
|
|
16
|
+
* describes uncommitted work. Anywhere else — a feature branch, or one of the
|
|
17
|
+
* linked worktrees agents are usually given — the base is the merge point with
|
|
18
|
+
* the default branch, making the answer the same range a pull request shows.
|
|
19
|
+
*/
|
|
20
|
+
/** A git call has to finish inside a browse, not eventually. */
|
|
21
|
+
export const GIT_COMMAND_TIMEOUT_MS = 10_000;
|
|
22
|
+
/**
|
|
23
|
+
* Ceilings this daemon answers within, kept in step with the Worker's shared
|
|
24
|
+
* git module rather than imported from it.
|
|
25
|
+
*
|
|
26
|
+
* This package is published on its own and builds with the CLI directory as
|
|
27
|
+
* its root, so a source file here cannot reach into the Worker's tree: an
|
|
28
|
+
* import across that boundary compiles under the repo-wide typecheck and then
|
|
29
|
+
* fails the package build, which is the build that produces what npm ships.
|
|
30
|
+
* The Worker's `MAX_GIT_STATUS_FILES`, `MAX_GIT_TEXT_BYTES`, and
|
|
31
|
+
* `MAX_GIT_UNTRACKED_COUNT_BYTES` are the same three numbers; the reader
|
|
32
|
+
* validates against its own copies, so a daemon that drifted upward would be
|
|
33
|
+
* trimmed rather than believed.
|
|
34
|
+
*/
|
|
35
|
+
const MAX_GIT_STATUS_FILES = 2_000;
|
|
36
|
+
const MAX_GIT_TEXT_BYTES = 4 * 1024 * 1024;
|
|
37
|
+
const MAX_GIT_UNTRACKED_COUNT_BYTES = 2 * 1024 * 1024;
|
|
38
|
+
/** Local branches are tried before remote-tracking ones: an agent working in a
|
|
39
|
+
* linked worktree diverged from the checkout beside it, not from the server. */
|
|
40
|
+
const DEFAULT_BRANCH_REFS = [
|
|
41
|
+
{ ref: 'refs/heads/main', label: 'main' },
|
|
42
|
+
{ ref: 'refs/heads/master', label: 'master' },
|
|
43
|
+
{ ref: 'refs/remotes/origin/main', label: 'origin/main' },
|
|
44
|
+
{ ref: 'refs/remotes/origin/master', label: 'origin/master' },
|
|
45
|
+
];
|
|
46
|
+
/**
|
|
47
|
+
* Run one git command and collect a bounded amount of its output.
|
|
48
|
+
*
|
|
49
|
+
* `stderr` is discarded rather than reported: git's diagnostics name paths and
|
|
50
|
+
* refs on someone else's machine, and every failure here has a useful meaning
|
|
51
|
+
* of its own — a directory that is not a repository, a file that did not exist
|
|
52
|
+
* at the base. A non-zero exit is returned rather than thrown so callers can
|
|
53
|
+
* treat "no such ref" as an answer.
|
|
54
|
+
*/
|
|
55
|
+
function runGit(args, { cwd, limit = MAX_GIT_TEXT_BYTES, timeoutMs = GIT_COMMAND_TIMEOUT_MS, }) {
|
|
56
|
+
return new Promise((resolve, reject) => {
|
|
57
|
+
// `--no-pager` and an empty config environment keep a reader's answer from
|
|
58
|
+
// depending on whatever aliases, pagers, or colour settings this machine has.
|
|
59
|
+
const child = spawn('git', ['--no-pager', ...args], {
|
|
60
|
+
cwd,
|
|
61
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
62
|
+
env: { ...process.env, GIT_PAGER: 'cat', GIT_OPTIONAL_LOCKS: '0', GIT_TERMINAL_PROMPT: '0' },
|
|
63
|
+
});
|
|
64
|
+
const chunks = [];
|
|
65
|
+
let size = 0;
|
|
66
|
+
let truncated = false;
|
|
67
|
+
let stopped = false;
|
|
68
|
+
let killTimer;
|
|
69
|
+
const stop = () => {
|
|
70
|
+
if (stopped)
|
|
71
|
+
return;
|
|
72
|
+
stopped = true;
|
|
73
|
+
child.kill('SIGTERM');
|
|
74
|
+
killTimer = setTimeout(() => child.kill('SIGKILL'), 100);
|
|
75
|
+
killTimer.unref?.();
|
|
76
|
+
};
|
|
77
|
+
child.stdout.on('data', (chunk) => {
|
|
78
|
+
if (size >= limit)
|
|
79
|
+
return;
|
|
80
|
+
const room = limit - size;
|
|
81
|
+
chunks.push(chunk.length > room ? chunk.subarray(0, room) : chunk);
|
|
82
|
+
size += Math.min(room, chunk.length);
|
|
83
|
+
if (size < limit)
|
|
84
|
+
return;
|
|
85
|
+
truncated = true;
|
|
86
|
+
stop();
|
|
87
|
+
});
|
|
88
|
+
child.on('error', (error) => {
|
|
89
|
+
clearTimeout(timeout);
|
|
90
|
+
clearTimeout(killTimer);
|
|
91
|
+
reject(error);
|
|
92
|
+
});
|
|
93
|
+
const timeout = setTimeout(() => {
|
|
94
|
+
truncated = true;
|
|
95
|
+
stop();
|
|
96
|
+
}, timeoutMs);
|
|
97
|
+
timeout.unref?.();
|
|
98
|
+
child.on('close', (code) => {
|
|
99
|
+
clearTimeout(timeout);
|
|
100
|
+
clearTimeout(killTimer);
|
|
101
|
+
resolve({ code: stopped ? 0 : Number(code ?? 1), stdout: Buffer.concat(chunks).toString('utf8'), truncated });
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
async function gitLine(args, cwd) {
|
|
106
|
+
const result = await runGit(args, { cwd, limit: 64 * 1024 });
|
|
107
|
+
return result.code === 0 ? result.stdout.trim() : '';
|
|
108
|
+
}
|
|
109
|
+
/** Records of a `-z` stream: NUL-terminated, with a trailing empty remainder. */
|
|
110
|
+
function nulRecords(value) {
|
|
111
|
+
const records = value.split('\u0000');
|
|
112
|
+
if (records.length && records[records.length - 1] === '')
|
|
113
|
+
records.pop();
|
|
114
|
+
return records;
|
|
115
|
+
}
|
|
116
|
+
function absoluteGitDir(root, value) {
|
|
117
|
+
if (!value)
|
|
118
|
+
return '';
|
|
119
|
+
const resolved = path.isAbsolute(value) ? value : path.resolve(root, value);
|
|
120
|
+
try {
|
|
121
|
+
return fs.realpathSync(resolved);
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
return resolved;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* The work tree holding `directory`, and the revision its changes are measured
|
|
129
|
+
* from. Returns null when the directory is not inside a git work tree at all,
|
|
130
|
+
* which is an ordinary answer rather than a failure: most directories are not.
|
|
131
|
+
*/
|
|
132
|
+
export async function readGitBaseRevision(directory) {
|
|
133
|
+
const rootLine = await gitLine(['rev-parse', '--show-toplevel'], directory);
|
|
134
|
+
if (!rootLine)
|
|
135
|
+
return null;
|
|
136
|
+
let root = rootLine;
|
|
137
|
+
try {
|
|
138
|
+
root = fs.realpathSync(rootLine);
|
|
139
|
+
}
|
|
140
|
+
catch { }
|
|
141
|
+
const [gitDir, commonDir, head, branchLine] = await Promise.all([
|
|
142
|
+
gitLine(['rev-parse', '--git-dir'], root),
|
|
143
|
+
gitLine(['rev-parse', '--git-common-dir'], root),
|
|
144
|
+
gitLine(['rev-parse', 'HEAD'], root),
|
|
145
|
+
gitLine(['rev-parse', '--abbrev-ref', 'HEAD'], root),
|
|
146
|
+
]);
|
|
147
|
+
// A repository with no commits has no HEAD to diff against and nothing a
|
|
148
|
+
// changes view could show, so it is reported as "not a comparable tree".
|
|
149
|
+
if (!head)
|
|
150
|
+
return null;
|
|
151
|
+
const branch = branchLine === 'HEAD' ? '' : branchLine;
|
|
152
|
+
const linkedWorkTree = Boolean(gitDir && commonDir && absoluteGitDir(root, gitDir) !== absoluteGitDir(root, commonDir));
|
|
153
|
+
// `origin/HEAD` is the remote's own statement of its default branch and is
|
|
154
|
+
// consulted only after the local branches, which is where a linked worktree
|
|
155
|
+
// actually diverged from.
|
|
156
|
+
const candidates = [...DEFAULT_BRANCH_REFS];
|
|
157
|
+
const originHead = await gitLine(['symbolic-ref', '--short', 'refs/remotes/origin/HEAD'], root);
|
|
158
|
+
if (originHead)
|
|
159
|
+
candidates.push({ ref: `refs/remotes/${originHead}`, label: originHead });
|
|
160
|
+
let defaultLabel = '';
|
|
161
|
+
for (const candidate of candidates) {
|
|
162
|
+
const resolved = await gitLine(['rev-parse', '--verify', '--quiet', candidate.ref], root);
|
|
163
|
+
if (!resolved)
|
|
164
|
+
continue;
|
|
165
|
+
defaultLabel = candidate.label;
|
|
166
|
+
break;
|
|
167
|
+
}
|
|
168
|
+
const defaultShortName = defaultLabel.replace(/^origin\//u, '');
|
|
169
|
+
const onDefaultBranch = Boolean(branch) && (branch === defaultLabel || branch === defaultShortName);
|
|
170
|
+
if (!defaultLabel || onDefaultBranch) {
|
|
171
|
+
return {
|
|
172
|
+
root,
|
|
173
|
+
branch,
|
|
174
|
+
head,
|
|
175
|
+
baseLabel: 'HEAD',
|
|
176
|
+
baseOid: head,
|
|
177
|
+
onDefaultBranch: true,
|
|
178
|
+
linkedWorkTree,
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
const mergeBase = await gitLine(['merge-base', 'HEAD', defaultLabel], root);
|
|
182
|
+
// Unrelated histories leave nothing to compare with; fall back to HEAD so the
|
|
183
|
+
// reader still sees their uncommitted work rather than an error.
|
|
184
|
+
if (!mergeBase) {
|
|
185
|
+
return { root, branch, head, baseLabel: 'HEAD', baseOid: head, onDefaultBranch: true, linkedWorkTree };
|
|
186
|
+
}
|
|
187
|
+
return { root, branch, head, baseLabel: defaultLabel, baseOid: mergeBase, onDefaultBranch: false, linkedWorkTree };
|
|
188
|
+
}
|
|
189
|
+
/** `add TAB del TAB path`, or `add TAB del TAB` followed by the two paths of a
|
|
190
|
+
* rename. Binary files report `-` for both counts. */
|
|
191
|
+
function parseNumstat(value) {
|
|
192
|
+
const records = nulRecords(value);
|
|
193
|
+
const entries = [];
|
|
194
|
+
for (let index = 0; index < records.length; index += 1) {
|
|
195
|
+
const record = records[index];
|
|
196
|
+
const fields = record.split('\t');
|
|
197
|
+
if (fields.length < 3)
|
|
198
|
+
continue;
|
|
199
|
+
const binary = fields[0] === '-' || fields[1] === '-';
|
|
200
|
+
const additions = binary ? 0 : Number(fields[0]) || 0;
|
|
201
|
+
const deletions = binary ? 0 : Number(fields[1]) || 0;
|
|
202
|
+
let relativePath = fields.slice(2).join('\t');
|
|
203
|
+
let oldRelativePath = '';
|
|
204
|
+
if (!relativePath) {
|
|
205
|
+
oldRelativePath = records[index + 1] || '';
|
|
206
|
+
relativePath = records[index + 2] || '';
|
|
207
|
+
index += 2;
|
|
208
|
+
}
|
|
209
|
+
if (!relativePath)
|
|
210
|
+
continue;
|
|
211
|
+
entries.push({ additions, deletions, binary, relativePath, oldRelativePath });
|
|
212
|
+
}
|
|
213
|
+
return entries;
|
|
214
|
+
}
|
|
215
|
+
/** `status NUL path`, or `Rnn NUL old NUL new` for a rename or copy. */
|
|
216
|
+
function parseNameStatus(value) {
|
|
217
|
+
const records = nulRecords(value);
|
|
218
|
+
const statuses = new Map();
|
|
219
|
+
for (let index = 0; index < records.length; index += 1) {
|
|
220
|
+
const code = records[index] || '';
|
|
221
|
+
if (!code)
|
|
222
|
+
continue;
|
|
223
|
+
if (code.startsWith('R') || code.startsWith('C')) {
|
|
224
|
+
const target = records[index + 2] || '';
|
|
225
|
+
if (target)
|
|
226
|
+
statuses.set(target, code[0]);
|
|
227
|
+
index += 2;
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
const target = records[index + 1] || '';
|
|
231
|
+
if (target)
|
|
232
|
+
statuses.set(target, code[0]);
|
|
233
|
+
index += 1;
|
|
234
|
+
}
|
|
235
|
+
return statuses;
|
|
236
|
+
}
|
|
237
|
+
function changeStatusFromCode(code, renamed) {
|
|
238
|
+
if (renamed)
|
|
239
|
+
return 'renamed';
|
|
240
|
+
if (code === 'A')
|
|
241
|
+
return 'added';
|
|
242
|
+
if (code === 'D')
|
|
243
|
+
return 'deleted';
|
|
244
|
+
return 'modified';
|
|
245
|
+
}
|
|
246
|
+
/**
|
|
247
|
+
* Lines a not-yet-tracked file would add.
|
|
248
|
+
*
|
|
249
|
+
* Git has nothing to say about a file it has never seen, and running a diff
|
|
250
|
+
* per untracked path would multiply process spawns by the size of a build
|
|
251
|
+
* directory. Counting newlines here is bounded by size and stops at the first
|
|
252
|
+
* NUL, which is also how the file browser decides a file is not text.
|
|
253
|
+
*/
|
|
254
|
+
function untrackedFileCount(absolutePath) {
|
|
255
|
+
let stat;
|
|
256
|
+
try {
|
|
257
|
+
stat = fs.statSync(absolutePath);
|
|
258
|
+
}
|
|
259
|
+
catch {
|
|
260
|
+
return null;
|
|
261
|
+
}
|
|
262
|
+
// Git can name something that is not a regular file — a symlink to a
|
|
263
|
+
// directory, most often a linked `node_modules`. There is no diff for it and
|
|
264
|
+
// reporting it as a zero-line change would put a mark against a row nothing
|
|
265
|
+
// happened in.
|
|
266
|
+
if (!stat.isFile())
|
|
267
|
+
return null;
|
|
268
|
+
if (stat.size > MAX_GIT_UNTRACKED_COUNT_BYTES)
|
|
269
|
+
return { additions: 0, binary: true };
|
|
270
|
+
let contents;
|
|
271
|
+
try {
|
|
272
|
+
contents = fs.readFileSync(absolutePath);
|
|
273
|
+
}
|
|
274
|
+
catch {
|
|
275
|
+
return { additions: 0, binary: false };
|
|
276
|
+
}
|
|
277
|
+
if (contents.includes(0))
|
|
278
|
+
return { additions: 0, binary: true };
|
|
279
|
+
if (!contents.length)
|
|
280
|
+
return { additions: 0, binary: false };
|
|
281
|
+
let lines = 0;
|
|
282
|
+
for (const byte of contents)
|
|
283
|
+
if (byte === 0x0a)
|
|
284
|
+
lines += 1;
|
|
285
|
+
// A final line without its newline is still a line the diff would show.
|
|
286
|
+
return { additions: contents[contents.length - 1] === 0x0a ? lines : lines + 1, binary: false };
|
|
287
|
+
}
|
|
288
|
+
function machinePath(root, relativePath) {
|
|
289
|
+
return path.posix.join(root.split(path.sep).join('/'), relativePath);
|
|
290
|
+
}
|
|
291
|
+
/**
|
|
292
|
+
* Every path in one work tree that differs from its base revision.
|
|
293
|
+
*
|
|
294
|
+
* Tracked differences come from a single `git diff` against the base, which is
|
|
295
|
+
* what makes the answer a branch range rather than a working-copy status:
|
|
296
|
+
* commits made on the branch and edits not yet committed are both in it, in
|
|
297
|
+
* the same way a pull request shows both. Untracked files are added afterwards
|
|
298
|
+
* because git will not describe a file it has not been told about.
|
|
299
|
+
*/
|
|
300
|
+
export async function readMachineGitStatus({ requested, workingDirectory = '', allowedDirectories = [], }) {
|
|
301
|
+
const resolved = resolveBrowsePath(String(requested || ''), { workingDirectory, allowedDirectories });
|
|
302
|
+
const directory = resolved.stat.isDirectory() ? resolved.path : path.dirname(resolved.path);
|
|
303
|
+
const revision = await readGitBaseRevision(directory);
|
|
304
|
+
if (!revision)
|
|
305
|
+
return null;
|
|
306
|
+
const [numstat, nameStatus, untracked] = await Promise.all([
|
|
307
|
+
runGit(['diff', '--numstat', '-z', '--find-renames', '--no-color', revision.baseOid, '--'], { cwd: revision.root }),
|
|
308
|
+
runGit(['diff', '--name-status', '-z', '--find-renames', '--no-color', revision.baseOid, '--'], {
|
|
309
|
+
cwd: revision.root,
|
|
310
|
+
}),
|
|
311
|
+
runGit(['ls-files', '-z', '--others', '--exclude-standard'], { cwd: revision.root }),
|
|
312
|
+
]);
|
|
313
|
+
const statuses = parseNameStatus(nameStatus.stdout);
|
|
314
|
+
const files = [];
|
|
315
|
+
for (const entry of parseNumstat(numstat.stdout)) {
|
|
316
|
+
const code = statuses.get(entry.relativePath) || 'M';
|
|
317
|
+
const status = changeStatusFromCode(code, Boolean(entry.oldRelativePath));
|
|
318
|
+
files.push({
|
|
319
|
+
path: machinePath(revision.root, entry.relativePath),
|
|
320
|
+
relativePath: entry.relativePath,
|
|
321
|
+
status,
|
|
322
|
+
additions: entry.additions,
|
|
323
|
+
deletions: entry.deletions,
|
|
324
|
+
...(entry.binary ? { binary: true } : {}),
|
|
325
|
+
...(entry.oldRelativePath ? { oldRelativePath: entry.oldRelativePath } : {}),
|
|
326
|
+
});
|
|
327
|
+
if (files.length >= MAX_GIT_STATUS_FILES)
|
|
328
|
+
break;
|
|
329
|
+
}
|
|
330
|
+
let truncated = numstat.truncated || nameStatus.truncated || untracked.truncated || files.length >= MAX_GIT_STATUS_FILES;
|
|
331
|
+
for (const relativePath of nulRecords(untracked.stdout)) {
|
|
332
|
+
if (!relativePath)
|
|
333
|
+
continue;
|
|
334
|
+
if (files.length >= MAX_GIT_STATUS_FILES) {
|
|
335
|
+
truncated = true;
|
|
336
|
+
break;
|
|
337
|
+
}
|
|
338
|
+
const absolute = machinePath(revision.root, relativePath);
|
|
339
|
+
const counted = untrackedFileCount(absolute);
|
|
340
|
+
if (!counted)
|
|
341
|
+
continue;
|
|
342
|
+
files.push({
|
|
343
|
+
path: absolute,
|
|
344
|
+
relativePath,
|
|
345
|
+
status: 'untracked',
|
|
346
|
+
additions: counted.additions,
|
|
347
|
+
deletions: 0,
|
|
348
|
+
...(counted.binary ? { binary: true } : {}),
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
return {
|
|
352
|
+
root: revision.root,
|
|
353
|
+
branch: revision.branch,
|
|
354
|
+
head: revision.head,
|
|
355
|
+
baseLabel: revision.baseLabel,
|
|
356
|
+
baseOid: revision.baseOid,
|
|
357
|
+
onDefaultBranch: revision.onDefaultBranch,
|
|
358
|
+
linkedWorkTree: revision.linkedWorkTree,
|
|
359
|
+
files,
|
|
360
|
+
additions: files.reduce((total, file) => total + file.additions, 0),
|
|
361
|
+
deletions: files.reduce((total, file) => total + file.deletions, 0),
|
|
362
|
+
truncated,
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
/**
|
|
366
|
+
* One unified patch for the whole work tree, as a review would read it.
|
|
367
|
+
*
|
|
368
|
+
* Untracked files are appended as separate `--no-index` patches against
|
|
369
|
+
* `/dev/null`, which is the same shape git emits for a new file in an ordinary
|
|
370
|
+
* diff — so the browser's existing unified-diff parser reads both without
|
|
371
|
+
* knowing which came from where. Only as many as fit under the byte ceiling
|
|
372
|
+
* are asked for; a patch this long has already stopped being reviewable.
|
|
373
|
+
*/
|
|
374
|
+
export async function readMachineGitPatch({ requested, workingDirectory = '', allowedDirectories = [], }) {
|
|
375
|
+
const resolved = resolveBrowsePath(String(requested || ''), { workingDirectory, allowedDirectories });
|
|
376
|
+
const directory = resolved.stat.isDirectory() ? resolved.path : path.dirname(resolved.path);
|
|
377
|
+
const revision = await readGitBaseRevision(directory);
|
|
378
|
+
if (!revision)
|
|
379
|
+
return null;
|
|
380
|
+
const tracked = await runGit(['diff', '--patch', '--no-color', '--find-renames', '--no-ext-diff', revision.baseOid, '--'], { cwd: revision.root });
|
|
381
|
+
let patch = tracked.stdout;
|
|
382
|
+
let truncated = tracked.truncated;
|
|
383
|
+
if (!truncated) {
|
|
384
|
+
const untracked = await runGit(['ls-files', '-z', '--others', '--exclude-standard'], { cwd: revision.root });
|
|
385
|
+
truncated = untracked.truncated;
|
|
386
|
+
for (const relativePath of nulRecords(untracked.stdout)) {
|
|
387
|
+
if (!relativePath)
|
|
388
|
+
continue;
|
|
389
|
+
if (patch.length >= MAX_GIT_TEXT_BYTES) {
|
|
390
|
+
truncated = true;
|
|
391
|
+
break;
|
|
392
|
+
}
|
|
393
|
+
// `--no-index` against /dev/null exits 1 on every difference, which is
|
|
394
|
+
// the normal outcome here rather than a failure.
|
|
395
|
+
const added = await runGit(['diff', '--no-color', '--no-ext-diff', '--no-index', '--', '/dev/null', relativePath], { cwd: revision.root, limit: Math.max(0, MAX_GIT_TEXT_BYTES - patch.length) });
|
|
396
|
+
if (added.truncated)
|
|
397
|
+
truncated = true;
|
|
398
|
+
if (added.stdout)
|
|
399
|
+
patch += added.stdout;
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
return {
|
|
403
|
+
root: revision.root,
|
|
404
|
+
baseLabel: revision.baseLabel,
|
|
405
|
+
baseOid: revision.baseOid,
|
|
406
|
+
patch: patch.slice(0, MAX_GIT_TEXT_BYTES),
|
|
407
|
+
truncated: truncated || patch.length > MAX_GIT_TEXT_BYTES,
|
|
408
|
+
};
|
|
409
|
+
}
|
|
410
|
+
/**
|
|
411
|
+
* One file as the base revision holds it, for the viewer's inline diff.
|
|
412
|
+
*
|
|
413
|
+
* A file that did not exist at the base is a normal answer — it is what the
|
|
414
|
+
* viewer needs in order to show the whole file as added — so a missing blob
|
|
415
|
+
* reports `found: false` rather than raising. Bytes that are not decodable
|
|
416
|
+
* text are reported as binary instead of being rendered as replacement noise.
|
|
417
|
+
*/
|
|
418
|
+
export async function readMachineGitBaseBlob({ requested, workingDirectory = '', allowedDirectories = [], }) {
|
|
419
|
+
const resolved = resolveBrowsePath(String(requested || ''), { workingDirectory, allowedDirectories });
|
|
420
|
+
if (!resolved.stat.isFile())
|
|
421
|
+
throw new Error(`Not a file: ${resolved.path}`);
|
|
422
|
+
const revision = await readGitBaseRevision(path.dirname(resolved.path));
|
|
423
|
+
if (!revision)
|
|
424
|
+
return null;
|
|
425
|
+
const relative = path.relative(revision.root, resolved.path).split(path.sep).join('/');
|
|
426
|
+
if (!relative || relative.startsWith('..'))
|
|
427
|
+
return null;
|
|
428
|
+
const shown = await runGit(['show', `${revision.baseOid}:${relative}`], { cwd: revision.root });
|
|
429
|
+
const found = shown.code === 0;
|
|
430
|
+
const binary = found && shown.stdout.includes('\u0000');
|
|
431
|
+
return {
|
|
432
|
+
path: resolved.path,
|
|
433
|
+
baseLabel: revision.baseLabel,
|
|
434
|
+
baseOid: revision.baseOid,
|
|
435
|
+
found,
|
|
436
|
+
text: found && !binary ? shown.stdout : '',
|
|
437
|
+
truncated: found && shown.truncated,
|
|
438
|
+
binary,
|
|
439
|
+
};
|
|
440
|
+
}
|
package/dist/path-resolution.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import os from 'node:os';
|
|
3
3
|
import path from 'node:path';
|
|
4
|
+
import { Transform } from 'node:stream';
|
|
4
5
|
/**
|
|
5
6
|
* Turning the paths an agent writes into things a reader can open.
|
|
6
7
|
*
|
|
@@ -169,6 +170,189 @@ function uploadHeaders(options) {
|
|
|
169
170
|
? { 'x-turns-dev-user': options.devUser, 'x-turns-dev-role': 'uploader' }
|
|
170
171
|
: { authorization: `Bearer ${options.token}` };
|
|
171
172
|
}
|
|
173
|
+
/** Keep directory separators readable while escaping characters that would
|
|
174
|
+
* otherwise change the URL's path, query, or fragment. */
|
|
175
|
+
function fileUploadUrl(baseUrl, machine, absolutePath) {
|
|
176
|
+
const encoded = absolutePath.split('/').map(encodeURIComponent).join('/');
|
|
177
|
+
const encodedPath = encoded.startsWith('//')
|
|
178
|
+
? `%2F%2F${encoded.slice(2)}`
|
|
179
|
+
: encoded.startsWith('/')
|
|
180
|
+
? encoded.slice(1)
|
|
181
|
+
: encoded;
|
|
182
|
+
return new URL(`/api/file/${encodeURIComponent(machine)}/${encodedPath}`, String(baseUrl));
|
|
183
|
+
}
|
|
184
|
+
/** Kept in step with the explicit-browse transport constants in shared/files.ts. */
|
|
185
|
+
export const MAX_SINGLE_BROWSE_UPLOAD_BYTES = 100 * 1024 * 1024;
|
|
186
|
+
export const BROWSE_MULTIPART_PART_BYTES = 32 * 1024 * 1024;
|
|
187
|
+
export const MAX_BROWSE_MULTIPART_PARTS = 64;
|
|
188
|
+
export function filePartRanges(bytes, partSize = BROWSE_MULTIPART_PART_BYTES) {
|
|
189
|
+
if (!Number.isSafeInteger(bytes) || bytes <= 0 || !Number.isSafeInteger(partSize) || partSize <= 0)
|
|
190
|
+
return [];
|
|
191
|
+
const ranges = [];
|
|
192
|
+
for (let start = 0, partNumber = 1; start < bytes; start += partSize, partNumber += 1) {
|
|
193
|
+
ranges.push({ partNumber, start, end: Math.min(bytes, start + partSize) });
|
|
194
|
+
}
|
|
195
|
+
return ranges;
|
|
196
|
+
}
|
|
197
|
+
function fileMultipartUrl(baseUrl, machine, absolutePath, suffix = '') {
|
|
198
|
+
const url = new URL(`/api/file-multipart/${encodeURIComponent(machine)}${suffix}`, String(baseUrl));
|
|
199
|
+
url.searchParams.set('path', absolutePath);
|
|
200
|
+
return url;
|
|
201
|
+
}
|
|
202
|
+
function progressStream(absolutePath, start, end, onProgress) {
|
|
203
|
+
const source = fs.createReadStream(absolutePath, { start, end: end - 1 });
|
|
204
|
+
let loaded = 0;
|
|
205
|
+
const body = new Transform({
|
|
206
|
+
transform(chunk, _encoding, callback) {
|
|
207
|
+
loaded += chunk.byteLength;
|
|
208
|
+
onProgress?.(loaded);
|
|
209
|
+
callback(null, chunk);
|
|
210
|
+
},
|
|
211
|
+
});
|
|
212
|
+
source.pipe(body);
|
|
213
|
+
return {
|
|
214
|
+
body,
|
|
215
|
+
destroy() {
|
|
216
|
+
source.destroy();
|
|
217
|
+
body.destroy();
|
|
218
|
+
},
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
async function uploadFailure(response, label) {
|
|
222
|
+
const detail = (await response.text().catch(() => '')).trim();
|
|
223
|
+
return new Error(`${label} (${response.status})${detail ? `: ${detail.slice(0, 500)}` : '.'}`);
|
|
224
|
+
}
|
|
225
|
+
function retryDelay(milliseconds) {
|
|
226
|
+
return new Promise((resolve) => setTimeout(resolve, milliseconds));
|
|
227
|
+
}
|
|
228
|
+
async function uploadMultipartFile({ baseUrl, options, machine, absolutePath, contentType, bytes, modifiedAt, contentEtag, onProgress, onFinalizing, fetchImpl, }) {
|
|
229
|
+
const createUrl = fileMultipartUrl(baseUrl, machine, absolutePath);
|
|
230
|
+
const createBody = JSON.stringify({
|
|
231
|
+
size: bytes,
|
|
232
|
+
contentType,
|
|
233
|
+
...(modifiedAt ? { modifiedAt } : {}),
|
|
234
|
+
...(contentEtag ? { contentEtag } : {}),
|
|
235
|
+
});
|
|
236
|
+
const created = await fetchImpl(createUrl, {
|
|
237
|
+
method: 'POST',
|
|
238
|
+
headers: {
|
|
239
|
+
...uploadHeaders(options),
|
|
240
|
+
'content-type': 'application/json',
|
|
241
|
+
'content-length': String(Buffer.byteLength(createBody)),
|
|
242
|
+
},
|
|
243
|
+
body: createBody,
|
|
244
|
+
});
|
|
245
|
+
if (!created.ok)
|
|
246
|
+
throw await uploadFailure(created, 'Multipart file upload could not start');
|
|
247
|
+
const payload = (await created.json().catch(() => ({})));
|
|
248
|
+
const uploadId = String(payload.upload?.uploadId || '');
|
|
249
|
+
const partSize = Number(payload.upload?.partSize);
|
|
250
|
+
const receiptToken = String(payload.upload?.receiptToken || '');
|
|
251
|
+
if (!uploadId ||
|
|
252
|
+
!receiptToken ||
|
|
253
|
+
!Number.isSafeInteger(partSize) ||
|
|
254
|
+
partSize <= 0 ||
|
|
255
|
+
partSize > BROWSE_MULTIPART_PART_BYTES) {
|
|
256
|
+
throw new Error('Multipart file upload returned an invalid session.');
|
|
257
|
+
}
|
|
258
|
+
const ranges = filePartRanges(bytes, partSize);
|
|
259
|
+
if (!ranges.length || ranges.length > MAX_BROWSE_MULTIPART_PARTS) {
|
|
260
|
+
throw new Error('Multipart file upload returned an invalid part size.');
|
|
261
|
+
}
|
|
262
|
+
const parts = [];
|
|
263
|
+
let completed = 0;
|
|
264
|
+
try {
|
|
265
|
+
for (const range of ranges) {
|
|
266
|
+
const length = range.end - range.start;
|
|
267
|
+
let receipt = null;
|
|
268
|
+
let greatestLoaded = 0;
|
|
269
|
+
let lastError = null;
|
|
270
|
+
for (let attempt = 0; attempt < 3 && !receipt; attempt += 1) {
|
|
271
|
+
const stream = progressStream(absolutePath, range.start, range.end, (loaded) => {
|
|
272
|
+
greatestLoaded = Math.max(greatestLoaded, loaded);
|
|
273
|
+
onProgress?.(Math.min(bytes, completed + greatestLoaded));
|
|
274
|
+
});
|
|
275
|
+
try {
|
|
276
|
+
const partUrl = fileMultipartUrl(baseUrl, machine, absolutePath, `/parts/${range.partNumber}`);
|
|
277
|
+
partUrl.searchParams.set('upload_id', uploadId);
|
|
278
|
+
const response = await fetchImpl(partUrl, {
|
|
279
|
+
method: 'PUT',
|
|
280
|
+
headers: {
|
|
281
|
+
...uploadHeaders(options),
|
|
282
|
+
'content-type': 'application/octet-stream',
|
|
283
|
+
'content-length': String(length),
|
|
284
|
+
},
|
|
285
|
+
body: stream.body,
|
|
286
|
+
duplex: 'half',
|
|
287
|
+
});
|
|
288
|
+
if (!response.ok)
|
|
289
|
+
throw await uploadFailure(response, `Multipart file part ${range.partNumber} failed`);
|
|
290
|
+
const answer = (await response.json().catch(() => ({})));
|
|
291
|
+
if (answer.part?.partNumber !== range.partNumber || !answer.part.etag) {
|
|
292
|
+
throw new Error(`Multipart file part ${range.partNumber} returned no receipt.`);
|
|
293
|
+
}
|
|
294
|
+
receipt = { partNumber: range.partNumber, etag: String(answer.part.etag) };
|
|
295
|
+
}
|
|
296
|
+
catch (error) {
|
|
297
|
+
lastError = error;
|
|
298
|
+
if (attempt < 2)
|
|
299
|
+
await retryDelay(250 * 2 ** attempt);
|
|
300
|
+
}
|
|
301
|
+
finally {
|
|
302
|
+
stream.destroy();
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
if (!receipt)
|
|
306
|
+
throw lastError instanceof Error ? lastError : new Error(String(lastError || 'File part failed.'));
|
|
307
|
+
parts.push(receipt);
|
|
308
|
+
completed += length;
|
|
309
|
+
onProgress?.(completed);
|
|
310
|
+
}
|
|
311
|
+
const completeUrl = fileMultipartUrl(baseUrl, machine, absolutePath, '/complete');
|
|
312
|
+
completeUrl.searchParams.set('upload_id', uploadId);
|
|
313
|
+
const completeBody = JSON.stringify({ parts, size: bytes, receiptToken });
|
|
314
|
+
onFinalizing?.();
|
|
315
|
+
let completedUpload = false;
|
|
316
|
+
let completionError = null;
|
|
317
|
+
for (let attempt = 0; attempt < 3 && !completedUpload; attempt += 1) {
|
|
318
|
+
try {
|
|
319
|
+
const response = await fetchImpl(completeUrl, {
|
|
320
|
+
method: 'POST',
|
|
321
|
+
headers: {
|
|
322
|
+
...uploadHeaders(options),
|
|
323
|
+
'content-type': 'application/json',
|
|
324
|
+
'content-length': String(Buffer.byteLength(completeBody)),
|
|
325
|
+
},
|
|
326
|
+
body: completeBody,
|
|
327
|
+
});
|
|
328
|
+
if (!response.ok) {
|
|
329
|
+
const failure = await uploadFailure(response, 'Multipart file upload could not finish');
|
|
330
|
+
if (response.status < 500 && response.status !== 408 && response.status !== 429)
|
|
331
|
+
throw failure;
|
|
332
|
+
completionError = failure;
|
|
333
|
+
}
|
|
334
|
+
else {
|
|
335
|
+
completedUpload = true;
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
catch (error) {
|
|
339
|
+
completionError = error;
|
|
340
|
+
}
|
|
341
|
+
if (!completedUpload && attempt < 2)
|
|
342
|
+
await retryDelay(250 * 2 ** attempt);
|
|
343
|
+
}
|
|
344
|
+
if (!completedUpload) {
|
|
345
|
+
throw completionError instanceof Error ? completionError : new Error('Multipart file upload could not finish.');
|
|
346
|
+
}
|
|
347
|
+
return true;
|
|
348
|
+
}
|
|
349
|
+
catch (error) {
|
|
350
|
+
const abortUrl = fileMultipartUrl(baseUrl, machine, absolutePath);
|
|
351
|
+
abortUrl.searchParams.set('upload_id', uploadId);
|
|
352
|
+
await fetchImpl(abortUrl, { method: 'DELETE', headers: uploadHeaders(options) }).catch(() => { });
|
|
353
|
+
throw error;
|
|
354
|
+
}
|
|
355
|
+
}
|
|
172
356
|
/**
|
|
173
357
|
* Put bytes in the account's private prefix, under the path they came from.
|
|
174
358
|
*
|
|
@@ -177,7 +361,7 @@ function uploadHeaders(options) {
|
|
|
177
361
|
* the upload is not part of what was stored.
|
|
178
362
|
*/
|
|
179
363
|
export async function uploadFileBytes({ baseUrl, options, machine, absolutePath, contentType, modifiedAt, body, fetchImpl = fetch, }) {
|
|
180
|
-
const url =
|
|
364
|
+
const url = fileUploadUrl(baseUrl, machine, absolutePath);
|
|
181
365
|
const response = await fetchImpl(url, {
|
|
182
366
|
method: 'PUT',
|
|
183
367
|
headers: {
|
|
@@ -193,9 +377,31 @@ export async function uploadFileBytes({ baseUrl, options, machine, absolutePath,
|
|
|
193
377
|
return true;
|
|
194
378
|
}
|
|
195
379
|
/** Stream one on-disk file to R2 without retaining its body in the daemon. */
|
|
196
|
-
export async function uploadFileStream({ baseUrl, options, machine, absolutePath, contentType, bytes, modifiedAt, contentEtag, fetchImpl = fetch, }) {
|
|
197
|
-
|
|
198
|
-
|
|
380
|
+
export async function uploadFileStream({ baseUrl, options, machine, absolutePath, contentType, bytes, modifiedAt, contentEtag, onProgress, onFinalizing, fetchImpl = fetch, }) {
|
|
381
|
+
if (bytes > MAX_SINGLE_BROWSE_UPLOAD_BYTES) {
|
|
382
|
+
return uploadMultipartFile({
|
|
383
|
+
baseUrl,
|
|
384
|
+
options,
|
|
385
|
+
machine,
|
|
386
|
+
absolutePath,
|
|
387
|
+
contentType,
|
|
388
|
+
bytes,
|
|
389
|
+
modifiedAt,
|
|
390
|
+
contentEtag,
|
|
391
|
+
onProgress,
|
|
392
|
+
onFinalizing,
|
|
393
|
+
fetchImpl,
|
|
394
|
+
});
|
|
395
|
+
}
|
|
396
|
+
const url = fileUploadUrl(baseUrl, machine, absolutePath);
|
|
397
|
+
let finalizing = false;
|
|
398
|
+
const stream = progressStream(absolutePath, 0, bytes, (uploadedBytes) => {
|
|
399
|
+
onProgress?.(uploadedBytes);
|
|
400
|
+
if (!finalizing && uploadedBytes >= bytes) {
|
|
401
|
+
finalizing = true;
|
|
402
|
+
onFinalizing?.();
|
|
403
|
+
}
|
|
404
|
+
});
|
|
199
405
|
try {
|
|
200
406
|
const response = await fetchImpl(url, {
|
|
201
407
|
method: 'PUT',
|
|
@@ -206,7 +412,7 @@ export async function uploadFileStream({ baseUrl, options, machine, absolutePath
|
|
|
206
412
|
...(modifiedAt ? { 'x-turns-file-modified-at': modifiedAt } : {}),
|
|
207
413
|
...(contentEtag ? { 'x-turns-content-etag': contentEtag } : {}),
|
|
208
414
|
},
|
|
209
|
-
body,
|
|
415
|
+
body: stream.body,
|
|
210
416
|
// Node requires this opt-in for a streaming request body.
|
|
211
417
|
duplex: 'half',
|
|
212
418
|
});
|
|
@@ -215,7 +421,7 @@ export async function uploadFileStream({ baseUrl, options, machine, absolutePath
|
|
|
215
421
|
return true;
|
|
216
422
|
}
|
|
217
423
|
finally {
|
|
218
|
-
|
|
424
|
+
stream.destroy();
|
|
219
425
|
}
|
|
220
426
|
}
|
|
221
427
|
/** Send one resolved file to the account's private prefix. */
|
package/dist/queue-worker.js
CHANGED
|
@@ -6,9 +6,10 @@ import { Readable } from 'node:stream';
|
|
|
6
6
|
import { pipeline } from 'node:stream/promises';
|
|
7
7
|
import { anchorId, connectExistingAgentAnchors, launchAgentAnchor, waitForAgentTurn } from "./agent-anchor-client.js";
|
|
8
8
|
import { assertAllowedWorkingDirectory, workingDirectoryIsAllowed } from "./directory-policy.js";
|
|
9
|
-
import { browseMachinePath } from "./file-browsing.js";
|
|
9
|
+
import { browseMachinePath, streamMachineFileRange } from "./file-browsing.js";
|
|
10
10
|
import { applyFileEdit } from "./file-edits.js";
|
|
11
11
|
import { searchMachinePaths } from "./file-search.js";
|
|
12
|
+
import { readMachineGitBaseBlob, readMachineGitPatch, readMachineGitStatus } from "./git-status.js";
|
|
12
13
|
import { nativeDescriptorAgentSessionId, nativeDescriptorWorkingDirectory } from "./local-session-sources.js";
|
|
13
14
|
import { compactRuntimeValue } from "./native-record-compaction.js";
|
|
14
15
|
import { createSecretRedactor } from "./secret-redaction.js";
|
|
@@ -1051,6 +1052,14 @@ export async function watchQueuedPrompts({ rpc, options, machine, getDescriptors
|
|
|
1051
1052
|
baseUrl,
|
|
1052
1053
|
options,
|
|
1053
1054
|
workingDirectory: await browseWorkingDirectory(sessionId),
|
|
1055
|
+
onUploadProgress: (progress) => {
|
|
1056
|
+
replyRpc.notify({
|
|
1057
|
+
type: 'session.file.browse.progress',
|
|
1058
|
+
requestId: message.requestId,
|
|
1059
|
+
sessionId,
|
|
1060
|
+
...progress,
|
|
1061
|
+
});
|
|
1062
|
+
},
|
|
1054
1063
|
});
|
|
1055
1064
|
replyRpc.notify({
|
|
1056
1065
|
type: 'session.file.browse.result',
|
|
@@ -1070,6 +1079,57 @@ export async function watchQueuedPrompts({ rpc, options, machine, getDescriptors
|
|
|
1070
1079
|
});
|
|
1071
1080
|
}
|
|
1072
1081
|
};
|
|
1082
|
+
/** Stream a small file window over the control socket. This never writes R2;
|
|
1083
|
+
* range-based viewers can therefore paint before a multi-gigabyte upload. */
|
|
1084
|
+
const handleFileRange = async (message, replyRpc) => {
|
|
1085
|
+
const sessionId = String(message.sessionId || '');
|
|
1086
|
+
let canonicalPath = '';
|
|
1087
|
+
try {
|
|
1088
|
+
await streamMachineFileRange({
|
|
1089
|
+
requested: String(message.path || ''),
|
|
1090
|
+
offset: Number(message.offset),
|
|
1091
|
+
length: Number(message.length),
|
|
1092
|
+
workingDirectory: await browseWorkingDirectory(sessionId),
|
|
1093
|
+
allowedDirectories: options.allowedDirectories || [],
|
|
1094
|
+
onReady: (range) => {
|
|
1095
|
+
canonicalPath = range.path;
|
|
1096
|
+
replyRpc.notify({
|
|
1097
|
+
type: 'session.file.range.ready',
|
|
1098
|
+
requestId: message.requestId,
|
|
1099
|
+
sessionId,
|
|
1100
|
+
...range,
|
|
1101
|
+
});
|
|
1102
|
+
},
|
|
1103
|
+
onChunk: (offset, bytes) => {
|
|
1104
|
+
replyRpc.notify({
|
|
1105
|
+
type: 'session.file.range.chunk',
|
|
1106
|
+
requestId: message.requestId,
|
|
1107
|
+
sessionId,
|
|
1108
|
+
path: canonicalPath,
|
|
1109
|
+
offset,
|
|
1110
|
+
data: Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength).toString('base64'),
|
|
1111
|
+
});
|
|
1112
|
+
},
|
|
1113
|
+
});
|
|
1114
|
+
replyRpc.notify({
|
|
1115
|
+
type: 'session.file.range.result',
|
|
1116
|
+
requestId: message.requestId,
|
|
1117
|
+
sessionId,
|
|
1118
|
+
path: canonicalPath,
|
|
1119
|
+
ok: true,
|
|
1120
|
+
});
|
|
1121
|
+
}
|
|
1122
|
+
catch (error) {
|
|
1123
|
+
replyRpc.notify({
|
|
1124
|
+
type: 'session.file.range.result',
|
|
1125
|
+
requestId: message.requestId,
|
|
1126
|
+
sessionId,
|
|
1127
|
+
...(canonicalPath ? { path: canonicalPath } : {}),
|
|
1128
|
+
ok: false,
|
|
1129
|
+
error: redactor.redactText(error?.message || String(error)),
|
|
1130
|
+
});
|
|
1131
|
+
}
|
|
1132
|
+
};
|
|
1073
1133
|
/** Refresh recursive path-name matches for directory typeahead.
|
|
1074
1134
|
*
|
|
1075
1135
|
* Like browsing, this is a machine filesystem operation rather than a
|
|
@@ -1103,6 +1163,49 @@ export async function watchQueuedPrompts({ rpc, options, machine, getDescriptors
|
|
|
1103
1163
|
});
|
|
1104
1164
|
}
|
|
1105
1165
|
};
|
|
1166
|
+
/**
|
|
1167
|
+
* Answer one git question about a path on this machine.
|
|
1168
|
+
*
|
|
1169
|
+
* Like browsing and searching, this is a property of the filesystem rather
|
|
1170
|
+
* than of a harness, so a stopped session's tree remains readable. A path
|
|
1171
|
+
* that is not inside a work tree answers `ok` with nothing attached: the
|
|
1172
|
+
* browser needs to tell "not a repository" apart from "the machine failed",
|
|
1173
|
+
* and only the first should leave its git column quietly empty.
|
|
1174
|
+
*/
|
|
1175
|
+
const handleGitRead = async (message, replyRpc) => {
|
|
1176
|
+
const sessionId = String(message.sessionId || '');
|
|
1177
|
+
const want = message.want === 'diff' ? 'diff' : message.want === 'base' ? 'base' : 'status';
|
|
1178
|
+
try {
|
|
1179
|
+
const input = {
|
|
1180
|
+
requested: String(message.path || ''),
|
|
1181
|
+
workingDirectory: await browseWorkingDirectory(sessionId),
|
|
1182
|
+
allowedDirectories: options.allowedDirectories || [],
|
|
1183
|
+
};
|
|
1184
|
+
const git = want === 'diff'
|
|
1185
|
+
? await readMachineGitPatch(input).then((patch) => (patch ? { patch } : null))
|
|
1186
|
+
: want === 'base'
|
|
1187
|
+
? await readMachineGitBaseBlob(input).then((base) => (base ? { base } : null))
|
|
1188
|
+
: await readMachineGitStatus(input).then((status) => (status ? { status } : null));
|
|
1189
|
+
replyRpc.notify({
|
|
1190
|
+
type: 'session.git.read.result',
|
|
1191
|
+
requestId: message.requestId,
|
|
1192
|
+
sessionId,
|
|
1193
|
+
ok: true,
|
|
1194
|
+
want,
|
|
1195
|
+
git,
|
|
1196
|
+
});
|
|
1197
|
+
}
|
|
1198
|
+
catch (error) {
|
|
1199
|
+
replyRpc.notify({
|
|
1200
|
+
type: 'session.git.read.result',
|
|
1201
|
+
requestId: message.requestId,
|
|
1202
|
+
sessionId,
|
|
1203
|
+
ok: false,
|
|
1204
|
+
want,
|
|
1205
|
+
error: redactor.redactText(error?.message || String(error)),
|
|
1206
|
+
});
|
|
1207
|
+
}
|
|
1208
|
+
};
|
|
1106
1209
|
/**
|
|
1107
1210
|
* Apply one queued browser edit, and report how it went.
|
|
1108
1211
|
*
|
|
@@ -1173,8 +1276,12 @@ export async function watchQueuedPrompts({ rpc, options, machine, getDescriptors
|
|
|
1173
1276
|
void handleInteractionAnswer(message, rpc);
|
|
1174
1277
|
if (message.type === 'session.file.browse.request')
|
|
1175
1278
|
void handleFileBrowse(message, rpc);
|
|
1279
|
+
if (message.type === 'session.file.range.request')
|
|
1280
|
+
void handleFileRange(message, rpc);
|
|
1176
1281
|
if (message.type === 'session.file.search.request')
|
|
1177
1282
|
void handleFileSearch(message, rpc);
|
|
1283
|
+
if (message.type === 'session.git.read.request')
|
|
1284
|
+
void handleGitRead(message, rpc);
|
|
1178
1285
|
if (message.type === 'session.file.edit.request')
|
|
1179
1286
|
void handleFileEdit(message, rpc);
|
|
1180
1287
|
});
|
|
@@ -1189,7 +1296,7 @@ export async function watchQueuedPrompts({ rpc, options, machine, getDescriptors
|
|
|
1189
1296
|
type: 'machine.register',
|
|
1190
1297
|
machineId: machine,
|
|
1191
1298
|
machineHostname: os.hostname(),
|
|
1192
|
-
capabilities: { fileSearch: true },
|
|
1299
|
+
capabilities: { fileSearch: true, git: true, fileRanges: true },
|
|
1193
1300
|
workerId,
|
|
1194
1301
|
workerInstanceId,
|
|
1195
1302
|
anchorSessionIds: [...anchors.keys()],
|
package/dist/upload-sessions.js
CHANGED
|
@@ -946,8 +946,9 @@ function gitAssociationForSession(cwd, redactor) {
|
|
|
946
946
|
* @param {number} [options.maxBatches]
|
|
947
947
|
* @param {() => boolean} [options.shouldYield]
|
|
948
948
|
* @param {boolean} [options.recoverRemoteAhead]
|
|
949
|
+
* @param {boolean} [options.forceReset]
|
|
949
950
|
*/
|
|
950
|
-
export async function uploadNativeSession(rpc, machine, descriptor, cursor = {}, onProgress = () => { }, redactor = DEFAULT_REDACTOR, { maxBatches = Number.POSITIVE_INFINITY, shouldYield = () => false, recoverRemoteAhead = true, onPathCandidates = (_sessionId, _workingDirectory, _candidates) => { }, } = {}) {
|
|
951
|
+
export async function uploadNativeSession(rpc, machine, descriptor, cursor = {}, onProgress = () => { }, redactor = DEFAULT_REDACTOR, { maxBatches = Number.POSITIVE_INFINITY, shouldYield = () => false, recoverRemoteAhead = true, forceReset = false, onPathCandidates = (_sessionId, _workingDirectory, _candidates) => { }, } = {}) {
|
|
951
952
|
const workingDirectory = await nativeDescriptorWorkingDirectory(descriptor);
|
|
952
953
|
const agentSessionId = await nativeDescriptorAgentSessionId(descriptor);
|
|
953
954
|
const git = gitAssociationForSession(workingDirectory, redactor);
|
|
@@ -976,7 +977,14 @@ export async function uploadNativeSession(rpc, machine, descriptor, cursor = {},
|
|
|
976
977
|
const uploadSessionId = String(opened.sessionId || sessionId);
|
|
977
978
|
let nextRow = Number(opened.nextRow) || 0;
|
|
978
979
|
let initialUpload = nextRow === 0 || cursor.initialUpload === true;
|
|
979
|
-
if (
|
|
980
|
+
if (forceReset && nextRow > 0) {
|
|
981
|
+
await rpc.request({ type: 'session.reset', sessionId: uploadSessionId, redactionVersion: REDACTION_VERSION });
|
|
982
|
+
nextRow = 0;
|
|
983
|
+
for (const key of Object.keys(cursor))
|
|
984
|
+
delete cursor[key];
|
|
985
|
+
initialUpload = true;
|
|
986
|
+
}
|
|
987
|
+
else if (Number(opened.redactionVersion || 0) !== REDACTION_VERSION) {
|
|
980
988
|
await rpc.request({ type: 'session.reset', sessionId: uploadSessionId, redactionVersion: REDACTION_VERSION });
|
|
981
989
|
nextRow = 0;
|
|
982
990
|
for (const key of Object.keys(cursor))
|