@opengeni/runtime 0.6.0 → 0.7.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/dist/{chunk-BHA2SU3H.js → chunk-RYDCVTMW.js} +1959 -208
- package/dist/chunk-RYDCVTMW.js.map +1 -0
- package/dist/{index-otZkoLYq.d.ts → index-DIiiwQY2.d.ts} +693 -55
- package/dist/index.d.ts +293 -121
- package/dist/index.js +1819 -786
- package/dist/index.js.map +1 -1
- package/dist/sandbox/index.d.ts +1 -1
- package/dist/sandbox/index.js +79 -3
- package/package.json +21 -20
- package/src/codex-tool-search.ts +185 -31
- package/src/context-compaction.ts +78 -243
- package/src/history-sanitizer.ts +172 -20
- package/src/index.ts +2573 -897
- package/src/metrics.ts +28 -2
- package/src/sandbox/capabilities.ts +12 -3
- package/src/sandbox/channel-a.ts +420 -72
- package/src/sandbox/display-stack.ts +18 -4
- package/src/sandbox/errors.ts +13 -0
- package/src/sandbox/index.ts +398 -112
- package/src/sandbox/op-correlation.ts +70 -0
- package/src/sandbox/providers/index.ts +9 -3
- package/src/sandbox/providers/modal.ts +99 -24
- package/src/sandbox/providers/selfhosted.ts +1 -4
- package/src/sandbox/recording.ts +146 -90
- package/src/sandbox/routing/backend-resolver.ts +144 -10
- package/src/sandbox/routing/routing-session.ts +129 -25
- package/src/sandbox/select.ts +60 -8
- package/src/sandbox/selfhosted/capabilities.ts +59 -25
- package/src/sandbox/selfhosted/control-rpc.ts +160 -28
- package/src/sandbox/selfhosted/fault-rendering.ts +226 -0
- package/src/sandbox/selfhosted/op-observer.ts +54 -0
- package/src/sandbox/selfhosted/op-stream.ts +801 -0
- package/src/sandbox/selfhosted/op-testing.ts +468 -0
- package/src/sandbox/selfhosted/op-transport.ts +151 -0
- package/src/sandbox/selfhosted/retry-policy.ts +177 -0
- package/src/sandbox/selfhosted/session.ts +498 -55
- package/src/sandbox/selfhosted/testing.ts +60 -19
- package/src/sandbox/stream-port.ts +17 -5
- package/src/sandbox/stream-token.ts +8 -2
- package/src/sandbox/terminal-server.ts +18 -4
- package/src/sandbox-computer.ts +332 -70
- package/src/usage-telemetry.ts +73 -0
- package/dist/chunk-BHA2SU3H.js.map +0 -1
- package/src/image-history.ts +0 -149
package/src/sandbox/channel-a.ts
CHANGED
|
@@ -39,7 +39,6 @@ import type {
|
|
|
39
39
|
GitChangedPayload,
|
|
40
40
|
GitCommit,
|
|
41
41
|
GitDiffHunk,
|
|
42
|
-
GitDiffLine,
|
|
43
42
|
GitDiffRequest,
|
|
44
43
|
GitDiffResponse,
|
|
45
44
|
GitFileDiff,
|
|
@@ -90,27 +89,50 @@ export type ChannelAEditor = {
|
|
|
90
89
|
export type ChannelASession = {
|
|
91
90
|
exec?(args: ChannelAExecArgs): Promise<ChannelAExecResult>;
|
|
92
91
|
execCommand?(args: ChannelAExecArgs): Promise<string>;
|
|
93
|
-
readFile?(args: {
|
|
94
|
-
|
|
92
|
+
readFile?(args: {
|
|
93
|
+
path: string;
|
|
94
|
+
runAs?: string;
|
|
95
|
+
maxBytes?: number;
|
|
96
|
+
}): Promise<string | Uint8Array>;
|
|
97
|
+
writeStdin?(args: {
|
|
98
|
+
sessionId: number;
|
|
99
|
+
chars?: string;
|
|
100
|
+
yieldTimeMs?: number;
|
|
101
|
+
maxOutputTokens?: number;
|
|
102
|
+
}): Promise<string>;
|
|
95
103
|
createEditor?(runAs?: string): ChannelAEditor;
|
|
96
104
|
supportsPty?(): boolean;
|
|
97
105
|
};
|
|
98
106
|
|
|
99
107
|
// ── Errors mapped to HTTP status at the route. ───────────────────────────────
|
|
100
108
|
export class ChannelAValidationError extends Error {
|
|
101
|
-
constructor(message: string) {
|
|
109
|
+
constructor(message: string) {
|
|
110
|
+
super(message);
|
|
111
|
+
this.name = "ChannelAValidationError";
|
|
112
|
+
}
|
|
102
113
|
}
|
|
103
114
|
export class ChannelAConflictError extends Error {
|
|
104
|
-
constructor(message: string) {
|
|
115
|
+
constructor(message: string) {
|
|
116
|
+
super(message);
|
|
117
|
+
this.name = "ChannelAConflictError";
|
|
118
|
+
}
|
|
105
119
|
}
|
|
106
120
|
export class ChannelANotFoundError extends Error {
|
|
107
|
-
constructor(message: string) {
|
|
121
|
+
constructor(message: string) {
|
|
122
|
+
super(message);
|
|
123
|
+
this.name = "ChannelANotFoundError";
|
|
124
|
+
}
|
|
108
125
|
}
|
|
109
126
|
export class ChannelAUnsupportedError extends Error {
|
|
110
|
-
constructor(message: string) {
|
|
127
|
+
constructor(message: string) {
|
|
128
|
+
super(message);
|
|
129
|
+
this.name = "ChannelAUnsupportedError";
|
|
130
|
+
}
|
|
111
131
|
}
|
|
112
132
|
|
|
113
|
-
export type ChannelAEmitter = (
|
|
133
|
+
export type ChannelAEmitter = (
|
|
134
|
+
events: { type: SessionEventType; payload: unknown }[],
|
|
135
|
+
) => Promise<void>;
|
|
114
136
|
|
|
115
137
|
export type SandboxChannelAServiceOptions = {
|
|
116
138
|
session: ChannelASession;
|
|
@@ -133,9 +155,24 @@ export type SandboxChannelAServiceOptions = {
|
|
|
133
155
|
runAs?: string;
|
|
134
156
|
};
|
|
135
157
|
|
|
158
|
+
export const REPOSITORY_DISCOVERY_LIMIT = 256;
|
|
159
|
+
export type RepositoryDiscoveryDegradedReason =
|
|
160
|
+
| "command_failed"
|
|
161
|
+
| "command_timed_out"
|
|
162
|
+
| "result_limit_exceeded";
|
|
163
|
+
export type RepositoryDiscoveryResult = {
|
|
164
|
+
repos: string[];
|
|
165
|
+
complete: boolean;
|
|
166
|
+
degradedReason: RepositoryDiscoveryDegradedReason | null;
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
const REPOSITORY_DISCOVERY_TRUNCATED_SENTINEL = "__OPENGENI_REPOSITORY_DISCOVERY_TRUNCATED__";
|
|
170
|
+
const REPOSITORY_DISCOVERY_STATUS_PREFIX = "__OPENGENI_REPOSITORY_DISCOVERY_STATUS__:";
|
|
171
|
+
|
|
136
172
|
const NUL = String.fromCharCode(0); // \0 NUL — find/porcelain/numstat -z separator
|
|
137
173
|
const US = String.fromCharCode(0x1f); // \x1f unit sep — git-log field separator
|
|
138
174
|
const RS = String.fromCharCode(0x1e); // \x1e record sep — git-log record separator
|
|
175
|
+
const SELFHOSTED_VIRTUAL_ROOT = "/workspace";
|
|
139
176
|
|
|
140
177
|
export class SandboxChannelAService {
|
|
141
178
|
private readonly session: ChannelASession;
|
|
@@ -160,7 +197,11 @@ export class SandboxChannelAService {
|
|
|
160
197
|
const hasExec = Boolean(s.exec || s.execCommand);
|
|
161
198
|
const hasFs = Boolean(s.readFile && (s.exec || s.execCommand || s.createEditor));
|
|
162
199
|
return {
|
|
163
|
-
FileSystem: {
|
|
200
|
+
FileSystem: {
|
|
201
|
+
available: hasFs,
|
|
202
|
+
readOnly: !(s.exec || s.createEditor),
|
|
203
|
+
root: this.workspaceRoot,
|
|
204
|
+
},
|
|
164
205
|
Terminal: {
|
|
165
206
|
events: hasExec,
|
|
166
207
|
exec: hasExec,
|
|
@@ -175,7 +216,13 @@ export class SandboxChannelAService {
|
|
|
175
216
|
// (the local/docker sessions return raw output); falls back to execCommand +
|
|
176
217
|
// a banner strip (last resort; banner-truncation can mangle, so exec is always
|
|
177
218
|
// preferred). Throws ChannelAUnsupportedError when neither exists.
|
|
178
|
-
private async run(args: ChannelAExecArgs): Promise<{
|
|
219
|
+
private async run(args: ChannelAExecArgs): Promise<{
|
|
220
|
+
stdout: string;
|
|
221
|
+
stderr: string;
|
|
222
|
+
exitCode: number | null;
|
|
223
|
+
sessionId?: number;
|
|
224
|
+
wallTimeSeconds: number;
|
|
225
|
+
}> {
|
|
179
226
|
const withRunAs = this.runAs ? { ...args, runAs: this.runAs } : args;
|
|
180
227
|
if (this.session.exec) {
|
|
181
228
|
const r = await this.session.exec(withRunAs);
|
|
@@ -220,7 +267,10 @@ export class SandboxChannelAService {
|
|
|
220
267
|
const depthArg = Math.max(1, req.depth);
|
|
221
268
|
const hidden = req.includeHidden ? "" : ` -not -path '*/.*'`;
|
|
222
269
|
const gnuFind = `find ${findRoot} -mindepth 1 -maxdepth ${depthArg}${hidden} -printf '%y\\t%s\\t%T@\\t%m\\t%p\\0' 2>/dev/null`;
|
|
223
|
-
let { stdout } = await this.run({
|
|
270
|
+
let { stdout } = await this.run({
|
|
271
|
+
cmd: `bash -lc ${shellQuote(gnuFind)}`,
|
|
272
|
+
workdir: this.workspaceRoot || undefined,
|
|
273
|
+
});
|
|
224
274
|
if (!stdout) {
|
|
225
275
|
const portableFind = [
|
|
226
276
|
`find ${findRoot} -mindepth 1 -maxdepth ${depthArg}${hidden} -print0 2>/dev/null | while IFS= read -r -d '' p; do`,
|
|
@@ -230,7 +280,10 @@ export class SandboxChannelAService {
|
|
|
230
280
|
`printf '%s\\t%s\\t%s\\t%s\\t%s\\0' "$t" "$size" "$mtime" "$mode" "$p";`,
|
|
231
281
|
`done`,
|
|
232
282
|
].join(" ");
|
|
233
|
-
({ stdout } = await this.run({
|
|
283
|
+
({ stdout } = await this.run({
|
|
284
|
+
cmd: `bash -lc ${shellQuote(portableFind)}`,
|
|
285
|
+
workdir: this.workspaceRoot || undefined,
|
|
286
|
+
}));
|
|
234
287
|
}
|
|
235
288
|
|
|
236
289
|
const entries = stdout.split(NUL).filter((s) => s.length > 0);
|
|
@@ -250,7 +303,10 @@ export class SandboxChannelAService {
|
|
|
250
303
|
let count = 0;
|
|
251
304
|
let truncated = false;
|
|
252
305
|
for (const entry of entries) {
|
|
253
|
-
if (count >= req.maxEntries) {
|
|
306
|
+
if (count >= req.maxEntries) {
|
|
307
|
+
truncated = true;
|
|
308
|
+
break;
|
|
309
|
+
}
|
|
254
310
|
const parts = entry.split("\t");
|
|
255
311
|
if (parts.length < 5) continue;
|
|
256
312
|
const [typeChar, sizeStr, mtimeStr, modeStr, ...pathParts] = parts;
|
|
@@ -289,7 +345,11 @@ export class SandboxChannelAService {
|
|
|
289
345
|
}
|
|
290
346
|
let raw: string | Uint8Array;
|
|
291
347
|
try {
|
|
292
|
-
raw = await this.session.readFile({
|
|
348
|
+
raw = await this.session.readFile({
|
|
349
|
+
path: this.joinRoot(path),
|
|
350
|
+
maxBytes: req.maxBytes,
|
|
351
|
+
...(this.runAs ? { runAs: this.runAs } : {}),
|
|
352
|
+
});
|
|
293
353
|
} catch (error) {
|
|
294
354
|
// The provider's native readFile applies a REMOTE workspace-escape guard:
|
|
295
355
|
// a SYMLINK whose target resolves outside /workspace (e.g.
|
|
@@ -303,7 +363,9 @@ export class SandboxChannelAService {
|
|
|
303
363
|
if (isWorkspaceEscapeError(error)) {
|
|
304
364
|
return await this.fsReadViaExec(path, req);
|
|
305
365
|
}
|
|
306
|
-
throw new ChannelANotFoundError(
|
|
366
|
+
throw new ChannelANotFoundError(
|
|
367
|
+
`file not found: ${path} (${error instanceof Error ? error.message : String(error)})`,
|
|
368
|
+
);
|
|
307
369
|
}
|
|
308
370
|
const bytes = typeof raw === "string" ? Buffer.from(raw, "utf8") : Buffer.from(raw);
|
|
309
371
|
return this.shapeRead(path, bytes, req);
|
|
@@ -315,7 +377,9 @@ export class SandboxChannelAService {
|
|
|
315
377
|
* node itself is in-workspace). `base64 <path>` follows the symlink. */
|
|
316
378
|
private async fsReadViaExec(path: string, req: FsReadRequest): Promise<FsReadResponse> {
|
|
317
379
|
const abs = this.joinRoot(path);
|
|
318
|
-
const { stdout, exitCode } = await this.run({
|
|
380
|
+
const { stdout, exitCode } = await this.run({
|
|
381
|
+
cmd: `base64 ${shellQuote(abs)} 2>/dev/null | head -c ${Math.ceil(req.maxBytes * 1.4)}`,
|
|
382
|
+
});
|
|
319
383
|
if (exitCode !== null && exitCode !== 0 && stdout === "") {
|
|
320
384
|
// The target may be a dangling symlink or a link to a directory; surface a
|
|
321
385
|
// clean, typed not-found rather than a raw provider validation error.
|
|
@@ -344,7 +408,10 @@ export class SandboxChannelAService {
|
|
|
344
408
|
async fsWrite(req: FsWriteRequest): Promise<FsWriteResponse> {
|
|
345
409
|
const path = assertSafeRelPath(req.path);
|
|
346
410
|
const abs = this.joinRoot(path);
|
|
347
|
-
const bytes =
|
|
411
|
+
const bytes =
|
|
412
|
+
req.encoding === "base64"
|
|
413
|
+
? Buffer.from(req.content, "base64")
|
|
414
|
+
: Buffer.from(req.content, "utf8");
|
|
348
415
|
|
|
349
416
|
if (!req.overwrite) {
|
|
350
417
|
const { exitCode } = await this.run({ cmd: `test -e ${shellQuote(abs)}` });
|
|
@@ -369,13 +436,21 @@ export class SandboxChannelAService {
|
|
|
369
436
|
// text payload (binary cannot go through apply-patch).
|
|
370
437
|
if (req.encoding !== "base64" && this.session.createEditor) {
|
|
371
438
|
const ok = await this.tryEditorWrite(abs, req.content);
|
|
372
|
-
if (!ok)
|
|
439
|
+
if (!ok)
|
|
440
|
+
throw new ChannelAValidationError(
|
|
441
|
+
`failed to write ${path}: ${stderr || `exit ${exitCode}`}`,
|
|
442
|
+
);
|
|
373
443
|
} else {
|
|
374
|
-
throw new ChannelAValidationError(
|
|
444
|
+
throw new ChannelAValidationError(
|
|
445
|
+
`failed to write ${path}: ${stderr || `exit ${exitCode}`}`,
|
|
446
|
+
);
|
|
375
447
|
}
|
|
376
448
|
}
|
|
377
449
|
this.revision++;
|
|
378
|
-
await this.emitFsChanged(
|
|
450
|
+
await this.emitFsChanged(
|
|
451
|
+
[{ path, kind: "modified", isDir: false, sizeBytes: bytes.byteLength }],
|
|
452
|
+
"write",
|
|
453
|
+
);
|
|
379
454
|
return { path, sizeBytes: bytes.byteLength, revision: this.revision };
|
|
380
455
|
}
|
|
381
456
|
|
|
@@ -384,7 +459,10 @@ export class SandboxChannelAService {
|
|
|
384
459
|
if (!editor?.createFile) return false;
|
|
385
460
|
try {
|
|
386
461
|
// The apply-patch op shape — a whole-file "create" diff (last-writer-wins).
|
|
387
|
-
const diff = content
|
|
462
|
+
const diff = content
|
|
463
|
+
.split("\n")
|
|
464
|
+
.map((line) => `+${line}`)
|
|
465
|
+
.join("\n");
|
|
388
466
|
await editor.createFile({ type: "create_file", path: absPath, diff });
|
|
389
467
|
return true;
|
|
390
468
|
} catch {
|
|
@@ -398,7 +476,9 @@ export class SandboxChannelAService {
|
|
|
398
476
|
const flag = req.recursive ? "-rf" : "-f";
|
|
399
477
|
const { exitCode, stderr } = await this.run({ cmd: `rm ${flag} ${shellQuote(abs)}` });
|
|
400
478
|
if (exitCode !== null && exitCode !== 0) {
|
|
401
|
-
throw new ChannelAValidationError(
|
|
479
|
+
throw new ChannelAValidationError(
|
|
480
|
+
`failed to delete ${path}: ${stderr || `exit ${exitCode}`}`,
|
|
481
|
+
);
|
|
402
482
|
}
|
|
403
483
|
this.revision++;
|
|
404
484
|
await this.emitFsChanged([{ path, kind: "deleted", isDir: false, sizeBytes: null }], "write");
|
|
@@ -428,7 +508,9 @@ export class SandboxChannelAService {
|
|
|
428
508
|
cmd: `mv ${flag}${shellQuote(abs)} ${shellQuote(newAbs)}`,
|
|
429
509
|
});
|
|
430
510
|
if (exitCode !== null && exitCode !== 0) {
|
|
431
|
-
throw new ChannelAValidationError(
|
|
511
|
+
throw new ChannelAValidationError(
|
|
512
|
+
`failed to move ${path} -> ${newPath}: ${stderr || `exit ${exitCode}`}`,
|
|
513
|
+
);
|
|
432
514
|
}
|
|
433
515
|
this.revision++;
|
|
434
516
|
await this.emitFsChanged(
|
|
@@ -460,11 +542,26 @@ export class SandboxChannelAService {
|
|
|
460
542
|
|
|
461
543
|
async gitStatus(req: GitStatusRequest): Promise<GitStatusResponse> {
|
|
462
544
|
const repo = this.repoWorkdir(req.path);
|
|
463
|
-
const inside = await this.run({
|
|
545
|
+
const inside = await this.run({
|
|
546
|
+
cmd: "git rev-parse --is-inside-work-tree 2>/dev/null",
|
|
547
|
+
workdir: repo,
|
|
548
|
+
});
|
|
464
549
|
if (inside.stdout.trim() !== "true") {
|
|
465
|
-
return {
|
|
550
|
+
return {
|
|
551
|
+
isRepo: false,
|
|
552
|
+
head: null,
|
|
553
|
+
detached: false,
|
|
554
|
+
upstream: null,
|
|
555
|
+
ahead: 0,
|
|
556
|
+
behind: 0,
|
|
557
|
+
files: [],
|
|
558
|
+
revision: this.revision,
|
|
559
|
+
};
|
|
466
560
|
}
|
|
467
|
-
const { stdout } = await this.run({
|
|
561
|
+
const { stdout } = await this.run({
|
|
562
|
+
cmd: "git status --porcelain=v2 --branch -z",
|
|
563
|
+
workdir: repo,
|
|
564
|
+
});
|
|
468
565
|
return { ...parsePorcelainV2(stdout), revision: this.revision };
|
|
469
566
|
}
|
|
470
567
|
|
|
@@ -480,7 +577,10 @@ export class SandboxChannelAService {
|
|
|
480
577
|
|
|
481
578
|
// Pass 1: numstat (stats + binary detection). -z gives NUL-separated fields;
|
|
482
579
|
// a rename emits old\0new for that record's path fields.
|
|
483
|
-
const numstat = await this.run({
|
|
580
|
+
const numstat = await this.run({
|
|
581
|
+
cmd: `git -c core.quotePath=false diff --no-color -z --numstat ${range}${pathspec}`.trim(),
|
|
582
|
+
workdir: repo,
|
|
583
|
+
});
|
|
484
584
|
const stats = parseNumstatZ(numstat.stdout);
|
|
485
585
|
|
|
486
586
|
const files: GitFileDiff[] = [];
|
|
@@ -507,7 +607,9 @@ export class SandboxChannelAService {
|
|
|
507
607
|
workdir: repo,
|
|
508
608
|
});
|
|
509
609
|
const oversized = Buffer.byteLength(patch.stdout, "utf8") > req.maxBytesPerFile;
|
|
510
|
-
const parsed = oversized
|
|
610
|
+
const parsed = oversized
|
|
611
|
+
? { hunks: [] as GitDiffHunk[], status: "modified" as GitFileStatusCode }
|
|
612
|
+
: parseUnifiedPatch(patch.stdout);
|
|
511
613
|
files.push({
|
|
512
614
|
path: target,
|
|
513
615
|
oldPath: stat.oldPath,
|
|
@@ -534,7 +636,10 @@ export class SandboxChannelAService {
|
|
|
534
636
|
if (exitCode !== null && exitCode !== 0) {
|
|
535
637
|
return { commits: [], hasMore: false };
|
|
536
638
|
}
|
|
537
|
-
const records = stdout
|
|
639
|
+
const records = stdout
|
|
640
|
+
.split(RS)
|
|
641
|
+
.map((r) => r.replace(/^\n/, ""))
|
|
642
|
+
.filter((r) => r.trim().length > 0);
|
|
538
643
|
const commits: GitCommit[] = [];
|
|
539
644
|
for (const rec of records.slice(0, req.maxCount)) {
|
|
540
645
|
const f = rec.split(US);
|
|
@@ -572,27 +677,149 @@ export class SandboxChannelAService {
|
|
|
572
677
|
return {
|
|
573
678
|
commit: null,
|
|
574
679
|
files: [],
|
|
575
|
-
blob: {
|
|
680
|
+
blob: {
|
|
681
|
+
content: encoding === "base64" ? clamped.toString("base64") : clamped.toString("utf8"),
|
|
682
|
+
encoding,
|
|
683
|
+
sizeBytes: clamped.byteLength,
|
|
684
|
+
truncated,
|
|
685
|
+
},
|
|
576
686
|
revision: this.revision,
|
|
577
687
|
};
|
|
578
688
|
}
|
|
579
689
|
// Commit mode: metadata + diff vs first parent.
|
|
580
|
-
const log = await this.gitLog({
|
|
690
|
+
const log = await this.gitLog({
|
|
691
|
+
path: req.path,
|
|
692
|
+
ref: req.ref,
|
|
693
|
+
maxCount: 1,
|
|
694
|
+
skip: 0,
|
|
695
|
+
pathspec: [],
|
|
696
|
+
});
|
|
581
697
|
const commit = log.commits[0] ?? null;
|
|
582
|
-
const diff = await this.gitDiff({
|
|
698
|
+
const diff = await this.gitDiff({
|
|
699
|
+
path: req.path,
|
|
700
|
+
staged: false,
|
|
701
|
+
fromRef: `${req.ref}^`,
|
|
702
|
+
toRef: req.ref,
|
|
703
|
+
pathspec: [],
|
|
704
|
+
contextLines: 3,
|
|
705
|
+
maxBytesPerFile: req.maxBytesPerFile,
|
|
706
|
+
});
|
|
583
707
|
return { commit, files: diff.files, blob: null, revision: this.revision };
|
|
584
708
|
}
|
|
585
709
|
|
|
586
|
-
/**
|
|
587
|
-
|
|
710
|
+
/**
|
|
711
|
+
* Detect repo roots within every supported workspace layout.
|
|
712
|
+
*
|
|
713
|
+
* The platform seeds GitHub repositories at
|
|
714
|
+
* `repos/<owner>/<repo>`, which puts the `.git` marker at depth four from the
|
|
715
|
+
* workspace root. Do not reintroduce a fixed maxdepth here. Traversal is
|
|
716
|
+
* bounded instead by pruning known machine/build residue, a wall-clock
|
|
717
|
+
* timeout, and a result limit. `.git` may be either a directory (ordinary
|
|
718
|
+
* clone/submodule) or a file (linked worktree).
|
|
719
|
+
*
|
|
720
|
+
* Callers that persist an authoritative snapshot must use this detailed
|
|
721
|
+
* result and surface an incomplete discovery. The compact `detectRepos()`
|
|
722
|
+
* wrapper remains for capability negotiation, where the best available list
|
|
723
|
+
* is preferable to failing the whole capabilities document.
|
|
724
|
+
*/
|
|
725
|
+
async detectReposDetailed(): Promise<RepositoryDiscoveryResult> {
|
|
588
726
|
try {
|
|
589
|
-
const
|
|
590
|
-
|
|
727
|
+
const discovery = [
|
|
728
|
+
"find . -xdev",
|
|
729
|
+
"\\( -name .git \\( -type d -o -type f \\) -print -prune \\)",
|
|
730
|
+
"-o \\( -type d \\( -name node_modules -o -name .cache -o -name .local",
|
|
731
|
+
"-o -name dist -o -name build -o -name target -o -name .venv",
|
|
732
|
+
"-o -name __pycache__ -o -name .next \\) -prune \\)",
|
|
733
|
+
"2>/dev/null",
|
|
734
|
+
].join(" ");
|
|
735
|
+
// The status trailer is necessary for providers whose only structural
|
|
736
|
+
// surface is `execCommand`: that fallback has stdout but no numeric exit
|
|
737
|
+
// code. Without the trailer a timed-out discovery could look like a
|
|
738
|
+
// successful authoritative zero-repo result.
|
|
739
|
+
//
|
|
740
|
+
// Do not use GNU `timeout` here. Connected Machines include macOS, whose
|
|
741
|
+
// stock userland does not provide it. The watchdog kills `find` directly
|
|
742
|
+
// after the wall-clock budget and records whether that kill won the race.
|
|
743
|
+
// Its stdio is detached so cancelling a fast discovery cannot leave the
|
|
744
|
+
// watchdog's `sleep` holding the provider output pipe open.
|
|
745
|
+
const command = [
|
|
746
|
+
'results_file=$(mktemp "${TMPDIR:-/tmp}/opengeni-repository-discovery.XXXXXX") || exit 70',
|
|
747
|
+
'timeout_file="${results_file}.timed-out"',
|
|
748
|
+
"discovery_pid=",
|
|
749
|
+
"watchdog_pid=",
|
|
750
|
+
'cleanup() { if [ -n "$discovery_pid" ]; then kill "$discovery_pid" 2>/dev/null || true; fi; if [ -n "$watchdog_pid" ]; then kill "$watchdog_pid" 2>/dev/null || true; fi; rm -f "$results_file" "$timeout_file"; }',
|
|
751
|
+
"abort() { trap - EXIT; cleanup; exit 143; }",
|
|
752
|
+
"trap cleanup EXIT",
|
|
753
|
+
"trap abort HUP INT TERM",
|
|
754
|
+
`${discovery} > "$results_file" &`,
|
|
755
|
+
"discovery_pid=$!",
|
|
756
|
+
'(sleeper_pid=; stop_watchdog() { if [ -n "$sleeper_pid" ]; then kill "$sleeper_pid" 2>/dev/null || true; wait "$sleeper_pid" 2>/dev/null || true; fi; exit 0; }; trap stop_watchdog HUP INT TERM; sleep 15 & sleeper_pid=$!; wait "$sleeper_pid"; sleeper_pid=; if kill -TERM "$discovery_pid" 2>/dev/null; then : > "$timeout_file"; fi) </dev/null >/dev/null 2>&1 &',
|
|
757
|
+
"watchdog_pid=$!",
|
|
758
|
+
'wait "$discovery_pid"',
|
|
759
|
+
"status=$?",
|
|
760
|
+
"discovery_pid=",
|
|
761
|
+
'kill "$watchdog_pid" 2>/dev/null || true',
|
|
762
|
+
'wait "$watchdog_pid" 2>/dev/null || true',
|
|
763
|
+
"watchdog_pid=",
|
|
764
|
+
'if [ "$status" -ne 0 ] && [ -f "$timeout_file" ]; then status=124; fi',
|
|
765
|
+
`if [ "$status" -eq 0 ]; then awk 'NR <= ${REPOSITORY_DISCOVERY_LIMIT} { print } NR == ${
|
|
766
|
+
REPOSITORY_DISCOVERY_LIMIT + 1
|
|
767
|
+
} { print "${REPOSITORY_DISCOVERY_TRUNCATED_SENTINEL}" }' "$results_file"; fi`,
|
|
768
|
+
`printf '\\n${REPOSITORY_DISCOVERY_STATUS_PREFIX}%s\\n' "$status"`,
|
|
769
|
+
'exit "$status"',
|
|
770
|
+
].join("\n");
|
|
771
|
+
const { stdout } = await this.run({
|
|
772
|
+
cmd: `bash -lc ${shellQuote(command)}`,
|
|
773
|
+
workdir: this.workspaceRoot || undefined,
|
|
774
|
+
yieldTimeMs: 20_000,
|
|
775
|
+
// At most 256 Unix paths (PATH_MAX each) plus the status trailer. This
|
|
776
|
+
// prevents a provider's ordinary output cap from dropping the trailer
|
|
777
|
+
// and making a partial list look complete.
|
|
778
|
+
maxOutputTokens: 300_000,
|
|
779
|
+
});
|
|
780
|
+
const statusLine = stdout
|
|
781
|
+
.split("\n")
|
|
782
|
+
.find((line) => line.startsWith(REPOSITORY_DISCOVERY_STATUS_PREFIX));
|
|
783
|
+
const embeddedStatus = statusLine
|
|
784
|
+
? Number.parseInt(statusLine.slice(REPOSITORY_DISCOVERY_STATUS_PREFIX.length), 10)
|
|
785
|
+
: null;
|
|
786
|
+
// The command always prints this trailer after discovery settles. Missing
|
|
787
|
+
// means provider-side output truncation or an outer-shell failure, neither
|
|
788
|
+
// of which is authoritative discovery even if exec reports exit 0.
|
|
789
|
+
const status = Number.isInteger(embeddedStatus) ? embeddedStatus : null;
|
|
790
|
+
if (status === 124) {
|
|
791
|
+
return { repos: [], complete: false, degradedReason: "command_timed_out" };
|
|
792
|
+
}
|
|
793
|
+
if (status !== 0) {
|
|
794
|
+
return { repos: [], complete: false, degradedReason: "command_failed" };
|
|
795
|
+
}
|
|
796
|
+
const lines = stdout
|
|
797
|
+
.split("\n")
|
|
798
|
+
.map((l) => l.trim())
|
|
799
|
+
.filter((line) => Boolean(line) && !line.startsWith(REPOSITORY_DISCOVERY_STATUS_PREFIX));
|
|
800
|
+
const truncated = lines.includes(REPOSITORY_DISCOVERY_TRUNCATED_SENTINEL);
|
|
801
|
+
const repos = [
|
|
802
|
+
...new Set(
|
|
803
|
+
lines
|
|
804
|
+
.filter((line) => line !== REPOSITORY_DISCOVERY_TRUNCATED_SENTINEL)
|
|
805
|
+
.map((gitMarker) => dirnameAbs(stripDotSlash(gitMarker, "")) || ""),
|
|
806
|
+
),
|
|
807
|
+
].sort();
|
|
808
|
+
return {
|
|
809
|
+
repos,
|
|
810
|
+
complete: !truncated,
|
|
811
|
+
degradedReason: truncated ? "result_limit_exceeded" : null,
|
|
812
|
+
};
|
|
591
813
|
} catch {
|
|
592
|
-
return [];
|
|
814
|
+
return { repos: [], complete: false, degradedReason: "command_failed" };
|
|
593
815
|
}
|
|
594
816
|
}
|
|
595
817
|
|
|
818
|
+
/** Detect repo roots within the workspace (for the Git.repos capability). */
|
|
819
|
+
async detectRepos(): Promise<string[]> {
|
|
820
|
+
return (await this.detectReposDetailed()).repos;
|
|
821
|
+
}
|
|
822
|
+
|
|
596
823
|
// ════════════════════════ Terminal exec + PTY (A2) ════════════════════════
|
|
597
824
|
|
|
598
825
|
/** Run a bounded command, return buffered stdout/stderr + exit code inline. The
|
|
@@ -602,15 +829,23 @@ export class SandboxChannelAService {
|
|
|
602
829
|
async terminalExec(req: TerminalExecRequest): Promise<TerminalExecResponse> {
|
|
603
830
|
const r = await this.run({
|
|
604
831
|
cmd: req.command,
|
|
605
|
-
workdir: this.
|
|
832
|
+
workdir: this.terminalWorkdir(req.cwd),
|
|
606
833
|
yieldTimeMs: req.timeoutMs,
|
|
607
834
|
});
|
|
608
835
|
const running = r.exitCode === null && typeof r.sessionId === "number";
|
|
609
836
|
if (req.emitStream && (r.stdout || r.stderr)) {
|
|
610
837
|
const events: { type: SessionEventType; payload: unknown }[] = [];
|
|
611
838
|
const commandId = crypto.randomUUID();
|
|
612
|
-
if (r.stdout)
|
|
613
|
-
|
|
839
|
+
if (r.stdout)
|
|
840
|
+
events.push({
|
|
841
|
+
type: "sandbox.command.output.delta",
|
|
842
|
+
payload: { stream: "stdout", chunk: r.stdout, commandId, seq: 0 },
|
|
843
|
+
});
|
|
844
|
+
if (r.stderr)
|
|
845
|
+
events.push({
|
|
846
|
+
type: "sandbox.command.output.delta",
|
|
847
|
+
payload: { stream: "stderr", chunk: r.stderr, commandId, seq: 1 },
|
|
848
|
+
});
|
|
614
849
|
await this.emitEvents(events);
|
|
615
850
|
}
|
|
616
851
|
return {
|
|
@@ -627,12 +862,20 @@ export class SandboxChannelAService {
|
|
|
627
862
|
* writeStdin can drive it. Returns the supportsInput gate (false when the
|
|
628
863
|
* backend has no writeStdin). The caller emits terminal.pty.started after it
|
|
629
864
|
* persists the row. */
|
|
630
|
-
async ptyOpen(
|
|
865
|
+
async ptyOpen(
|
|
866
|
+
req: PtyOpenRequest,
|
|
867
|
+
ptyId: string,
|
|
868
|
+
): Promise<{
|
|
869
|
+
response: PtyOpenResponse;
|
|
870
|
+
execSessionId: number | null;
|
|
871
|
+
shell: string;
|
|
872
|
+
initialOutput: string;
|
|
873
|
+
}> {
|
|
631
874
|
const supportsInput = Boolean(this.session.supportsPty?.() && this.session.writeStdin);
|
|
632
875
|
const shell = req.shell ?? "/bin/bash";
|
|
633
876
|
const r = await this.run({
|
|
634
877
|
cmd: shell,
|
|
635
|
-
workdir: this.
|
|
878
|
+
workdir: this.terminalWorkdir(req.cwd),
|
|
636
879
|
tty: true,
|
|
637
880
|
login: true,
|
|
638
881
|
yieldTimeMs: 250,
|
|
@@ -652,7 +895,11 @@ export class SandboxChannelAService {
|
|
|
652
895
|
if (!this.session.writeStdin) {
|
|
653
896
|
throw new ChannelAUnsupportedError("interactive terminal unsupported on this backend");
|
|
654
897
|
}
|
|
655
|
-
const out = await this.session.writeStdin({
|
|
898
|
+
const out = await this.session.writeStdin({
|
|
899
|
+
sessionId: execSessionId,
|
|
900
|
+
chars: data,
|
|
901
|
+
yieldTimeMs: 250,
|
|
902
|
+
});
|
|
656
903
|
// The Modal exec surface reports a vanished exec-session as a NON-throwing
|
|
657
904
|
// string ("write_stdin failed: session not found: N") that we used to stream
|
|
658
905
|
// verbatim into the terminal. That happens when the persisted exec-session no
|
|
@@ -672,7 +919,11 @@ export class SandboxChannelAService {
|
|
|
672
919
|
async ptyResize(req: PtyResizeRequest, execSessionId: number): Promise<void> {
|
|
673
920
|
if (!this.session.writeStdin) return;
|
|
674
921
|
// Send a stty in-band on the same pty session.
|
|
675
|
-
await this.session.writeStdin({
|
|
922
|
+
await this.session.writeStdin({
|
|
923
|
+
sessionId: execSessionId,
|
|
924
|
+
chars: `stty cols ${req.cols} rows ${req.rows}\n`,
|
|
925
|
+
yieldTimeMs: 50,
|
|
926
|
+
});
|
|
676
927
|
}
|
|
677
928
|
|
|
678
929
|
/** Close an open PTY: write exit/EOF. The caller marks the row closed + emits
|
|
@@ -690,7 +941,9 @@ export class SandboxChannelAService {
|
|
|
690
941
|
// ──────────────────────────── helpers ──────────────────────────────────────
|
|
691
942
|
|
|
692
943
|
/** The current FS revision (for the caller to persist/seed). */
|
|
693
|
-
currentRevision(): number {
|
|
944
|
+
currentRevision(): number {
|
|
945
|
+
return this.revision;
|
|
946
|
+
}
|
|
694
947
|
|
|
695
948
|
private joinRoot(rel: string): string {
|
|
696
949
|
if (!this.workspaceRoot) return rel === "" ? "." : rel;
|
|
@@ -703,13 +956,35 @@ export class SandboxChannelAService {
|
|
|
703
956
|
return joined === "." ? this.workspaceRoot || undefined : joined;
|
|
704
957
|
}
|
|
705
958
|
|
|
959
|
+
private terminalWorkdir(cwd: string): string | undefined {
|
|
960
|
+
// Model-facing terminal tools may send the manifest-rooted virtual frame.
|
|
961
|
+
// Preserve it for sessions like selfhosted whose own adapter maps it onto
|
|
962
|
+
// the real machine working dir; repo-relative dock fs/git still use repoWorkdir.
|
|
963
|
+
if (cwd === SELFHOSTED_VIRTUAL_ROOT || cwd.startsWith(`${SELFHOSTED_VIRTUAL_ROOT}/`)) {
|
|
964
|
+
return cwd;
|
|
965
|
+
}
|
|
966
|
+
return this.repoWorkdir(cwd);
|
|
967
|
+
}
|
|
968
|
+
|
|
706
969
|
private async emitEvents(events: { type: SessionEventType; payload: unknown }[]): Promise<void> {
|
|
707
970
|
if (!this.emit || events.length === 0) return;
|
|
708
|
-
try {
|
|
971
|
+
try {
|
|
972
|
+
await this.emit(events);
|
|
973
|
+
} catch {
|
|
974
|
+
/* durable spine retries; not fatal */
|
|
975
|
+
}
|
|
709
976
|
}
|
|
710
977
|
|
|
711
|
-
private async emitFsChanged(
|
|
712
|
-
|
|
978
|
+
private async emitFsChanged(
|
|
979
|
+
changes: FsChangedPayload["changes"],
|
|
980
|
+
source: FsChangedPayload["source"],
|
|
981
|
+
): Promise<void> {
|
|
982
|
+
const payload: FsChangedPayload = {
|
|
983
|
+
changes,
|
|
984
|
+
source,
|
|
985
|
+
revision: this.revision,
|
|
986
|
+
leaseEpoch: this.leaseEpoch,
|
|
987
|
+
};
|
|
713
988
|
await this.emitEvents([{ type: "fs.changed", payload }]);
|
|
714
989
|
}
|
|
715
990
|
|
|
@@ -754,7 +1029,10 @@ export function stripExecBanner(raw: string): string {
|
|
|
754
1029
|
export function isWorkspaceEscapeError(error: unknown): boolean {
|
|
755
1030
|
const msg = error instanceof Error ? error.message : String(error ?? "");
|
|
756
1031
|
const lower = msg.toLowerCase();
|
|
757
|
-
return
|
|
1032
|
+
return (
|
|
1033
|
+
lower.includes("workspace escape") ||
|
|
1034
|
+
(lower.includes("remote validation") && lower.includes("escape"))
|
|
1035
|
+
);
|
|
758
1036
|
}
|
|
759
1037
|
|
|
760
1038
|
// Detect the Modal "the exec-session you're writing to no longer exists" banner.
|
|
@@ -771,7 +1049,9 @@ export function isExecSessionLostBanner(out: string, execSessionId: number): boo
|
|
|
771
1049
|
if (!lower.includes("session not found")) return false;
|
|
772
1050
|
// When the id is present require it to match ours; when absent, the generic
|
|
773
1051
|
// "session not found" still classifies (it is never legitimate stdout here).
|
|
774
|
-
return
|
|
1052
|
+
return (
|
|
1053
|
+
lower.includes(`session not found: ${execSessionId}`) || !/session not found:\s*\d+/.test(lower)
|
|
1054
|
+
);
|
|
775
1055
|
}
|
|
776
1056
|
|
|
777
1057
|
// Recover the numeric exec-session id the SDK embeds in a formatExecResponse
|
|
@@ -808,7 +1088,8 @@ export function assertSafeRelPath(p: string): string {
|
|
|
808
1088
|
const norm = normalizeRelPath(p);
|
|
809
1089
|
if (norm === "") throw new ChannelAValidationError("path is required");
|
|
810
1090
|
if (p.startsWith("/")) throw new ChannelAValidationError(`absolute paths are not allowed: ${p}`);
|
|
811
|
-
if (norm.split("/").some((seg) => seg === ".."))
|
|
1091
|
+
if (norm.split("/").some((seg) => seg === ".."))
|
|
1092
|
+
throw new ChannelAValidationError(`path traversal is not allowed: ${p}`);
|
|
812
1093
|
return norm;
|
|
813
1094
|
}
|
|
814
1095
|
|
|
@@ -896,12 +1177,18 @@ export function parsePorcelainV2(z: string): Omit<GitStatusResponse, "revision">
|
|
|
896
1177
|
if (rec === "") continue;
|
|
897
1178
|
if (rec.startsWith("# branch.head ")) {
|
|
898
1179
|
const v = rec.slice("# branch.head ".length);
|
|
899
|
-
if (v === "(detached)") {
|
|
1180
|
+
if (v === "(detached)") {
|
|
1181
|
+
detached = true;
|
|
1182
|
+
head = null;
|
|
1183
|
+
} else head = v;
|
|
900
1184
|
} else if (rec.startsWith("# branch.upstream ")) {
|
|
901
1185
|
upstream = rec.slice("# branch.upstream ".length);
|
|
902
1186
|
} else if (rec.startsWith("# branch.ab ")) {
|
|
903
1187
|
const m = rec.slice("# branch.ab ".length).match(/\+(\d+)\s+-(\d+)/);
|
|
904
|
-
if (m) {
|
|
1188
|
+
if (m) {
|
|
1189
|
+
ahead = Number(m[1]);
|
|
1190
|
+
behind = Number(m[2]);
|
|
1191
|
+
}
|
|
905
1192
|
} else if (rec.startsWith("1 ")) {
|
|
906
1193
|
// 1 <XY> <sub> <mH> <mI> <mW> <hH> <hI> <path>
|
|
907
1194
|
const fields = rec.split(" ");
|
|
@@ -919,11 +1206,29 @@ export function parsePorcelainV2(z: string): Omit<GitStatusResponse, "revision">
|
|
|
919
1206
|
} else if (rec.startsWith("u ")) {
|
|
920
1207
|
const fields = rec.split(" ");
|
|
921
1208
|
const path = fields.slice(10).join(" ");
|
|
922
|
-
files.push({
|
|
1209
|
+
files.push({
|
|
1210
|
+
path,
|
|
1211
|
+
oldPath: null,
|
|
1212
|
+
index: "conflicted",
|
|
1213
|
+
worktree: "conflicted",
|
|
1214
|
+
isConflicted: true,
|
|
1215
|
+
});
|
|
923
1216
|
} else if (rec.startsWith("? ")) {
|
|
924
|
-
files.push({
|
|
1217
|
+
files.push({
|
|
1218
|
+
path: rec.slice(2),
|
|
1219
|
+
oldPath: null,
|
|
1220
|
+
index: null,
|
|
1221
|
+
worktree: "untracked",
|
|
1222
|
+
isConflicted: false,
|
|
1223
|
+
});
|
|
925
1224
|
} else if (rec.startsWith("! ")) {
|
|
926
|
-
files.push({
|
|
1225
|
+
files.push({
|
|
1226
|
+
path: rec.slice(2),
|
|
1227
|
+
oldPath: null,
|
|
1228
|
+
index: null,
|
|
1229
|
+
worktree: "ignored",
|
|
1230
|
+
isConflicted: false,
|
|
1231
|
+
});
|
|
927
1232
|
}
|
|
928
1233
|
}
|
|
929
1234
|
return { isRepo: true, head, detached, upstream, ahead, behind, files };
|
|
@@ -931,15 +1236,24 @@ export function parsePorcelainV2(z: string): Omit<GitStatusResponse, "revision">
|
|
|
931
1236
|
|
|
932
1237
|
function xyCode(c: string): GitFileStatusCode | null {
|
|
933
1238
|
switch (c) {
|
|
934
|
-
case "A":
|
|
935
|
-
|
|
936
|
-
case "
|
|
937
|
-
|
|
938
|
-
case "
|
|
939
|
-
|
|
940
|
-
case "
|
|
941
|
-
|
|
942
|
-
|
|
1239
|
+
case "A":
|
|
1240
|
+
return "added";
|
|
1241
|
+
case "M":
|
|
1242
|
+
return "modified";
|
|
1243
|
+
case "D":
|
|
1244
|
+
return "deleted";
|
|
1245
|
+
case "R":
|
|
1246
|
+
return "renamed";
|
|
1247
|
+
case "C":
|
|
1248
|
+
return "copied";
|
|
1249
|
+
case "T":
|
|
1250
|
+
return "typechange";
|
|
1251
|
+
case "U":
|
|
1252
|
+
return "conflicted";
|
|
1253
|
+
case ".":
|
|
1254
|
+
return null;
|
|
1255
|
+
default:
|
|
1256
|
+
return null;
|
|
943
1257
|
}
|
|
944
1258
|
}
|
|
945
1259
|
|
|
@@ -956,17 +1270,29 @@ function statusFromXY(xy: string, path: string, oldPath: string | null): GitFile
|
|
|
956
1270
|
}
|
|
957
1271
|
|
|
958
1272
|
// ── numstat -z parser (additions/deletions/binary + rename old\0new) ─────────
|
|
959
|
-
export type NumstatEntry = {
|
|
1273
|
+
export type NumstatEntry = {
|
|
1274
|
+
additions: number;
|
|
1275
|
+
deletions: number;
|
|
1276
|
+
binary: boolean;
|
|
1277
|
+
oldPath: string | null;
|
|
1278
|
+
newPath: string;
|
|
1279
|
+
};
|
|
960
1280
|
export function parseNumstatZ(z: string): NumstatEntry[] {
|
|
961
1281
|
const fields = z.split(NUL);
|
|
962
1282
|
const out: NumstatEntry[] = [];
|
|
963
1283
|
let i = 0;
|
|
964
1284
|
while (i < fields.length) {
|
|
965
1285
|
const head = fields[i]!;
|
|
966
|
-
if (head === "") {
|
|
1286
|
+
if (head === "") {
|
|
1287
|
+
i++;
|
|
1288
|
+
continue;
|
|
1289
|
+
}
|
|
967
1290
|
// "<add>\t<del>\t<path>" OR for a rename "<add>\t<del>\t" then old\0new follow.
|
|
968
1291
|
const m = head.match(/^(\d+|-)\t(\d+|-)\t(.*)$/s);
|
|
969
|
-
if (!m) {
|
|
1292
|
+
if (!m) {
|
|
1293
|
+
i++;
|
|
1294
|
+
continue;
|
|
1295
|
+
}
|
|
970
1296
|
const addStr = m[1]!;
|
|
971
1297
|
const delStr = m[2]!;
|
|
972
1298
|
const pathPart = m[3]!;
|
|
@@ -975,10 +1301,22 @@ export function parseNumstatZ(z: string): NumstatEntry[] {
|
|
|
975
1301
|
// rename: the next two NUL fields are old, new
|
|
976
1302
|
const oldPath = fields[i + 1] ?? null;
|
|
977
1303
|
const newPath = fields[i + 2] ?? "";
|
|
978
|
-
out.push({
|
|
1304
|
+
out.push({
|
|
1305
|
+
additions: binary ? 0 : Number(addStr),
|
|
1306
|
+
deletions: binary ? 0 : Number(delStr),
|
|
1307
|
+
binary,
|
|
1308
|
+
oldPath,
|
|
1309
|
+
newPath,
|
|
1310
|
+
});
|
|
979
1311
|
i += 3;
|
|
980
1312
|
} else {
|
|
981
|
-
out.push({
|
|
1313
|
+
out.push({
|
|
1314
|
+
additions: binary ? 0 : Number(addStr),
|
|
1315
|
+
deletions: binary ? 0 : Number(delStr),
|
|
1316
|
+
binary,
|
|
1317
|
+
oldPath: null,
|
|
1318
|
+
newPath: pathPart,
|
|
1319
|
+
});
|
|
982
1320
|
i++;
|
|
983
1321
|
}
|
|
984
1322
|
}
|
|
@@ -986,7 +1324,10 @@ export function parseNumstatZ(z: string): NumstatEntry[] {
|
|
|
986
1324
|
}
|
|
987
1325
|
|
|
988
1326
|
// ── unified-diff parser -> GitDiffHunk[] (the Pierre-diff shape) ─────────────
|
|
989
|
-
export function parseUnifiedPatch(patch: string): {
|
|
1327
|
+
export function parseUnifiedPatch(patch: string): {
|
|
1328
|
+
hunks: GitDiffHunk[];
|
|
1329
|
+
status: GitFileStatusCode;
|
|
1330
|
+
} {
|
|
990
1331
|
const lines = patch.split("\n");
|
|
991
1332
|
const hunks: GitDiffHunk[] = [];
|
|
992
1333
|
let status: GitFileStatusCode = "modified";
|
|
@@ -1004,7 +1345,14 @@ export function parseUnifiedPatch(patch: string): { hunks: GitDiffHunk[]; status
|
|
|
1004
1345
|
const oldLines = m[2] !== undefined ? Number(m[2]) : 1;
|
|
1005
1346
|
const newStart = Number(m[3]);
|
|
1006
1347
|
const newLines = m[4] !== undefined ? Number(m[4]) : 1;
|
|
1007
|
-
current = {
|
|
1348
|
+
current = {
|
|
1349
|
+
oldStart,
|
|
1350
|
+
oldLines,
|
|
1351
|
+
newStart,
|
|
1352
|
+
newLines,
|
|
1353
|
+
header: (m[5] ?? "").trim(),
|
|
1354
|
+
lines: [],
|
|
1355
|
+
};
|
|
1008
1356
|
hunks.push(current);
|
|
1009
1357
|
oldNo = oldStart;
|
|
1010
1358
|
newNo = newStart;
|