@proagentstore/cli 0.4.139 → 0.4.141

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.
@@ -0,0 +1,128 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { existsSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { InspectError } from "./inspect.js";
5
+ /**
6
+ * Set a parked run's uncommitted work aside, and put it back (#1077).
7
+ *
8
+ * ── Why this exists beside `repo-write.ts`, whose rule it bends
9
+ *
10
+ * `repo-write.ts` closes the platform's unattended write vocabulary at two pointer moves and says why
11
+ * nothing in the `stash`/`reset`/`clean` family is reachable: those are how work gets swallowed. A
12
+ * run parked on a question (#1076) has to release its checkout so the next objective can run (#1077,
13
+ * the design on #1072 §B), and a checkout with somebody's edits in it cannot be handed to another
14
+ * run as it is. So the work is set aside — and every way that could lose a byte is closed here:
15
+ *
16
+ * * The save is `git stash push --include-untracked` with a message naming the run: git's own
17
+ * atomic save-then-clean, which writes the whole tree (tracked, staged and untracked; ignored
18
+ * files are never touched) into a commit BEFORE it cleans anything.
19
+ * * The new entry is identified by that message — never by `stash@{0}` alone — and its exact
20
+ * commit id is pinned on a ref of its own, `refs/pags/parked/<run>`. Nothing here pops, drops or
21
+ * clears a stash, so the entry stays in `git stash list` as a second, visible copy, and the ref
22
+ * keeps the commit reachable even if somebody drops the entry by hand. A ref under `refs/pags/`
23
+ * is not a branch and is not pushed.
24
+ * * The restore applies by that exact id, only onto a clean tree, and only after checking it
25
+ * applies cleanly — tracked changes with `git apply --check`, untracked files by refusing any that
26
+ * already exist. A restore that would conflict changes NOTHING and says so; the work stays on the
27
+ * ref for a person (or #1078's reconciliation) to bring back.
28
+ *
29
+ * Fixed argv, no shell. The run id is the only caller token, and it must be a uuid.
30
+ */
31
+ const RUN_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
32
+ export const parkRef = (runId) => `refs/pags/parked/${runId}`;
33
+ const parkMessage = (runId) => `pags-park ${runId}`;
34
+ function git(workDir, argv, input) {
35
+ return execFileSync("git", argv, { cwd: workDir, encoding: "utf-8", timeout: 60_000, maxBuffer: 64 * 1024 * 1024, input, stdio: ["pipe", "pipe", "pipe"] });
36
+ }
37
+ function checkout(workDir, runId) {
38
+ if (!RUN_ID.test(runId))
39
+ throw new InspectError(`unusable run id: ${runId}`);
40
+ if (!existsSync(join(workDir, ".git")))
41
+ throw new InspectError(`not a git checkout: ${workDir}`);
42
+ }
43
+ /** Every path git would report changed, untracked included — the whole working-tree state. */
44
+ function changes(workDir) {
45
+ return git(workDir, ["status", "--porcelain=v1", "--untracked-files=all"]).split("\n").filter(Boolean);
46
+ }
47
+ /** Save the parked run's uncommitted work and leave the checkout clean. Idempotent per run. */
48
+ export function parkSnapshot(workDir, runId) {
49
+ checkout(workDir, runId);
50
+ const ref = parkRef(runId);
51
+ const existing = (() => {
52
+ try {
53
+ return git(workDir, ["rev-parse", "--verify", "--quiet", ref]).trim() || null;
54
+ }
55
+ catch {
56
+ return null;
57
+ }
58
+ })();
59
+ const before = changes(workDir);
60
+ // A replay of a park that already saved: the work is on the ref; report it rather than save a second copy.
61
+ if (existing)
62
+ return { snapshot: existing, files: before.length, clean: before.length === 0, ref };
63
+ if (before.length === 0)
64
+ return { snapshot: null, files: 0, clean: true, ref: null };
65
+ git(workDir, ["stash", "push", "--include-untracked", "-m", parkMessage(runId)]);
66
+ // The entry git just wrote, found by its message: never assume `stash@{0}` is ours.
67
+ const list = git(workDir, ["stash", "list", "--format=%H %s"]).split("\n").filter(Boolean);
68
+ const mine = list.find((l) => l.endsWith(parkMessage(runId)));
69
+ if (!mine)
70
+ throw new InspectError("the stash entry for this park was not found after saving it; nothing was released");
71
+ const snapshot = mine.split(" ")[0];
72
+ git(workDir, ["update-ref", ref, snapshot]);
73
+ return { snapshot, files: before.length, clean: changes(workDir).length === 0, ref };
74
+ }
75
+ /** The paths `git apply --check` names when it refuses — "patch failed: <path>:<line>", "<path>: does not match index", … */
76
+ export function applyConflicts(stderr) {
77
+ const out = new Set();
78
+ for (const line of stderr.split("\n")) {
79
+ const m = line.match(/^error: patch failed: (.+):\d+$/) ?? line.match(/^error: (.+): (?:does not match index|already exists in working directory|does not exist in index|No such file or directory)$/);
80
+ if (m?.[1])
81
+ out.add(m[1]);
82
+ }
83
+ return [...out];
84
+ }
85
+ /** Put a parked run's work back by its exact id — only onto a clean tree, only if it applies cleanly. */
86
+ export function parkRestore(workDir, runId, snapshot) {
87
+ checkout(workDir, runId);
88
+ if (!/^[0-9a-f]{40}([0-9a-f]{24})?$/i.test(snapshot))
89
+ throw new InspectError(`unusable snapshot id: ${snapshot}`);
90
+ const ref = parkRef(runId);
91
+ let pinned = "";
92
+ try {
93
+ pinned = git(workDir, ["rev-parse", "--verify", "--quiet", ref]).trim();
94
+ }
95
+ catch {
96
+ pinned = "";
97
+ }
98
+ if (pinned !== snapshot)
99
+ return { restored: false, refused: `the saved work is not at ${ref} on this machine (expected ${snapshot})` };
100
+ const dirty = changes(workDir);
101
+ if (dirty.length)
102
+ return { restored: false, refused: `the checkout is not clean (${dirty.length} changed file${dirty.length === 1 ? "" : "s"}), so the saved work was not put back on top of it` };
103
+ // Tracked changes: the stash's own diff against its base, checked against the current tree.
104
+ const patch = git(workDir, ["diff", "--binary", `${snapshot}^1`, snapshot]);
105
+ if (patch.trim()) {
106
+ try {
107
+ git(workDir, ["apply", "--check"], patch);
108
+ }
109
+ catch (e) {
110
+ const conflicts = applyConflicts(String(e.stderr ?? ""));
111
+ return { restored: false, refused: "the saved work no longer applies cleanly to this checkout (it changed underneath while the run was parked)", conflicts };
112
+ }
113
+ }
114
+ // Untracked files: the stash's third parent. Any that already exists would be refused by git mid-apply.
115
+ let untracked = [];
116
+ try {
117
+ untracked = git(workDir, ["ls-tree", "-r", "--name-only", `${snapshot}^3`]).split("\n").filter(Boolean);
118
+ }
119
+ catch {
120
+ untracked = [];
121
+ }
122
+ const clash = untracked.filter((p) => existsSync(join(workDir, p)));
123
+ if (clash.length)
124
+ return { restored: false, refused: `${clash.length} file${clash.length === 1 ? "" : "s"} the saved work would create already exist${clash.length === 1 ? "s" : ""}`, conflicts: clash };
125
+ git(workDir, ["stash", "apply", snapshot]);
126
+ git(workDir, ["update-ref", "-d", ref, snapshot]);
127
+ return { restored: true };
128
+ }
@@ -1,4 +1,5 @@
1
1
  import { actDigest } from "./input-ledger.js";
2
+ import { parkRestore, parkSnapshot } from "./park-snapshot.js";
2
3
  import { runObjective } from "./run-objective.js";
3
4
  import { homedir } from "node:os";
4
5
  import { join, resolve } from "node:path";
@@ -94,6 +95,22 @@ export class CodingRuntime {
94
95
  return resolve(input.workDir.replace(/^~(?=$|\/)/, homedir()));
95
96
  throw new InspectError("no session or workDir to inspect");
96
97
  }
98
+ /** Where a parked run's checkout is (#1077): its declared folder, else the managed clone dir `start()` uses. */
99
+ parkWorkDir(input) {
100
+ if (input.workDir)
101
+ return resolve(input.workDir.replace(/^~(?=$|\/)/, homedir()));
102
+ if (input.repoId)
103
+ return join(this.reposBaseDir, sanitizeSessionName(input.repoId));
104
+ throw new InspectError("no workDir or repoId for the parked checkout");
105
+ }
106
+ /** Set a parked run's uncommitted work aside on its own ref and leave the checkout clean (#1077, `park-snapshot.ts`). */
107
+ parkSnapshot(input) {
108
+ return parkSnapshot(this.parkWorkDir(input), input.runId);
109
+ }
110
+ /** Put a parked run's work back by its exact id — onto a clean tree, only if it applies cleanly (#1077). */
111
+ parkRestore(input) {
112
+ return parkRestore(this.parkWorkDir(input), input.runId, input.snapshot);
113
+ }
97
114
  /** Read one file inside the session's repo (traversal-guarded, size-capped). */
98
115
  readFile(input) {
99
116
  return readRepoFile(this.resolveWorkDir(input), input.path, input.maxBytes, { startLine: input.startLine, startColumn: input.startColumn });
@@ -503,6 +503,17 @@ async function route(runner, req, res) {
503
503
  return json(res, 400, { error: e instanceof Error ? e.message : String(e) });
504
504
  }
505
505
  }
506
+ // A parked run's checkout, set aside and put back (#1077, `coding/park-snapshot.ts`). An older runner
507
+ // 404s these, and the cloud then keeps the checkout held — exactly what happened before them.
508
+ if (req.method === "POST" && (path === "/coding/park-snapshot" || path === "/coding/park-restore")) {
509
+ const b = await readJson(req);
510
+ try {
511
+ return json(res, 200, path === "/coding/park-snapshot" ? runner.coding.parkSnapshot(b) : runner.coding.parkRestore({ ...b, snapshot: String(b.snapshot ?? "") }));
512
+ }
513
+ catch (e) {
514
+ return json(res, 400, { error: e instanceof Error ? e.message : String(e) });
515
+ }
516
+ }
506
517
  if (req.method === "POST" && path === "/coding/git-remote") {
507
518
  const b = await readJson(req);
508
519
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@proagentstore/cli",
3
- "version": "0.4.139",
3
+ "version": "0.4.141",
4
4
  "description": "CLI for creating, publishing, and running ProAgentStore agents",
5
5
  "license": "MIT",
6
6
  "type": "module",