@proagentstore/cli 0.4.47 → 0.4.49
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.
|
@@ -44,15 +44,29 @@ export function gitArgv(cmd, opts = {}) {
|
|
|
44
44
|
// caller can learn a tree is dirty but never which branch it is dirty on, which is how
|
|
45
45
|
// a delegated run pushed a PR branch and left the checkout parked there unnoticed
|
|
46
46
|
// (#276). Additive: every existing consumer keeps the same file lines it always got.
|
|
47
|
-
|
|
47
|
+
//
|
|
48
|
+
// `path` reaches this one too (#508). Narrowing every command rather than four of the
|
|
49
|
+
// five is what lets the tool description say "it applies" with no caveat — and a
|
|
50
|
+
// caveat is what a model has to reason about and can get wrong.
|
|
51
|
+
return opts.relPath ? ["status", "--short", "--branch", "--", opts.relPath] : ["status", "--short", "--branch"];
|
|
52
|
+
// `path` used to reach exactly ONE of these five (#508). It is advertised on the tool as
|
|
53
|
+
// "Limit the command to one file or folder", `runRepoGit` resolves and validates it, and
|
|
54
|
+
// then four of the five branches dropped it on the floor — so
|
|
55
|
+
// `repo_git {cmd:"ls-files", path:"admin/lib/features/events"}` answered with every tracked
|
|
56
|
+
// file in the repository, truncated mid-list at 12KB. `git ls-files -- <path>` is the
|
|
57
|
+
// file-finder a Repo Coder has been missing, and the parameter to reach it was already in
|
|
58
|
+
// the schema and already validated; it was dropped one function later.
|
|
59
|
+
//
|
|
60
|
+
// Every one of these is git's own `--` pathspec discipline, unchanged: the validated path
|
|
61
|
+
// is appended after a literal separator and can never be read as a flag or a revision.
|
|
48
62
|
case "diff":
|
|
49
63
|
return opts.relPath ? ["diff", "--", opts.relPath] : ["diff"];
|
|
50
64
|
case "diff-stat":
|
|
51
|
-
return ["diff", "--stat"];
|
|
65
|
+
return opts.relPath ? ["diff", "--stat", "--", opts.relPath] : ["diff", "--stat"];
|
|
52
66
|
case "log":
|
|
53
|
-
return ["log", "--oneline", "-n", String(clampN)];
|
|
67
|
+
return opts.relPath ? ["log", "--oneline", "-n", String(clampN), "--", opts.relPath] : ["log", "--oneline", "-n", String(clampN)];
|
|
54
68
|
case "ls-files":
|
|
55
|
-
return ["ls-files"];
|
|
69
|
+
return opts.relPath ? ["ls-files", "--", opts.relPath] : ["ls-files"];
|
|
56
70
|
default:
|
|
57
71
|
throw new InspectError(`unsupported git command: ${cmd}`);
|
|
58
72
|
}
|
|
@@ -86,6 +100,12 @@ export function runRepoGit(workDir, cmd, opts = {}) {
|
|
|
86
100
|
throw new InspectError("not a git repo");
|
|
87
101
|
const relPath = opts.path ? relative(workDir, resolveInside(workDir, opts.path)) : undefined;
|
|
88
102
|
const argv = gitArgv(cmd, { relPath, n: opts.n });
|
|
103
|
+
// Did the path the caller asked for actually reach git? Reported rather than assumed, because
|
|
104
|
+
// a runner is a SEPARATE release from the cloud that calls it: before #508 four of the five
|
|
105
|
+
// commands ignored `path` silently, and the answer — the whole repository — was indistinguishable
|
|
106
|
+
// from a correct one. An older runner omits this field entirely, which is what lets the cloud
|
|
107
|
+
// say "your machine ignored the filter" instead of relaying a wrong answer as a right one.
|
|
108
|
+
const pathApplied = relPath !== undefined && argv.includes(relPath);
|
|
89
109
|
let out = "";
|
|
90
110
|
try {
|
|
91
111
|
out = execFileSync("git", argv, { cwd: workDir, encoding: "utf-8", timeout: 10_000, maxBuffer: 4 * 1024 * 1024 });
|
|
@@ -99,7 +119,7 @@ export function runRepoGit(workDir, cmd, opts = {}) {
|
|
|
99
119
|
}
|
|
100
120
|
const cap = opts.maxBytes ?? 64 * 1024;
|
|
101
121
|
const truncated = out.length > cap;
|
|
102
|
-
return { cmd, output: truncated ? out.slice(0, cap) : out, truncated };
|
|
122
|
+
return { cmd, output: truncated ? out.slice(0, cap) : out, truncated, pathApplied };
|
|
103
123
|
}
|
|
104
124
|
/** Read the repo's `origin` remote URL — used to auto-associate a local checkout with its
|
|
105
125
|
* GitHub repo (so build status can query Actions). Fixed argv, no shell, no user input;
|
|
@@ -120,14 +140,125 @@ export function readGitRemoteOrigin(workDir) {
|
|
|
120
140
|
}
|
|
121
141
|
}
|
|
122
142
|
const IGNORE_DIRS = new Set(["node_modules", ".git", "dist", "build", ".next", ".turbo", "coverage", ".wrangler"]);
|
|
123
|
-
/**
|
|
143
|
+
/**
|
|
144
|
+
* Search the repo — the capability a Repo Coder never had (#508).
|
|
145
|
+
*
|
|
146
|
+
* Before this there was no grep, no filename match, no content match anywhere in the connector or
|
|
147
|
+
* the runner, so locating a file meant walking the tree by hand and guessing when it ran out.
|
|
148
|
+
*
|
|
149
|
+
* ── The bounds, and why each one is here rather than "cap the bytes at the end"
|
|
150
|
+
*
|
|
151
|
+
* A byte cap alone just moves the problem into the tool-result cap (#503): the model receives an
|
|
152
|
+
* arbitrary prefix of a list and no statement that a list was cut. Every bound below is COUNTED,
|
|
153
|
+
* so the result can say `50 of 812` and the model knows to narrow rather than conclude.
|
|
154
|
+
*
|
|
155
|
+
* MAX_RESULTS the whole answer, not per file — one number the caller can reason about.
|
|
156
|
+
* PER_FILE git's own `--max-count`, so a minified bundle cannot spend the whole budget.
|
|
157
|
+
* MAX_LINE a matching line from a generated file can be tens of KB on its own.
|
|
158
|
+
* maxBuffer git's stdout is read into memory before any of the above can apply.
|
|
159
|
+
*
|
|
160
|
+
* Worst case is ~50 × (path + 160) ≈ 11KB, comfortably inside the 12KB the connector allows and
|
|
161
|
+
* the 24,000 chars a tool result may carry.
|
|
162
|
+
*
|
|
163
|
+
* ── Fixed strings, not regexes
|
|
164
|
+
*
|
|
165
|
+
* `-F`. The job is "where does this identifier / filename appear", which is what a model supplies
|
|
166
|
+
* naturally, and a bare `foo(` — the obvious thing to search for in code — is an invalid regex.
|
|
167
|
+
* It also means no pattern the model invents can become a pathological match on the owner's own
|
|
168
|
+
* machine. Deliberate deviation from the argv sketched in #508, which used `-e` alone.
|
|
169
|
+
*
|
|
170
|
+
* ── The safety property is unchanged
|
|
171
|
+
*
|
|
172
|
+
* `path` is still resolveInside-validated and still appended after a literal `--`. The PATTERN is
|
|
173
|
+
* never a git token in `path` mode (the filter runs here, in JS) and in `content` mode it is the
|
|
174
|
+
* fixed operand of `-e`, which git cannot read as a flag whatever it contains.
|
|
175
|
+
*/
|
|
176
|
+
export const SEARCH_MAX_RESULTS = 50;
|
|
177
|
+
const SEARCH_PER_FILE = 5;
|
|
178
|
+
const SEARCH_MAX_LINE = 160;
|
|
179
|
+
const SEARCH_MAX_PATTERN = 200;
|
|
180
|
+
export function repoSearch(workDir, opts) {
|
|
181
|
+
if (!existsSync(resolve(workDir, ".git")))
|
|
182
|
+
throw new InspectError("not a git repo");
|
|
183
|
+
const pattern = (opts.pattern ?? "").trim();
|
|
184
|
+
if (!pattern)
|
|
185
|
+
throw new InspectError("a search `pattern` is required");
|
|
186
|
+
if (pattern.length > SEARCH_MAX_PATTERN)
|
|
187
|
+
throw new InspectError(`search pattern is too long (max ${SEARCH_MAX_PATTERN} characters)`);
|
|
188
|
+
const mode = opts.mode === "path" ? "path" : "content";
|
|
189
|
+
const limit = Math.max(1, Math.min(SEARCH_MAX_RESULTS, Math.floor(opts.maxResults ?? SEARCH_MAX_RESULTS)));
|
|
190
|
+
const relPath = opts.path ? relative(workDir, resolveInside(workDir, opts.path)) : undefined;
|
|
191
|
+
const argv = mode === "content"
|
|
192
|
+
? ["grep", "-n", "-I", "-i", "-F", "--untracked", "--max-count", String(SEARCH_PER_FILE), "-e", pattern]
|
|
193
|
+
: // Tracked PLUS untracked-not-ignored: a file created ten minutes ago is exactly the one
|
|
194
|
+
// somebody is trying to find, and `ls-files` alone would deny it exists.
|
|
195
|
+
["ls-files", "--cached", "--others", "--exclude-standard"];
|
|
196
|
+
// `.` rather than nothing: `git grep` run from a subdirectory would otherwise search the whole
|
|
197
|
+
// tree, and the caller asked for a folder.
|
|
198
|
+
if (relPath && relPath !== "")
|
|
199
|
+
argv.push("--", relPath);
|
|
200
|
+
let out;
|
|
201
|
+
try {
|
|
202
|
+
out = execFileSync("git", argv, { cwd: workDir, encoding: "utf-8", timeout: 15_000, maxBuffer: 4 * 1024 * 1024 });
|
|
203
|
+
}
|
|
204
|
+
catch (e) {
|
|
205
|
+
const err = e;
|
|
206
|
+
// `git grep` exits 1 for "no matches", which is an ANSWER, not a failure.
|
|
207
|
+
if (err.status === 1)
|
|
208
|
+
out = err.stdout ?? "";
|
|
209
|
+
else if (err.code === "ENOBUFS")
|
|
210
|
+
throw new InspectError(`too many matches for "${pattern}" to read safely — search a narrower path, or a longer/more specific pattern`);
|
|
211
|
+
else if (err.stdout)
|
|
212
|
+
out = err.stdout;
|
|
213
|
+
else
|
|
214
|
+
throw new InspectError(err.message || `git ${mode} search failed`);
|
|
215
|
+
}
|
|
216
|
+
const lines = out.split("\n").filter((l) => l !== "");
|
|
217
|
+
const all = mode === "content"
|
|
218
|
+
? lines.map(parseGrepLine).filter((m) => m !== null)
|
|
219
|
+
: // The pattern never reaches git here — the filter is a plain case-insensitive substring
|
|
220
|
+
// over the path, which is what "find the file called X" actually means.
|
|
221
|
+
lines.filter((p) => p.toLowerCase().includes(pattern.toLowerCase())).map((p) => ({ path: p }));
|
|
222
|
+
return { mode, pattern, matches: all.slice(0, limit), shown: Math.min(all.length, limit), total: all.length, truncated: all.length > limit };
|
|
223
|
+
}
|
|
224
|
+
/** `path:line:text` — split on the FIRST two colons only, since code is full of them. */
|
|
225
|
+
function parseGrepLine(line) {
|
|
226
|
+
const first = line.indexOf(":");
|
|
227
|
+
if (first <= 0)
|
|
228
|
+
return null;
|
|
229
|
+
const second = line.indexOf(":", first + 1);
|
|
230
|
+
if (second < 0)
|
|
231
|
+
return null;
|
|
232
|
+
const n = Number.parseInt(line.slice(first + 1, second), 10);
|
|
233
|
+
if (!Number.isFinite(n))
|
|
234
|
+
return null;
|
|
235
|
+
return { path: line.slice(0, first), line: n, text: line.slice(second + 1).trim().slice(0, SEARCH_MAX_LINE) };
|
|
236
|
+
}
|
|
237
|
+
/** The deepest `maxDepth` this will honour. Named because the TOOL has to say it (#508). */
|
|
238
|
+
export const TREE_MAX_DEPTH = 4;
|
|
239
|
+
/**
|
|
240
|
+
* Bounded recursive file tree (names/type/size only — no contents).
|
|
241
|
+
*
|
|
242
|
+
* Two caps stop it, and until #508 only ONE of them was reported. `truncated` was set by the
|
|
243
|
+
* ENTRY cap alone; a directory sitting at the depth boundary was emitted as an entry and its
|
|
244
|
+
* children simply not queued, so it rendered exactly like a directory with nothing in it.
|
|
245
|
+
*
|
|
246
|
+
* From the model's side "this folder is empty" and "this folder is deeper than I am allowed to
|
|
247
|
+
* look" were the same observation, and it cannot navigate on that: asked about a file seven
|
|
248
|
+
* segments down, it saw a leaf, decided the leaf WAS the file, and called `repo_read_file` on a
|
|
249
|
+
* directory — twice in the turn that produced this ticket. `deeper` is the per-entry fact that
|
|
250
|
+
* removes the ambiguity, and it is deliberately "contents not listed" rather than "has more
|
|
251
|
+
* inside": checking the latter costs a readdir per boundary directory, and the model's next move
|
|
252
|
+
* (call again with `path`) is the same either way.
|
|
253
|
+
*/
|
|
124
254
|
export function repoTree(workDir, relPath = ".", maxDepth = 3, maxEntries = 500) {
|
|
125
255
|
const start = resolveInside(workDir, relPath, { checkSymlink: true });
|
|
126
|
-
const depthCap = Math.max(1, Math.min(
|
|
256
|
+
const depthCap = Math.max(1, Math.min(TREE_MAX_DEPTH, maxDepth));
|
|
127
257
|
const entryCap = Math.max(1, Math.min(1000, maxEntries));
|
|
128
258
|
const entries = [];
|
|
129
259
|
const queue = [{ dir: start, depth: 0 }];
|
|
130
260
|
let truncated = false;
|
|
261
|
+
let truncatedByDepth = false;
|
|
131
262
|
while (queue.length) {
|
|
132
263
|
const { dir, depth } = queue.shift();
|
|
133
264
|
let items;
|
|
@@ -142,14 +273,17 @@ export function repoTree(workDir, relPath = ".", maxDepth = 3, maxEntries = 500)
|
|
|
142
273
|
continue;
|
|
143
274
|
if (entries.length >= entryCap) {
|
|
144
275
|
truncated = true;
|
|
145
|
-
return { root: relPath, entries, truncated };
|
|
276
|
+
return { root: relPath, entries, truncated, truncatedByDepth, depthCap };
|
|
146
277
|
}
|
|
147
278
|
const abs = resolve(dir, it.name);
|
|
148
279
|
const rel = relative(workDir, abs);
|
|
149
280
|
if (it.isDirectory()) {
|
|
150
|
-
|
|
151
|
-
|
|
281
|
+
const walk = depth + 1 < depthCap;
|
|
282
|
+
entries.push(walk ? { path: rel, type: "dir" } : { path: rel, type: "dir", deeper: true });
|
|
283
|
+
if (walk)
|
|
152
284
|
queue.push({ dir: abs, depth: depth + 1 });
|
|
285
|
+
else
|
|
286
|
+
truncatedByDepth = true;
|
|
153
287
|
}
|
|
154
288
|
else if (it.isFile()) {
|
|
155
289
|
let size;
|
|
@@ -161,5 +295,5 @@ export function repoTree(workDir, relPath = ".", maxDepth = 3, maxEntries = 500)
|
|
|
161
295
|
}
|
|
162
296
|
}
|
|
163
297
|
}
|
|
164
|
-
return { root: relPath, entries, truncated };
|
|
298
|
+
return { root: relPath, entries, truncated, truncatedByDepth, depthCap };
|
|
165
299
|
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { resolve } from "node:path";
|
|
4
|
+
import { InspectError } from "./inspect.js";
|
|
5
|
+
/**
|
|
6
|
+
* May this string become a git token?
|
|
7
|
+
*
|
|
8
|
+
* An allowlist, not a denylist: `execFile` means there is no shell to escape, but a name beginning
|
|
9
|
+
* `-` would still be read by git as an OPTION, which is the one injection this argv shape is open
|
|
10
|
+
* to. The rest is `git check-ref-format` reduced to the subset a real branch uses.
|
|
11
|
+
*/
|
|
12
|
+
export function isSwitchableBranchName(name) {
|
|
13
|
+
if (typeof name !== "string")
|
|
14
|
+
return false;
|
|
15
|
+
const n = name.trim();
|
|
16
|
+
if (!n || n.length > 200)
|
|
17
|
+
return false;
|
|
18
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._/-]*$/.test(n))
|
|
19
|
+
return false; // no leading `-` or `.`, no spaces, no `~^:?*[\`
|
|
20
|
+
if (n.includes("..") || n.includes("//") || n.endsWith("/") || n.endsWith(".lock") || n.includes("@{"))
|
|
21
|
+
return false;
|
|
22
|
+
return true;
|
|
23
|
+
}
|
|
24
|
+
/** Map the enum to a fixed argv. The branch is the only caller-supplied token, and it is validated. */
|
|
25
|
+
export function gitWriteArgv(cmd, opts = {}) {
|
|
26
|
+
switch (cmd) {
|
|
27
|
+
case "switch-branch": {
|
|
28
|
+
if (!isSwitchableBranchName(opts.branch))
|
|
29
|
+
throw new InspectError(`unusable branch name: ${String(opts.branch)}`);
|
|
30
|
+
// `--` terminates option parsing AND says "what follows is a ref, not a path", so a branch
|
|
31
|
+
// that shares a name with a file cannot turn this into a file checkout — which WOULD
|
|
32
|
+
// discard work.
|
|
33
|
+
return ["checkout", opts.branch.trim(), "--"];
|
|
34
|
+
}
|
|
35
|
+
default:
|
|
36
|
+
throw new InspectError(`unsupported git write command: ${cmd}`);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
function git(workDir, argv) {
|
|
40
|
+
// stderr is PIPED rather than inherited: git narrates a checkout on stderr, and the runner's
|
|
41
|
+
// console is a user-facing log, not a place for `Switched to branch 'main'`. Piping it is also
|
|
42
|
+
// what makes `e.stderr` available, which is the only honest sentence to put on the card when git
|
|
43
|
+
// itself refuses.
|
|
44
|
+
return execFileSync("git", argv, {
|
|
45
|
+
cwd: workDir,
|
|
46
|
+
encoding: "utf-8",
|
|
47
|
+
timeout: 15_000,
|
|
48
|
+
maxBuffer: 1024 * 1024,
|
|
49
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
50
|
+
}).toString();
|
|
51
|
+
}
|
|
52
|
+
/** The current branch, or the short SHA when detached, or null when even that fails. */
|
|
53
|
+
function currentBranch(workDir) {
|
|
54
|
+
try {
|
|
55
|
+
const name = git(workDir, ["rev-parse", "--abbrev-ref", "HEAD"]).trim();
|
|
56
|
+
if (name && name !== "HEAD")
|
|
57
|
+
return name;
|
|
58
|
+
return git(workDir, ["rev-parse", "--short", "HEAD"]).trim() || null;
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
function isDirty(workDir) {
|
|
65
|
+
// `--porcelain` alone: untracked files COUNT as dirty here, because they are exactly the ones a
|
|
66
|
+
// checkout would carry across without git saying a word about it.
|
|
67
|
+
return git(workDir, ["status", "--porcelain"]).trim().length > 0;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Put the checkout back on `branch`, or refuse and change nothing.
|
|
71
|
+
*
|
|
72
|
+
* Never creates a branch, never fetches, never touches the remote: the target must already exist
|
|
73
|
+
* locally, because inventing one would be deciding what should be true rather than restoring what
|
|
74
|
+
* was declared. Every refusal path returns BEFORE any write.
|
|
75
|
+
*
|
|
76
|
+
* The repo test is `.git` at THIS path, not `git rev-parse --is-inside-work-tree` — the opposite of
|
|
77
|
+
* the choice #405 made for the read side, and deliberately so. `rev-parse` answers yes from a
|
|
78
|
+
* subdirectory, so a workdir that happens to sit inside a larger checkout (`~/dev/stores` is one)
|
|
79
|
+
* would have its ENCLOSING repo switched by a policy declared on something else. A write acts only
|
|
80
|
+
* on the repo the owner actually named; the cost is a refusal on a subdirectory workdir, which is
|
|
81
|
+
* visible on the card and safe.
|
|
82
|
+
*/
|
|
83
|
+
export function switchRepoBranch(workDir, branch) {
|
|
84
|
+
if (!isSwitchableBranchName(branch))
|
|
85
|
+
throw new InspectError(`unusable branch name: ${String(branch)}`);
|
|
86
|
+
const to = branch.trim();
|
|
87
|
+
const base = { ok: false, changed: false, from: null, to, branch: null, dirty: false };
|
|
88
|
+
if (!existsSync(resolve(workDir, ".git")))
|
|
89
|
+
return { ...base, refused: "not-a-repo" };
|
|
90
|
+
const from = currentBranch(workDir);
|
|
91
|
+
if (!from)
|
|
92
|
+
return { ...base, refused: "unknown-head" };
|
|
93
|
+
let dirty;
|
|
94
|
+
try {
|
|
95
|
+
dirty = isDirty(workDir);
|
|
96
|
+
}
|
|
97
|
+
catch (e) {
|
|
98
|
+
return { ...base, from, branch: from, error: e.message?.slice(0, 200) };
|
|
99
|
+
}
|
|
100
|
+
// THE PRECONDITION. Uncommitted work rides along through a checkout; refusing is the only
|
|
101
|
+
// answer that cannot move somebody's diff onto a branch they did not put it on.
|
|
102
|
+
if (dirty)
|
|
103
|
+
return { ...base, from, branch: from, dirty: true, refused: "dirty" };
|
|
104
|
+
if (from === to)
|
|
105
|
+
return { ok: true, changed: false, from, to, branch: from, dirty: false };
|
|
106
|
+
try {
|
|
107
|
+
git(workDir, ["rev-parse", "--verify", "--quiet", `refs/heads/${to}`]);
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
return { ...base, from, branch: from, refused: "unknown-branch" };
|
|
111
|
+
}
|
|
112
|
+
try {
|
|
113
|
+
git(workDir, gitWriteArgv("switch-branch", { branch: to }));
|
|
114
|
+
}
|
|
115
|
+
catch (e) {
|
|
116
|
+
const err = e;
|
|
117
|
+
return { ...base, from, branch: currentBranch(workDir), error: (err.stderr || err.message || "git checkout failed").slice(0, 200) };
|
|
118
|
+
}
|
|
119
|
+
// CONFIRM, do not assume. The exit code says the command ran; only reading HEAD back says where
|
|
120
|
+
// the checkout actually is, and that is the only thing the cloud is allowed to report as done.
|
|
121
|
+
const after = currentBranch(workDir);
|
|
122
|
+
let dirtyAfter = false;
|
|
123
|
+
try {
|
|
124
|
+
dirtyAfter = isDirty(workDir);
|
|
125
|
+
}
|
|
126
|
+
catch { }
|
|
127
|
+
return { ok: after === to, changed: after === to, from, to, branch: after, dirty: dirtyAfter };
|
|
128
|
+
}
|
|
@@ -2,7 +2,8 @@ import { homedir } from "node:os";
|
|
|
2
2
|
import { join, resolve } from "node:path";
|
|
3
3
|
import { RunnerInputError } from "../errors.js";
|
|
4
4
|
import { defaultStatePath, HeadlessSession } from "./headless.js";
|
|
5
|
-
import { InspectError, readGitRemoteOrigin, readRepoFile, repoTree, runRepoGit } from "./inspect.js";
|
|
5
|
+
import { InspectError, readGitRemoteOrigin, readRepoFile, repoSearch, repoTree, runRepoGit } from "./inspect.js";
|
|
6
|
+
import { switchRepoBranch } from "./repo-write.js";
|
|
6
7
|
import { checkWorkdir, ensureRepo, sanitizeSessionName } from "./repo.js";
|
|
7
8
|
/** Hard cap on a pane returned to the brain/console (matches the worker MAX_PANE_CHARS). */
|
|
8
9
|
const MAX_PANE = 64 * 1024;
|
|
@@ -24,7 +25,11 @@ export class CodingRuntime {
|
|
|
24
25
|
* read-only code-inspection endpoints exist, so the cloud offers the grounding tools
|
|
25
26
|
* (older runners omit it → the cloud degrades to terminal-only). */
|
|
26
27
|
static capabilities() {
|
|
27
|
-
|
|
28
|
+
// `coding.repo-write` announces the ONE write verb (#322). Advertised rather than probed, so
|
|
29
|
+
// a reader of the registration can see which machines can restore a branch invariant; the
|
|
30
|
+
// cloud still never trusts it, because an older runner simply 404s and the policy reports
|
|
31
|
+
// that it asked and was not answered.
|
|
32
|
+
return ["coding.sessions", "coding.stream", "human.takeover", "coding.inspect", "coding.repo-write"];
|
|
28
33
|
}
|
|
29
34
|
/**
|
|
30
35
|
* Resolve the workDir for a read-only inspection. Prefer the tracked session's real
|
|
@@ -50,6 +55,26 @@ export class CodingRuntime {
|
|
|
50
55
|
git(input) {
|
|
51
56
|
return runRepoGit(this.resolveWorkDir(input), input.cmd, { path: input.path, n: input.n });
|
|
52
57
|
}
|
|
58
|
+
/**
|
|
59
|
+
* The ONE write the platform may make in a checkout by itself (#322) — put it back on a branch
|
|
60
|
+
* it declared, or refuse. See `repo-write.ts`: fixed argv, clean tree required, nothing in the
|
|
61
|
+
* `checkout .`/`reset`/`clean`/`stash` family exists to be reached.
|
|
62
|
+
*/
|
|
63
|
+
gitWrite(input) {
|
|
64
|
+
if (input.cmd !== "switch-branch")
|
|
65
|
+
throw new InspectError(`unsupported git write command: ${String(input.cmd)}`);
|
|
66
|
+
return switchRepoBranch(this.resolveWorkDir(input), input.branch);
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Find something in the repo — by file CONTENT or by file NAME (#508).
|
|
70
|
+
*
|
|
71
|
+
* The one capability the read-only inspection surface never had. Without it, locating a file
|
|
72
|
+
* meant walking `tree` by hand, and a path deeper than the tree's four-level cap was not
|
|
73
|
+
* reachable in any number of calls that did not already know the answer.
|
|
74
|
+
*/
|
|
75
|
+
search(input) {
|
|
76
|
+
return repoSearch(this.resolveWorkDir(input), input);
|
|
77
|
+
}
|
|
53
78
|
/** Bounded recursive file tree of the session's repo (names/type/size only). */
|
|
54
79
|
tree(input) {
|
|
55
80
|
return repoTree(this.resolveWorkDir(input), input.path, input.maxDepth, input.maxEntries);
|
|
@@ -214,6 +214,19 @@ async function route(runner, req, res) {
|
|
|
214
214
|
return json(res, 400, { error: e instanceof Error ? e.message : String(e) });
|
|
215
215
|
}
|
|
216
216
|
}
|
|
217
|
+
// Find a file by content or by name (#508). A SEPARATE endpoint rather than another `/coding/git`
|
|
218
|
+
// command on purpose: an older runner answers an unknown path with a 404, which the cloud reads
|
|
219
|
+
// unambiguously as "this machine is too old" — an unknown `cmd` would arrive as a 400 that reads
|
|
220
|
+
// identically to a real search failure.
|
|
221
|
+
if (req.method === "POST" && path === "/coding/search") {
|
|
222
|
+
const b = await readJson(req);
|
|
223
|
+
try {
|
|
224
|
+
return json(res, 200, runner.coding.search(b));
|
|
225
|
+
}
|
|
226
|
+
catch (e) {
|
|
227
|
+
return json(res, 400, { error: e instanceof Error ? e.message : String(e) });
|
|
228
|
+
}
|
|
229
|
+
}
|
|
217
230
|
if (req.method === "POST" && path === "/coding/git") {
|
|
218
231
|
const b = await readJson(req);
|
|
219
232
|
try {
|
|
@@ -223,6 +236,18 @@ async function route(runner, req, res) {
|
|
|
223
236
|
return json(res, 400, { error: e instanceof Error ? e.message : String(e) });
|
|
224
237
|
}
|
|
225
238
|
}
|
|
239
|
+
// The ONE write surface (#322). A standing policy may put a checkout back on the branch it
|
|
240
|
+
// declared; it may not commit, discard, or touch a remote. An older runner 404s this, which the
|
|
241
|
+
// cloud reports as "asked, not confirmed" rather than as done.
|
|
242
|
+
if (req.method === "POST" && path === "/coding/git-write") {
|
|
243
|
+
const b = await readJson(req);
|
|
244
|
+
try {
|
|
245
|
+
return json(res, 200, runner.coding.gitWrite(b));
|
|
246
|
+
}
|
|
247
|
+
catch (e) {
|
|
248
|
+
return json(res, 400, { error: e instanceof Error ? e.message : String(e) });
|
|
249
|
+
}
|
|
250
|
+
}
|
|
226
251
|
if (req.method === "POST" && path === "/coding/git-remote") {
|
|
227
252
|
const b = await readJson(req);
|
|
228
253
|
try {
|