@wierdbytes/pi-facelift 0.4.0 → 0.4.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/index.ts +118 -46
- 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
|
})
|
|
@@ -1902,12 +1952,20 @@ export default function piFaceliftExtension(pi: PiFaceliftApi, deps?: PiFacelift
|
|
|
1902
1952
|
}
|
|
1903
1953
|
|
|
1904
1954
|
if (d?._type === "writeDiff") {
|
|
1905
|
-
|
|
1955
|
+
// `+A -B` already conveys the change size. We used to append
|
|
1956
|
+
// `(N diff lines)` to hint at how much was hidden under the
|
|
1957
|
+
// preview cap, but that cap is gone (we always render the
|
|
1958
|
+
// whole diff). N also included context + hunk separators, so
|
|
1959
|
+
// it never matched A+B and just looked like a miscount.
|
|
1960
|
+
const lab = summarizeDiff(d.diff.added, d.diff.removed);
|
|
1961
|
+
// Diff output is always rendered in full — no compact mode,
|
|
1962
|
+
// no `… N more lines` footer — so `ctx.expanded` is
|
|
1963
|
+
// irrelevant to the rendered body and stays out of the key.
|
|
1906
1964
|
const key = `writeDiff:${themeCacheKey(theme)}:${d.filePath}:${d.diff.added}:${d.diff.removed}:${d.diff.lines.length}:${w}:${status}`;
|
|
1907
1965
|
if (ctx.state._wk !== key) {
|
|
1908
1966
|
ctx.state._wk = key;
|
|
1909
1967
|
ctx.state._wt = frameResultWithBottomLabel("", lab, status, t, w);
|
|
1910
|
-
const maxLines =
|
|
1968
|
+
const maxLines = d.diff.lines.length;
|
|
1911
1969
|
const innerW = Math.max(40, w - 1);
|
|
1912
1970
|
const layout = decideDiffLayout([d.diff], innerW, maxLines);
|
|
1913
1971
|
// Reserve 1 col for the outer frame rail.
|
|
@@ -1994,14 +2052,21 @@ export default function piFaceliftExtension(pi: PiFaceliftApi, deps?: PiFacelift
|
|
|
1994
2052
|
// `parseDiff(op.oldText, op.newText)` numbers every diff from
|
|
1995
2053
|
// line 1 and the rendered gutter ends up off by the line
|
|
1996
2054
|
// offset of the edit inside the file.
|
|
1997
|
-
|
|
1998
|
-
|
|
1999
|
-
|
|
2000
|
-
|
|
2001
|
-
|
|
2002
|
-
}
|
|
2003
|
-
|
|
2004
|
-
|
|
2055
|
+
//
|
|
2056
|
+
// Pair the snapshot with the underlying execute() inside a
|
|
2057
|
+
// per-path lock so concurrent edits to the same file don't
|
|
2058
|
+
// both snapshot the same stale pre-state and render diffs
|
|
2059
|
+
// against the wrong baseline.
|
|
2060
|
+
const { preEditFile, result } = await serializePerPath(fp, async () => {
|
|
2061
|
+
let snapshot: string | null = null;
|
|
2062
|
+
try {
|
|
2063
|
+
if (fp && existsSync(fp)) snapshot = readFileSync(fp, "utf-8");
|
|
2064
|
+
} catch {
|
|
2065
|
+
snapshot = null;
|
|
2066
|
+
}
|
|
2067
|
+
const r = (await origEdit.execute(tid, params, sig, upd, ctx)) as ToolResultLike;
|
|
2068
|
+
return { preEditFile: snapshot, result: r };
|
|
2069
|
+
});
|
|
2005
2070
|
const ops = getEditOperations(params);
|
|
2006
2071
|
if (ops.length === 0) return result;
|
|
2007
2072
|
|
|
@@ -2076,41 +2141,53 @@ export default function piFaceliftExtension(pi: PiFaceliftApi, deps?: PiFacelift
|
|
|
2076
2141
|
|
|
2077
2142
|
const dc: DiffColors = resolveDiffColors(theme);
|
|
2078
2143
|
const editCount = d.edits.length;
|
|
2079
|
-
const diffLineTotal = d.edits.reduce((acc, e) => acc + e.diff.lines.length, 0);
|
|
2080
2144
|
const summary = summarizeDiff(d.totalAdded, d.totalRemoved);
|
|
2081
|
-
|
|
2082
|
-
|
|
2083
|
-
|
|
2084
|
-
|
|
2085
|
-
|
|
2145
|
+
// `+A -B` already conveys the change size. We used to append
|
|
2146
|
+
// `(N diff lines)` here, but N counted context rows + hunk
|
|
2147
|
+
// separators on top of A+B, so it never matched the +A -B
|
|
2148
|
+
// summary and looked like a miscount. With always-full
|
|
2149
|
+
// rendering there's nothing useful to disambiguate either.
|
|
2150
|
+
//
|
|
2151
|
+
// `N edits` keeps the same FG_DIM the old parenthetical used:
|
|
2152
|
+
// it's a quiet meta-label so the colored +A -B summary stays
|
|
2153
|
+
// the visual anchor of the bottom border.
|
|
2154
|
+
const editsCountLabel =
|
|
2155
|
+
editCount === 1 ? `${FG_DIM}1 edit${RST}` : `${FG_DIM}${editCount} edits${RST}`;
|
|
2156
|
+
const lab = `${editsCountLabel} ${summary}`;
|
|
2157
|
+
|
|
2158
|
+
// Every edit block is rendered in full — no compact mode,
|
|
2159
|
+
// no `… N more edit blocks` footer, no per-edit line cap —
|
|
2160
|
+
// so `ctx.expanded` doesn't affect the body and stays out of
|
|
2161
|
+
// the key.
|
|
2162
|
+
const key = `editDiff:${themeCacheKey(theme)}:${d.filePath}:${editCount}:${d.totalAdded}:${d.totalRemoved}:${w}:${status}`;
|
|
2086
2163
|
if (ctx.state._ek !== key) {
|
|
2087
2164
|
ctx.state._ek = key;
|
|
2088
2165
|
ctx.state._et = frameResultWithBottomLabel("", lab, status, t, w);
|
|
2089
2166
|
|
|
2090
2167
|
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
2168
|
|
|
2097
2169
|
// Pick one layout for every edit in this call ("consistent" mode,
|
|
2098
2170
|
// the default) so we never end up with `Edit 1 split, Edit 2
|
|
2099
2171
|
// unified`. The user can override via .pi/settings.json's
|
|
2100
|
-
// `diffLayout` ("split"/"unified"/"per-edit").
|
|
2101
|
-
|
|
2172
|
+
// `diffLayout` ("split"/"unified"/"per-edit"). We probe layout
|
|
2173
|
+
// with the longest edit so a single tall diff doesn't force
|
|
2174
|
+
// every other edit into unified mode unnecessarily.
|
|
2175
|
+
const probeCap = Math.max(
|
|
2176
|
+
1,
|
|
2177
|
+
...d.edits.map((e) => e.diff.lines.length),
|
|
2178
|
+
);
|
|
2102
2179
|
const layout = decideDiffLayout(
|
|
2103
|
-
|
|
2180
|
+
d.edits.map((e) => e.diff),
|
|
2104
2181
|
innerW,
|
|
2105
2182
|
probeCap,
|
|
2106
2183
|
);
|
|
2107
2184
|
|
|
2108
2185
|
Promise.all(
|
|
2109
|
-
|
|
2186
|
+
d.edits.map((edit, idx) =>
|
|
2110
2187
|
renderSplit(
|
|
2111
2188
|
edit.diff,
|
|
2112
2189
|
d.language,
|
|
2113
|
-
|
|
2190
|
+
edit.diff.lines.length,
|
|
2114
2191
|
dc,
|
|
2115
2192
|
innerW,
|
|
2116
2193
|
{ frameless: true, layout },
|
|
@@ -2129,12 +2206,7 @@ export default function piFaceliftExtension(pi: PiFaceliftApi, deps?: PiFacelift
|
|
|
2129
2206
|
)
|
|
2130
2207
|
.then((sections) => {
|
|
2131
2208
|
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);
|
|
2209
|
+
ctx.state._et = frameResultWithBottomLabel(sections.join("\n\n"), lab, status, t, w);
|
|
2138
2210
|
ctx.invalidate();
|
|
2139
2211
|
})
|
|
2140
2212
|
.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.2",
|
|
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
|
+
});
|