pi-shorthand 0.3.1 → 0.3.2
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 +15 -82
- package/display.ts +6 -7
- package/file-outcomes.ts +100 -0
- package/index.ts +39 -39
- package/overlay-linux.ts +0 -7
- package/overlay-macos.ts +4 -9
- package/package.json +1 -1
- package/placement.ts +21 -0
- package/prelude.ts +47 -22
- package/runner.ts +106 -82
- package/skills/shorthand/advanced-refactors.md +9 -4
- package/history.ts +0 -252
package/README.md
CHANGED
|
@@ -1,100 +1,33 @@
|
|
|
1
1
|
# pi-shorthand
|
|
2
2
|
|
|
3
|
-

|
|
4
4
|
|
|
5
5
|
A [Pi](https://github.com/earendil-works/pi) tool for editing repositories with Bun programs.
|
|
6
|
-
The model can combine ordinary JavaScript, text edits and structural transformations in one call.
|
|
7
|
-
|
|
8
|
-
The program sees your repo as normal, but its writes are held back. By default, they're applied only
|
|
9
|
-
if the program succeeds and destination files are unchanged, and the model gets the diff.
|
|
10
|
-
The program performs the edit; tests, type-checks and other verification run separately afterward.
|
|
11
6
|
|
|
12
7
|
## Install
|
|
13
8
|
|
|
9
|
+
Install pi-shorthand and initialize Grit:
|
|
10
|
+
|
|
14
11
|
```sh
|
|
15
12
|
pi install npm:pi-shorthand
|
|
13
|
+
npx @getgrit/cli init --global
|
|
16
14
|
```
|
|
17
15
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
A project install (`pi install -l`) only loads once you trust the project: Pi asks, or run `pi --approve`.
|
|
21
|
-
|
|
22
|
-
You also need Bun, git, and either [bubblewrap](https://github.com/containers/bubblewrap) 0.9+
|
|
23
|
-
(Linux) or [AgentFS](https://github.com/tursodatabase/agentfs) and `clang` (macOS:
|
|
24
|
-
`curl -fsSL https://agentfs.ai/install | bash`).
|
|
16
|
+
On macOS, install clang and AgentFS:
|
|
25
17
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
## What a program can use
|
|
31
|
-
|
|
32
|
-
Anything in Bun or Node, plus these globals (no imports):
|
|
33
|
-
|
|
34
|
-
```ts
|
|
35
|
-
await $`git ls-files`.text(); // Bun's shell (the only async one)
|
|
36
|
-
glob("src/**/*.ts"); // → ["src/a.ts", …]
|
|
37
|
-
grep("oldApi(", "src"); // → [{ file, line, text }, …]
|
|
38
|
-
sg.find("oldApi($$$ARGS)", "src"); // ast-grep search
|
|
39
|
-
sg.rewrite("oldApi($$$ARGS)", "newApi($$$ARGS)", "src");
|
|
40
|
-
sg.insert("initialize();", { before: sg.one("run();", "src/app.ts") });
|
|
41
|
-
sg.move(sg.one("function helper() { $$$BODY }", "src/old.ts"), { endOf: sg.file("src/new.ts") });
|
|
42
|
-
sg.remove(sg.one("obsolete();", "src/app.ts"));
|
|
43
|
-
sg.parse(sg.Lang.TypeScript, source); // ast-grep's own API (or import from "@ast-grep/napi")
|
|
44
|
-
grit("`console.log($x)` => `logger.info($x)`", "src");
|
|
18
|
+
```sh
|
|
19
|
+
xcode-select --install
|
|
20
|
+
curl -fsSL https://agentfs.ai/install | bash
|
|
45
21
|
```
|
|
46
22
|
|
|
47
|
-
|
|
48
|
-
in arrays mixed with paths. Search reads the file's current contents. Missing targets are valid insertion
|
|
49
|
-
destinations but cannot be searched. An explicit target can select an ignored file inside the workspace;
|
|
50
|
-
the tool still only applies Git-visible changes.
|
|
23
|
+
On Linux, install bubblewrap 0.9 or later. For Debian and Ubuntu:
|
|
51
24
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
globals. Bun execution does not automatically type-check programs or run application verification.
|
|
56
|
-
|
|
57
|
-
## Options
|
|
58
|
-
|
|
59
|
-
- `title`: a short description shown with the call (required).
|
|
60
|
-
- `rollback`: `"all"` (default) applies nothing if the program fails. After a timeout, `"file"`
|
|
61
|
-
keeps changed files that were no longer open for writing if writer inspection succeeds. Other
|
|
62
|
-
failures apply nothing because open writers cannot be identified after the process exits.
|
|
63
|
-
- `timeout`: seconds before the program is killed. Default 2.
|
|
64
|
-
|
|
65
|
-
## Good to know
|
|
66
|
-
|
|
67
|
-
- Only files git tracks, or would track, are applied.
|
|
68
|
-
- Successful edits are formatted with detected installed project tools (Prettier, oxfmt, Biome, Ruff,
|
|
69
|
-
Black, gofmt or rustfmt) before the final diff. Ambiguous setups are skipped; formatter failures warn
|
|
70
|
-
without discarding completed edits. Set `PI_SHORTHAND_FORMAT=0` to disable. No project config is required.
|
|
71
|
-
- Each run snapshots the checkout first; reflinks make that cheap where supported, while other
|
|
72
|
-
filesystems copy its contents and use corresponding temporary space. On macOS the program runs at
|
|
73
|
-
a private AgentFS mount, so use paths relative to its working directory for repository files.
|
|
74
|
-
- Generated programs can write to the private workspace and run-specific temporary space; host files
|
|
75
|
-
outside those roots are read-only, including targets reached through repository symlinks. Run-history
|
|
76
|
-
logging has a narrow write exception. On macOS, the live checkout is also unreadable.
|
|
77
|
-
- Runs against the same checkout are serialized. If another process edits a destination while a run
|
|
78
|
-
is in progress, shorthand checks it again immediately before replacing it and reports a conflict.
|
|
79
|
-
A non-cooperating writer can still race the final filesystem rename or removal itself.
|
|
80
|
-
- To try it with only `read` and `code`: `pi --tools read,code`.
|
|
81
|
-
- Run history is stored in `~/.cache/pi-shorthand/runs.jsonl` with directory mode `0700` and file
|
|
82
|
-
mode `0600`. It records timestamps, opaque run IDs, lifecycle events, exit status, durations,
|
|
83
|
-
counts, helper names, and shell executable names. It does not record programs, output, errors,
|
|
84
|
-
arguments, repository paths, file paths, or diffs. The log rotates at 1 MiB and expires after
|
|
85
|
-
seven days. Set `PI_SHORTHAND_HISTORY=0` to disable it. To watch enabled history:
|
|
86
|
-
`tail -f ~/.cache/pi-shorthand/runs.jsonl`.
|
|
87
|
-
|
|
88
|
-
## Developing
|
|
25
|
+
```sh
|
|
26
|
+
sudo apt install bubblewrap
|
|
27
|
+
```
|
|
89
28
|
|
|
90
|
-
|
|
91
|
-
hook runs it; `npm run format` fixes formatting.
|
|
29
|
+
## Technical choices
|
|
92
30
|
|
|
93
|
-
|
|
94
|
-
`test/linux.sh` runs them on Linux in Docker.
|
|
31
|
+
pi-shorthand gives Pi a programming environment instead of a patch format. This makes multi-file and structural edits possible in one call, but does not guarantee that Pi will find it easier or more reliable than its built-in edit tool. Which works better depends on the model and the task.
|
|
95
32
|
|
|
96
|
-
|
|
97
|
-
The [comparison harness](e2e/README.md) supports stock, optional and replacement editing tools, controlled
|
|
98
|
-
documentation/skills, independent task checks, saved final changes and session reports.
|
|
99
|
-
`bun e2e/run.ts --repo <path or git URL> --task "…" --setups baseline,replace,code --check "…"` runs Pi
|
|
100
|
-
with a real model on fresh copies of your own repository.
|
|
33
|
+
Programs edit an isolated snapshot, with host files outside the repository kept read-only. By default, a failure keeps completed files and rolls back files involved in failed or interrupted edits; changes are applied only if their destination files have not changed. Progress is reported while a program runs, but no run history is written to disk.
|
package/display.ts
CHANGED
|
@@ -21,7 +21,7 @@ const LISTED_FILES = 8; // …showing this many, then "and N more files"
|
|
|
21
21
|
const EXPANDED_DIFF_LINES = 2000; // even expanded, a diff of hundreds of files stops here
|
|
22
22
|
|
|
23
23
|
export function callLine(args: { title?: string; timeout?: number; rollback?: string }, theme: Theme): string {
|
|
24
|
-
const settings = [args.rollback === "
|
|
24
|
+
const settings = [args.rollback === "all" && "rollback all", args.timeout && `timeout ${args.timeout}s`];
|
|
25
25
|
const suffix = settings.filter(Boolean).join(", ");
|
|
26
26
|
return `${theme.fg("toolTitle", theme.bold("code"))} ${args.title ?? ""}${suffix ? theme.fg("muted", ` (${suffix})`) : ""}`;
|
|
27
27
|
}
|
|
@@ -69,17 +69,16 @@ export function resultLines(run: RunResult, expanded: boolean, theme: Theme): st
|
|
|
69
69
|
}
|
|
70
70
|
for (const file of run.rolledBack) {
|
|
71
71
|
const reason = run.writerInspectionFailed
|
|
72
|
-
? "open writers could not be inspected
|
|
73
|
-
:
|
|
74
|
-
? "half-written when the program was killed"
|
|
75
|
-
: "finished writes unknown after the program exited";
|
|
72
|
+
? "open writers could not be inspected"
|
|
73
|
+
: "file edit failed or was interrupted";
|
|
76
74
|
lines.push(indent(theme.fg("warning", `rolled back ${file}: ${reason}`)));
|
|
77
75
|
}
|
|
78
76
|
for (const warning of [...run.warnings, ...printedWarnings]) lines.push(indent(theme.fg("warning", `⚠ ${warning}`)));
|
|
79
77
|
|
|
80
78
|
// The sections, each after a blank line.
|
|
81
79
|
const sections: string[][] = [];
|
|
82
|
-
const background: ToolBackground =
|
|
80
|
+
const background: ToolBackground =
|
|
81
|
+
run.exitCode === 0 && run.conflicts.length === 0 && run.rolledBack.length === 0 ? "toolSuccessBg" : "toolErrorBg";
|
|
83
82
|
if (applied.length > 0) sections.push(diffLines(applied, expanded, theme, background));
|
|
84
83
|
if (output && (expanded || !error)) sections.push(outputLines(output, expanded, run.changes.length > 0, theme));
|
|
85
84
|
if (notApplied.length > 0) sections.push(notAppliedLines(notApplied, expanded, theme, background));
|
|
@@ -104,7 +103,7 @@ function verdict(run: RunResult, applied: FileChange[], theme: Theme): string {
|
|
|
104
103
|
return theme.fg("error", `✕ Conflict · ${application}${program}`) + took;
|
|
105
104
|
}
|
|
106
105
|
if (run.exitCode === 0 && run.changes.length === 0) return theme.fg("success", "✓ No changes") + took;
|
|
107
|
-
if (run.exitCode === 0) {
|
|
106
|
+
if (run.exitCode === 0 && run.rolledBack.length === 0) {
|
|
108
107
|
return theme.fg("success", `✓ Applied ${fileCount(applied)}`) + ` · ${stats(applied, theme)}` + took;
|
|
109
108
|
}
|
|
110
109
|
if (applied.length > 0) {
|
package/file-outcomes.ts
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/** Per-file edit outcomes sent to the runner over a private inherited descriptor. */
|
|
2
|
+
import { spawnSync } from "node:child_process";
|
|
3
|
+
import { realpathSync, writeSync } from "node:fs";
|
|
4
|
+
import { isAbsolute, relative, resolve } from "node:path";
|
|
5
|
+
|
|
6
|
+
export type FileOutcomeEvent =
|
|
7
|
+
| { type: "begin"; id: number; files: string[] }
|
|
8
|
+
| { type: "end" | "fail"; id: number }
|
|
9
|
+
| { type: "error"; files: string[] }
|
|
10
|
+
| { type: "writers"; files: string[] | null };
|
|
11
|
+
|
|
12
|
+
const descriptor = process.env.PI_SHORTHAND_OUTCOMES_FD;
|
|
13
|
+
const root = process.env.PI_SHORTHAND_EXECUTION_ROOT;
|
|
14
|
+
const forceInspectionFailure = process.env.PI_SHORTHAND_INSPECTION_FAILURE === "1";
|
|
15
|
+
// Subprocesses do not inherit descriptor 3 by default, so do not advertise it to them.
|
|
16
|
+
delete process.env.PI_SHORTHAND_OUTCOMES_FD;
|
|
17
|
+
delete process.env.PI_SHORTHAND_EXECUTION_ROOT;
|
|
18
|
+
delete process.env.PI_SHORTHAND_INSPECTION_FAILURE;
|
|
19
|
+
let nextId = 0;
|
|
20
|
+
|
|
21
|
+
function send(event: FileOutcomeEvent): void {
|
|
22
|
+
if (!descriptor) return;
|
|
23
|
+
const bytes = Buffer.from(JSON.stringify(event) + "\n");
|
|
24
|
+
let offset = 0;
|
|
25
|
+
while (offset < bytes.length) offset += writeSync(Number(descriptor), bytes, offset);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function paths(files: readonly string[]): string[] {
|
|
29
|
+
if (!root) return [];
|
|
30
|
+
return [
|
|
31
|
+
...new Set(
|
|
32
|
+
files
|
|
33
|
+
.filter((file) => typeof file === "string")
|
|
34
|
+
.flatMap((file) => {
|
|
35
|
+
const absolute = resolve(file);
|
|
36
|
+
try {
|
|
37
|
+
return [relative(root, absolute), relative(root, realpathSync(absolute))];
|
|
38
|
+
} catch {
|
|
39
|
+
return [relative(root, absolute)];
|
|
40
|
+
}
|
|
41
|
+
}),
|
|
42
|
+
),
|
|
43
|
+
].filter((file) => file !== ".." && !file.startsWith("../") && !isAbsolute(file));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** An operation may touch several files (for example, moving a node between files). */
|
|
47
|
+
export function editingFiles<T>(files: readonly string[], edit: () => T): T {
|
|
48
|
+
if (!descriptor) return edit();
|
|
49
|
+
const id = nextId++;
|
|
50
|
+
send({ type: "begin", id, files: paths(files) });
|
|
51
|
+
try {
|
|
52
|
+
const result = edit();
|
|
53
|
+
send({ type: "end", id });
|
|
54
|
+
return result;
|
|
55
|
+
} catch (error) {
|
|
56
|
+
send({ type: "fail", id });
|
|
57
|
+
throw error;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function installFileOutcomeTracking(): void {
|
|
62
|
+
if (!descriptor) return;
|
|
63
|
+
process.on("uncaughtExceptionMonitor", (error) => {
|
|
64
|
+
// Native filesystem errors identify their paths even when no editing helper was involved.
|
|
65
|
+
const details = (typeof error === "object" && error !== null ? error : {}) as { path?: unknown; dest?: unknown };
|
|
66
|
+
const files = [details.path, details.dest].filter((file): file is string => typeof file === "string");
|
|
67
|
+
send({ type: "error", files: paths(files) });
|
|
68
|
+
});
|
|
69
|
+
process.on("exit", (code) => {
|
|
70
|
+
if (code === 0) return;
|
|
71
|
+
send({ type: "writers", files: inspectWriters() });
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Exit handlers run before descriptors close, so ordinary errors can be inspected too. */
|
|
76
|
+
function inspectWriters(): string[] | null {
|
|
77
|
+
if (!root || forceInspectionFailure) return null;
|
|
78
|
+
// bubblewrap replaces /dev, hiding the host's message-queue mount. Exempt that unrelated
|
|
79
|
+
// mount from stat probes; otherwise lsof reports incomplete output for every Linux run.
|
|
80
|
+
const args = ["-n", "-P", "-F", "an", ...(process.platform === "linux" ? ["-e", "/dev/mqueue"] : [])];
|
|
81
|
+
const result = spawnSync("lsof", args, {
|
|
82
|
+
encoding: "utf8",
|
|
83
|
+
timeout: 2000,
|
|
84
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
85
|
+
});
|
|
86
|
+
if (result.error || result.status !== 0 || result.stderr) return null;
|
|
87
|
+
return parseOpenWriters(result.stdout, root);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function parseOpenWriters(output: string, directory: string): string[] {
|
|
91
|
+
const files = new Set<string>();
|
|
92
|
+
let access = "";
|
|
93
|
+
for (const line of output.split("\n")) {
|
|
94
|
+
if (line.startsWith("f")) access = "";
|
|
95
|
+
if (line.startsWith("a")) access = line.slice(1);
|
|
96
|
+
if (line.startsWith(`n${directory}/`) && (access === "w" || access === "u"))
|
|
97
|
+
files.add(relative(directory, line.slice(1)));
|
|
98
|
+
}
|
|
99
|
+
return [...files];
|
|
100
|
+
}
|
package/index.ts
CHANGED
|
@@ -4,15 +4,15 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import { spawn } from "node:child_process";
|
|
7
|
-
import {
|
|
7
|
+
import { writeFileSync } from "node:fs";
|
|
8
8
|
import { tmpdir } from "node:os";
|
|
9
9
|
import * as path from "node:path";
|
|
10
|
+
import type { Readable } from "node:stream";
|
|
10
11
|
import { StringEnum } from "@earendil-works/pi-ai";
|
|
11
12
|
import { type ExtensionAPI, type Theme, truncateHead, truncateTail } from "@earendil-works/pi-coding-agent";
|
|
12
13
|
import { Text } from "@earendil-works/pi-tui";
|
|
13
14
|
import { Type } from "typebox";
|
|
14
15
|
import { callLine, countLines, fileMetadataSummary, resultLines, unstructuredResultText } from "./display.ts";
|
|
15
|
-
import { RUN_HISTORY_FILE } from "./history.ts";
|
|
16
16
|
import type { FileChange, RunOptions, RunResult } from "./runner.ts";
|
|
17
17
|
|
|
18
18
|
// Runs typically take well under a second. Longer transformations can request more time.
|
|
@@ -34,14 +34,19 @@ export default function (pi: ExtensionAPI) {
|
|
|
34
34
|
// rather than throwing, since a thrown error loses them.)
|
|
35
35
|
pi.on("tool_result", async (event) => {
|
|
36
36
|
const run = event.details as RunResult | undefined;
|
|
37
|
-
if (
|
|
37
|
+
if (
|
|
38
|
+
event.toolName === "code" &&
|
|
39
|
+
run &&
|
|
40
|
+
(run.exitCode !== 0 || run.conflicts.length > 0 || run.rolledBack.length > 0)
|
|
41
|
+
)
|
|
42
|
+
return { isError: true };
|
|
38
43
|
});
|
|
39
44
|
|
|
40
45
|
pi.registerTool({
|
|
41
46
|
name: "code",
|
|
42
47
|
label: "Code",
|
|
43
48
|
description: DESCRIPTION,
|
|
44
|
-
promptSnippet: "Make a change with one
|
|
49
|
+
promptSnippet: "Make a change with one Bun editing program; run verification separately afterward",
|
|
45
50
|
|
|
46
51
|
parameters: Type.Object({
|
|
47
52
|
title: Type.String({ description: "A few words describing the change, shown to the user" }),
|
|
@@ -49,19 +54,18 @@ export default function (pi: ExtensionAPI) {
|
|
|
49
54
|
rollback: Type.Optional(
|
|
50
55
|
StringEnum(["all", "file"] as const, {
|
|
51
56
|
description:
|
|
52
|
-
'On failure: "
|
|
57
|
+
'On failure: "file" (default) rolls back failed or interrupted file edits and retains the others; "all" applies nothing',
|
|
53
58
|
}),
|
|
54
59
|
),
|
|
55
60
|
timeout: Type.Optional(Type.Number({ description: "Seconds before the program is killed (default 2)" })),
|
|
56
61
|
}),
|
|
57
62
|
|
|
58
63
|
async execute(toolCallId, params, signal, onUpdate, ctx) {
|
|
59
|
-
// While it runs, show how long it's been going and the latest step
|
|
60
|
-
const runId = Math.random().toString(36).slice(2, 10);
|
|
64
|
+
// While it runs, show how long it's been going and the latest reported step.
|
|
61
65
|
const startedAt = Date.now();
|
|
66
|
+
let latest: string | undefined;
|
|
62
67
|
const progress = setInterval(() => {
|
|
63
68
|
const elapsed = `${((Date.now() - startedAt) / 1000).toFixed(1)} s`;
|
|
64
|
-
const latest = latestStep(runId);
|
|
65
69
|
onUpdate?.({
|
|
66
70
|
content: [{ type: "text", text: "running" }],
|
|
67
71
|
details: { progress: latest ? `${elapsed} · ${latest}` : elapsed },
|
|
@@ -70,13 +74,13 @@ export default function (pi: ExtensionAPI) {
|
|
|
70
74
|
|
|
71
75
|
const result = await runWithBun(
|
|
72
76
|
{
|
|
73
|
-
runId,
|
|
74
77
|
cwd: ctx.cwd,
|
|
75
78
|
program: params.program,
|
|
76
79
|
timeoutMs: (params.timeout ?? DEFAULT_TIMEOUT_SECONDS) * 1000,
|
|
77
|
-
rollback: params.rollback ?? "
|
|
80
|
+
rollback: params.rollback ?? "file",
|
|
78
81
|
},
|
|
79
82
|
signal,
|
|
83
|
+
(step) => (latest = step),
|
|
80
84
|
).finally(() => clearInterval(progress));
|
|
81
85
|
return {
|
|
82
86
|
content: [{ type: "text", text: textForModel(result, toolCallId) }],
|
|
@@ -113,10 +117,17 @@ export function renderCodeResult(
|
|
|
113
117
|
* to stop (it then puts the repository back and applies nothing). It has its own process group,
|
|
114
118
|
* which is killed afterwards so no subprocess the program started is left behind.
|
|
115
119
|
*/
|
|
116
|
-
export function runWithBun(
|
|
120
|
+
export function runWithBun(
|
|
121
|
+
options: RunOptions,
|
|
122
|
+
signal?: AbortSignal,
|
|
123
|
+
onProgress?: (step: string) => void,
|
|
124
|
+
): Promise<RunResult> {
|
|
117
125
|
return new Promise((resolve, reject) => {
|
|
118
126
|
if (signal?.aborted) return reject(new Error("Aborted"));
|
|
119
|
-
const runner = spawn("bun", [path.join(import.meta.dirname, "runner.ts")], {
|
|
127
|
+
const runner = spawn("bun", [path.join(import.meta.dirname, "runner.ts")], {
|
|
128
|
+
detached: true,
|
|
129
|
+
stdio: ["pipe", "pipe", "pipe", "pipe"],
|
|
130
|
+
});
|
|
120
131
|
const stop = () => runner.kill("SIGTERM");
|
|
121
132
|
signal?.addEventListener("abort", stop);
|
|
122
133
|
|
|
@@ -126,6 +137,22 @@ export function runWithBun(options: RunOptions, signal?: AbortSignal): Promise<R
|
|
|
126
137
|
runner.stderr.setEncoding("utf8");
|
|
127
138
|
runner.stdout.on("data", (chunk) => (stdout += chunk));
|
|
128
139
|
runner.stderr.on("data", (chunk) => (stderr += chunk));
|
|
140
|
+
let progressBuffer = "";
|
|
141
|
+
const progress = runner.stdio[3] as Readable;
|
|
142
|
+
progress.setEncoding("utf8");
|
|
143
|
+
progress.on("data", (chunk) => {
|
|
144
|
+
progressBuffer += chunk;
|
|
145
|
+
const lines = progressBuffer.split("\n");
|
|
146
|
+
progressBuffer = lines.pop() ?? "";
|
|
147
|
+
for (const line of lines) {
|
|
148
|
+
try {
|
|
149
|
+
const event = JSON.parse(line) as { step?: unknown };
|
|
150
|
+
if (typeof event.step === "string") onProgress?.(event.step);
|
|
151
|
+
} catch {
|
|
152
|
+
// Progress is advisory; malformed events do not affect the run.
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
});
|
|
129
156
|
runner.on("error", reject);
|
|
130
157
|
runner.on("close", (code) => {
|
|
131
158
|
signal?.removeEventListener("abort", stop);
|
|
@@ -145,33 +172,6 @@ export function runWithBun(options: RunOptions, signal?: AbortSignal): Promise<R
|
|
|
145
172
|
});
|
|
146
173
|
}
|
|
147
174
|
|
|
148
|
-
/**
|
|
149
|
-
* The latest step logged for a run, e.g. "$ find / -name x" or "grep (18 ms)". Reads only the end of
|
|
150
|
-
* the log, which is shared by every run.
|
|
151
|
-
*/
|
|
152
|
-
function latestStep(runId: string): string | undefined {
|
|
153
|
-
let tail: string;
|
|
154
|
-
try {
|
|
155
|
-
const size = statSync(RUN_HISTORY_FILE).size;
|
|
156
|
-
const length = Math.min(size, 64 * 1024);
|
|
157
|
-
const buffer = Buffer.alloc(length);
|
|
158
|
-
const file = openSync(RUN_HISTORY_FILE, "r");
|
|
159
|
-
readSync(file, buffer, 0, length, size - length);
|
|
160
|
-
closeSync(file);
|
|
161
|
-
tail = buffer.toString("utf8");
|
|
162
|
-
} catch {
|
|
163
|
-
return undefined; // no log yet
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
const lines = tail.split("\n").filter((line) => line.includes(`"run":"${runId}"`));
|
|
167
|
-
const last = lines.at(-1);
|
|
168
|
-
if (!last) return undefined;
|
|
169
|
-
const event = JSON.parse(last);
|
|
170
|
-
if (event.event === "command") return `$ ${event.command}`;
|
|
171
|
-
if (event.event === "helper") return `${event.helper} (${event.ms} ms)`;
|
|
172
|
-
return event.event;
|
|
173
|
-
}
|
|
174
|
-
|
|
175
175
|
/** e.g. "✓ exit 0 · 326 ms · 6 files · +48 −17 · applied" */
|
|
176
176
|
function summaryLine(run: RunResult): string {
|
|
177
177
|
const parts = [];
|
package/overlay-linux.ts
CHANGED
|
@@ -9,7 +9,6 @@ import * as fs from "node:fs/promises";
|
|
|
9
9
|
import { homedir } from "node:os";
|
|
10
10
|
import * as path from "node:path";
|
|
11
11
|
import { $ } from "bun";
|
|
12
|
-
import { historyEnabled, RUN_HISTORY_FILE, RUN_HISTORY_LOCK_DIR } from "./history.ts";
|
|
13
12
|
import type { FilesystemEntry, Overlay } from "./runner.ts";
|
|
14
13
|
|
|
15
14
|
export async function openLinuxOverlay(repo: string, tempDir: string): Promise<Overlay> {
|
|
@@ -24,8 +23,6 @@ export async function openLinuxOverlay(repo: string, tempDir: string): Promise<O
|
|
|
24
23
|
const cacheDir = path.join(homedir(), ".cache", "pi-shorthand");
|
|
25
24
|
const internalDir = path.join(cacheDir, "sandbox");
|
|
26
25
|
const sandboxExcludesFile = path.join(internalDir, `${path.basename(tempDir)}.exclude`);
|
|
27
|
-
const logFile = RUN_HISTORY_FILE;
|
|
28
|
-
const keepHistory = historyEnabled();
|
|
29
26
|
await copyStableTree(repo, lower);
|
|
30
27
|
const filesAtStart = await gitVisibleFiles(lower);
|
|
31
28
|
await fs.mkdir(upper);
|
|
@@ -41,8 +38,6 @@ export async function openLinuxOverlay(repo: string, tempDir: string): Promise<O
|
|
|
41
38
|
}
|
|
42
39
|
if ((internalStats.mode & 0o077) !== 0) await fs.chmod(internalDir, 0o700);
|
|
43
40
|
await fs.writeFile(sandboxExcludesFile, "", { flag: "wx", mode: 0o600 });
|
|
44
|
-
if (keepHistory) await fs.appendFile(logFile, "", { mode: 0o600 });
|
|
45
|
-
if (keepHistory) await fs.mkdir(RUN_HISTORY_LOCK_DIR, { recursive: true, mode: 0o700 });
|
|
46
41
|
|
|
47
42
|
const wrap = (command: string[], cwd: string) => [
|
|
48
43
|
bwrap,
|
|
@@ -61,8 +56,6 @@ export async function openLinuxOverlay(repo: string, tempDir: string): Promise<O
|
|
|
61
56
|
upper,
|
|
62
57
|
work,
|
|
63
58
|
repo,
|
|
64
|
-
...(keepHistory ? ["--bind", logFile, logFile] : []),
|
|
65
|
-
...(keepHistory ? ["--bind", RUN_HISTORY_LOCK_DIR, RUN_HISTORY_LOCK_DIR] : []),
|
|
66
59
|
"--tmpfs",
|
|
67
60
|
"/dev/shm",
|
|
68
61
|
"--chdir",
|
package/overlay-macos.ts
CHANGED
|
@@ -11,7 +11,6 @@ import { homedir, tmpdir } from "node:os";
|
|
|
11
11
|
import * as path from "node:path";
|
|
12
12
|
import { $ } from "bun";
|
|
13
13
|
import { Database } from "bun:sqlite";
|
|
14
|
-
import { historyEnabled, RUN_HISTORY_FILE, RUN_HISTORY_LOCK_DIR } from "./history.ts";
|
|
15
14
|
import { copyStableTree } from "./overlay-linux.ts";
|
|
16
15
|
import type { FilesystemEntry, Overlay } from "./runner.ts";
|
|
17
16
|
|
|
@@ -203,7 +202,9 @@ async function serveAndMount(
|
|
|
203
202
|
try {
|
|
204
203
|
await onSpawn(server.pid);
|
|
205
204
|
await waitForPort(port);
|
|
206
|
-
|
|
205
|
+
// AgentFS copy-up can change directory attributes. Stale NFS directory caches can make
|
|
206
|
+
// getcwd() fail in nested directories; keep file caching but revalidate directories immediately.
|
|
207
|
+
const options = `locallocks,vers=3,tcp,port=${port},mountport=${port},soft,timeo=100,retrans=5,acdirmin=0,acdirmax=0`;
|
|
207
208
|
await $`/sbin/mount_nfs -o ${options} 127.0.0.1:/ ${mount}`.quiet();
|
|
208
209
|
return server;
|
|
209
210
|
} catch (error) {
|
|
@@ -213,7 +214,7 @@ async function serveAndMount(
|
|
|
213
214
|
}
|
|
214
215
|
}
|
|
215
216
|
|
|
216
|
-
/** Restrict writes to the workspace, private scratch space
|
|
217
|
+
/** Restrict writes to the workspace, private scratch space and devices. */
|
|
217
218
|
export function sandboxProfile(
|
|
218
219
|
repo: string,
|
|
219
220
|
tempDir: string,
|
|
@@ -231,12 +232,6 @@ export function sandboxProfile(
|
|
|
231
232
|
`(allow file-write* (require-all (subpath ${JSON.stringify(mount)}) (require-not (subpath ${JSON.stringify(path.join(mount, ".git"))}))))`,
|
|
232
233
|
`(allow file-write* (subpath ${JSON.stringify(scratch)}))`,
|
|
233
234
|
'(allow file-write-data (literal "/dev/null") (literal "/dev/tty"))',
|
|
234
|
-
...(historyEnabled()
|
|
235
|
-
? [
|
|
236
|
-
`(allow file-write-data (literal ${JSON.stringify(RUN_HISTORY_FILE)}))`,
|
|
237
|
-
`(allow file-write* (subpath ${JSON.stringify(RUN_HISTORY_LOCK_DIR)}))`,
|
|
238
|
-
]
|
|
239
|
-
: []),
|
|
240
235
|
`(deny file-read* (subpath ${JSON.stringify(repo)}))`,
|
|
241
236
|
`(deny file-write* (subpath ${JSON.stringify(repo)}))`,
|
|
242
237
|
`(deny file-write* (subpath ${JSON.stringify(tempDir)}))`,
|
package/package.json
CHANGED
package/placement.ts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { dirname, resolve } from "node:path";
|
|
4
4
|
import { Lang, parse, type SgNode } from "@ast-grep/napi";
|
|
5
|
+
import { editingFiles } from "./file-outcomes.ts";
|
|
5
6
|
|
|
6
7
|
export interface Match {
|
|
7
8
|
file: string;
|
|
@@ -291,11 +292,23 @@ function apply(plans: { saved: Snapshot; edits: Edit[] }[]) {
|
|
|
291
292
|
}
|
|
292
293
|
|
|
293
294
|
export function insert(text: string, destination: Destination): void {
|
|
295
|
+
return editingFiles(destinationFiles(destination), () => insertNodes(text, destination));
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function destinationFiles(destination: Destination): string[] {
|
|
299
|
+
return Object.values(destination).flatMap((match) => (typeof match?.file === "string" ? [match.file] : []));
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function insertNodes(text: string, destination: Destination): void {
|
|
294
303
|
const { saved, edit } = placement(text, destination);
|
|
295
304
|
apply([{ saved, edits: [edit] }]);
|
|
296
305
|
}
|
|
297
306
|
|
|
298
307
|
export function move(match: Match, destination: Destination, transform?: (text: string) => string): void {
|
|
308
|
+
return editingFiles([match.file, ...destinationFiles(destination)], () => moveNodes(match, destination, transform));
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function moveNodes(match: Match, destination: Destination, transform?: (text: string) => string): void {
|
|
299
312
|
const source = snapshot(match);
|
|
300
313
|
const deletion = removal(source);
|
|
301
314
|
const text = transform ? transform(source.node.text()) : source.node.text();
|
|
@@ -322,6 +335,14 @@ export function move(match: Match, destination: Destination, transform?: (text:
|
|
|
322
335
|
}
|
|
323
336
|
|
|
324
337
|
export function remove(matches: Match | readonly Match[]): void {
|
|
338
|
+
const selected = Array.isArray(matches) ? matches : [matches as Match];
|
|
339
|
+
return editingFiles(
|
|
340
|
+
selected.map((match) => match.file),
|
|
341
|
+
() => removeNodes(matches),
|
|
342
|
+
);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function removeNodes(matches: Match | readonly Match[]): void {
|
|
325
346
|
const plans = new Map<string, { saved: Snapshot; edits: Edit[] }>();
|
|
326
347
|
for (const match of Array.isArray(matches) ? matches : [matches as Match]) {
|
|
327
348
|
const saved = snapshot(match);
|
package/prelude.ts
CHANGED
|
@@ -9,16 +9,15 @@
|
|
|
9
9
|
* File lists come from git (tracked, plus untracked files that aren't ignored), so node_modules
|
|
10
10
|
* and build output are left out on every platform.
|
|
11
11
|
*
|
|
12
|
-
* Each $ command and helper call is
|
|
13
|
-
* happens, so `tail -f` shows what a program is doing, including which command it's stuck on.
|
|
12
|
+
* Each $ command and helper call is reported to the runner while the program is active.
|
|
14
13
|
*/
|
|
15
14
|
|
|
16
|
-
import { lstatSync, readFileSync, realpathSync, statSync, writeFileSync } from "node:fs";
|
|
15
|
+
import { lstatSync, readFileSync, realpathSync, statSync, writeFileSync, writeSync } from "node:fs";
|
|
17
16
|
import { basename, dirname, isAbsolute, relative, resolve, sep } from "node:path";
|
|
18
17
|
import * as astGrep from "@ast-grep/napi";
|
|
19
18
|
import { type Edit, Lang, type NapiConfig, parse, type SgNode } from "@ast-grep/napi";
|
|
20
19
|
import { $ as bunShell, Glob } from "bun";
|
|
21
|
-
import {
|
|
20
|
+
import { editingFiles, installFileOutcomeTracking } from "./file-outcomes.ts";
|
|
22
21
|
import {
|
|
23
22
|
file as selectFile,
|
|
24
23
|
getMatchSnapshot,
|
|
@@ -30,10 +29,19 @@ import {
|
|
|
30
29
|
type FileTarget,
|
|
31
30
|
} from "./placement.ts";
|
|
32
31
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
32
|
+
installFileOutcomeTracking();
|
|
33
|
+
|
|
34
|
+
const progressDescriptor = process.env.PI_SHORTHAND_PROGRESS_FD;
|
|
35
|
+
// Subprocesses do not inherit this descriptor by default, so do not advertise it to them.
|
|
36
|
+
delete process.env.PI_SHORTHAND_PROGRESS_FD;
|
|
37
|
+
|
|
38
|
+
function report(event: Record<string, unknown>) {
|
|
39
|
+
if (!progressDescriptor) return;
|
|
40
|
+
try {
|
|
41
|
+
writeSync(Number(progressDescriptor), JSON.stringify(event) + "\n");
|
|
42
|
+
} catch {
|
|
43
|
+
// Progress is advisory and must never change the program's result.
|
|
44
|
+
}
|
|
37
45
|
}
|
|
38
46
|
|
|
39
47
|
/** Runs a helper, logging how long it took and how many results it returned. */
|
|
@@ -41,7 +49,8 @@ function logged<T>(helper: string, _args: unknown[], run: () => T): T {
|
|
|
41
49
|
const startedAt = performance.now();
|
|
42
50
|
const result = run();
|
|
43
51
|
const results = Array.isArray(result) ? result.length : typeof result === "number" ? result : undefined;
|
|
44
|
-
|
|
52
|
+
report({
|
|
53
|
+
type: "helper",
|
|
45
54
|
helper,
|
|
46
55
|
ms: Math.round(performance.now() - startedAt),
|
|
47
56
|
results,
|
|
@@ -54,7 +63,7 @@ const $ = new Proxy(bunShell, {
|
|
|
54
63
|
apply(target, thisArg, args: Parameters<typeof bunShell>) {
|
|
55
64
|
const [strings] = args;
|
|
56
65
|
const words = strings.raw[0].trim().split(/\s+/);
|
|
57
|
-
|
|
66
|
+
report({ type: "command", command: words.find((word) => !word.includes("=")) ?? "" });
|
|
58
67
|
return Reflect.apply(target, thisArg, args);
|
|
59
68
|
},
|
|
60
69
|
});
|
|
@@ -354,7 +363,7 @@ function rewrite(...[target, replacement, files]: RewriteArgs): number {
|
|
|
354
363
|
const groups = new Map<string, SgMatch[]>();
|
|
355
364
|
const sources = new Map<string, string | null>();
|
|
356
365
|
for (const match of (Array.isArray(target) ? target : [target]) as SgMatch[]) {
|
|
357
|
-
const saved = getMatchSnapshot(match, sources, rewriteStaleAdvice);
|
|
366
|
+
const saved = editingFiles([match.file], () => getMatchSnapshot(match, sources, rewriteStaleAdvice));
|
|
358
367
|
if (!match.vars || typeof match.line !== "number")
|
|
359
368
|
throw new Error("sg.rewrite expects matches from sg.one or sg.find");
|
|
360
369
|
const file = explicitPath(match.file);
|
|
@@ -365,9 +374,11 @@ function rewrite(...[target, replacement, files]: RewriteArgs): number {
|
|
|
365
374
|
}
|
|
366
375
|
let count = 0;
|
|
367
376
|
for (const [file, matches] of groups) {
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
377
|
+
count += editingFiles([file], () => {
|
|
378
|
+
const currentSources = new Map<string, string | null>();
|
|
379
|
+
for (const match of matches) getMatchSnapshot(match, currentSources, rewriteStaleAdvice);
|
|
380
|
+
return applyRewrites(matches, replacement, file);
|
|
381
|
+
});
|
|
371
382
|
}
|
|
372
383
|
return count;
|
|
373
384
|
}
|
|
@@ -376,13 +387,15 @@ function rewrite(...[target, replacement, files]: RewriteArgs): number {
|
|
|
376
387
|
let count = 0,
|
|
377
388
|
matched = 0;
|
|
378
389
|
for (const file of sourceFiles("sg.rewrite", scope)) {
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
390
|
+
count += editingFiles([file], () => {
|
|
391
|
+
const parsed = parseFile(file);
|
|
392
|
+
if (!parsed) return 0;
|
|
393
|
+
const matches = findNodes("sg.rewrite", parsed.root, pattern).map((node) =>
|
|
394
|
+
toMatch(file, node, parsed.source, pattern),
|
|
395
|
+
);
|
|
396
|
+
matched += matches.length;
|
|
397
|
+
return applyRewrites(matches, replacement, file);
|
|
398
|
+
});
|
|
386
399
|
}
|
|
387
400
|
if (matched === 0) {
|
|
388
401
|
const paths = (Array.isArray(scope) ? scope : [scope]).map((entry) =>
|
|
@@ -438,6 +451,15 @@ function grit(pattern: string, paths: FileScope = ".", options: { lang?: string;
|
|
|
438
451
|
if (options.lang) flags.push("--language", options.lang);
|
|
439
452
|
|
|
440
453
|
const targets = selected.map((file) => resolve(repositoryRoot, file));
|
|
454
|
+
|
|
455
|
+
if (options.dryRun) return applyGrit(pattern, flags, targets);
|
|
456
|
+
const affected = targets.flatMap((file) =>
|
|
457
|
+
statSync(file).isDirectory() ? selectFiles(file).map((entry) => resolve(repositoryRoot, entry)) : [file],
|
|
458
|
+
);
|
|
459
|
+
return editingFiles(affected, () => applyGrit(pattern, flags, targets));
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
function applyGrit(pattern: string, flags: string[], targets: string[]) {
|
|
441
463
|
const result = Bun.spawnSync(["grit", "apply", ...flags, pattern, ...targets], { env: process.env });
|
|
442
464
|
if (result.exitCode !== 0) {
|
|
443
465
|
const diagnostic = result.stderr.toString().trim() || result.stdout.toString().trim();
|
|
@@ -504,7 +526,10 @@ function editText({ path, oldText, newText }: { path: string; oldText: string; n
|
|
|
504
526
|
|
|
505
527
|
const globals = {
|
|
506
528
|
$,
|
|
507
|
-
edit: (...args: Parameters<typeof editText>) =>
|
|
529
|
+
edit: (...args: Parameters<typeof editText>) =>
|
|
530
|
+
logged("edit", args, () =>
|
|
531
|
+
editingFiles(typeof args[0]?.path === "string" ? [args[0].path] : [], () => editText(...args)),
|
|
532
|
+
),
|
|
508
533
|
glob: (...args: Parameters<typeof glob>) => logged("glob", args, () => glob(...args)),
|
|
509
534
|
grep: (...args: Parameters<typeof grep>) => logged("grep", args, () => grep(...args)),
|
|
510
535
|
sg: {
|
package/runner.ts
CHANGED
|
@@ -4,31 +4,29 @@
|
|
|
4
4
|
*
|
|
5
5
|
* Usage: echo '<RunOptions as JSON>' | bun runner.ts → prints a RunResult as JSON
|
|
6
6
|
* SIGTERM aborts: the program is killed, the overlay is closed and nothing is applied.
|
|
7
|
-
*
|
|
7
|
+
* Command and helper progress is streamed to the parent process while the run is active.
|
|
8
8
|
*
|
|
9
9
|
* If the program fails:
|
|
10
10
|
* - rollback "all": nothing is applied;
|
|
11
|
-
* - rollback "file":
|
|
12
|
-
* failure, nothing is applied because the process has exited and its open files can't be inspected.
|
|
11
|
+
* - rollback "file": failed or interrupted file edits are discarded; other files are applied.
|
|
13
12
|
*/
|
|
14
13
|
|
|
15
14
|
import { type ChildProcess, spawn } from "node:child_process";
|
|
16
15
|
import { createHash, randomUUID } from "node:crypto";
|
|
17
|
-
import { constants, type Stats } from "node:fs";
|
|
16
|
+
import { constants, type Stats, writeSync } from "node:fs";
|
|
18
17
|
import * as fs from "node:fs/promises";
|
|
19
18
|
import { homedir, tmpdir } from "node:os";
|
|
20
19
|
import * as path from "node:path";
|
|
21
20
|
import { Lang, parse } from "@ast-grep/napi";
|
|
22
21
|
import { $ } from "bun";
|
|
23
22
|
import { structuredPatch } from "diff";
|
|
24
|
-
import { appendRunHistory, historyEnabled, RUN_HISTORY_FILE } from "./history.ts";
|
|
25
23
|
import { openLinuxOverlay } from "./overlay-linux.ts";
|
|
26
24
|
import { openMacOverlay } from "./overlay-macos.ts";
|
|
27
25
|
import { discardedEdits } from "./program-lint.ts";
|
|
28
26
|
import { preserveTextFormat } from "./text-format.ts";
|
|
27
|
+
import { type FileOutcomeEvent, parseOpenWriters } from "./file-outcomes.ts";
|
|
29
28
|
|
|
30
29
|
export interface RunOptions {
|
|
31
|
-
runId: string; // identifies this run's events in the log
|
|
32
30
|
cwd: string;
|
|
33
31
|
program: string;
|
|
34
32
|
timeoutMs: number;
|
|
@@ -64,7 +62,7 @@ export interface RunResult {
|
|
|
64
62
|
applied: string[]; // the changed files that were applied
|
|
65
63
|
conflicts: string[]; // destinations changed after the run's baseline was captured
|
|
66
64
|
rolledBack: string[]; // rollback "file": changed files not retained because completion was not established
|
|
67
|
-
writerInspectionFailed: boolean; //
|
|
65
|
+
writerInspectionFailed: boolean; // inspection unavailable: no changed file was retained on failure
|
|
68
66
|
stillRunning: string[]; // on timeout: commands the program was still running, e.g. "find / -name x (for 58s)"
|
|
69
67
|
lastStep?: string; // on timeout: the last step the program logged, e.g. "$ find / -name x" or "grep (18 ms)"
|
|
70
68
|
errorLine?: string; // on failure: the program's line the error came from, e.g. "line 3: throw new Error(…)"
|
|
@@ -113,12 +111,6 @@ const PRELUDE = path.join(import.meta.dir, "prelude.ts");
|
|
|
113
111
|
// (e.g. Pi's project installs), one further up. Like `npm run`, look in every one from here up.
|
|
114
112
|
const NODE_MODULES = ancestors(import.meta.dir).map((dir) => path.join(dir, "node_modules"));
|
|
115
113
|
const MAX_OUTPUT_CHARS = 1024 * 1024; // a safety cap; index.ts decides how much the model sees
|
|
116
|
-
let RUN_ID = ""; // set from RunOptions when the runner starts
|
|
117
|
-
|
|
118
|
-
/** Appends one event to the log, e.g. log("program exited", { exitCode: 0 }). */
|
|
119
|
-
function log(event: string, details: Record<string, unknown> = {}) {
|
|
120
|
-
appendRunHistory(event, { run: RUN_ID, ...details });
|
|
121
|
-
}
|
|
122
114
|
|
|
123
115
|
async function run(options: RunOptions, abort: AbortSignal): Promise<RunResult> {
|
|
124
116
|
const startedAt = performance.now();
|
|
@@ -133,10 +125,8 @@ async function run(options: RunOptions, abort: AbortSignal): Promise<RunResult>
|
|
|
133
125
|
|
|
134
126
|
try {
|
|
135
127
|
tempDir = await fs.mkdtemp(path.join(await fs.realpath(tmpdir()), "pi-shorthand-"));
|
|
136
|
-
log("started", { timeoutMs: options.timeoutMs, rollback: options.rollback });
|
|
137
128
|
const open = process.platform === "darwin" ? openMacOverlay : openLinuxOverlay;
|
|
138
129
|
const overlay = await open(repo, tempDir);
|
|
139
|
-
log("overlay opened");
|
|
140
130
|
let program: ProgramRun;
|
|
141
131
|
let changes: Change[];
|
|
142
132
|
let executionError: unknown;
|
|
@@ -155,7 +145,13 @@ async function run(options: RunOptions, abort: AbortSignal): Promise<RunResult>
|
|
|
155
145
|
changes = changes.filter((change) => !entriesEqual(change.before, change.after));
|
|
156
146
|
}
|
|
157
147
|
const files = changes.filter((change) => change.after?.type === "file").map((change) => change.file);
|
|
158
|
-
if (
|
|
148
|
+
if (
|
|
149
|
+
program.exitCode === 0 &&
|
|
150
|
+
program.failedFiles.length === 0 &&
|
|
151
|
+
!abort.aborted &&
|
|
152
|
+
files.length &&
|
|
153
|
+
process.env.PI_SHORTHAND_FORMAT !== "0"
|
|
154
|
+
) {
|
|
159
155
|
try {
|
|
160
156
|
const module = executionPath(path.join(import.meta.dir, "format.ts"), repo, overlay);
|
|
161
157
|
const formatting = await runProgram(
|
|
@@ -218,12 +214,6 @@ console.log(JSON.stringify(await formatChanged(${JSON.stringify(files)}, process
|
|
|
218
214
|
warnings: applicationWarnings,
|
|
219
215
|
} = await applyChanges(repo, requested, { abort, testHooks: options.testHooks?.apply }));
|
|
220
216
|
}
|
|
221
|
-
log("finished", {
|
|
222
|
-
changed: changes.length,
|
|
223
|
-
applied: applied.length,
|
|
224
|
-
conflicts: conflicts.length,
|
|
225
|
-
});
|
|
226
|
-
|
|
227
217
|
result = {
|
|
228
218
|
exitCode: program.exitCode,
|
|
229
219
|
timedOut: program.timedOut,
|
|
@@ -237,7 +227,7 @@ console.log(JSON.stringify(await formatChanged(${JSON.stringify(files)}, process
|
|
|
237
227
|
rolledBack: rolledBack.map((change) => shown(change.file)),
|
|
238
228
|
writerInspectionFailed: program.openForWriting === null,
|
|
239
229
|
stillRunning: program.stillRunning,
|
|
240
|
-
lastStep: program.timedOut ?
|
|
230
|
+
lastStep: program.timedOut ? program.lastStep : undefined,
|
|
241
231
|
errorLine: program.exitCode !== 0 ? failingLine(options.program, program.output) : undefined,
|
|
242
232
|
timeoutMs: options.timeoutMs,
|
|
243
233
|
rollback: options.rollback,
|
|
@@ -276,7 +266,6 @@ async function closeOverlay(overlay: Overlay, injectFailure = false) {
|
|
|
276
266
|
function cleanupWarning(resource: string, error: unknown): string {
|
|
277
267
|
const detail = error instanceof Error ? error.message : String(error);
|
|
278
268
|
const warning = `The run reached its reported outcome, but cleanup of its ${resource} failed: ${detail}`;
|
|
279
|
-
log("cleanup warning", { warning });
|
|
280
269
|
return warning;
|
|
281
270
|
}
|
|
282
271
|
|
|
@@ -390,17 +379,17 @@ async function safeParentChain(repo: string, target: string, allowMissing = fals
|
|
|
390
379
|
return true;
|
|
391
380
|
}
|
|
392
381
|
|
|
393
|
-
/**
|
|
382
|
+
/** Retain independent file edits, excluding failed operations and interrupted writers. */
|
|
394
383
|
function whatToApply(changes: Change[], program: ProgramRun, rollback: RunOptions["rollback"], aborted: boolean) {
|
|
395
384
|
if (aborted) return { applied: [], rolledBack: [] };
|
|
396
|
-
if (
|
|
397
|
-
if (rollback === "all") return { applied: [], rolledBack: [] };
|
|
398
|
-
if (!program.timedOut) return { applied: [], rolledBack: changes };
|
|
385
|
+
if (rollback === "all") return { applied: program.exitCode === 0 ? changes : [], rolledBack: [] };
|
|
399
386
|
if (program.openForWriting === null) return { applied: [], rolledBack: changes };
|
|
400
387
|
|
|
401
|
-
const
|
|
402
|
-
|
|
403
|
-
|
|
388
|
+
const failed = new Set([...program.failedFiles, ...program.openForWriting]);
|
|
389
|
+
return {
|
|
390
|
+
applied: changes.filter((change) => !failed.has(change.file)),
|
|
391
|
+
rolledBack: changes.filter((change) => failed.has(change.file)),
|
|
392
|
+
};
|
|
404
393
|
}
|
|
405
394
|
|
|
406
395
|
/** A `$` command that isn't awaited never runs: Bun's shell starts a command when it's awaited. */
|
|
@@ -441,8 +430,10 @@ interface ProgramRun {
|
|
|
441
430
|
exitCode: number | null;
|
|
442
431
|
timedOut: boolean;
|
|
443
432
|
output: string;
|
|
444
|
-
openForWriting: string[] | null; // on timeout: open writers, or null when inspection
|
|
433
|
+
openForWriting: string[] | null; // on failure/timeout: open writers, or null when inspection was unavailable
|
|
434
|
+
failedFiles: string[];
|
|
445
435
|
stillRunning: string[]; // on timeout: the commands it was still running
|
|
436
|
+
lastStep?: string;
|
|
446
437
|
}
|
|
447
438
|
|
|
448
439
|
async function runProgram(
|
|
@@ -468,22 +459,29 @@ async function runProgram(
|
|
|
468
459
|
// detached: the program gets its own process group, so killing the group kills anything it started too.
|
|
469
460
|
const outputFile = path.join(tempDir, "output");
|
|
470
461
|
const output = await fs.open(outputFile, "w");
|
|
462
|
+
const trackFiles = options.rollback === "file";
|
|
463
|
+
const outcomePath = path.join(tempDir, "file-outcomes");
|
|
464
|
+
const outcomeFile = await fs.open(outcomePath, "w");
|
|
471
465
|
const [command, ...args] = overlay.wrap(
|
|
472
466
|
[process.execPath, "--preload", executionPrelude, executionProgramPath],
|
|
473
467
|
executionCwd,
|
|
474
468
|
);
|
|
475
|
-
log("program started", { timeoutMs: options.timeoutMs });
|
|
476
469
|
if (options.testHooks?.programStartMarker) await Bun.write(options.testHooks.programStartMarker, "started");
|
|
477
470
|
const child = spawn(command, args, {
|
|
478
471
|
cwd: executionCwd,
|
|
479
472
|
detached: true,
|
|
480
|
-
stdio: ["ignore", output.fd, output.fd],
|
|
473
|
+
stdio: ["ignore", output.fd, output.fd, trackFiles ? outcomeFile.fd : "ignore", "pipe"],
|
|
481
474
|
env: {
|
|
482
475
|
...process.env,
|
|
483
476
|
...overlay.environment,
|
|
484
477
|
...programEnvironment(excludesFile, repo, overlay),
|
|
478
|
+
PI_SHORTHAND_OUTCOMES_FD: trackFiles ? "3" : "",
|
|
479
|
+
PI_SHORTHAND_PROGRESS_FD: "4",
|
|
480
|
+
PI_SHORTHAND_EXECUTION_ROOT: overlay.executionDir,
|
|
481
|
+
PI_SHORTHAND_INSPECTION_FAILURE: options.testHooks?.writerInspectionFailure ? "1" : "",
|
|
485
482
|
},
|
|
486
483
|
});
|
|
484
|
+
const progress = trackProgress(child);
|
|
487
485
|
const killAll = () => killGroup(child);
|
|
488
486
|
abort.addEventListener("abort", killAll);
|
|
489
487
|
if (abort.aborted) killAll();
|
|
@@ -494,15 +492,16 @@ async function runProgram(
|
|
|
494
492
|
overlay.executionDir,
|
|
495
493
|
options.testHooks?.writerInspectionFailure,
|
|
496
494
|
);
|
|
497
|
-
log("program exited", { exitCode, timedOut, aborted: abort.aborted, stillRunning: stillRunning.length });
|
|
498
495
|
killAll(); // anything it left running
|
|
499
496
|
abort.removeEventListener("abort", killAll);
|
|
500
497
|
try {
|
|
501
498
|
await overlay.terminateProcesses?.();
|
|
502
499
|
} finally {
|
|
503
|
-
await output.close();
|
|
500
|
+
await Promise.all([output.close(), outcomeFile.close()]);
|
|
501
|
+
await progress.closed;
|
|
504
502
|
await fs.rm(programFile, { force: true });
|
|
505
503
|
}
|
|
504
|
+
const outcomes = fileOutcomes(await Bun.file(outcomePath).text());
|
|
506
505
|
|
|
507
506
|
// Keep the tail, where errors are. Show stack traces as "program.ts:3:11", and drop Bun's version footer.
|
|
508
507
|
let text = await Bun.file(outputFile).text();
|
|
@@ -511,7 +510,69 @@ async function runProgram(
|
|
|
511
510
|
}
|
|
512
511
|
text = text.replaceAll(executionProgramPath, "program.ts").replace(/\nBun v[\d.]+ \([^)]*\)\n?$/, "\n");
|
|
513
512
|
|
|
514
|
-
return {
|
|
513
|
+
return {
|
|
514
|
+
exitCode,
|
|
515
|
+
timedOut,
|
|
516
|
+
output: text,
|
|
517
|
+
openForWriting: trackFiles && exitCode !== 0 && !timedOut ? outcomes.writers : openForWriting,
|
|
518
|
+
stillRunning,
|
|
519
|
+
failedFiles: outcomes.failedFiles,
|
|
520
|
+
lastStep: progress.latest(),
|
|
521
|
+
};
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
/** Keep the latest command/helper in memory and relay it to index.ts over the runner's descriptor 3. */
|
|
525
|
+
function trackProgress(child: ChildProcess) {
|
|
526
|
+
const stream = child.stdio[4];
|
|
527
|
+
let buffer = "";
|
|
528
|
+
let lastStep: string | undefined;
|
|
529
|
+
const closed = new Promise<void>((resolve) => {
|
|
530
|
+
if (!stream || !("setEncoding" in stream)) return resolve();
|
|
531
|
+
stream.setEncoding("utf8");
|
|
532
|
+
stream.on("data", (chunk) => {
|
|
533
|
+
buffer += chunk;
|
|
534
|
+
const lines = buffer.split("\n");
|
|
535
|
+
buffer = lines.pop() ?? "";
|
|
536
|
+
for (const line of lines) {
|
|
537
|
+
try {
|
|
538
|
+
const event = JSON.parse(line) as { type?: unknown; command?: unknown; helper?: unknown; ms?: unknown };
|
|
539
|
+
if (event.type === "command" && typeof event.command === "string") lastStep = `$ ${event.command}`;
|
|
540
|
+
else if (event.type === "helper" && typeof event.helper === "string" && typeof event.ms === "number")
|
|
541
|
+
lastStep = `${event.helper} (${event.ms} ms)`;
|
|
542
|
+
else continue;
|
|
543
|
+
try {
|
|
544
|
+
writeSync(3, JSON.stringify({ step: lastStep }) + "\n");
|
|
545
|
+
} catch {
|
|
546
|
+
// Direct runner callers do not provide a progress descriptor.
|
|
547
|
+
}
|
|
548
|
+
} catch {
|
|
549
|
+
// Progress is advisory; malformed events do not affect the run.
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
});
|
|
553
|
+
stream.once("end", resolve);
|
|
554
|
+
stream.once("error", resolve);
|
|
555
|
+
});
|
|
556
|
+
return { closed, latest: () => lastStep };
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
/** Replay helper outcomes after all program processes have stopped. */
|
|
560
|
+
function fileOutcomes(text: string) {
|
|
561
|
+
const active = new Map<number, string[]>();
|
|
562
|
+
const failed = new Set<string>();
|
|
563
|
+
let writers: string[] | null = null;
|
|
564
|
+
for (const line of text.split("\n").slice(0, -1).filter(Boolean)) {
|
|
565
|
+
const event = JSON.parse(line) as FileOutcomeEvent;
|
|
566
|
+
if (event.type === "begin") active.set(event.id, event.files);
|
|
567
|
+
else if (event.type === "end") active.delete(event.id);
|
|
568
|
+
else if (event.type === "fail") {
|
|
569
|
+
for (const file of active.get(event.id) ?? []) failed.add(file);
|
|
570
|
+
active.delete(event.id);
|
|
571
|
+
} else if (event.type === "error") {
|
|
572
|
+
for (const file of event.files) failed.add(file);
|
|
573
|
+
} else if (event.type === "writers") writers = event.files;
|
|
574
|
+
}
|
|
575
|
+
return { writers, failedFiles: [...new Set([...failed, ...[...active.values()].flat()])] };
|
|
515
576
|
}
|
|
516
577
|
|
|
517
578
|
/**
|
|
@@ -535,7 +596,12 @@ async function waitWithTimeout(child: ChildProcess, timeoutMs: number, repo: str
|
|
|
535
596
|
]);
|
|
536
597
|
killGroup(child);
|
|
537
598
|
await exited;
|
|
538
|
-
return {
|
|
599
|
+
return {
|
|
600
|
+
exitCode: null,
|
|
601
|
+
timedOut: true,
|
|
602
|
+
openForWriting,
|
|
603
|
+
stillRunning,
|
|
604
|
+
};
|
|
539
605
|
}
|
|
540
606
|
|
|
541
607
|
/**
|
|
@@ -577,28 +643,6 @@ function seconds(elapsed: string): number {
|
|
|
577
643
|
return Number(days) * 86400 + hours * 3600 + minutes * 60 + secs;
|
|
578
644
|
}
|
|
579
645
|
|
|
580
|
-
/** The last command or helper call this run's program logged, e.g. "$ find / -name x". */
|
|
581
|
-
async function lastLoggedStep(): Promise<string | undefined> {
|
|
582
|
-
let lines: string[];
|
|
583
|
-
try {
|
|
584
|
-
lines = (await Bun.file(RUN_HISTORY_FILE).text()).trimEnd().split("\n").slice(-500);
|
|
585
|
-
} catch {
|
|
586
|
-
return undefined;
|
|
587
|
-
}
|
|
588
|
-
for (const line of lines.toReversed()) {
|
|
589
|
-
let event: Record<string, unknown>;
|
|
590
|
-
try {
|
|
591
|
-
event = JSON.parse(line);
|
|
592
|
-
} catch {
|
|
593
|
-
continue;
|
|
594
|
-
}
|
|
595
|
-
if (event.run !== RUN_ID) continue;
|
|
596
|
-
if (event.event === "command") return `$ ${event.command}`;
|
|
597
|
-
if (event.event === "helper") return `${event.helper} (${event.ms} ms)`;
|
|
598
|
-
}
|
|
599
|
-
return undefined;
|
|
600
|
-
}
|
|
601
|
-
|
|
602
646
|
function killGroup(child: ChildProcess) {
|
|
603
647
|
try {
|
|
604
648
|
process.kill(-child.pid!, "SIGKILL");
|
|
@@ -620,8 +664,6 @@ function programEnvironment(excludesFile: string, repo: string, overlay: Overlay
|
|
|
620
664
|
// So programs can import the extension's own packages, e.g. "@ast-grep/napi". A repository's own
|
|
621
665
|
// node_modules still wins: NODE_PATH is only a fallback.
|
|
622
666
|
NODE_PATH: [...nodeModules, process.env.NODE_PATH].filter(Boolean).join(path.delimiter),
|
|
623
|
-
...(historyEnabled() ? { PI_SHORTHAND_LOG: RUN_HISTORY_FILE, PI_SHORTHAND_HISTORY_SANDBOX: "1" } : {}),
|
|
624
|
-
PI_SHORTHAND_RUN: RUN_ID,
|
|
625
667
|
GIT_OPTIONAL_LOCKS: "0", // read-only git commands should not dirty copied or mounted metadata
|
|
626
668
|
GIT_CONFIG_COUNT: String(count + 1),
|
|
627
669
|
[`GIT_CONFIG_KEY_${count}`]: "core.excludesFile",
|
|
@@ -651,18 +693,7 @@ async function filesOpenForWriting(dir: string, forceFailure: boolean): Promise<
|
|
|
651
693
|
// detached or were reparented; unlike lsof +D, it doesn't walk every file in a large repository.
|
|
652
694
|
const result = await $`lsof -n -P -F an`.nothrow().quiet();
|
|
653
695
|
if (result.exitCode !== 0 || result.stderr.length > 0) return null;
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
const files = new Set<string>();
|
|
657
|
-
let access = "";
|
|
658
|
-
for (const line of output.split("\n")) {
|
|
659
|
-
if (line.startsWith("f")) access = "";
|
|
660
|
-
if (line.startsWith("a")) access = line.slice(1);
|
|
661
|
-
if (line.startsWith(`n${dir}/`) && (access === "w" || access === "u")) {
|
|
662
|
-
files.add(path.relative(dir, line.slice(1)));
|
|
663
|
-
}
|
|
664
|
-
}
|
|
665
|
-
return [...files];
|
|
696
|
+
return parseOpenWriters(result.stdout.toString(), dir);
|
|
666
697
|
}
|
|
667
698
|
|
|
668
699
|
// ── Finding, describing and applying changes ──────────────────────────────────────
|
|
@@ -957,7 +988,6 @@ async function cleanupWarnings(items: PreparedChange[], failCleanup = false): Pr
|
|
|
957
988
|
} catch (error) {
|
|
958
989
|
const detail = error instanceof Error ? error.message : String(error);
|
|
959
990
|
const warning = `Changes reached their reported state, but transaction backup cleanup failed: ${detail}`;
|
|
960
|
-
log("cleanup warning", { warning });
|
|
961
991
|
return [warning];
|
|
962
992
|
}
|
|
963
993
|
}
|
|
@@ -1079,11 +1109,5 @@ if (import.meta.main) {
|
|
|
1079
1109
|
const abort = new AbortController();
|
|
1080
1110
|
process.on("SIGTERM", () => abort.abort());
|
|
1081
1111
|
const options: RunOptions = await Bun.stdin.json();
|
|
1082
|
-
|
|
1083
|
-
try {
|
|
1084
|
-
console.log(JSON.stringify(await run(options, abort.signal)));
|
|
1085
|
-
} catch (error) {
|
|
1086
|
-
log("failed", { error: String(error) });
|
|
1087
|
-
throw error;
|
|
1088
|
-
}
|
|
1112
|
+
console.log(JSON.stringify(await run(options, abort.signal)));
|
|
1089
1113
|
}
|
|
@@ -159,10 +159,15 @@ belong to `code`, not necessarily ordinary shell calls. `node:fs` and ordinary B
|
|
|
159
159
|
|
|
160
160
|
Programs run in an isolated repository workspace. Use relative paths: the live checkout's absolute
|
|
161
161
|
path is inaccessible on macOS. On both platforms, host paths outside the workspace are read-only
|
|
162
|
-
(including external symlink targets)
|
|
162
|
+
(including external symlink targets). `$TMPDIR` is private to the run.
|
|
163
163
|
Writes to `.git` are blocked.
|
|
164
164
|
|
|
165
165
|
The default timeout is two seconds; request more for longer transformations.
|
|
166
|
-
By default
|
|
167
|
-
|
|
168
|
-
|
|
166
|
+
By default, rollback happens per file: files involved in failed or interrupted edits are discarded,
|
|
167
|
+
while the others are retained, including after exceptions. Unattributed failures
|
|
168
|
+
(such as failed checks) preserve completed edits and report the failure. A failed file loses all
|
|
169
|
+
its edits from this run, even if the error is caught. Multi-file operations such as moves and a
|
|
170
|
+
single Grit invocation share one outcome. Arbitrary shell failures cannot identify failed closed
|
|
171
|
+
files. Open writers are inspected before failure exits and timeout termination; inspection failures
|
|
172
|
+
or crashes that bypass exit handling retain nothing. Cancellation still discards everything.
|
|
173
|
+
Use `rollback: "all"` when the whole change must be atomic.
|
package/history.ts
DELETED
|
@@ -1,252 +0,0 @@
|
|
|
1
|
-
import {
|
|
2
|
-
chmodSync,
|
|
3
|
-
closeSync,
|
|
4
|
-
constants,
|
|
5
|
-
fchmodSync,
|
|
6
|
-
fstatSync,
|
|
7
|
-
lstatSync,
|
|
8
|
-
mkdirSync,
|
|
9
|
-
openSync,
|
|
10
|
-
readSync,
|
|
11
|
-
renameSync,
|
|
12
|
-
rmSync,
|
|
13
|
-
type Stats,
|
|
14
|
-
writeSync,
|
|
15
|
-
} from "node:fs";
|
|
16
|
-
import { homedir } from "node:os";
|
|
17
|
-
import * as path from "node:path";
|
|
18
|
-
|
|
19
|
-
export const RUN_HISTORY_FILE = path.join(homedir(), ".cache", "pi-shorthand", "runs.jsonl");
|
|
20
|
-
export const RUN_HISTORY_LOCK_DIR = `${RUN_HISTORY_FILE}.locks`;
|
|
21
|
-
export const RUN_HISTORY_MAX_BYTES = 1024 * 1024;
|
|
22
|
-
export const RUN_HISTORY_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
|
|
23
|
-
const HISTORY_EVENTS = new Set([
|
|
24
|
-
"started",
|
|
25
|
-
"overlay opened",
|
|
26
|
-
"program started",
|
|
27
|
-
"program exited",
|
|
28
|
-
"finished",
|
|
29
|
-
"command",
|
|
30
|
-
"helper",
|
|
31
|
-
"cleanup warning",
|
|
32
|
-
"failed",
|
|
33
|
-
]);
|
|
34
|
-
|
|
35
|
-
interface HistoryOptions {
|
|
36
|
-
maxBytes?: number;
|
|
37
|
-
maxAgeMs?: number;
|
|
38
|
-
now?: number;
|
|
39
|
-
env?: NodeJS.ProcessEnv;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
export function historyEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
|
|
43
|
-
return !["0", "false", "off"].includes((env.PI_SHORTHAND_HISTORY ?? "").toLowerCase());
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
/** Persist a bounded event containing only the allowlisted, non-source fields used for diagnostics. */
|
|
47
|
-
export function appendRunHistory(
|
|
48
|
-
event: string,
|
|
49
|
-
details: Record<string, unknown> = {},
|
|
50
|
-
file = RUN_HISTORY_FILE,
|
|
51
|
-
options: HistoryOptions = {},
|
|
52
|
-
) {
|
|
53
|
-
if (!historyEnabled(options.env)) return;
|
|
54
|
-
if (!HISTORY_EVENTS.has(event)) return;
|
|
55
|
-
const record = JSON.stringify({
|
|
56
|
-
time: new Date(options.now ?? Date.now()).toISOString(),
|
|
57
|
-
event,
|
|
58
|
-
...safeDetails(event, details),
|
|
59
|
-
});
|
|
60
|
-
const env = options.env ?? process.env;
|
|
61
|
-
const sandboxed = env.PI_SHORTHAND_HISTORY_SANDBOX === "1";
|
|
62
|
-
withHistoryLock(file, sandboxed, () => appendBounded(file, `${record}\n`, options, sandboxed));
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
const lockWait = new Int32Array(new SharedArrayBuffer(4));
|
|
66
|
-
|
|
67
|
-
function withHistoryLock(file: string, sandboxed: boolean, run: () => void) {
|
|
68
|
-
const directory = `${file}.locks`;
|
|
69
|
-
if (!sandboxed) mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
70
|
-
const directoryStats = lstatSync(directory);
|
|
71
|
-
if (
|
|
72
|
-
!directoryStats.isDirectory() ||
|
|
73
|
-
directoryStats.isSymbolicLink() ||
|
|
74
|
-
(process.getuid && directoryStats.uid !== process.getuid())
|
|
75
|
-
) {
|
|
76
|
-
throw new Error(`Unsafe run history lock directory: ${directory}`);
|
|
77
|
-
}
|
|
78
|
-
if (!sandboxed) chmodSync(directory, 0o700);
|
|
79
|
-
|
|
80
|
-
const lock = path.join(directory, "append");
|
|
81
|
-
let acquired = false;
|
|
82
|
-
for (let attempt = 0; attempt < 200; attempt++) {
|
|
83
|
-
try {
|
|
84
|
-
mkdirSync(lock, { mode: 0o700 });
|
|
85
|
-
acquired = true;
|
|
86
|
-
break;
|
|
87
|
-
} catch (error) {
|
|
88
|
-
if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
|
|
89
|
-
const stats = lstatSync(lock, { throwIfNoEntry: false });
|
|
90
|
-
if (stats && Date.now() - stats.mtimeMs > 10_000) {
|
|
91
|
-
rmSync(lock, { recursive: true, force: true });
|
|
92
|
-
continue;
|
|
93
|
-
}
|
|
94
|
-
Atomics.wait(lockWait, 0, 0, 5);
|
|
95
|
-
}
|
|
96
|
-
}
|
|
97
|
-
if (!acquired) return; // history must never hold up the transaction itself
|
|
98
|
-
try {
|
|
99
|
-
run();
|
|
100
|
-
} finally {
|
|
101
|
-
rmSync(lock, { recursive: true, force: true });
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
function safeDetails(event: string, details: Record<string, unknown>): Record<string, unknown> {
|
|
106
|
-
const number = (key: string) => (typeof details[key] === "number" ? details[key] : undefined);
|
|
107
|
-
const boolean = (key: string) => (typeof details[key] === "boolean" ? details[key] : undefined);
|
|
108
|
-
const text = (key: string, allowed: readonly string[]) =>
|
|
109
|
-
typeof details[key] === "string" && allowed.includes(details[key]) ? details[key] : undefined;
|
|
110
|
-
|
|
111
|
-
switch (event) {
|
|
112
|
-
case "started":
|
|
113
|
-
return compact({
|
|
114
|
-
run: safeIdentifier(details.run),
|
|
115
|
-
timeoutMs: number("timeoutMs"),
|
|
116
|
-
rollback: text("rollback", ["all", "file"]),
|
|
117
|
-
});
|
|
118
|
-
case "program started":
|
|
119
|
-
return compact({ run: safeIdentifier(details.run), timeoutMs: number("timeoutMs") });
|
|
120
|
-
case "program exited":
|
|
121
|
-
return compact({
|
|
122
|
-
run: safeIdentifier(details.run),
|
|
123
|
-
exitCode: details.exitCode === null ? null : number("exitCode"),
|
|
124
|
-
timedOut: boolean("timedOut"),
|
|
125
|
-
aborted: boolean("aborted"),
|
|
126
|
-
stillRunning: number("stillRunning"),
|
|
127
|
-
});
|
|
128
|
-
case "finished":
|
|
129
|
-
return compact({
|
|
130
|
-
run: safeIdentifier(details.run),
|
|
131
|
-
changed: number("changed"),
|
|
132
|
-
applied: number("applied"),
|
|
133
|
-
conflicts: number("conflicts"),
|
|
134
|
-
});
|
|
135
|
-
case "command":
|
|
136
|
-
return compact({ run: safeIdentifier(details.run), command: safeCommand(details.command) });
|
|
137
|
-
case "helper":
|
|
138
|
-
return compact({
|
|
139
|
-
run: safeIdentifier(details.run),
|
|
140
|
-
helper: text("helper", [
|
|
141
|
-
"glob",
|
|
142
|
-
"grep",
|
|
143
|
-
"sg.find",
|
|
144
|
-
"sg.one",
|
|
145
|
-
"sg.file",
|
|
146
|
-
"sg.insert",
|
|
147
|
-
"sg.move",
|
|
148
|
-
"sg.remove",
|
|
149
|
-
"sg.rewrite",
|
|
150
|
-
"grit",
|
|
151
|
-
]),
|
|
152
|
-
ms: number("ms"),
|
|
153
|
-
results: number("results"),
|
|
154
|
-
});
|
|
155
|
-
default:
|
|
156
|
-
return compact({ run: safeIdentifier(details.run) });
|
|
157
|
-
}
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
function compact(value: Record<string, unknown>): Record<string, unknown> {
|
|
161
|
-
return Object.fromEntries(Object.entries(value).filter(([, item]) => item !== undefined));
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
function safeIdentifier(value: unknown): string | undefined {
|
|
165
|
-
return typeof value === "string" && /^[a-zA-Z0-9_-]{1,64}$/.test(value) ? value : undefined;
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
function safeCommand(value: unknown): string | undefined {
|
|
169
|
-
if (typeof value !== "string") return undefined;
|
|
170
|
-
const command = path.basename(value);
|
|
171
|
-
return /^[a-zA-Z0-9_.-]{1,64}$/.test(command) ? command : undefined;
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
function appendBounded(file: string, line: string, options: HistoryOptions, sandboxed: boolean) {
|
|
175
|
-
const maxBytes = options.maxBytes ?? RUN_HISTORY_MAX_BYTES;
|
|
176
|
-
const maxAgeMs = options.maxAgeMs ?? RUN_HISTORY_MAX_AGE_MS;
|
|
177
|
-
const now = options.now ?? Date.now();
|
|
178
|
-
const directory = path.dirname(file);
|
|
179
|
-
const rotated = `${file}.1`;
|
|
180
|
-
if (!sandboxed) {
|
|
181
|
-
mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
182
|
-
const directoryStats = lstatSync(directory);
|
|
183
|
-
if (
|
|
184
|
-
!directoryStats.isDirectory() ||
|
|
185
|
-
directoryStats.isSymbolicLink() ||
|
|
186
|
-
(process.getuid && directoryStats.uid !== process.getuid())
|
|
187
|
-
) {
|
|
188
|
-
throw new Error(`Unsafe run history directory: ${directory}`);
|
|
189
|
-
}
|
|
190
|
-
chmodSync(directory, 0o700);
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
let existing: Stats | undefined;
|
|
194
|
-
try {
|
|
195
|
-
existing = lstatSync(file) as Stats;
|
|
196
|
-
} catch (error) {
|
|
197
|
-
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
198
|
-
}
|
|
199
|
-
if (existing?.isSymbolicLink() || (existing && !existing.isFile()))
|
|
200
|
-
throw new Error(`Unsafe run history file: ${file}`);
|
|
201
|
-
if (!sandboxed) {
|
|
202
|
-
const rotatedStats = lstatSync(rotated, { throwIfNoEntry: false });
|
|
203
|
-
if (rotatedStats?.isSymbolicLink() || (rotatedStats && !rotatedStats.isFile())) {
|
|
204
|
-
rmSync(rotated, { recursive: true, force: true });
|
|
205
|
-
} else if (rotatedStats && now - cohortStartedAt(rotated, rotatedStats) > maxAgeMs) {
|
|
206
|
-
rmSync(rotated, { force: true });
|
|
207
|
-
}
|
|
208
|
-
}
|
|
209
|
-
if (!sandboxed && existing && now - cohortStartedAt(file, existing) > maxAgeMs) {
|
|
210
|
-
rmSync(file, { force: true });
|
|
211
|
-
existing = undefined;
|
|
212
|
-
} else if (!sandboxed && existing && existing.size + Buffer.byteLength(line) > maxBytes) {
|
|
213
|
-
rmSync(rotated, { force: true });
|
|
214
|
-
renameSync(file, rotated);
|
|
215
|
-
existing = undefined;
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
const descriptor = openSync(
|
|
219
|
-
file,
|
|
220
|
-
constants.O_WRONLY | constants.O_CREAT | constants.O_APPEND | constants.O_NOFOLLOW,
|
|
221
|
-
0o600,
|
|
222
|
-
);
|
|
223
|
-
try {
|
|
224
|
-
const stats = fstatSync(descriptor);
|
|
225
|
-
if (!stats.isFile() || (process.getuid && stats.uid !== process.getuid()))
|
|
226
|
-
throw new Error(`Unsafe run history file: ${file}`);
|
|
227
|
-
if (sandboxed) {
|
|
228
|
-
if ((stats.mode & 0o077) !== 0) throw new Error(`Unsafe run history permissions: ${file}`);
|
|
229
|
-
} else {
|
|
230
|
-
fchmodSync(descriptor, 0o600);
|
|
231
|
-
}
|
|
232
|
-
if (stats.size + Buffer.byteLength(line) <= maxBytes) writeSync(descriptor, line);
|
|
233
|
-
} finally {
|
|
234
|
-
closeSync(descriptor);
|
|
235
|
-
}
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
/** The first record starts a cohort; later appends cannot extend that cohort's retention deadline. */
|
|
239
|
-
function cohortStartedAt(file: string, stats: Stats): number {
|
|
240
|
-
const descriptor = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
241
|
-
try {
|
|
242
|
-
const buffer = Buffer.alloc(2048);
|
|
243
|
-
const length = readSync(descriptor, buffer, 0, buffer.length, 0);
|
|
244
|
-
const firstLine = buffer.subarray(0, length).toString("utf8").split("\n", 1)[0];
|
|
245
|
-
const time = Date.parse(JSON.parse(firstLine).time);
|
|
246
|
-
return Number.isFinite(time) ? time : stats.birthtimeMs;
|
|
247
|
-
} catch {
|
|
248
|
-
return stats.birthtimeMs;
|
|
249
|
-
} finally {
|
|
250
|
-
closeSync(descriptor);
|
|
251
|
-
}
|
|
252
|
-
}
|