pi-agent-edit 0.0.0-stage → 0.2.3
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/CHANGELOG.md +100 -0
- package/LICENSE +21 -0
- package/README.md +125 -2
- package/README.zh-CN.md +87 -0
- package/engine.ts +1237 -0
- package/index.ts +335 -0
- package/package.json +33 -4
- package/writer.ts +221 -0
package/index.ts
ADDED
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi-agent-edit — an agent-oriented replacement for Pi's built-in `edit` tool.
|
|
3
|
+
*/
|
|
4
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
5
|
+
import { withFileMutationQueue } from "@earendil-works/pi-coding-agent";
|
|
6
|
+
import { Type } from "typebox";
|
|
7
|
+
import { promises as fs } from "node:fs";
|
|
8
|
+
import * as os from "node:os";
|
|
9
|
+
import * as path from "node:path";
|
|
10
|
+
import { applyEdits, decode, encode, EditError, formatReport, whitespaceWarnings, type EditSpec } from "./engine.ts";
|
|
11
|
+
import { atomicWrite, ConcurrentModificationError, sha } from "./writer.ts";
|
|
12
|
+
|
|
13
|
+
export { atomicWrite } from "./writer.ts";
|
|
14
|
+
|
|
15
|
+
// ───────────────────────────── schema ─────────────────────────────
|
|
16
|
+
|
|
17
|
+
const EditItem = Type.Object({
|
|
18
|
+
oldText: Type.Optional(
|
|
19
|
+
Type.String({ description: "Text to replace, copied from the file. Small but unique. Locator option 1." }),
|
|
20
|
+
),
|
|
21
|
+
newText: Type.String({ description: "Replacement text (or text to insert). Empty string deletes." }),
|
|
22
|
+
replaceAll: Type.Optional(Type.Boolean({ description: "With oldText: replace every occurrence." })),
|
|
23
|
+
nearLine: Type.Optional(
|
|
24
|
+
Type.Integer({ minimum: 1, description: "With oldText: if it occurs several times, pick the occurrence closest to this line." }),
|
|
25
|
+
),
|
|
26
|
+
startLine: Type.Optional(
|
|
27
|
+
Type.Integer({ minimum: 1, description: "Locator option 2: first line (1-based) of the range to replace." }),
|
|
28
|
+
),
|
|
29
|
+
endLine: Type.Optional(Type.Integer({ minimum: 1, description: "With startLine: last line of the range (inclusive). Defaults to startLine." })),
|
|
30
|
+
afterLine: Type.Optional(
|
|
31
|
+
Type.Integer({ minimum: 0, description: "Locator option 3: insert newText as new line(s) after this line (0 = top of file)." }),
|
|
32
|
+
),
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
const EditParams = Type.Object({
|
|
36
|
+
path: Type.String({ description: "Path of the file to edit (relative or absolute)." }),
|
|
37
|
+
edits: Type.Array(EditItem, { minItems: 1, description: "One or more edits, all relative to the file BEFORE this call." }),
|
|
38
|
+
dryRun: Type.Optional(Type.Boolean({ description: "Preview the diff without writing." })),
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
const DESCRIPTION = `Edit an existing file with one or more targeted changes, applied atomically (all succeed or nothing is written).
|
|
42
|
+
Each item in edits uses exactly ONE locator:
|
|
43
|
+
- oldText: replace it with newText. Copy oldText from the file; keep it small but unique. Matching tolerates trailing whitespace, indentation (newText is re-indented to fit) and whitespace/quote/fullwidth-punctuation differences, but never rewrites text outside the match. If oldText occurs several times, the error shows context for up to 5 occurrences, unique oldText suggestions when available, and up to 12 matching line numbers you can pass as nearLine right away (unless multiple matches start on the same line); or set replaceAll: true.
|
|
44
|
+
- startLine (+ optional endLine): replace that inclusive line range with newText; empty newText deletes the lines.
|
|
45
|
+
- afterLine: insert newText as new line(s) after that line (0 = top of file).
|
|
46
|
+
Line-number locators only work if you read or wrote the file after its last external change; otherwise use oldText.
|
|
47
|
+
All edits refer to the file as it was BEFORE this call and must not overlap. dryRun: true previews without writing.
|
|
48
|
+
The result shows a numbered diff of the changed lines, so re-reading is usually unnecessary. On failure the error shows the closest matching text with line numbers — fix oldText from it instead of re-reading.
|
|
49
|
+
Use write (not edit) to create new files or fully rewrite small files.`;
|
|
50
|
+
|
|
51
|
+
// ───────────────────────────── helpers ─────────────────────────────
|
|
52
|
+
|
|
53
|
+
const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50 MB
|
|
54
|
+
|
|
55
|
+
const utf8 = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true });
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Decode strictly. A lenient utf8 read turns every invalid byte (GBK, Latin-1, …) into
|
|
59
|
+
* U+FFFD and writing that back rewrites the WHOLE file, not just the edited region.
|
|
60
|
+
*/
|
|
61
|
+
function decodeUtf8(buf: Buffer, shown: string): string {
|
|
62
|
+
if (buf.includes(0)) throw new Error(`${shown} looks like a binary file; refusing to edit it.`);
|
|
63
|
+
try {
|
|
64
|
+
return utf8.decode(buf);
|
|
65
|
+
} catch {
|
|
66
|
+
throw new Error(
|
|
67
|
+
`${shown} is not valid UTF-8 (perhaps GBK, Latin-1 or another legacy encoding). Refusing to edit it, ` +
|
|
68
|
+
`because rewriting it as UTF-8 would corrupt every non-ASCII character in the file. ` +
|
|
69
|
+
`Convert it first (e.g. iconv -f GBK -t UTF-8) or edit it with a tool that preserves its encoding.`,
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function resolvePath(cwd: string, p: string): string {
|
|
75
|
+
let q = p.trim();
|
|
76
|
+
if (q === "~" || q.startsWith("~/")) q = path.join(os.homedir(), q.slice(1));
|
|
77
|
+
return path.resolve(cwd, q);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Resolve to a real, existing path. Models often write file mentions as "@src/app.ts";
|
|
82
|
+
* the literal name is tried first so a file that really starts with "@" still works.
|
|
83
|
+
*/
|
|
84
|
+
async function resolveExisting(cwd: string, p: string): Promise<string> {
|
|
85
|
+
const q = p.trim();
|
|
86
|
+
const tries = [q];
|
|
87
|
+
if (q.startsWith("@") && q.length > 1) tries.push(q.slice(1));
|
|
88
|
+
let lastErr: unknown;
|
|
89
|
+
for (const t of tries) {
|
|
90
|
+
try {
|
|
91
|
+
return await fs.realpath(resolvePath(cwd, t));
|
|
92
|
+
} catch (err) {
|
|
93
|
+
lastErr = err;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
throw lastErr;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const ITEM_ALIAS_KEYS = [
|
|
100
|
+
"old_text", "old_str", "old_string",
|
|
101
|
+
"new_text", "new_str", "new_string",
|
|
102
|
+
"replace_all", "near_line", "start_line", "end_line", "after_line",
|
|
103
|
+
];
|
|
104
|
+
|
|
105
|
+
const TOP_ALIAS_KEYS = [
|
|
106
|
+
"file_path", "filePath", "file", "dry_run",
|
|
107
|
+
"oldText", "newText", "replaceAll", "nearLine", "startLine", "endLine", "afterLine",
|
|
108
|
+
...ITEM_ALIAS_KEYS,
|
|
109
|
+
];
|
|
110
|
+
|
|
111
|
+
/** Accept legacy / foreign argument shapes before schema validation. */
|
|
112
|
+
function normalizeArgs(raw: any): any {
|
|
113
|
+
if (!raw || typeof raw !== "object") return raw;
|
|
114
|
+
const a: any = { ...raw };
|
|
115
|
+
a.path ??= a.file_path ?? a.filePath ?? a.file;
|
|
116
|
+
a.dryRun ??= a.dry_run;
|
|
117
|
+
if (a.dryRun === undefined) delete a.dryRun;
|
|
118
|
+
|
|
119
|
+
if (typeof a.edits === "string") {
|
|
120
|
+
try {
|
|
121
|
+
a.edits = JSON.parse(a.edits);
|
|
122
|
+
} catch {
|
|
123
|
+
/* leave for validation error */
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
if (a.edits && !Array.isArray(a.edits) && typeof a.edits === "object") a.edits = [a.edits];
|
|
127
|
+
|
|
128
|
+
const oldT = a.oldText ?? a.old_text ?? a.old_str ?? a.old_string;
|
|
129
|
+
const newT = a.newText ?? a.new_text ?? a.new_str ?? a.new_string;
|
|
130
|
+
const startL = a.startLine ?? a.start_line;
|
|
131
|
+
const afterL = a.afterLine ?? a.after_line;
|
|
132
|
+
if (!a.edits && newT !== undefined && (oldT !== undefined || startL !== undefined || afterL !== undefined)) {
|
|
133
|
+
a.edits = [{
|
|
134
|
+
oldText: oldT,
|
|
135
|
+
newText: newT,
|
|
136
|
+
replaceAll: a.replaceAll ?? a.replace_all,
|
|
137
|
+
nearLine: a.nearLine ?? a.near_line,
|
|
138
|
+
startLine: startL,
|
|
139
|
+
endLine: a.endLine ?? a.end_line,
|
|
140
|
+
afterLine: afterL,
|
|
141
|
+
}];
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (Array.isArray(a.edits)) {
|
|
145
|
+
a.edits = a.edits.map((e: any, i: number) => {
|
|
146
|
+
if (!e || typeof e !== "object") return e;
|
|
147
|
+
const o: any = { ...e };
|
|
148
|
+
o.oldText ??= o.old_text ?? o.old_str ?? o.old_string;
|
|
149
|
+
o.newText ??= o.new_text ?? o.new_str ?? o.new_string;
|
|
150
|
+
o.replaceAll ??= o.replace_all;
|
|
151
|
+
o.nearLine ??= o.near_line;
|
|
152
|
+
o.startLine ??= o.start_line;
|
|
153
|
+
o.endLine ??= o.end_line;
|
|
154
|
+
o.afterLine ??= o.after_line;
|
|
155
|
+
// Pi's schema conversion truncates Integer values before execute().
|
|
156
|
+
// Validate normalized locators here, while their original values survive.
|
|
157
|
+
for (const field of ["startLine", "endLine", "afterLine", "nearLine"] as const) {
|
|
158
|
+
// null means "not set"; "12" is a common way for models to send a number.
|
|
159
|
+
if (o[field] === null) o[field] = undefined;
|
|
160
|
+
if (typeof o[field] === "string" && /^\s*-?\d+\s*$/.test(o[field])) o[field] = Number(o[field]);
|
|
161
|
+
if (o[field] !== undefined && !Number.isSafeInteger(o[field])) {
|
|
162
|
+
throw new Error(`Edit #${i + 1}: ${field} must be a safe integer; received ${String(o[field])}. No changes written.`);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
for (const k of ITEM_ALIAS_KEYS) delete o[k];
|
|
166
|
+
for (const k of ["oldText", "replaceAll", "nearLine", "startLine", "endLine", "afterLine"]) {
|
|
167
|
+
if (o[k] == null) delete o[k]; // null or undefined: not set
|
|
168
|
+
}
|
|
169
|
+
return o;
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
for (const k of TOP_ALIAS_KEYS) delete a[k];
|
|
173
|
+
return a;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function jsonWarning(file: string, before: string, after: string): string | undefined {
|
|
177
|
+
if (!/\.json$/i.test(file)) return undefined;
|
|
178
|
+
try {
|
|
179
|
+
JSON.parse(before.replace(/^\ufeff/, ""));
|
|
180
|
+
} catch {
|
|
181
|
+
return undefined;
|
|
182
|
+
}
|
|
183
|
+
try {
|
|
184
|
+
JSON.parse(after.replace(/^\ufeff/, ""));
|
|
185
|
+
return undefined;
|
|
186
|
+
} catch (e: any) {
|
|
187
|
+
return `WARNING: file was valid JSON before this edit and is not anymore (${e?.message ?? e}). Fix it now.`;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// ───────────────────────────── extension ─────────────────────────────
|
|
192
|
+
|
|
193
|
+
interface View {
|
|
194
|
+
hash: string;
|
|
195
|
+
linesValid: boolean;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export default function (pi: ExtensionAPI) {
|
|
199
|
+
const views = new Map<string, View>();
|
|
200
|
+
// Line numbers shown by the last ambiguity error per file, valid only for that content.
|
|
201
|
+
const offers = new Map<string, { hash: string; lines: Set<number> }>();
|
|
202
|
+
|
|
203
|
+
// A "view" is what the model saw in its current context. Anything that swaps or rewrites
|
|
204
|
+
// that context invalidates it: a new session, switching/forking sessions, jumping in the
|
|
205
|
+
// session tree, or compaction. Event names differ between Pi versions; registering one a
|
|
206
|
+
// version does not emit is harmless.
|
|
207
|
+
for (const event of ["session_start", "session_switch", "session_fork", "session_branch", "session_tree", "session_compact"]) {
|
|
208
|
+
try {
|
|
209
|
+
pi.on(event as any, async () => {
|
|
210
|
+
views.clear();
|
|
211
|
+
offers.clear();
|
|
212
|
+
});
|
|
213
|
+
} catch {
|
|
214
|
+
/* event not supported by this Pi version */
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
pi.on("tool_result", async (event: any, ctx: any) => {
|
|
219
|
+
if ((event?.toolName !== "read" && event?.toolName !== "write") || event?.isError) return;
|
|
220
|
+
const p = event?.input?.path ?? event?.input?.file_path;
|
|
221
|
+
if (typeof p !== "string") return;
|
|
222
|
+
try {
|
|
223
|
+
const real = await resolveExisting(ctx.cwd, p);
|
|
224
|
+
const st = await fs.stat(real);
|
|
225
|
+
if (!st.isFile() || st.size > MAX_FILE_SIZE) return;
|
|
226
|
+
views.set(real, { hash: sha(await fs.readFile(real)), linesValid: true });
|
|
227
|
+
} catch {
|
|
228
|
+
/* ignore */
|
|
229
|
+
}
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
pi.registerTool({
|
|
233
|
+
name: "edit",
|
|
234
|
+
label: "Edit",
|
|
235
|
+
description: DESCRIPTION,
|
|
236
|
+
promptSnippet: "Edit files: oldText replace (tolerant, lossless), line-range replace/delete, insert after line; atomic multi-edit",
|
|
237
|
+
promptGuidelines: [
|
|
238
|
+
"Prefer edit over write for changes to existing files; batch all changes to one file into a single edit call.",
|
|
239
|
+
"If an edit fails, use the closest-candidate text shown in the error to fix oldText instead of re-reading the whole file.",
|
|
240
|
+
],
|
|
241
|
+
parameters: EditParams,
|
|
242
|
+
prepareArguments: normalizeArgs,
|
|
243
|
+
|
|
244
|
+
async execute(_toolCallId: string, params: any, signal: AbortSignal | undefined, _onUpdate: any, ctx: any) {
|
|
245
|
+
let target: string;
|
|
246
|
+
try {
|
|
247
|
+
target = await resolveExisting(ctx.cwd, params.path);
|
|
248
|
+
} catch {
|
|
249
|
+
throw new Error(`File not found: ${params.path}. Use the write tool to create a new file.`);
|
|
250
|
+
}
|
|
251
|
+
const st = await fs.stat(target);
|
|
252
|
+
if (st.isDirectory()) throw new Error(`${params.path} is a directory, not a file.`);
|
|
253
|
+
if (st.size > MAX_FILE_SIZE) throw new Error(`${params.path} is too large (${(st.size / 1024 / 1024).toFixed(1)} MB, limit ${MAX_FILE_SIZE / 1024 / 1024} MB). Refusing to edit.`);
|
|
254
|
+
|
|
255
|
+
return withFileMutationQueue(target, async () => {
|
|
256
|
+
const buf = await fs.readFile(target);
|
|
257
|
+
const raw = decodeUtf8(buf, params.path);
|
|
258
|
+
const hashBefore = sha(buf);
|
|
259
|
+
|
|
260
|
+
// Can line numbers the model remembers be trusted? Needed for startLine/afterLine,
|
|
261
|
+
// and for nearLine when it has to pick between several matches.
|
|
262
|
+
const v = views.get(target);
|
|
263
|
+
const staleReason = !v
|
|
264
|
+
? `you have not read ${params.path} in this session`
|
|
265
|
+
: v.hash !== hashBefore
|
|
266
|
+
? `${params.path} changed on disk since you last read it (by another tool or process)`
|
|
267
|
+
: !v.linesValid
|
|
268
|
+
? `line count changed in part of ${params.path} during a previous edit since you last read it, so line numbers may have shifted`
|
|
269
|
+
: undefined;
|
|
270
|
+
|
|
271
|
+
const edits: EditSpec[] = params.edits;
|
|
272
|
+
const usesLines = edits.some((e) => e.startLine !== undefined || e.afterLine !== undefined);
|
|
273
|
+
if (usesLines && staleReason) {
|
|
274
|
+
const hint = "Re-read the relevant region with the read tool (offset/limit is fine) and retry, or use oldText instead of line numbers.";
|
|
275
|
+
if (!v) throw new Error(`Line-number edits need a fresh view: ${staleReason}. ${hint}`);
|
|
276
|
+
throw new Error(`Line-number edits rejected: ${staleReason}. ${hint}`);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const decoded = decode(raw);
|
|
280
|
+
let res;
|
|
281
|
+
try {
|
|
282
|
+
const offer = offers.get(target);
|
|
283
|
+
res = applyEdits(decoded, edits, {
|
|
284
|
+
lineNumbersTrusted: !staleReason,
|
|
285
|
+
untrustedReason: staleReason,
|
|
286
|
+
offeredNearLines: offer?.hash === hashBefore ? offer.lines : undefined,
|
|
287
|
+
});
|
|
288
|
+
} catch (err) {
|
|
289
|
+
if (err instanceof EditError) {
|
|
290
|
+
if (err.nearLineOffers?.length) offers.set(target, { hash: hashBefore, lines: new Set(err.nearLineOffers) });
|
|
291
|
+
throw new Error(err.message);
|
|
292
|
+
}
|
|
293
|
+
throw err;
|
|
294
|
+
}
|
|
295
|
+
const newRaw = encode(decoded, res.text);
|
|
296
|
+
|
|
297
|
+
if (!params.dryRun) {
|
|
298
|
+
if (signal?.aborted) throw new Error("Edit aborted before writing; file unchanged.");
|
|
299
|
+
try {
|
|
300
|
+
// Refuses to write if another process changed the file since it was read above.
|
|
301
|
+
await atomicWrite(target, newRaw, { expectedHash: hashBefore });
|
|
302
|
+
offers.delete(target);
|
|
303
|
+
} catch (err) {
|
|
304
|
+
if (err instanceof ConcurrentModificationError) views.delete(target);
|
|
305
|
+
throw err;
|
|
306
|
+
}
|
|
307
|
+
if (v) {
|
|
308
|
+
views.set(target, {
|
|
309
|
+
hash: sha(newRaw),
|
|
310
|
+
// Per-hunk check: equal totals can still shift every line in between.
|
|
311
|
+
linesValid: v.linesValid && v.hash === hashBefore && !res.linesShifted,
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
let text = formatReport(res, { path: params.path, dryRun: !!params.dryRun });
|
|
317
|
+
const warnings = [jsonWarning(target, raw, newRaw), ...whitespaceWarnings(target, res).map((w) => "WARNING: " + w)]
|
|
318
|
+
.filter((w): w is string => !!w);
|
|
319
|
+
if (warnings.length) text = warnings.join("\n") + "\n\n" + text;
|
|
320
|
+
|
|
321
|
+
return {
|
|
322
|
+
content: [{ type: "text" as const, text }],
|
|
323
|
+
details: {
|
|
324
|
+
path: target,
|
|
325
|
+
dryRun: !!params.dryRun,
|
|
326
|
+
added: res.added,
|
|
327
|
+
removed: res.removed,
|
|
328
|
+
levels: [...new Set(res.ranges.map((r) => r.level))],
|
|
329
|
+
report: text,
|
|
330
|
+
},
|
|
331
|
+
};
|
|
332
|
+
});
|
|
333
|
+
},
|
|
334
|
+
} as any);
|
|
335
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,35 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-agent-edit",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.2.3",
|
|
4
|
+
"description": "An agent-built edit tool for Pi: lossless tolerant matching, actionable ambiguity recovery, stale-read guards, and recoverable file writes.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"keywords": ["pi-package", "pi-extension", "edit", "coding-agent"],
|
|
7
|
+
"license": "MIT",
|
|
8
|
+
"author": "bjstan1979",
|
|
9
|
+
"files": [
|
|
10
|
+
"index.ts",
|
|
11
|
+
"engine.ts",
|
|
12
|
+
"writer.ts",
|
|
13
|
+
"README.md",
|
|
14
|
+
"README.zh-CN.md",
|
|
15
|
+
"CHANGELOG.md",
|
|
16
|
+
"LICENSE"
|
|
17
|
+
],
|
|
18
|
+
"publishConfig": {
|
|
19
|
+
"access": "public",
|
|
20
|
+
"registry": "https://registry.npmjs.org/"
|
|
21
|
+
},
|
|
22
|
+
"pi": {
|
|
23
|
+
"extensions": ["./index.ts"]
|
|
24
|
+
},
|
|
25
|
+
"scripts": {
|
|
26
|
+
"test": "tsx --test test/*.test.ts"
|
|
27
|
+
},
|
|
28
|
+
"peerDependencies": {
|
|
29
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
30
|
+
"typebox": "*"
|
|
31
|
+
},
|
|
32
|
+
"devDependencies": {
|
|
33
|
+
"tsx": "^4.21.0"
|
|
34
|
+
}
|
|
35
|
+
}
|
package/writer.ts
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi-agent-edit — safe file replacement (no Pi dependency, unit-testable).
|
|
3
|
+
*
|
|
4
|
+
* Preferred path: write a temp file next to the target, fsync it, re-check that the
|
|
5
|
+
* target still holds the bytes the edit was computed from, then rename over it.
|
|
6
|
+
*
|
|
7
|
+
* Fallback path (hard links, directories we cannot create files in, ownership we cannot
|
|
8
|
+
* restore): write through the existing inode. That cannot be atomic on POSIX, so it is
|
|
9
|
+
* made recoverable instead:
|
|
10
|
+
* - the content is verified and written through ONE file descriptor;
|
|
11
|
+
* - the original bytes are kept in memory and, best effort, in a backup file;
|
|
12
|
+
* - new bytes are written over the old ones without O_TRUNC and the file is truncated
|
|
13
|
+
* afterwards, so a failure never leaves an empty file;
|
|
14
|
+
* - on any error the original is written back; only if that also fails does the error
|
|
15
|
+
* point the user at the backup file.
|
|
16
|
+
*/
|
|
17
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
18
|
+
import { promises as fs } from "node:fs";
|
|
19
|
+
import type { FileHandle } from "node:fs/promises";
|
|
20
|
+
import * as os from "node:os";
|
|
21
|
+
import * as path from "node:path";
|
|
22
|
+
|
|
23
|
+
/** Hash of the file's bytes (a string is hashed as its UTF-8 encoding, i.e. the bytes written). */
|
|
24
|
+
export const sha = (s: string | Buffer) => createHash("sha1").update(s).digest("hex");
|
|
25
|
+
|
|
26
|
+
/** The target changed between reading it and writing it. Nothing was written. */
|
|
27
|
+
export class ConcurrentModificationError extends Error {}
|
|
28
|
+
|
|
29
|
+
export interface WriteOptions {
|
|
30
|
+
/**
|
|
31
|
+
* sha1 of the bytes the new content was computed from. When set, the write is refused
|
|
32
|
+
* if the target no longer holds exactly those bytes (another process changed it).
|
|
33
|
+
*/
|
|
34
|
+
expectedHash?: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Errors meaning "a new file cannot be created or swapped in here" (read-only or sticky
|
|
39
|
+
* directory, bind-mounted file, …). Only for these is an in-place write a safe fallback.
|
|
40
|
+
* Anything else — ENOSPC, EDQUOT, EIO — fails the whole write with the original untouched.
|
|
41
|
+
*/
|
|
42
|
+
const IN_PLACE_FALLBACK_CODES = new Set(["EACCES", "EPERM", "EROFS", "EXDEV", "EBUSY", "ENOTSUP", "EOPNOTSUPP", "EINVAL", "ENAMETOOLONG"]);
|
|
43
|
+
|
|
44
|
+
const errCode = (err: unknown) => (err as NodeJS.ErrnoException)?.code ?? "";
|
|
45
|
+
const errMsg = (err: unknown) => (err as Error)?.message ?? String(err);
|
|
46
|
+
|
|
47
|
+
function concurrentError(target: string): ConcurrentModificationError {
|
|
48
|
+
return new ConcurrentModificationError(
|
|
49
|
+
`${target} was changed by another process while this edit was being prepared. Nothing was written. ` +
|
|
50
|
+
`Re-read the file and redo the edit against its current content.`,
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function verifyUnchanged(target: string, expectedHash: string | undefined): Promise<void> {
|
|
55
|
+
if (expectedHash === undefined) return;
|
|
56
|
+
let current: Buffer;
|
|
57
|
+
try {
|
|
58
|
+
current = await fs.readFile(target);
|
|
59
|
+
} catch (err) {
|
|
60
|
+
if (errCode(err) === "ENOENT") throw concurrentError(target);
|
|
61
|
+
throw err;
|
|
62
|
+
}
|
|
63
|
+
if (sha(current) !== expectedHash) throw concurrentError(target);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function writeAll(h: FileHandle, buf: Buffer): Promise<void> {
|
|
67
|
+
let off = 0;
|
|
68
|
+
while (off < buf.length) {
|
|
69
|
+
const { bytesWritten } = await h.write(buf, off, buf.length - off, off);
|
|
70
|
+
if (bytesWritten <= 0) throw Object.assign(new Error("short write"), { code: "EIO" });
|
|
71
|
+
off += bytesWritten;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function readAll(h: FileHandle): Promise<Buffer> {
|
|
76
|
+
const { size } = await h.stat();
|
|
77
|
+
const chunks: Buffer[] = [];
|
|
78
|
+
let pos = 0;
|
|
79
|
+
for (;;) {
|
|
80
|
+
const buf = Buffer.alloc(Math.max(size - pos, 64 * 1024));
|
|
81
|
+
const { bytesRead } = await h.read(buf, 0, buf.length, pos);
|
|
82
|
+
if (bytesRead === 0) break;
|
|
83
|
+
chunks.push(buf.subarray(0, bytesRead));
|
|
84
|
+
pos += bytesRead;
|
|
85
|
+
}
|
|
86
|
+
return Buffer.concat(chunks);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Best-effort: persist the rename itself. Some file systems refuse to fsync directories. */
|
|
90
|
+
async function syncDir(dir: string): Promise<void> {
|
|
91
|
+
let h: FileHandle | undefined;
|
|
92
|
+
try {
|
|
93
|
+
h = await fs.open(dir, "r");
|
|
94
|
+
await h.sync();
|
|
95
|
+
} catch {
|
|
96
|
+
/* not supported here; the data itself was already fsynced */
|
|
97
|
+
} finally {
|
|
98
|
+
await h?.close().catch(() => {});
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async function writeBackup(target: string, original: Buffer): Promise<string | undefined> {
|
|
103
|
+
const name = `pi-agent-edit-backup-${path.basename(target)}-${Date.now()}-${randomBytes(3).toString("hex")}`;
|
|
104
|
+
const file = path.join(os.tmpdir(), name);
|
|
105
|
+
try {
|
|
106
|
+
await fs.writeFile(file, original, { mode: 0o600, flag: "wx" });
|
|
107
|
+
return file;
|
|
108
|
+
} catch {
|
|
109
|
+
return undefined; // the in-memory copy is still used for rollback
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Write through the existing inode, recoverably (see the file header). Exported for tests.
|
|
115
|
+
*/
|
|
116
|
+
export async function writeInPlace(target: string, content: string, opts: WriteOptions = {}): Promise<void> {
|
|
117
|
+
const next = Buffer.from(content, "utf8");
|
|
118
|
+
const h = await fs.open(target, "r+");
|
|
119
|
+
let backup: string | undefined;
|
|
120
|
+
try {
|
|
121
|
+
// Verify and write through the same descriptor: the content checked is the inode written.
|
|
122
|
+
const original = await readAll(h);
|
|
123
|
+
if (opts.expectedHash !== undefined && sha(original) !== opts.expectedHash) throw concurrentError(target);
|
|
124
|
+
backup = await writeBackup(target, original);
|
|
125
|
+
// Backup I/O can be slow. Recheck even without expectedHash: rollback must
|
|
126
|
+
// never intentionally use a snapshot already superseded during preparation.
|
|
127
|
+
if (sha(await readAll(h)) !== sha(original)) throw concurrentError(target);
|
|
128
|
+
try {
|
|
129
|
+
await writeAll(h, next);
|
|
130
|
+
await h.truncate(next.length);
|
|
131
|
+
await h.sync();
|
|
132
|
+
} catch (err) {
|
|
133
|
+
let restored = false;
|
|
134
|
+
try {
|
|
135
|
+
// Overwriting already-allocated blocks first, then truncating, needs no new space on
|
|
136
|
+
// ordinary file systems, so this normally succeeds even after ENOSPC.
|
|
137
|
+
await writeAll(h, original);
|
|
138
|
+
await h.truncate(original.length);
|
|
139
|
+
await h.sync();
|
|
140
|
+
restored = true;
|
|
141
|
+
} catch {
|
|
142
|
+
/* reported below */
|
|
143
|
+
}
|
|
144
|
+
if (restored) {
|
|
145
|
+
throw new Error(`Could not write ${target}: ${errMsg(err)}. The original content was restored; nothing was changed.`);
|
|
146
|
+
}
|
|
147
|
+
const keep = backup;
|
|
148
|
+
backup = undefined; // do not delete it in finally
|
|
149
|
+
throw new Error(
|
|
150
|
+
`Could not write ${target}: ${errMsg(err)}, and restoring the original also failed. The file may be partially written. ` +
|
|
151
|
+
(keep ? `A copy of the original is at ${keep}.` : `No backup copy could be created.`),
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
} finally {
|
|
155
|
+
await h.close().catch(() => {});
|
|
156
|
+
if (backup) await fs.rm(backup, { force: true }).catch(() => {});
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export async function atomicWrite(target: string, content: string, opts: WriteOptions = {}): Promise<void> {
|
|
161
|
+
const st = await fs.stat(target);
|
|
162
|
+
if (st.nlink > 1) {
|
|
163
|
+
// Replacing the inode would split the hard links; write through it instead.
|
|
164
|
+
await writeInPlace(target, content, opts);
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
const tmp = path.join(path.dirname(target), `.${path.basename(target)}.pi-edit-${randomBytes(4).toString("hex")}.tmp`);
|
|
168
|
+
const fallback = async (err: unknown, why: string) => {
|
|
169
|
+
await fs.rm(tmp, { force: true }).catch(() => {});
|
|
170
|
+
if (err instanceof ConcurrentModificationError) throw err;
|
|
171
|
+
const code = errCode(err);
|
|
172
|
+
if (!IN_PLACE_FALLBACK_CODES.has(code)) {
|
|
173
|
+
throw new Error(`Could not write ${target}: ${errMsg(err)}. The file was left unchanged.`);
|
|
174
|
+
}
|
|
175
|
+
console.warn(`[pi-agent-edit] ${why} (${code}); falling back to an in-place write for ${target}`);
|
|
176
|
+
await writeInPlace(target, content, opts);
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
try {
|
|
180
|
+
await fs.writeFile(tmp, content, { encoding: "utf8", mode: st.mode & 0o7777, flag: "wx" });
|
|
181
|
+
} catch (err) {
|
|
182
|
+
return fallback(err, "could not create a temporary file");
|
|
183
|
+
}
|
|
184
|
+
let keepInode = false;
|
|
185
|
+
try {
|
|
186
|
+
// Data must be on disk before the rename makes it the file's only copy.
|
|
187
|
+
const th = await fs.open(tmp, "r+");
|
|
188
|
+
try {
|
|
189
|
+
await th.sync();
|
|
190
|
+
} finally {
|
|
191
|
+
await th.close().catch(() => {});
|
|
192
|
+
}
|
|
193
|
+
const tst = await fs.stat(tmp);
|
|
194
|
+
if (tst.uid !== st.uid || tst.gid !== st.gid) {
|
|
195
|
+
// A new inode belongs to whoever runs Pi. Hand it back to the original owner/group;
|
|
196
|
+
// if that is not permitted, keep the original inode by writing in place.
|
|
197
|
+
try {
|
|
198
|
+
await fs.chown(tmp, st.uid, st.gid);
|
|
199
|
+
} catch (err) {
|
|
200
|
+
if (!["EPERM", "EACCES"].includes(errCode(err))) throw err;
|
|
201
|
+
keepInode = true;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
if (!keepInode) {
|
|
205
|
+
// chown can clear setuid/setgid; restore mode only after ownership.
|
|
206
|
+
await fs.chmod(tmp, st.mode & 0o7777);
|
|
207
|
+
// Last check as close to the rename as possible. A writer that slips in between
|
|
208
|
+
// this read and the rename is the one window left (POSIX has no compare-and-rename).
|
|
209
|
+
await verifyUnchanged(target, opts.expectedHash);
|
|
210
|
+
await fs.rename(tmp, target);
|
|
211
|
+
}
|
|
212
|
+
} catch (err) {
|
|
213
|
+
return fallback(err, "atomic rename failed");
|
|
214
|
+
}
|
|
215
|
+
if (keepInode) {
|
|
216
|
+
await fs.rm(tmp, { force: true }).catch(() => {});
|
|
217
|
+
await writeInPlace(target, content, opts);
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
await syncDir(path.dirname(target));
|
|
221
|
+
}
|