@wierdbytes/pi-facelift 0.4.0 → 0.4.1
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/index.ts +99 -40
- package/package.json +1 -1
- package/write-edit-concurrency.test.ts +300 -0
package/index.ts
CHANGED
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
|
|
26
26
|
import * as childProcess from "node:child_process";
|
|
27
27
|
import { existsSync, readFileSync } from "node:fs";
|
|
28
|
-
import { basename, dirname, extname, join, relative } from "node:path";
|
|
28
|
+
import { basename, dirname, extname, join, relative, resolve as resolvePath } from "node:path";
|
|
29
29
|
|
|
30
30
|
import type { ImageContent, TextContent } from "@earendil-works/pi-ai";
|
|
31
31
|
import type {
|
|
@@ -1782,6 +1782,52 @@ export default function piFaceliftExtension(pi: PiFaceliftApi, deps?: PiFacelift
|
|
|
1782
1782
|
},
|
|
1783
1783
|
});
|
|
1784
1784
|
|
|
1785
|
+
// ===================================================================
|
|
1786
|
+
// Per-path mutation lock
|
|
1787
|
+
// -------------------------------------------------------------------
|
|
1788
|
+
// Both `write` and `edit` snapshot the file *before* delegating to the
|
|
1789
|
+
// built-in tool's execute(). When two mutations to the same path are
|
|
1790
|
+
// queued concurrently the built-in tool serializes the actual write,
|
|
1791
|
+
// but our pre-execute snapshots run in parallel — so the second call
|
|
1792
|
+
// would diff against the file state from *before* the first call, not
|
|
1793
|
+
// the state the first call left behind. Result: stale, misleading
|
|
1794
|
+
// diffs in the rendered output.
|
|
1795
|
+
//
|
|
1796
|
+
// We fix it here (not in the built-in tool) by chaining
|
|
1797
|
+
// snapshot→execute pairs per resolved path. The chain is purely
|
|
1798
|
+
// in-process and only covers mutations going through our wrapper;
|
|
1799
|
+
// external writers (other processes, the user) are still racy, but
|
|
1800
|
+
// that's the same correctness contract the built-in tool already has.
|
|
1801
|
+
// ===================================================================
|
|
1802
|
+
|
|
1803
|
+
const pathMutationTails = new Map<string, Promise<void>>();
|
|
1804
|
+
function serializePerPath<T>(path: string, fn: () => Promise<T>): Promise<T> {
|
|
1805
|
+
if (!path) return fn();
|
|
1806
|
+
const key = (() => {
|
|
1807
|
+
try {
|
|
1808
|
+
return resolvePath(path);
|
|
1809
|
+
} catch {
|
|
1810
|
+
return path;
|
|
1811
|
+
}
|
|
1812
|
+
})();
|
|
1813
|
+
const prev = pathMutationTails.get(key) ?? Promise.resolve();
|
|
1814
|
+
// `prev.then(fn, fn)` — run fn after prev settles, regardless of
|
|
1815
|
+
// whether prev rejected. We must not let an earlier failure poison
|
|
1816
|
+
// later snapshots on the same path.
|
|
1817
|
+
const result = prev.then(fn, fn);
|
|
1818
|
+
const tail = result.then(
|
|
1819
|
+
() => undefined,
|
|
1820
|
+
() => undefined,
|
|
1821
|
+
);
|
|
1822
|
+
pathMutationTails.set(key, tail);
|
|
1823
|
+
tail.then(() => {
|
|
1824
|
+
// Drop the entry only if no newer caller has chained onto us;
|
|
1825
|
+
// otherwise we'd unblock callers waiting in the chain.
|
|
1826
|
+
if (pathMutationTails.get(key) === tail) pathMutationTails.delete(key);
|
|
1827
|
+
});
|
|
1828
|
+
return result;
|
|
1829
|
+
}
|
|
1830
|
+
|
|
1785
1831
|
// ===================================================================
|
|
1786
1832
|
// write — split diff (or syntax-highlighted preview for new files)
|
|
1787
1833
|
// ===================================================================
|
|
@@ -1802,14 +1848,19 @@ export default function piFaceliftExtension(pi: PiFaceliftApi, deps?: PiFacelift
|
|
|
1802
1848
|
ctx: ExtensionContext,
|
|
1803
1849
|
) {
|
|
1804
1850
|
const fp = params.path ?? "";
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
}
|
|
1809
|
-
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
1851
|
+
// Snapshot + execute must run atomically per-path so concurrent
|
|
1852
|
+
// writes to the same file don't both diff against the same
|
|
1853
|
+
// stale pre-state.
|
|
1854
|
+
const { oldContent, result } = await serializePerPath(fp, async () => {
|
|
1855
|
+
let snapshot: string | null = null;
|
|
1856
|
+
try {
|
|
1857
|
+
if (fp && existsSync(fp)) snapshot = readFileSync(fp, "utf-8");
|
|
1858
|
+
} catch {
|
|
1859
|
+
snapshot = null;
|
|
1860
|
+
}
|
|
1861
|
+
const r = (await origWrite.execute(tid, params, sig, upd, ctx)) as ToolResultLike;
|
|
1862
|
+
return { oldContent: snapshot, result: r };
|
|
1863
|
+
});
|
|
1813
1864
|
const newContent = params.content ?? "";
|
|
1814
1865
|
const language = diffLang(fp);
|
|
1815
1866
|
|
|
@@ -1875,6 +1926,9 @@ export default function piFaceliftExtension(pi: PiFaceliftApi, deps?: PiFacelift
|
|
|
1875
1926
|
|
|
1876
1927
|
if (d?._type === "writeNew") {
|
|
1877
1928
|
const lab = `${FG_GREEN}✓ new file${RST} ${FG_DIM}(${d.lineCount} lines)${RST}`;
|
|
1929
|
+
// Diff/preview output is always rendered in full — no compact
|
|
1930
|
+
// mode, no `… N more lines` footer — so `ctx.expanded` is
|
|
1931
|
+
// irrelevant to the rendered body and stays out of the key.
|
|
1878
1932
|
const key = `writeNew:${themeCacheKey(theme)}:${d.filePath}:${d.lineCount}:${w}:${status}`;
|
|
1879
1933
|
if (ctx.state._wk !== key) {
|
|
1880
1934
|
ctx.state._wk = key;
|
|
@@ -1883,11 +1937,7 @@ export default function piFaceliftExtension(pi: PiFaceliftApi, deps?: PiFacelift
|
|
|
1883
1937
|
hlDiffBlock(d.content, d.language)
|
|
1884
1938
|
.then((hlLines: string[]) => {
|
|
1885
1939
|
if (ctx.state._wk !== key) return;
|
|
1886
|
-
const
|
|
1887
|
-
const preview = hlLines.slice(0, maxShow).join("\n");
|
|
1888
|
-
const rem = hlLines.length - maxShow;
|
|
1889
|
-
const body =
|
|
1890
|
-
rem > 0 ? `${preview}\n${FG_DIM}… ${rem} more lines${RST}` : preview;
|
|
1940
|
+
const body = hlLines.join("\n");
|
|
1891
1941
|
ctx.state._wt = frameResultWithBottomLabel(body, lab, status, t, w);
|
|
1892
1942
|
ctx.invalidate();
|
|
1893
1943
|
})
|
|
@@ -1903,11 +1953,14 @@ export default function piFaceliftExtension(pi: PiFaceliftApi, deps?: PiFacelift
|
|
|
1903
1953
|
|
|
1904
1954
|
if (d?._type === "writeDiff") {
|
|
1905
1955
|
const lab = `${summarizeDiff(d.diff.added, d.diff.removed)} ${FG_DIM}(${d.diff.lines.length} diff lines)${RST}`;
|
|
1956
|
+
// Diff output is always rendered in full — no compact mode,
|
|
1957
|
+
// no `… N more lines` footer — so `ctx.expanded` is
|
|
1958
|
+
// irrelevant to the rendered body and stays out of the key.
|
|
1906
1959
|
const key = `writeDiff:${themeCacheKey(theme)}:${d.filePath}:${d.diff.added}:${d.diff.removed}:${d.diff.lines.length}:${w}:${status}`;
|
|
1907
1960
|
if (ctx.state._wk !== key) {
|
|
1908
1961
|
ctx.state._wk = key;
|
|
1909
1962
|
ctx.state._wt = frameResultWithBottomLabel("", lab, status, t, w);
|
|
1910
|
-
const maxLines =
|
|
1963
|
+
const maxLines = d.diff.lines.length;
|
|
1911
1964
|
const innerW = Math.max(40, w - 1);
|
|
1912
1965
|
const layout = decideDiffLayout([d.diff], innerW, maxLines);
|
|
1913
1966
|
// Reserve 1 col for the outer frame rail.
|
|
@@ -1994,14 +2047,21 @@ export default function piFaceliftExtension(pi: PiFaceliftApi, deps?: PiFacelift
|
|
|
1994
2047
|
// `parseDiff(op.oldText, op.newText)` numbers every diff from
|
|
1995
2048
|
// line 1 and the rendered gutter ends up off by the line
|
|
1996
2049
|
// offset of the edit inside the file.
|
|
1997
|
-
|
|
1998
|
-
|
|
1999
|
-
|
|
2000
|
-
|
|
2001
|
-
|
|
2002
|
-
}
|
|
2003
|
-
|
|
2004
|
-
|
|
2050
|
+
//
|
|
2051
|
+
// Pair the snapshot with the underlying execute() inside a
|
|
2052
|
+
// per-path lock so concurrent edits to the same file don't
|
|
2053
|
+
// both snapshot the same stale pre-state and render diffs
|
|
2054
|
+
// against the wrong baseline.
|
|
2055
|
+
const { preEditFile, result } = await serializePerPath(fp, async () => {
|
|
2056
|
+
let snapshot: string | null = null;
|
|
2057
|
+
try {
|
|
2058
|
+
if (fp && existsSync(fp)) snapshot = readFileSync(fp, "utf-8");
|
|
2059
|
+
} catch {
|
|
2060
|
+
snapshot = null;
|
|
2061
|
+
}
|
|
2062
|
+
const r = (await origEdit.execute(tid, params, sig, upd, ctx)) as ToolResultLike;
|
|
2063
|
+
return { preEditFile: snapshot, result: r };
|
|
2064
|
+
});
|
|
2005
2065
|
const ops = getEditOperations(params);
|
|
2006
2066
|
if (ops.length === 0) return result;
|
|
2007
2067
|
|
|
@@ -2082,35 +2142,39 @@ export default function piFaceliftExtension(pi: PiFaceliftApi, deps?: PiFacelift
|
|
|
2082
2142
|
editCount === 1 ? `1 edit ${summary}` : `${editCount} edits ${summary}`;
|
|
2083
2143
|
const lab = `${editsLabel} ${FG_DIM}(${diffLineTotal} diff lines)${RST}`;
|
|
2084
2144
|
|
|
2085
|
-
|
|
2145
|
+
// Every edit block is rendered in full — no compact mode,
|
|
2146
|
+
// no `… N more edit blocks` footer, no per-edit line cap —
|
|
2147
|
+
// so `ctx.expanded` doesn't affect the body and stays out of
|
|
2148
|
+
// the key.
|
|
2149
|
+
const key = `editDiff:${themeCacheKey(theme)}:${d.filePath}:${editCount}:${d.totalAdded}:${d.totalRemoved}:${diffLineTotal}:${w}:${status}`;
|
|
2086
2150
|
if (ctx.state._ek !== key) {
|
|
2087
2151
|
ctx.state._ek = key;
|
|
2088
2152
|
ctx.state._et = frameResultWithBottomLabel("", lab, status, t, w);
|
|
2089
2153
|
|
|
2090
2154
|
const innerW = Math.max(40, w - 1);
|
|
2091
|
-
const perEditCap = ctx.expanded
|
|
2092
|
-
? Number.POSITIVE_INFINITY
|
|
2093
|
-
: Math.max(8, Math.floor(MAX_PREVIEW_LINES / Math.max(1, Math.min(editCount, 3))));
|
|
2094
|
-
const shownEdits = ctx.expanded ? d.edits : d.edits.slice(0, 3);
|
|
2095
|
-
const remaining = d.edits.length - shownEdits.length;
|
|
2096
2155
|
|
|
2097
2156
|
// Pick one layout for every edit in this call ("consistent" mode,
|
|
2098
2157
|
// the default) so we never end up with `Edit 1 split, Edit 2
|
|
2099
2158
|
// unified`. The user can override via .pi/settings.json's
|
|
2100
|
-
// `diffLayout` ("split"/"unified"/"per-edit").
|
|
2101
|
-
|
|
2159
|
+
// `diffLayout` ("split"/"unified"/"per-edit"). We probe layout
|
|
2160
|
+
// with the longest edit so a single tall diff doesn't force
|
|
2161
|
+
// every other edit into unified mode unnecessarily.
|
|
2162
|
+
const probeCap = Math.max(
|
|
2163
|
+
1,
|
|
2164
|
+
...d.edits.map((e) => e.diff.lines.length),
|
|
2165
|
+
);
|
|
2102
2166
|
const layout = decideDiffLayout(
|
|
2103
|
-
|
|
2167
|
+
d.edits.map((e) => e.diff),
|
|
2104
2168
|
innerW,
|
|
2105
2169
|
probeCap,
|
|
2106
2170
|
);
|
|
2107
2171
|
|
|
2108
2172
|
Promise.all(
|
|
2109
|
-
|
|
2173
|
+
d.edits.map((edit, idx) =>
|
|
2110
2174
|
renderSplit(
|
|
2111
2175
|
edit.diff,
|
|
2112
2176
|
d.language,
|
|
2113
|
-
|
|
2177
|
+
edit.diff.lines.length,
|
|
2114
2178
|
dc,
|
|
2115
2179
|
innerW,
|
|
2116
2180
|
{ frameless: true, layout },
|
|
@@ -2129,12 +2193,7 @@ export default function piFaceliftExtension(pi: PiFaceliftApi, deps?: PiFacelift
|
|
|
2129
2193
|
)
|
|
2130
2194
|
.then((sections) => {
|
|
2131
2195
|
if (ctx.state._ek !== key) return;
|
|
2132
|
-
|
|
2133
|
-
const suffix =
|
|
2134
|
-
remaining > 0
|
|
2135
|
-
? `\n\n${FG_DIM}… ${remaining} more edit block${remaining === 1 ? "" : "s"}${RST}`
|
|
2136
|
-
: "";
|
|
2137
|
-
ctx.state._et = frameResultWithBottomLabel(`${joined}${suffix}`, lab, status, t, w);
|
|
2196
|
+
ctx.state._et = frameResultWithBottomLabel(sections.join("\n\n"), lab, status, t, w);
|
|
2138
2197
|
ctx.invalidate();
|
|
2139
2198
|
})
|
|
2140
2199
|
.catch((err: unknown) => {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wierdbytes/pi-facelift",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.1",
|
|
4
4
|
"description": "Cosmetic facelift for built-in pi tool output — open-right rounded frames, syntax-highlighted reads, status-aware bash summaries, find/grep.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"files": [
|
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Regression tests for the per-path mutation lock that pairs the
|
|
3
|
+
* pre-execute file snapshot with the underlying tool's execute() call.
|
|
4
|
+
*
|
|
5
|
+
* Bug history: when two `write` (or `edit`) calls for the *same* path
|
|
6
|
+
* landed in the agent's tool queue concurrently, both wrappers ran
|
|
7
|
+
*
|
|
8
|
+
* oldContent = readFileSync(path)
|
|
9
|
+
* result = await origExecute(...)
|
|
10
|
+
*
|
|
11
|
+
* in parallel. The built-in tool serialized the actual writes, but both
|
|
12
|
+
* wrappers had already captured the same pre-state snapshot — so the
|
|
13
|
+
* second wrapper rendered its diff against the *original* file rather
|
|
14
|
+
* than against whatever the first wrapper had just persisted.
|
|
15
|
+
*
|
|
16
|
+
* The fix routes snapshot + execute through `serializePerPath()`, an
|
|
17
|
+
* in-process per-path promise chain. These tests pin that ordering by
|
|
18
|
+
* making the mock origExecute() the thing that actually updates the
|
|
19
|
+
* file on disk: if the snapshot ran before our chained predecessor's
|
|
20
|
+
* execute(), the second snapshot would be the original contents and
|
|
21
|
+
* the diff details would prove it.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { describe, expect, it } from "vitest";
|
|
25
|
+
|
|
26
|
+
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
27
|
+
import { tmpdir } from "node:os";
|
|
28
|
+
import { join } from "node:path";
|
|
29
|
+
|
|
30
|
+
import piFaceliftExtension from "./index.ts";
|
|
31
|
+
|
|
32
|
+
class MockText {
|
|
33
|
+
private text = "";
|
|
34
|
+
constructor(_text = "", _x = 0, _y = 0) {}
|
|
35
|
+
setText(value: string) {
|
|
36
|
+
this.text = value;
|
|
37
|
+
}
|
|
38
|
+
getText() {
|
|
39
|
+
return this.text;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function mockToolFactory(exec: any) {
|
|
44
|
+
return (_cwd: string) => ({
|
|
45
|
+
name: "mock",
|
|
46
|
+
description: "mock",
|
|
47
|
+
parameters: { type: "object", properties: {} },
|
|
48
|
+
execute: exec,
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function loadTools(opts: { writeExec?: any; editExec?: any } = {}) {
|
|
53
|
+
const noopExec = async () => ({ content: [{ type: "text", text: "" }] });
|
|
54
|
+
const tools = new Map<string, any>();
|
|
55
|
+
const pi = {
|
|
56
|
+
registerTool: (tool: any) => tools.set(tool.name, tool),
|
|
57
|
+
registerCommand: () => {},
|
|
58
|
+
on: () => {},
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
piFaceliftExtension(pi, {
|
|
62
|
+
sdk: {
|
|
63
|
+
createReadToolDefinition: mockToolFactory(noopExec),
|
|
64
|
+
createBashToolDefinition: mockToolFactory(noopExec),
|
|
65
|
+
createLsToolDefinition: mockToolFactory(noopExec),
|
|
66
|
+
createFindToolDefinition: mockToolFactory(noopExec),
|
|
67
|
+
createGrepToolDefinition: mockToolFactory(noopExec),
|
|
68
|
+
createWriteToolDefinition: mockToolFactory(opts.writeExec ?? noopExec),
|
|
69
|
+
createEditToolDefinition: mockToolFactory(opts.editExec ?? noopExec),
|
|
70
|
+
getAgentDir: () => "/tmp/pi-facelift-test",
|
|
71
|
+
},
|
|
72
|
+
TextComponent: MockText,
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
return tools;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
interface DiffLine {
|
|
79
|
+
type: "ctx" | "add" | "del" | "sep";
|
|
80
|
+
oldNum: number | null;
|
|
81
|
+
newNum: number | null;
|
|
82
|
+
content: string;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
interface WriteDiffDetails {
|
|
86
|
+
_type: "writeDiff";
|
|
87
|
+
filePath: string;
|
|
88
|
+
diff: { lines: DiffLine[]; added: number; removed: number };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
interface EditDiffDetails {
|
|
92
|
+
_type: "editDiff";
|
|
93
|
+
edits: Array<{ oldText: string; newText: string; diff: { lines: DiffLine[] } }>;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
describe("write execute: per-path snapshot/execute serialization", () => {
|
|
97
|
+
it("the second concurrent write diffs against the first write's result, not the original file", async () => {
|
|
98
|
+
const dir = mkdtempSync(join(tmpdir(), "facelift-write-concur-"));
|
|
99
|
+
const file = join(dir, "state.txt");
|
|
100
|
+
writeFileSync(file, "A\n", "utf-8");
|
|
101
|
+
|
|
102
|
+
// `writeExec` is what the agent SDK's built-in write tool would
|
|
103
|
+
// do: actually persist `params.content` to disk. By doing this
|
|
104
|
+
// synchronously inside the mock we make the "second snapshot
|
|
105
|
+
// must run after the first execute" property *observable* —
|
|
106
|
+
// the resulting `details.diff` shows which baseline was used.
|
|
107
|
+
const writeExec = async (
|
|
108
|
+
_tid: string,
|
|
109
|
+
params: { path: string; content: string },
|
|
110
|
+
) => {
|
|
111
|
+
// Yield once so a parallel caller has a chance to race with us.
|
|
112
|
+
await Promise.resolve();
|
|
113
|
+
writeFileSync(params.path, params.content, "utf-8");
|
|
114
|
+
return { content: [{ type: "text", text: `wrote ${params.path}` }] };
|
|
115
|
+
};
|
|
116
|
+
const tools = loadTools({ writeExec });
|
|
117
|
+
const writeTool = tools.get("write");
|
|
118
|
+
|
|
119
|
+
// Fire both calls without awaiting — the lock should serialize
|
|
120
|
+
// them so the second one sees "B\n", not "A\n".
|
|
121
|
+
const [r1, r2] = await Promise.all([
|
|
122
|
+
writeTool.execute("t1", { path: file, content: "B\n" }, null, null, {}),
|
|
123
|
+
writeTool.execute("t2", { path: file, content: "C\n" }, null, null, {}),
|
|
124
|
+
]);
|
|
125
|
+
|
|
126
|
+
const d1 = r1.details as WriteDiffDetails;
|
|
127
|
+
const d2 = r2.details as WriteDiffDetails;
|
|
128
|
+
|
|
129
|
+
expect(d1._type).toBe("writeDiff");
|
|
130
|
+
expect(d2._type).toBe("writeDiff");
|
|
131
|
+
|
|
132
|
+
// First call: A → B. One deletion of "A", one addition of "B".
|
|
133
|
+
const d1Del = d1.diff.lines.find((l) => l.type === "del");
|
|
134
|
+
const d1Add = d1.diff.lines.find((l) => l.type === "add");
|
|
135
|
+
expect(d1Del?.content).toBe("A");
|
|
136
|
+
expect(d1Add?.content).toBe("B");
|
|
137
|
+
|
|
138
|
+
// Second call: with the bug, oldContent would still be "A" and
|
|
139
|
+
// the diff would say "A → C". With the lock, oldContent is the
|
|
140
|
+
// state the first call left behind ("B"), so the diff is B → C.
|
|
141
|
+
const d2Del = d2.diff.lines.find((l) => l.type === "del");
|
|
142
|
+
const d2Add = d2.diff.lines.find((l) => l.type === "add");
|
|
143
|
+
expect(d2Del?.content).toBe("B");
|
|
144
|
+
expect(d2Add?.content).toBe("C");
|
|
145
|
+
|
|
146
|
+
// And the file on disk reflects the second write.
|
|
147
|
+
expect(readFileSync(file, "utf-8")).toBe("C\n");
|
|
148
|
+
|
|
149
|
+
rmSync(dir, { recursive: true, force: true });
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("a failing first write does not poison subsequent writes to the same path", async () => {
|
|
153
|
+
const dir = mkdtempSync(join(tmpdir(), "facelift-write-poison-"));
|
|
154
|
+
const file = join(dir, "state.txt");
|
|
155
|
+
writeFileSync(file, "A\n", "utf-8");
|
|
156
|
+
|
|
157
|
+
let call = 0;
|
|
158
|
+
const writeExec = async (
|
|
159
|
+
_tid: string,
|
|
160
|
+
params: { path: string; content: string },
|
|
161
|
+
) => {
|
|
162
|
+
call += 1;
|
|
163
|
+
await Promise.resolve();
|
|
164
|
+
if (call === 1) throw new Error("boom");
|
|
165
|
+
writeFileSync(params.path, params.content, "utf-8");
|
|
166
|
+
return { content: [{ type: "text", text: "ok" }] };
|
|
167
|
+
};
|
|
168
|
+
const tools = loadTools({ writeExec });
|
|
169
|
+
const writeTool = tools.get("write");
|
|
170
|
+
|
|
171
|
+
// First call rejects; the chain must still let the second call
|
|
172
|
+
// proceed and snapshot the (untouched) "A\n" baseline.
|
|
173
|
+
const p1 = writeTool
|
|
174
|
+
.execute("t1", { path: file, content: "B\n" }, null, null, {})
|
|
175
|
+
.catch((e: Error) => e);
|
|
176
|
+
const p2 = writeTool.execute("t2", { path: file, content: "C\n" }, null, null, {});
|
|
177
|
+
const [r1, r2] = await Promise.all([p1, p2]);
|
|
178
|
+
|
|
179
|
+
expect(r1).toBeInstanceOf(Error);
|
|
180
|
+
const d2 = (r2 as any).details as WriteDiffDetails;
|
|
181
|
+
expect(d2._type).toBe("writeDiff");
|
|
182
|
+
// First call never wrote, so the snapshot for the second call
|
|
183
|
+
// is the original "A\n".
|
|
184
|
+
const del = d2.diff.lines.find((l) => l.type === "del");
|
|
185
|
+
const add = d2.diff.lines.find((l) => l.type === "add");
|
|
186
|
+
expect(del?.content).toBe("A");
|
|
187
|
+
expect(add?.content).toBe("C");
|
|
188
|
+
|
|
189
|
+
rmSync(dir, { recursive: true, force: true });
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
describe("edit execute: per-path snapshot/execute serialization", () => {
|
|
194
|
+
it("the second concurrent edit sees the file state produced by the first edit", async () => {
|
|
195
|
+
const dir = mkdtempSync(join(tmpdir(), "facelift-edit-concur-"));
|
|
196
|
+
const file = join(dir, "src.ts");
|
|
197
|
+
const original = ["alpha", "beta", "gamma"].join("\n");
|
|
198
|
+
writeFileSync(file, original, "utf-8");
|
|
199
|
+
|
|
200
|
+
// Mock built-in edit: replace oldText → newText in the file so
|
|
201
|
+
// the second snapshot can prove it ran after the first execute.
|
|
202
|
+
const editExec = async (
|
|
203
|
+
_tid: string,
|
|
204
|
+
params: { path: string; edits: Array<{ oldText: string; newText: string }> },
|
|
205
|
+
) => {
|
|
206
|
+
await Promise.resolve();
|
|
207
|
+
let body = readFileSync(params.path, "utf-8");
|
|
208
|
+
for (const e of params.edits) body = body.replace(e.oldText, e.newText);
|
|
209
|
+
writeFileSync(params.path, body, "utf-8");
|
|
210
|
+
return { content: [{ type: "text", text: "ok" }] };
|
|
211
|
+
};
|
|
212
|
+
const tools = loadTools({ editExec });
|
|
213
|
+
const editTool = tools.get("edit");
|
|
214
|
+
|
|
215
|
+
const [r1, r2] = await Promise.all([
|
|
216
|
+
editTool.execute(
|
|
217
|
+
"t1",
|
|
218
|
+
{ path: file, edits: [{ oldText: "alpha", newText: "ALPHA" }] },
|
|
219
|
+
null,
|
|
220
|
+
null,
|
|
221
|
+
{},
|
|
222
|
+
),
|
|
223
|
+
// Targets the *result* of edit 1. Only succeeds if the
|
|
224
|
+
// snapshot for edit 2 happens after edit 1's execute().
|
|
225
|
+
editTool.execute(
|
|
226
|
+
"t2",
|
|
227
|
+
{ path: file, edits: [{ oldText: "ALPHA", newText: "Alpha!" }] },
|
|
228
|
+
null,
|
|
229
|
+
null,
|
|
230
|
+
{},
|
|
231
|
+
),
|
|
232
|
+
]);
|
|
233
|
+
|
|
234
|
+
const d2 = r2.details as EditDiffDetails;
|
|
235
|
+
expect(d2._type).toBe("editDiff");
|
|
236
|
+
// The edit operated on the line that previously was "alpha"
|
|
237
|
+
// (file line 1). Without the lock the snapshot would still show
|
|
238
|
+
// "alpha", `findLineOffset` for "ALPHA" would miss, and the
|
|
239
|
+
// diff would silently number from line 1 of a fake baseline.
|
|
240
|
+
const del = d2.edits[0].diff.lines.find((l) => l.type === "del");
|
|
241
|
+
const add = d2.edits[0].diff.lines.find((l) => l.type === "add");
|
|
242
|
+
expect(del?.content).toBe("ALPHA");
|
|
243
|
+
expect(add?.content).toBe("Alpha!");
|
|
244
|
+
expect(del?.oldNum).toBe(1);
|
|
245
|
+
expect(add?.newNum).toBe(1);
|
|
246
|
+
|
|
247
|
+
// Final on-disk content reflects both edits applied in order.
|
|
248
|
+
expect(readFileSync(file, "utf-8")).toBe(["Alpha!", "beta", "gamma"].join("\n"));
|
|
249
|
+
|
|
250
|
+
rmSync(dir, { recursive: true, force: true });
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it("does not serialize unrelated paths against each other", async () => {
|
|
254
|
+
const dir = mkdtempSync(join(tmpdir(), "facelift-edit-indep-"));
|
|
255
|
+
const fileA = join(dir, "a.txt");
|
|
256
|
+
const fileB = join(dir, "b.txt");
|
|
257
|
+
writeFileSync(fileA, "a\n", "utf-8");
|
|
258
|
+
writeFileSync(fileB, "b\n", "utf-8");
|
|
259
|
+
|
|
260
|
+
// First call to `fileA` is intentionally slow. Second call to
|
|
261
|
+
// `fileB` must NOT wait for it.
|
|
262
|
+
let order: string[] = [];
|
|
263
|
+
const editExec = async (
|
|
264
|
+
_tid: string,
|
|
265
|
+
params: { path: string; edits: Array<{ oldText: string; newText: string }> },
|
|
266
|
+
) => {
|
|
267
|
+
order.push(`start:${params.path}`);
|
|
268
|
+
if (params.path === fileA) await new Promise((r) => setTimeout(r, 30));
|
|
269
|
+
order.push(`end:${params.path}`);
|
|
270
|
+
return { content: [{ type: "text", text: "ok" }] };
|
|
271
|
+
};
|
|
272
|
+
const tools = loadTools({ editExec });
|
|
273
|
+
const editTool = tools.get("edit");
|
|
274
|
+
|
|
275
|
+
await Promise.all([
|
|
276
|
+
editTool.execute(
|
|
277
|
+
"a",
|
|
278
|
+
{ path: fileA, edits: [{ oldText: "a", newText: "aa" }] },
|
|
279
|
+
null,
|
|
280
|
+
null,
|
|
281
|
+
{},
|
|
282
|
+
),
|
|
283
|
+
editTool.execute(
|
|
284
|
+
"b",
|
|
285
|
+
{ path: fileB, edits: [{ oldText: "b", newText: "bb" }] },
|
|
286
|
+
null,
|
|
287
|
+
null,
|
|
288
|
+
{},
|
|
289
|
+
),
|
|
290
|
+
]);
|
|
291
|
+
|
|
292
|
+
// fileB should finish before fileA — proves the lock is
|
|
293
|
+
// per-path, not a global mutex.
|
|
294
|
+
const endA = order.indexOf(`end:${fileA}`);
|
|
295
|
+
const endB = order.indexOf(`end:${fileB}`);
|
|
296
|
+
expect(endB).toBeLessThan(endA);
|
|
297
|
+
|
|
298
|
+
rmSync(dir, { recursive: true, force: true });
|
|
299
|
+
});
|
|
300
|
+
});
|