dsh-hashline-edit 0.2.2 → 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/README.md +57 -45
- package/cordis.patch.yml +3 -3
- package/lib/index.js +3175 -3943
- package/lib/types/dsh/contracts.d.ts +12 -3
- package/lib/types/dsh/fs.d.ts +3 -0
- package/lib/types/dsh/guidance.d.ts +5 -5
- package/lib/types/dsh/hide.d.ts +79 -0
- package/lib/types/dsh/host-compat.d.ts +7 -78
- package/lib/types/dsh/index.d.ts +21 -9
- package/lib/types/dsh/metrics/events.d.ts +1 -1
- package/lib/types/dsh/metrics/observer.d.ts +1 -1
- package/lib/types/dsh/sandbox.d.ts +2 -2
- package/lib/types/dsh/shell-hook.d.ts +1 -1
- package/lib/types/dsh/tools/apply-patch.d.ts +38 -0
- package/lib/types/dsh/tools/read.d.ts +3 -3
- package/lib/types/src/apply-patch/commit.d.ts +49 -0
- package/lib/types/src/apply-patch/grammar.d.ts +29 -0
- package/lib/types/src/apply-patch/lifecycle.d.ts +32 -0
- package/lib/types/src/apply-patch/plan.d.ts +50 -0
- package/lib/types/src/apply-patch/render.d.ts +51 -0
- package/lib/types/src/apply-patch/resolve.d.ts +129 -0
- package/lib/types/src/apply-patch/tool.d.ts +54 -0
- package/lib/types/src/apply-patch/types.d.ts +362 -0
- package/lib/types/src/domain-errors.d.ts +16 -2
- package/lib/types/src/edit-undo.d.ts +11 -2
- package/lib/types/src/file-content/index.d.ts +1 -1
- package/lib/types/src/file-content/preview.d.ts +1 -1
- package/lib/types/src/fs-lifecycle.d.ts +44 -0
- package/lib/types/src/hashline/alphabet.d.ts +4 -3
- package/lib/types/src/hashline/hash-identity.d.ts +12 -7
- package/lib/types/src/host-utils.d.ts +63 -0
- package/lib/types/src/mutation-engine/edit-request.d.ts +34 -0
- package/lib/types/src/mutation-engine/pipeline.d.ts +8 -1
- package/lib/types/src/mutation-engine/types.d.ts +40 -50
- package/lib/types/src/served-session/serve-render.d.ts +6 -0
- package/lib/types/src/undo-store.d.ts +17 -1
- package/package.json +18 -21
- package/prompts/apply-patch-guidelines.md +10 -0
- package/prompts/apply-patch.md +1 -0
- package/prompts/read-guidelines.md +4 -4
- package/prompts/read.md +1 -1
- package/prompts/undo-last-edit-guidelines.md +2 -2
- package/prompts/undo-last-edit-snippet.md +1 -1
- package/prompts/undo-last-edit.md +1 -1
- package/src/apply-patch/commit.ts +190 -0
- package/src/apply-patch/grammar.ts +620 -0
- package/src/apply-patch/index.ts +17 -0
- package/src/apply-patch/lifecycle.ts +195 -0
- package/src/apply-patch/plan.ts +800 -0
- package/src/apply-patch/render.ts +137 -0
- package/src/apply-patch/resolve.ts +470 -0
- package/src/apply-patch/tool.ts +257 -0
- package/src/apply-patch/types.ts +387 -0
- package/src/cut-repair.ts +77 -11
- package/src/domain-errors.ts +35 -3
- package/src/edit-undo.ts +379 -25
- package/src/file-content/index.ts +1 -1
- package/src/file-content/preview.ts +1 -1
- package/src/file-content/served-header.ts +1 -1
- package/src/fs-lifecycle.ts +201 -0
- package/src/hash-store.ts +18 -1
- package/src/hashline/alphabet.ts +7 -7
- package/src/hashline/hash-identity.ts +12 -7
- package/src/host-utils.ts +224 -0
- package/src/mutation-engine/edit-request.ts +39 -0
- package/src/mutation-engine/pipeline.ts +198 -27
- package/src/mutation-engine/types.ts +39 -57
- package/src/mutation-queue.ts +1 -1
- package/src/prompts.ts +3 -30
- package/src/served-session/serve-render.ts +2 -2
- package/src/undo-store.ts +74 -5
- package/lib/types/dsh/tools/edit.d.ts +0 -10
- package/lib/types/src/edit-tool.d.ts +0 -41
- package/lib/types/src/mutation-engine/engine.d.ts +0 -31
- package/lib/types/src/payload-contract.d.ts +0 -135
- package/prompts/edit-guidelines.md +0 -10
- package/prompts/edit-snippet.md +0 -1
- package/prompts/edit.md +0 -1
- package/src/bash-classifier.ts +0 -1626
- package/src/bash-conversion/decide.ts +0 -812
- package/src/bash-display-transform.ts +0 -50
- package/src/bash-multifile-config.ts +0 -239
- package/src/bash-view-config.ts +0 -158
- package/src/edit-normalize.ts +0 -8
- package/src/edit-render.ts +0 -161
- package/src/edit-tool.ts +0 -118
- package/src/edit.ts +0 -136
- package/src/integrations/pi-lens/command.ts +0 -712
- package/src/integrations/pi-lens/config.ts +0 -148
- package/src/integrations/pi-lens/io-bridge-adapter.ts +0 -234
- package/src/integrations/pi-lens/io-bridge.ts +0 -112
- package/src/integrations/pi-lens/settings-overlay.ts +0 -272
- package/src/lifecycle-hooks/index.ts +0 -1992
- package/src/lifecycle-hooks/types.ts +0 -64
- package/src/mutated-files.ts +0 -75
- package/src/mutation-engine/engine.ts +0 -239
- package/src/mutation-engine/index.ts +0 -17
- package/src/payload-contract.ts +0 -685
- package/src/preview-controller.ts +0 -84
- package/src/read.ts +0 -865
- package/src/search-hook.ts +0 -41
- package/src/store-config.ts +0 -73
- package/src/store-janitor-warning.ts +0 -169
- package/src/store-janitor.ts +0 -539
- package/src/store-kernel/settings.ts +0 -233
- package/src/store.ts +0 -154
- package/src/tui-presenter.ts +0 -134
- package/src/write-hook.ts +0 -130
package/src/cut-repair.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { restoreEndings, type LineEnding } from "./edit-diff.js";
|
|
2
2
|
import { readBytes, writeAtomic } from "./fs-write.js";
|
|
3
|
+
import { pathKind, removeFile, renameFile } from "./fs-lifecycle.js";
|
|
3
4
|
import { withSortedMutationQueues } from "./mutation-queue.js";
|
|
4
5
|
import { errCode } from "./utils.js";
|
|
5
6
|
import { loadHashStore, type HashStore } from "./hash-store.js";
|
|
@@ -73,20 +74,87 @@ function candidateFor(row: MemberRow, which: "pre" | "post"): Buffer | undefined
|
|
|
73
74
|
}
|
|
74
75
|
|
|
75
76
|
async function stateOf(row: MemberRow): Promise<MemberState> {
|
|
76
|
-
|
|
77
|
-
try {
|
|
78
|
-
bytes = await readBytes(row.path);
|
|
79
|
-
} catch (error) {
|
|
80
|
-
if (errCode(error) === "ENOENT") return "other";
|
|
81
|
-
throw error;
|
|
82
|
-
}
|
|
77
|
+
const lifecycle = row.lifecycle ?? "edit";
|
|
83
78
|
const post = candidateFor(row, "post");
|
|
84
|
-
if (post !== undefined && bytes.equals(post)) return "post";
|
|
85
79
|
const pre = candidateFor(row, "pre");
|
|
80
|
+
const readOrUndefined = async (path: string | null | undefined): Promise<Buffer | undefined> => {
|
|
81
|
+
if (path === null || path === undefined) return undefined;
|
|
82
|
+
try {
|
|
83
|
+
return await readBytes(path);
|
|
84
|
+
} catch (error) {
|
|
85
|
+
if (errCode(error) === "ENOENT") return undefined;
|
|
86
|
+
throw error;
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
if (lifecycle === "add") {
|
|
90
|
+
const bytes = await readOrUndefined(row.path);
|
|
91
|
+
if (bytes === undefined) return "pre";
|
|
92
|
+
return post !== undefined && bytes.equals(post) ? "post" : "other";
|
|
93
|
+
}
|
|
94
|
+
if (lifecycle === "delete") {
|
|
95
|
+
const bytes = await readOrUndefined(row.path);
|
|
96
|
+
if (bytes === undefined) return "post";
|
|
97
|
+
return pre !== undefined && bytes.equals(pre) ? "pre" : "other";
|
|
98
|
+
}
|
|
99
|
+
if (lifecycle === "move") {
|
|
100
|
+
const to = await readOrUndefined(row.path);
|
|
101
|
+
const from = await readOrUndefined(row.fromPath);
|
|
102
|
+
if (to !== undefined && post !== undefined && to.equals(post)) return "post";
|
|
103
|
+
if (from !== undefined && pre !== undefined && from.equals(pre)) return "pre";
|
|
104
|
+
// WHY: crash after the rename but before the content rewrite — the destination holds the raw
|
|
105
|
+
// WHY: pre bytes and the source is gone. That is the PRE state of an as-yet-unapplied move.
|
|
106
|
+
if (to !== undefined && pre !== undefined && to.equals(pre) && from === undefined) return "pre";
|
|
107
|
+
return "other";
|
|
108
|
+
}
|
|
109
|
+
const bytes = await readOrUndefined(row.path);
|
|
110
|
+
if (bytes === undefined) return "other";
|
|
111
|
+
if (post !== undefined && bytes.equals(post)) return "post";
|
|
86
112
|
if (pre !== undefined && bytes.equals(pre)) return "pre";
|
|
87
113
|
return "other";
|
|
88
114
|
}
|
|
89
115
|
|
|
116
|
+
/** Write one lifecycle row to its named state; returns false when the row cannot express it. */
|
|
117
|
+
async function writeState(row: MemberRow, which: "pre" | "post"): Promise<boolean> {
|
|
118
|
+
const lifecycle = row.lifecycle ?? "edit";
|
|
119
|
+
const bytes = candidateFor(row, which);
|
|
120
|
+
if (lifecycle === "add") {
|
|
121
|
+
if (which === "post") {
|
|
122
|
+
if (bytes === undefined) return false;
|
|
123
|
+
await writeAtomic(row.path, bytes);
|
|
124
|
+
return true;
|
|
125
|
+
}
|
|
126
|
+
if ((await pathKind(row.path)) !== "absent") await removeFile(row.path);
|
|
127
|
+
return true;
|
|
128
|
+
}
|
|
129
|
+
if (lifecycle === "delete") {
|
|
130
|
+
if (which === "post") {
|
|
131
|
+
if ((await pathKind(row.path)) !== "absent") await removeFile(row.path);
|
|
132
|
+
return true;
|
|
133
|
+
}
|
|
134
|
+
if (bytes === undefined) return false;
|
|
135
|
+
await writeAtomic(row.path, bytes);
|
|
136
|
+
return true;
|
|
137
|
+
}
|
|
138
|
+
if (lifecycle === "move") {
|
|
139
|
+
const source = row.fromPath;
|
|
140
|
+
if (source === null || source === undefined) return false;
|
|
141
|
+
if (which === "post") {
|
|
142
|
+
if ((await pathKind(row.path)) === "absent") {
|
|
143
|
+
if ((await pathKind(source)) === "absent") return false;
|
|
144
|
+
await renameFile(source, row.path);
|
|
145
|
+
}
|
|
146
|
+
if (bytes !== undefined) await writeAtomic(row.path, bytes);
|
|
147
|
+
return true;
|
|
148
|
+
}
|
|
149
|
+
if ((await pathKind(row.path)) !== "absent") await renameFile(row.path, source);
|
|
150
|
+
if (bytes !== undefined) await writeAtomic(source, bytes);
|
|
151
|
+
return true;
|
|
152
|
+
}
|
|
153
|
+
if (bytes === undefined) return false;
|
|
154
|
+
await writeAtomic(row.path, bytes);
|
|
155
|
+
return true;
|
|
156
|
+
}
|
|
157
|
+
|
|
90
158
|
/** Returns true when the resolution completed (bytes written or nothing to write) and the intent retired. */
|
|
91
159
|
async function applyResolution(
|
|
92
160
|
store: HashStore,
|
|
@@ -106,9 +174,7 @@ async function applyResolution(
|
|
|
106
174
|
if (now !== expected) return false;
|
|
107
175
|
}
|
|
108
176
|
for (const row of toWrite) {
|
|
109
|
-
|
|
110
|
-
if (bytes === undefined) return false;
|
|
111
|
-
await writeAtomic(row.path, bytes);
|
|
177
|
+
if (!(await writeState(row, which))) return false;
|
|
112
178
|
}
|
|
113
179
|
deleteCutIntent(store, txnId);
|
|
114
180
|
return true;
|
package/src/domain-errors.ts
CHANGED
|
@@ -54,19 +54,27 @@ export type DomainErrorCode =
|
|
|
54
54
|
| "E_UNDO_REVERT_FAILED"
|
|
55
55
|
| "E_UNKNOWN"
|
|
56
56
|
| "E_LARGE_FILE"
|
|
57
|
-
| "E_INTERNAL_INVARIANT"
|
|
57
|
+
| "E_INTERNAL_INVARIANT"
|
|
58
|
+
// WHY: apply_patch's two genuinely new codes (E_STALE_ANCHOR already exists above).
|
|
59
|
+
| "E_PATCH_SYNTAX"
|
|
60
|
+
| "E_UNIQUE_MATCH";
|
|
58
61
|
|
|
59
62
|
/**
|
|
60
63
|
* The registered tool whose call produced an `E_BAD_PAYLOAD`. WHY the registered name: the model
|
|
61
64
|
* reads the tool list, so `undo_last_edit` names the call it must repair and a short form would
|
|
62
65
|
* name nothing. A tool belongs here only after a live path is measured, never by assumption.
|
|
63
66
|
*/
|
|
64
|
-
export type PayloadSubject = "read" | "edit" | "undo_last_edit";
|
|
67
|
+
export type PayloadSubject = "read" | "edit" | "apply_patch" | "undo_last_edit";
|
|
65
68
|
|
|
66
69
|
// WHY: this is the runtime companion of the `PayloadSubject` union, like `isDomainErrorCode`.
|
|
67
70
|
// WHY: Only a member may name a tool.
|
|
68
71
|
// WHY: A non-member renders the neutral wording instead.
|
|
69
|
-
const PAYLOAD_SUBJECTS: readonly PayloadSubject[] = [
|
|
72
|
+
const PAYLOAD_SUBJECTS: readonly PayloadSubject[] = [
|
|
73
|
+
"read",
|
|
74
|
+
"edit",
|
|
75
|
+
"apply_patch",
|
|
76
|
+
"undo_last_edit",
|
|
77
|
+
];
|
|
70
78
|
|
|
71
79
|
export function isPayloadSubject(value: unknown): value is PayloadSubject {
|
|
72
80
|
return typeof value === "string" && (PAYLOAD_SUBJECTS as readonly string[]).includes(value);
|
|
@@ -252,6 +260,20 @@ export interface ErrorPayloadMap {
|
|
|
252
260
|
*/
|
|
253
261
|
subject?: PayloadSubject;
|
|
254
262
|
};
|
|
263
|
+
E_PATCH_SYNTAX: {
|
|
264
|
+
message: string;
|
|
265
|
+
path?: string;
|
|
266
|
+
patchLine?: number;
|
|
267
|
+
servedRows?: ServedRow[];
|
|
268
|
+
servedBlock?: string;
|
|
269
|
+
};
|
|
270
|
+
E_UNIQUE_MATCH: {
|
|
271
|
+
message: string;
|
|
272
|
+
path?: string;
|
|
273
|
+
patchLine?: number;
|
|
274
|
+
servedRows?: ServedRow[];
|
|
275
|
+
servedBlock?: string;
|
|
276
|
+
};
|
|
255
277
|
}
|
|
256
278
|
|
|
257
279
|
export interface CodeSpec<P> {
|
|
@@ -578,6 +600,16 @@ export const ERROR_REGISTRY: { [K in DomainErrorCode]: CodeSpec<ErrorPayloadMap[
|
|
|
578
600
|
// WHY: no options — read needs `mode: "verbatim"` and edit needs a split, but the payload
|
|
579
601
|
// WHY: names neither tool. Add `subject` before serving advice (ADR-0047).
|
|
580
602
|
},
|
|
603
|
+
E_PATCH_SYNTAX: {
|
|
604
|
+
audience: "MODEL",
|
|
605
|
+
// WHY: the parser already rendered every problem with its patch line; the envelope carries
|
|
606
|
+
// WHY: that aggregate so one resubmission can fix every syntax problem.
|
|
607
|
+
format: ({ message }) => message,
|
|
608
|
+
},
|
|
609
|
+
E_UNIQUE_MATCH: {
|
|
610
|
+
audience: "MODEL",
|
|
611
|
+
format: ({ message }) => message,
|
|
612
|
+
},
|
|
581
613
|
};
|
|
582
614
|
|
|
583
615
|
export interface WarningPayloadMap {
|
package/src/edit-undo.ts
CHANGED
|
@@ -1,6 +1,4 @@
|
|
|
1
|
-
import
|
|
2
|
-
import { withFileMutationQueue as piFileMutationQueue } from "@earendil-works/pi-coding-agent";
|
|
3
|
-
import { Type } from "typebox";
|
|
1
|
+
import { withFileMutationQueue as piFileMutationQueue } from "./host-utils.js";
|
|
4
2
|
import {
|
|
5
3
|
readUndo,
|
|
6
4
|
writeUndo,
|
|
@@ -21,13 +19,14 @@ import {
|
|
|
21
19
|
import { loadHashStore } from "./hash-store.js";
|
|
22
20
|
import { sessionKeyFor } from "./served-session/session.js";
|
|
23
21
|
import { readBytes, resolveTarget, writeAtomic } from "./fs-write.js";
|
|
22
|
+
import { pruneEmptyDirs, removeFile, renameFile } from "./fs-lifecycle.js";
|
|
24
23
|
import { toCwd } from "./paths.js";
|
|
25
24
|
import { DEFERRED_STORE_SYNC_WARNING } from "./constants.js";
|
|
26
25
|
import { toLF, stripBOM, genDiff, restoreEndings, type LineEnding } from "./edit-diff.js";
|
|
27
26
|
import { visLines, splitLines, errCode } from "./utils.js";
|
|
28
|
-
import { loadP, loadGuide } from "./prompts.js";
|
|
29
27
|
import { buildMetrics, type EditDetails } from "./edit-response.js";
|
|
30
28
|
import { DomainError, withPayloadSubject } from "./domain-errors.js";
|
|
29
|
+
import type { UndoLifecycle } from "./undo-store.js";
|
|
31
30
|
import { ANCHOR_GENERATION, lineHashes } from "./hashline/hash-identity.js";
|
|
32
31
|
|
|
33
32
|
/**
|
|
@@ -68,6 +67,16 @@ export interface UndoEntry {
|
|
|
68
67
|
rawPre?: string | null;
|
|
69
68
|
/** The anchor generation the stored hashes were derived under (0 = pre-generation). */
|
|
70
69
|
anchorGeneration?: number | null;
|
|
70
|
+
/** The lifecycle action this entry reverses; absent/null is an ordinary edit. */
|
|
71
|
+
lifecycle?: UndoLifecycle | null;
|
|
72
|
+
/** Move: the absolute pre-transaction path. */
|
|
73
|
+
fromPath?: string | null;
|
|
74
|
+
/** Move: the absolute committed path. */
|
|
75
|
+
toPath?: string | null;
|
|
76
|
+
/** The raw post-transaction text when the commit wrote non-canonical bytes (pure move). */
|
|
77
|
+
rawPost?: string | null;
|
|
78
|
+
/** Add: directories the add created, outermost first. */
|
|
79
|
+
createdDirs?: readonly string[] | null;
|
|
71
80
|
}
|
|
72
81
|
|
|
73
82
|
export async function saveUndo(
|
|
@@ -89,6 +98,11 @@ export async function saveUndo(
|
|
|
89
98
|
snapshotHash: entry.snapshotHash ?? snapshotHashFor(entry.content),
|
|
90
99
|
transactionId: entry.transactionId ?? null,
|
|
91
100
|
rawPre: entry.rawPre ?? null,
|
|
101
|
+
lifecycle: entry.lifecycle ?? null,
|
|
102
|
+
fromPath: entry.fromPath ?? null,
|
|
103
|
+
toPath: entry.toPath ?? null,
|
|
104
|
+
rawPost: entry.rawPost ?? null,
|
|
105
|
+
createdDirs: entry.createdDirs ?? null,
|
|
92
106
|
// WHY: ADR-0031 §4 — forward the payload generation so the store stamp (upsertUndo)
|
|
93
107
|
// WHY: can preserve it on replay; absent (ordinary edit) defaults to ANCHOR_GENERATION there.
|
|
94
108
|
anchorGeneration: entry.anchorGeneration ?? ANCHOR_GENERATION,
|
|
@@ -121,7 +135,7 @@ export async function getUndo(path: string): Promise<UndoEntry | undefined> {
|
|
|
121
135
|
await removeUndo(path);
|
|
122
136
|
return undefined;
|
|
123
137
|
}
|
|
124
|
-
|
|
138
|
+
const entry: UndoEntry = {
|
|
125
139
|
content: record.content,
|
|
126
140
|
bom: record.bom,
|
|
127
141
|
originalEnding,
|
|
@@ -131,6 +145,15 @@ export async function getUndo(path: string): Promise<UndoEntry | undefined> {
|
|
|
131
145
|
transactionId: record.transactionId ?? null,
|
|
132
146
|
anchorGeneration: record.anchorGeneration ?? 0,
|
|
133
147
|
};
|
|
148
|
+
if (record.lifecycle !== null && record.lifecycle !== undefined)
|
|
149
|
+
entry.lifecycle = record.lifecycle;
|
|
150
|
+
if (record.fromPath !== null && record.fromPath !== undefined) entry.fromPath = record.fromPath;
|
|
151
|
+
if (record.toPath !== null && record.toPath !== undefined) entry.toPath = record.toPath;
|
|
152
|
+
if (record.rawPost !== null && record.rawPost !== undefined) entry.rawPost = record.rawPost;
|
|
153
|
+
if (record.createdDirs !== null && record.createdDirs !== undefined) {
|
|
154
|
+
entry.createdDirs = record.createdDirs;
|
|
155
|
+
}
|
|
156
|
+
return entry;
|
|
134
157
|
} catch (error) {
|
|
135
158
|
// SAFETY: best-effort undo load — failures return undefined (no history) and caller reports "No undo history"; stale or corrupt store is recoverable on next edit, not silent undefined without log.
|
|
136
159
|
reportDiagnostic("edit-undo", "Failed to load undo entry:", error);
|
|
@@ -162,6 +185,16 @@ interface UndoMember {
|
|
|
162
185
|
rawPre: string | null;
|
|
163
186
|
/** The anchor generation the stored hashes were derived under (0 = pre-generation). */
|
|
164
187
|
anchorGeneration: number;
|
|
188
|
+
/** Lifecycle action this row reverses; `edit` is the back-compatible default. */
|
|
189
|
+
lifecycle: UndoLifecycle;
|
|
190
|
+
/** Move: absolute pre-transaction path. */
|
|
191
|
+
fromPath: string | null;
|
|
192
|
+
/** Move: absolute committed path. */
|
|
193
|
+
toPath: string | null;
|
|
194
|
+
/** The raw post bytes when the commit wrote a non-canonical image (pure move). */
|
|
195
|
+
rawPost: string | null;
|
|
196
|
+
/** Add: the directories the add created, outermost first. */
|
|
197
|
+
createdDirs: readonly string[];
|
|
165
198
|
}
|
|
166
199
|
|
|
167
200
|
function toMember(row: UndoRecord & { path: string }, displayPath: string): UndoMember | undefined {
|
|
@@ -178,6 +211,11 @@ function toMember(row: UndoRecord & { path: string }, displayPath: string): Undo
|
|
|
178
211
|
snapshotHash: row.snapshotHash ?? null,
|
|
179
212
|
rawPre: row.rawPre ?? null,
|
|
180
213
|
anchorGeneration: row.anchorGeneration ?? 0,
|
|
214
|
+
lifecycle: (row.lifecycle as UndoLifecycle | null) ?? "edit",
|
|
215
|
+
fromPath: row.fromPath ?? null,
|
|
216
|
+
toPath: row.toPath ?? null,
|
|
217
|
+
rawPost: row.rawPost ?? null,
|
|
218
|
+
createdDirs: row.createdDirs ?? [],
|
|
181
219
|
};
|
|
182
220
|
}
|
|
183
221
|
|
|
@@ -236,6 +274,18 @@ async function undoCorrelatedTransaction(
|
|
|
236
274
|
};
|
|
237
275
|
}
|
|
238
276
|
|
|
277
|
+
// WHY: a row whose lifecycle is anything but `edit` reverses a namespace action (add/delete/
|
|
278
|
+
// WHY: move). Those need path-level restore steps, not `writeAtomic(pre)`; one lifecycle-aware
|
|
279
|
+
// WHY: executor handles every member of the batch (including the edit members beside them).
|
|
280
|
+
if (members.some((m) => m.lifecycle !== "edit")) {
|
|
281
|
+
return await undoLifecycleTransaction(
|
|
282
|
+
txnId,
|
|
283
|
+
requestedAbsolutePath,
|
|
284
|
+
requestedDisplayPath,
|
|
285
|
+
members,
|
|
286
|
+
ctx,
|
|
287
|
+
);
|
|
288
|
+
}
|
|
239
289
|
const sessionKeyForUndo = sessionKeyFor(
|
|
240
290
|
// SAFETY: ctx is untyped at the pi boundary — cast validated by pi's runtime shape
|
|
241
291
|
ctx as unknown as { sessionManager?: { getSessionId(): string } },
|
|
@@ -563,6 +613,330 @@ async function undoCorrelatedTransaction(
|
|
|
563
613
|
);
|
|
564
614
|
}
|
|
565
615
|
|
|
616
|
+
/**
|
|
617
|
+
* Undo ONE correlated transaction that contains lifecycle members (apply_patch's Add/Delete/Move
|
|
618
|
+
* batch, possibly beside ordinary text edits). Same durable recipe as `undoCorrelatedTransaction`:
|
|
619
|
+
* validate every member by BYTES first, land the revert intent before the first write, complete a
|
|
620
|
+
* partially failed revert inline, fail closed with `E_UNDO_REVERT_FAILED` only when completion is
|
|
621
|
+
* itself defeated. The namespace steps are the only addition: an add undo removes the created
|
|
622
|
+
* file (and the empty directories it made), a delete undo re-creates it from the raw pre-image,
|
|
623
|
+
* and a move undo renames back before restoring the pre bytes.
|
|
624
|
+
*/
|
|
625
|
+
async function undoLifecycleTransaction(
|
|
626
|
+
txnId: string,
|
|
627
|
+
requestedAbsolutePath: string,
|
|
628
|
+
requestedDisplayPath: string,
|
|
629
|
+
members: UndoMember[],
|
|
630
|
+
ctx: unknown,
|
|
631
|
+
): Promise<UndoLastEditResult> {
|
|
632
|
+
const sessionKeyForUndo = sessionKeyFor(
|
|
633
|
+
// SAFETY: ctx is untyped at the pi boundary — cast validated by pi's runtime shape
|
|
634
|
+
ctx as unknown as { sessionManager?: { getSessionId(): string } },
|
|
635
|
+
);
|
|
636
|
+
const names = members.map((member) => member.displayPath).join(", ");
|
|
637
|
+
const fileExists = async (path: string): Promise<boolean> => {
|
|
638
|
+
try {
|
|
639
|
+
await readBytes(path);
|
|
640
|
+
return true;
|
|
641
|
+
} catch (error) {
|
|
642
|
+
if (errCode(error) === "ENOENT") return false;
|
|
643
|
+
throw error;
|
|
644
|
+
}
|
|
645
|
+
};
|
|
646
|
+
const stale = (member: UndoMember, reason: "deleted" | "modified"): UndoLastEditResult => ({
|
|
647
|
+
content: [
|
|
648
|
+
{
|
|
649
|
+
type: "text" as const,
|
|
650
|
+
text: new DomainError("E_UNDO_STALE", { path: member.displayPath, reason }).message,
|
|
651
|
+
},
|
|
652
|
+
],
|
|
653
|
+
isError: true,
|
|
654
|
+
details: {},
|
|
655
|
+
});
|
|
656
|
+
|
|
657
|
+
return withSortedMutationQueues(
|
|
658
|
+
members.map((member) => member.absolutePath),
|
|
659
|
+
async () => {
|
|
660
|
+
// WHY: STEP 1 — validate EVERY member before reverting ANY. A delete row's post state
|
|
661
|
+
// WHY: is "absent"; every other row's post state is its bytes. A move's pre path must not exist.
|
|
662
|
+
for (const member of members) {
|
|
663
|
+
const committedPath =
|
|
664
|
+
member.lifecycle === "move"
|
|
665
|
+
? (member.toPath ?? member.absolutePath)
|
|
666
|
+
: member.absolutePath;
|
|
667
|
+
if (member.lifecycle === "delete") {
|
|
668
|
+
if (await fileExists(committedPath)) return stale(member, "modified");
|
|
669
|
+
continue;
|
|
670
|
+
}
|
|
671
|
+
let bytes: Buffer;
|
|
672
|
+
try {
|
|
673
|
+
bytes = await readBytes(committedPath);
|
|
674
|
+
} catch (error) {
|
|
675
|
+
if (errCode(error) === "ENOENT") return stale(member, "deleted");
|
|
676
|
+
throw error;
|
|
677
|
+
}
|
|
678
|
+
const expectedPost =
|
|
679
|
+
member.rawPost !== null
|
|
680
|
+
? Buffer.from(member.rawPost, "utf-8")
|
|
681
|
+
: Buffer.from(
|
|
682
|
+
member.bom + restoreEndings(member.resultContent, member.ending),
|
|
683
|
+
"utf-8",
|
|
684
|
+
);
|
|
685
|
+
if (!bytes.equals(expectedPost)) return stale(member, "modified");
|
|
686
|
+
if (member.lifecycle === "move" && member.fromPath !== null) {
|
|
687
|
+
if (await fileExists(member.fromPath)) return stale(member, "modified");
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
// WHY: STEP 2 — prepare every restore IN MEMORY (no write side touched yet).
|
|
692
|
+
interface RestorePlan {
|
|
693
|
+
member: UndoMember;
|
|
694
|
+
restorePath: string;
|
|
695
|
+
restoreBytes: Buffer | null;
|
|
696
|
+
removeOnly: boolean;
|
|
697
|
+
moveFrom: string | null;
|
|
698
|
+
moveTo: string | null;
|
|
699
|
+
dirs: readonly string[];
|
|
700
|
+
content: string;
|
|
701
|
+
currentNormalized: string;
|
|
702
|
+
diff: string;
|
|
703
|
+
servedRows: ServedRow[];
|
|
704
|
+
restoredContentHash: string;
|
|
705
|
+
restoredLineCount: number;
|
|
706
|
+
restoredHashes: string[];
|
|
707
|
+
displacedAnchors: string[];
|
|
708
|
+
}
|
|
709
|
+
const plans: RestorePlan[] = [];
|
|
710
|
+
for (const member of members) {
|
|
711
|
+
const isDelete = member.lifecycle === "delete";
|
|
712
|
+
const isAdd = member.lifecycle === "add";
|
|
713
|
+
const isMove = member.lifecycle === "move";
|
|
714
|
+
const committedPath = isMove ? (member.toPath ?? member.absolutePath) : member.absolutePath;
|
|
715
|
+
const restorePath = isMove ? (member.fromPath ?? member.absolutePath) : member.absolutePath;
|
|
716
|
+
const currentNormalized = isDelete ? "" : member.resultContent;
|
|
717
|
+
const content = isDelete || isMove || !isAdd ? member.content : "";
|
|
718
|
+
const storedIsCurrentGeneration = member.anchorGeneration === ANCHOR_GENERATION;
|
|
719
|
+
const restoredContentHash =
|
|
720
|
+
storedIsCurrentGeneration && member.lifecycle !== "add"
|
|
721
|
+
? (member.snapshotHash ?? snapshotHashFor(content))
|
|
722
|
+
: snapshotHashFor(content);
|
|
723
|
+
const restoredLineCount = visLines(content).length;
|
|
724
|
+
let restoredHashes: string[] = [];
|
|
725
|
+
if (!isAdd) {
|
|
726
|
+
try {
|
|
727
|
+
restoredHashes = await restoreHashes(content, restorePath);
|
|
728
|
+
} catch (error) {
|
|
729
|
+
reportDiagnostic("edit-undo", "Failed to derive lifecycle restore anchors:", error);
|
|
730
|
+
restoredHashes = [];
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
const currentHashes = isAdd
|
|
734
|
+
? await restoreHashes(member.resultContent, committedPath)
|
|
735
|
+
: isDelete
|
|
736
|
+
? []
|
|
737
|
+
: await restoreHashes(member.resultContent, restorePath);
|
|
738
|
+
const undoDenseRows = denseServeRows(restoredHashes);
|
|
739
|
+
const curSet = new Set(currentHashes);
|
|
740
|
+
const restoredSet = new Set(restoredHashes);
|
|
741
|
+
const displacedAnchors = [...curSet].filter((hash) => !restoredSet.has(hash));
|
|
742
|
+
const undoDiff = isAdd
|
|
743
|
+
? genDiff(currentNormalized, "", 1, [], currentHashes).diff
|
|
744
|
+
: isDelete
|
|
745
|
+
? genDiff("", content, 1, restoredHashes, []).diff
|
|
746
|
+
: genDiff(currentNormalized, content, 1, restoredHashes, currentHashes).diff;
|
|
747
|
+
const restoreBytes = isAdd
|
|
748
|
+
? null
|
|
749
|
+
: member.rawPre !== null
|
|
750
|
+
? Buffer.from(member.rawPre, "utf-8")
|
|
751
|
+
: Buffer.from(member.bom + restoreEndings(content, member.ending), "utf-8");
|
|
752
|
+
plans.push({
|
|
753
|
+
member,
|
|
754
|
+
restorePath,
|
|
755
|
+
restoreBytes,
|
|
756
|
+
removeOnly: isAdd,
|
|
757
|
+
moveFrom: isMove ? restorePath : null,
|
|
758
|
+
moveTo: isMove ? committedPath : null,
|
|
759
|
+
dirs: member.createdDirs,
|
|
760
|
+
content,
|
|
761
|
+
currentNormalized,
|
|
762
|
+
diff: undoDiff,
|
|
763
|
+
servedRows: undoDenseRows,
|
|
764
|
+
restoredContentHash,
|
|
765
|
+
restoredLineCount,
|
|
766
|
+
restoredHashes,
|
|
767
|
+
displacedAnchors,
|
|
768
|
+
});
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
// WHY: STEP 3 — durable revert intent BEFORE the first write.
|
|
772
|
+
await saveCutIntent(txnId, requestedAbsolutePath, "revert");
|
|
773
|
+
const undoCtx = ctx as { onBeforeUndoWrites?: () => void | Promise<void> };
|
|
774
|
+
if (undoCtx?.onBeforeUndoWrites) await undoCtx.onBeforeUndoWrites();
|
|
775
|
+
|
|
776
|
+
// WHY: STEP 4 — apply the restore steps; a failed step first COMPLETES the remaining members.
|
|
777
|
+
const applyRestore = async (plan: RestorePlan): Promise<void> => {
|
|
778
|
+
if (plan.removeOnly) {
|
|
779
|
+
if (await fileExists(plan.restorePath)) await removeFile(plan.restorePath);
|
|
780
|
+
await pruneEmptyDirs(plan.dirs);
|
|
781
|
+
return;
|
|
782
|
+
}
|
|
783
|
+
if (plan.moveFrom !== null && plan.moveTo !== null) {
|
|
784
|
+
if (await fileExists(plan.moveTo)) await renameFile(plan.moveTo, plan.moveFrom);
|
|
785
|
+
}
|
|
786
|
+
if (plan.restoreBytes !== null) await writeAtomic(plan.restorePath, plan.restoreBytes);
|
|
787
|
+
};
|
|
788
|
+
const reverted = new Set<string>();
|
|
789
|
+
let defeated: UndoMember | undefined;
|
|
790
|
+
for (const plan of plans) {
|
|
791
|
+
try {
|
|
792
|
+
await applyRestore(plan);
|
|
793
|
+
reverted.add(plan.member.absolutePath);
|
|
794
|
+
} catch (error) {
|
|
795
|
+
reportDiagnostic(
|
|
796
|
+
"edit-undo",
|
|
797
|
+
"Lifecycle undo write failed; completing the revert:",
|
|
798
|
+
error,
|
|
799
|
+
);
|
|
800
|
+
for (const rest of plans) {
|
|
801
|
+
if (reverted.has(rest.member.absolutePath)) continue;
|
|
802
|
+
try {
|
|
803
|
+
await applyRestore(rest);
|
|
804
|
+
reverted.add(rest.member.absolutePath);
|
|
805
|
+
} catch (completionError) {
|
|
806
|
+
reportDiagnostic(
|
|
807
|
+
"edit-undo",
|
|
808
|
+
"Failed to complete the interrupted lifecycle revert:",
|
|
809
|
+
completionError,
|
|
810
|
+
);
|
|
811
|
+
defeated ??= rest.member;
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
break;
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
if (defeated !== undefined) {
|
|
818
|
+
return {
|
|
819
|
+
content: [
|
|
820
|
+
{
|
|
821
|
+
type: "text" as const,
|
|
822
|
+
text: new DomainError("E_UNDO_REVERT_FAILED", { path: defeated.displayPath }).message,
|
|
823
|
+
},
|
|
824
|
+
],
|
|
825
|
+
isError: true,
|
|
826
|
+
details: {},
|
|
827
|
+
};
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
// WHY: STEP 5 — store commit for every restored file (a removed add owns no snapshot).
|
|
831
|
+
const deferredSyncWarnings: string[] = [];
|
|
832
|
+
const servedRowsAll: ServedRow[] = [];
|
|
833
|
+
const sections: { plan: RestorePlan }[] = [];
|
|
834
|
+
for (const plan of plans) {
|
|
835
|
+
if (plan.removeOnly) {
|
|
836
|
+
sections.push({ plan });
|
|
837
|
+
continue;
|
|
838
|
+
}
|
|
839
|
+
try {
|
|
840
|
+
await adoptPinnedSnapshotFor(
|
|
841
|
+
{
|
|
842
|
+
path: plan.restorePath,
|
|
843
|
+
snapshotHash: plan.restoredContentHash,
|
|
844
|
+
lineCount: plan.restoredLineCount,
|
|
845
|
+
hashes: plan.restoredHashes,
|
|
846
|
+
content: plan.content,
|
|
847
|
+
},
|
|
848
|
+
{
|
|
849
|
+
retireLeases: true,
|
|
850
|
+
leases: { sessionKey: sessionKeyForUndo, rows: plan.servedRows },
|
|
851
|
+
servedMirror: {
|
|
852
|
+
sessionKey: sessionKeyForUndo,
|
|
853
|
+
rows: plan.servedRows,
|
|
854
|
+
shape: { lineCount: plan.restoredLineCount, clearFrom: 0 },
|
|
855
|
+
retireAnchors: plan.displacedAnchors,
|
|
856
|
+
},
|
|
857
|
+
},
|
|
858
|
+
);
|
|
859
|
+
} catch (error) {
|
|
860
|
+
reportDiagnostic("edit-undo", "Failed to commit the lifecycle undo restore:", error);
|
|
861
|
+
deferredSyncWarnings.push(DEFERRED_STORE_SYNC_WARNING);
|
|
862
|
+
}
|
|
863
|
+
servedRowsAll.push(...plan.servedRows);
|
|
864
|
+
sections.push({ plan });
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
await clearUndoTransaction(txnId);
|
|
868
|
+
try {
|
|
869
|
+
await dropCutIntent(txnId);
|
|
870
|
+
} catch (error) {
|
|
871
|
+
reportDiagnostic("edit-undo", "Failed to clear the revert intent after commit:", error);
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
const parts: string[] = ["Undone last edit on " + names + "."];
|
|
875
|
+
for (const { plan } of sections) {
|
|
876
|
+
switch (plan.member.lifecycle) {
|
|
877
|
+
case "add":
|
|
878
|
+
parts.push("Removed the created file " + plan.member.displayPath + ".");
|
|
879
|
+
break;
|
|
880
|
+
case "delete":
|
|
881
|
+
parts.push("Restored the deleted file " + plan.member.displayPath + ".");
|
|
882
|
+
break;
|
|
883
|
+
case "move":
|
|
884
|
+
parts.push(
|
|
885
|
+
"Moved " +
|
|
886
|
+
(plan.member.toPath ?? plan.member.absolutePath) +
|
|
887
|
+
" back to " +
|
|
888
|
+
plan.restorePath +
|
|
889
|
+
".",
|
|
890
|
+
);
|
|
891
|
+
break;
|
|
892
|
+
default:
|
|
893
|
+
break;
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
parts.push(
|
|
897
|
+
deferredSyncWarnings.length > 0
|
|
898
|
+
? "Files reverted; store synchronization is deferred (see the warning below)."
|
|
899
|
+
: "Files reverted; diff rows carry fresh anchors for follow-up edits.",
|
|
900
|
+
);
|
|
901
|
+
parts.push(...deferredSyncWarnings);
|
|
902
|
+
|
|
903
|
+
const totalAdded = plans.reduce(
|
|
904
|
+
(total, plan) =>
|
|
905
|
+
total +
|
|
906
|
+
Math.max(0, visLines(plan.content).length - visLines(plan.currentNormalized).length),
|
|
907
|
+
0,
|
|
908
|
+
);
|
|
909
|
+
const totalRemoved = plans.reduce(
|
|
910
|
+
(total, plan) =>
|
|
911
|
+
total +
|
|
912
|
+
Math.max(0, visLines(plan.currentNormalized).length - visLines(plan.content).length),
|
|
913
|
+
0,
|
|
914
|
+
);
|
|
915
|
+
const details: EditDetails = {
|
|
916
|
+
diff: sections
|
|
917
|
+
.map(({ plan }) => "--- " + plan.restorePath + " ---\n" + plan.diff)
|
|
918
|
+
.join("\n"),
|
|
919
|
+
resultLineCount: visLines(plans[0]!.content).length,
|
|
920
|
+
servedRows: servedRowsAll,
|
|
921
|
+
contentHash: plans[0]!.restoredContentHash,
|
|
922
|
+
...(deferredSyncWarnings.length > 0 ? { warnings: deferredSyncWarnings } : {}),
|
|
923
|
+
metrics: buildMetrics({
|
|
924
|
+
classification: "applied",
|
|
925
|
+
editsAttempted: plans.length,
|
|
926
|
+
noopEditsCount: 0,
|
|
927
|
+
warningsCount: deferredSyncWarnings.length,
|
|
928
|
+
addedLines: totalAdded,
|
|
929
|
+
removedLines: totalRemoved,
|
|
930
|
+
}),
|
|
931
|
+
};
|
|
932
|
+
return {
|
|
933
|
+
content: [{ type: "text" as const, text: parts.join("\n") }],
|
|
934
|
+
details,
|
|
935
|
+
};
|
|
936
|
+
},
|
|
937
|
+
);
|
|
938
|
+
}
|
|
939
|
+
|
|
566
940
|
/**
|
|
567
941
|
* Execution facts one host supplies for the undo use case: the directory relative `path` resolves
|
|
568
942
|
* against, and the session whose served anchors the restore re-leases.
|
|
@@ -884,23 +1258,3 @@ export async function executeUndoLastEdit(
|
|
|
884
1258
|
throw withPayloadSubject(error, "undo_last_edit");
|
|
885
1259
|
}
|
|
886
1260
|
}
|
|
887
|
-
|
|
888
|
-
export function regEditUndo(pi: ExtensionAPI): void {
|
|
889
|
-
pi.registerTool({
|
|
890
|
-
name: "undo_last_edit",
|
|
891
|
-
label: "Undo Last Edit",
|
|
892
|
-
description: loadP("../prompts/undo-last-edit.md"),
|
|
893
|
-
promptSnippet: loadP("../prompts/undo-last-edit-snippet.md"),
|
|
894
|
-
promptGuidelines: loadGuide("../prompts/undo-last-edit-guidelines.md"),
|
|
895
|
-
parameters: Type.Object({
|
|
896
|
-
path: Type.String({
|
|
897
|
-
description: "Path to the file to undo",
|
|
898
|
-
}),
|
|
899
|
-
}),
|
|
900
|
-
|
|
901
|
-
// WHY: the registration owns only the pi wire shape; the use case lives in the host-agnostic seam.
|
|
902
|
-
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
903
|
-
return executeUndoLastEdit(params, ctx as unknown as UndoLastEditContext);
|
|
904
|
-
},
|
|
905
|
-
});
|
|
906
|
-
}
|
|
@@ -32,7 +32,7 @@ import {
|
|
|
32
32
|
} from "./preview.js";
|
|
33
33
|
import { findStatSnapshotFor } from "../snapshot-store";
|
|
34
34
|
import type { ServedRow } from "../served-session/serve-render.js";
|
|
35
|
-
import type { TruncationResult } from "
|
|
35
|
+
import type { TruncationResult } from "../host-utils.js";
|
|
36
36
|
|
|
37
37
|
export type { FileStats, LFile, LoadFileOptions } from "./detection.js";
|
|
38
38
|
export { decodeUtf8Strict, loadFileKindAndText } from "./detection.js";
|
|
@@ -4,7 +4,7 @@ import {
|
|
|
4
4
|
DEFAULT_MAX_LINES,
|
|
5
5
|
DEFAULT_MAX_BYTES,
|
|
6
6
|
type TruncationResult,
|
|
7
|
-
} from "
|
|
7
|
+
} from "../host-utils.js";
|
|
8
8
|
import { MAX_READ_WINDOWS, MAX_READ_WINDOWS_MESSAGE } from "../constants.js";
|
|
9
9
|
import { DomainError } from "../domain-errors.js";
|
|
10
10
|
import {
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* WHY: one result. Both builders live here, so a header's shape has ONE definition site and the footer
|
|
6
6
|
* WHY: they replaced cannot come back from a call site.
|
|
7
7
|
*/
|
|
8
|
-
import { formatSize } from "
|
|
8
|
+
import { formatSize } from "../host-utils.js";
|
|
9
9
|
|
|
10
10
|
/** The lines a page rendered, 1-based and inclusive. */
|
|
11
11
|
interface RenderedRange {
|