dsh-diff-approval 0.13.0 → 0.14.0
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/lib/client.js +161 -119
- package/lib/index.js +355 -441
- package/lib/types/client/reference.d.ts +9 -4
- package/lib/types/client/whole-file-diff.d.ts +9 -0
- package/lib/types/pending.d.ts +43 -43
- package/lib/types/persist.d.ts +32 -39
- package/lib/types/types.d.ts +13 -10
- package/package.json +1 -1
package/lib/index.js
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
1
|
+
import { mkdir, readFile, readdir, rename, rm, writeFile } from "node:fs/promises";
|
|
3
2
|
import { dirname, join, resolve, sep } from "node:path";
|
|
4
3
|
import { dshHomePath, expandHomePath } from "@deepseek-ai/dsh-home-paths";
|
|
5
4
|
import { SessionId } from "@deepseek-ai/dsh-session";
|
|
@@ -7,51 +6,40 @@ import { spawn } from "node:child_process";
|
|
|
7
6
|
import { existsSync } from "node:fs";
|
|
8
7
|
//#region lib/types/pending.js
|
|
9
8
|
/**
|
|
10
|
-
* In-memory pending-diff store: one entry per
|
|
11
|
-
* file's full set of unhandled changes as one cumulative span
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
9
|
+
* In-memory pending-diff store: one entry per file path, globally, holding the
|
|
10
|
+
* file's full set of unhandled changes as one cumulative span (earliest basis →
|
|
11
|
+
* latest content) across every session and workspace that touched it. Every
|
|
12
|
+
* later operation to a tracked path folds into its entry — `oldText` stays the
|
|
13
|
+
* earliest basis, `newText` takes the latest content, and the touching sessions
|
|
14
|
+
* accumulate in `sessionIds` — even when the chain breaks (an outside writer
|
|
15
|
+
* changed the file between operations). Pure state and transitions; the plugin
|
|
16
16
|
* body owns the `tools/result` observation, the RPC surface, and the
|
|
17
17
|
* filesystem I/O.
|
|
18
18
|
* @module dsh-diff-approval/src/pending
|
|
19
19
|
*/
|
|
20
|
-
/** Map key
|
|
21
|
-
function
|
|
22
|
-
return
|
|
20
|
+
/** Map key: the file path (the single global identity of a pending change). */
|
|
21
|
+
function pathKeyOf(path) {
|
|
22
|
+
return path;
|
|
23
23
|
}
|
|
24
|
-
/**
|
|
25
|
-
function
|
|
26
|
-
|
|
24
|
+
/** The session ids an entry was touched by (tolerant of legacy rows without the field). */
|
|
25
|
+
function touchedBy(entry) {
|
|
26
|
+
if (Array.isArray(entry.sessionIds) && entry.sessionIds.length > 0) return entry.sessionIds;
|
|
27
|
+
return [entry.sessionId];
|
|
27
28
|
}
|
|
28
29
|
/** Whether a merge changed nothing (a repeated operation is a no-op). */
|
|
29
30
|
function sameEntry(left, right) {
|
|
30
|
-
|
|
31
|
+
const lIds = touchedBy(left);
|
|
32
|
+
const rIds = touchedBy(right);
|
|
33
|
+
return left.path === right.path && left.kind === right.kind && left.oldText === right.oldText && left.newText === right.newText && left.updatedAt === right.updatedAt && left.sessionId === right.sessionId && lIds.length === rIds.length && lIds.every((id, index) => id === rIds[index]);
|
|
31
34
|
}
|
|
32
35
|
/**
|
|
33
|
-
* The pending-diff store
|
|
36
|
+
* The pending-diff store: one entry per file path across all sessions. Keep /
|
|
37
|
+
* Revert decides one whole file at a time.
|
|
34
38
|
*/
|
|
35
39
|
var PendingDiffStore = class {
|
|
36
40
|
entries = /* @__PURE__ */ new Map();
|
|
37
|
-
pathIndex = /* @__PURE__ */ new Map();
|
|
38
|
-
/** Merge one operation into the store, keyed by (session, path). */
|
|
39
|
-
merge(entry) {
|
|
40
|
-
const key = pathKey(entry.sessionId, entry.path);
|
|
41
|
-
const currentId = this.pathIndex.get(key);
|
|
42
|
-
const current = currentId === void 0 ? void 0 : this.entries.get(entryKey(entry.sessionId, currentId));
|
|
43
|
-
const merged = current === void 0 ? { ...entry } : {
|
|
44
|
-
...current,
|
|
45
|
-
newText: entry.newText,
|
|
46
|
-
updatedAt: entry.updatedAt
|
|
47
|
-
};
|
|
48
|
-
if (current !== void 0 && sameEntry(current, merged)) return false;
|
|
49
|
-
this.entries.set(entryKey(entry.sessionId, merged.id), merged);
|
|
50
|
-
this.pathIndex.set(key, merged.id);
|
|
51
|
-
return true;
|
|
52
|
-
}
|
|
53
41
|
/**
|
|
54
|
-
*
|
|
42
|
+
* Merge one captured operation into its file's entry. A no-op (equal before
|
|
55
43
|
* and after) folds nothing.
|
|
56
44
|
* @param entry - the captured operation (id assigned by the caller).
|
|
57
45
|
* @returns whether the stored entry changed.
|
|
@@ -60,103 +48,105 @@ var PendingDiffStore = class {
|
|
|
60
48
|
if (entry.oldText === entry.newText) return false;
|
|
61
49
|
return this.merge(entry);
|
|
62
50
|
}
|
|
51
|
+
/** Fold a captured entry into the path's single global entry. */
|
|
52
|
+
merge(entry) {
|
|
53
|
+
const current = this.entries.get(pathKeyOf(entry.path));
|
|
54
|
+
const merged = current === void 0 ? { ...entry } : this.mergeEntry(current, entry);
|
|
55
|
+
if (current !== void 0 && sameEntry(current, merged)) return false;
|
|
56
|
+
this.entries.set(pathKeyOf(merged.path), merged);
|
|
57
|
+
return true;
|
|
58
|
+
}
|
|
63
59
|
/**
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
* @param sessionId - the session the entries belong to.
|
|
69
|
-
* @param entries - persisted entries, oldest capture first, to fold in.
|
|
60
|
+
* Combine a stored entry and a new capture. `oldText` comes from the earlier
|
|
61
|
+
* capture (the file's original basis), `newText` from the later (latest
|
|
62
|
+
* content), and the session set is the union — so the entry always spans the
|
|
63
|
+
* whole pending change regardless of which session contributed which part.
|
|
70
64
|
*/
|
|
71
|
-
|
|
72
|
-
const
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
65
|
+
mergeEntry(a, b) {
|
|
66
|
+
const earlier = a.updatedAt <= b.updatedAt ? a : b;
|
|
67
|
+
const later = earlier === a ? b : a;
|
|
68
|
+
const sessionIds = [.../* @__PURE__ */ new Set([...touchedBy(a), b.sessionId])];
|
|
69
|
+
return {
|
|
70
|
+
...later,
|
|
71
|
+
id: later.path,
|
|
72
|
+
oldText: earlier.oldText,
|
|
73
|
+
kind: earlier.kind === "create" || later.kind === "create" ? "create" : "edit",
|
|
74
|
+
sessionIds
|
|
75
|
+
};
|
|
81
76
|
}
|
|
82
77
|
/**
|
|
83
|
-
* Copy the session's
|
|
84
|
-
*
|
|
78
|
+
* Copy every entry touching a session (the session's view), oldest capture
|
|
79
|
+
* first. The file list is session-scoped: an entry appears for each session
|
|
80
|
+
* that touched it, so a session only sees files it worked on.
|
|
81
|
+
* @param sessionId - the viewing session.
|
|
85
82
|
* @returns detached entries in capture order.
|
|
86
83
|
*/
|
|
87
84
|
list(sessionId) {
|
|
88
85
|
const found = [];
|
|
89
|
-
for (const entry of this.entries.values()) if (entry.sessionId
|
|
86
|
+
for (const entry of this.entries.values()) if (touchedBy(entry).includes(sessionId)) found.push(entry);
|
|
90
87
|
return found.sort((left, right) => left.updatedAt - right.updatedAt);
|
|
91
88
|
}
|
|
89
|
+
/** Every entry (all paths, all sessions), oldest capture first. */
|
|
90
|
+
all() {
|
|
91
|
+
return [...this.entries.values()].sort((left, right) => left.updatedAt - right.updatedAt);
|
|
92
|
+
}
|
|
92
93
|
/**
|
|
93
|
-
* Read one entry without removing it.
|
|
94
|
-
* @param
|
|
95
|
-
* @
|
|
96
|
-
* @returns the entry, or `undefined` when the pair has none.
|
|
94
|
+
* Read one path's entry without removing it.
|
|
95
|
+
* @param path - the file path (the global entry key).
|
|
96
|
+
* @returns the entry, or `undefined` when none is tracked.
|
|
97
97
|
*/
|
|
98
|
-
get(
|
|
99
|
-
return this.entries.get(
|
|
98
|
+
get(path) {
|
|
99
|
+
return this.entries.get(pathKeyOf(path));
|
|
100
100
|
}
|
|
101
101
|
/**
|
|
102
|
-
* Remove one entry.
|
|
103
|
-
* @param
|
|
104
|
-
* @param id - the entry id.
|
|
102
|
+
* Remove one path's entry.
|
|
103
|
+
* @param path - the file path.
|
|
105
104
|
* @returns whether an entry was removed.
|
|
106
105
|
*/
|
|
107
|
-
remove(
|
|
108
|
-
|
|
109
|
-
for (const [key, indexedId] of this.pathIndex) if (indexedId === id) this.pathIndex.delete(key);
|
|
110
|
-
return true;
|
|
106
|
+
remove(path) {
|
|
107
|
+
return this.entries.delete(pathKeyOf(path));
|
|
111
108
|
}
|
|
112
109
|
/**
|
|
113
110
|
* Advance one entry's tracked content after a block-level keep/revert. The
|
|
114
|
-
* entry keeps its
|
|
115
|
-
*
|
|
116
|
-
* instead of updating it.
|
|
117
|
-
* @param sessionId - the owning session.
|
|
118
|
-
* @param id - the entry id.
|
|
111
|
+
* entry keeps its path; only the given side's text and capture time move.
|
|
112
|
+
* @param path - the file path.
|
|
119
113
|
* @param patch - the side to advance (`oldText` for keep, `newText` for revert).
|
|
120
114
|
* @returns whether the entry changed.
|
|
121
115
|
*/
|
|
122
|
-
update(
|
|
123
|
-
const
|
|
124
|
-
const entry = this.entries.get(key);
|
|
116
|
+
update(path, patch) {
|
|
117
|
+
const entry = this.entries.get(pathKeyOf(path));
|
|
125
118
|
if (entry === void 0) return false;
|
|
126
119
|
const next = {
|
|
127
120
|
...entry,
|
|
128
121
|
...patch,
|
|
129
122
|
updatedAt: Date.now()
|
|
130
123
|
};
|
|
131
|
-
this.entries.set(
|
|
124
|
+
this.entries.set(pathKeyOf(path), next);
|
|
132
125
|
return true;
|
|
133
126
|
}
|
|
134
127
|
/**
|
|
135
128
|
* Restore one entry exactly as given (an undo/redo replays a snapshot). The
|
|
136
|
-
* entry is inserted or replaced by
|
|
137
|
-
* later capture folds into the restored entry. The store keeps one entry per
|
|
138
|
-
* path: restoring an entry for a path that a DIFFERENT entry currently owns
|
|
139
|
-
* drops that occupant first, so an undo can never leave two list items for
|
|
140
|
-
* the same file (e.g. an imported entry and a replayed keep of the same path).
|
|
141
|
-
* @param sessionId - the owning session.
|
|
129
|
+
* entry is inserted or replaced by path.
|
|
142
130
|
* @param entry - the entry state to restore.
|
|
143
131
|
* @returns whether the store changed.
|
|
144
132
|
*/
|
|
145
|
-
restore(
|
|
146
|
-
const key =
|
|
133
|
+
restore(entry) {
|
|
134
|
+
const key = pathKeyOf(entry.path);
|
|
147
135
|
const existing = this.entries.get(key);
|
|
148
136
|
if (existing !== void 0 && sameEntry(existing, entry)) return false;
|
|
149
|
-
|
|
150
|
-
const occupantId = this.pathIndex.get(pathKey_);
|
|
151
|
-
if (occupantId !== void 0 && occupantId !== entry.id) this.entries.delete(entryKey(sessionId, occupantId));
|
|
152
|
-
this.entries.set(key, {
|
|
153
|
-
...entry,
|
|
154
|
-
sessionId
|
|
155
|
-
});
|
|
156
|
-
this.pathIndex.set(pathKey_, entry.id);
|
|
137
|
+
this.entries.set(key, { ...entry });
|
|
157
138
|
return true;
|
|
158
139
|
}
|
|
159
|
-
/**
|
|
140
|
+
/**
|
|
141
|
+
* Merge persisted entries into the store, one per path after folding. A live
|
|
142
|
+
* entry wins over a persisted one only when its time is newer (folders are
|
|
143
|
+
* applied in capture order, so a later persisted capture is strictly newer).
|
|
144
|
+
* @param entries - persisted entries, oldest capture first, to fold in.
|
|
145
|
+
*/
|
|
146
|
+
hydrate(entries) {
|
|
147
|
+
for (const entry of entries) this.merge({ ...entry });
|
|
148
|
+
}
|
|
149
|
+
/** Total entry count (one per tracked file path). */
|
|
160
150
|
get size() {
|
|
161
151
|
return this.entries.size;
|
|
162
152
|
}
|
|
@@ -164,182 +154,202 @@ var PendingDiffStore = class {
|
|
|
164
154
|
//#endregion
|
|
165
155
|
//#region lib/types/persist.js
|
|
166
156
|
/**
|
|
167
|
-
* Durable pending-entry persistence under the harness home
|
|
168
|
-
*
|
|
169
|
-
* `<storageDir
|
|
170
|
-
*
|
|
171
|
-
*
|
|
172
|
-
*
|
|
173
|
-
*
|
|
174
|
-
*
|
|
175
|
-
*
|
|
176
|
-
*
|
|
177
|
-
*
|
|
157
|
+
* Durable pending-entry persistence under the harness home: one global JSON
|
|
158
|
+
* file holding one entry per file path (across all sessions and workspaces), at
|
|
159
|
+
* `<storageDir>/pending.json`; the default root is `<dshHome>/diff-approval/workspaces`
|
|
160
|
+
* and the plugin's `storageDir` config relocates it. Saves rewrite the whole
|
|
161
|
+
* file (the entry set is small), staged as a sibling temp file and atomically
|
|
162
|
+
* renamed, so a crash leaves either the old or the new file. A missing file
|
|
163
|
+
* reads as empty; corrupt content and unknown versions throw.
|
|
164
|
+
*
|
|
165
|
+
* Legacy layout: the pre-global schema stored one JSON file per workspace
|
|
166
|
+
* (`<storageDir>/<workspaceId>.json`, `{ version: 2, sessions: { [sessionId]:
|
|
167
|
+
* PendingEntry[] } }`). On first load without a global file, `loadAll` scans for
|
|
168
|
+
* those files, flattens every session's entries, and reports them so the caller
|
|
169
|
+
* can fold them into the global store once; `save` then writes the global file
|
|
170
|
+
* and deletes the legacy files.
|
|
178
171
|
* @module dsh-diff-approval/src/persist
|
|
179
172
|
*/
|
|
180
173
|
/** Default persistence root: this plugin's own directory under the harness home. */
|
|
181
174
|
function defaultStorageDir() {
|
|
182
175
|
return dshHomePath("diff-approval", "workspaces");
|
|
183
176
|
}
|
|
184
|
-
/**
|
|
185
|
-
const FILE_VERSION =
|
|
177
|
+
/** Global-file version (post per-workspace schema). */
|
|
178
|
+
const FILE_VERSION = 3;
|
|
179
|
+
/** Legacy per-workspace-file version, accepted only for migration. */
|
|
180
|
+
const LEGACY_FILE_VERSION = 2;
|
|
186
181
|
/** Narrow one JSON value to a pending entry; malformed rows are skipped. */
|
|
187
182
|
function pendingEntryOf(value) {
|
|
188
183
|
if (typeof value !== "object" || value === null || Array.isArray(value)) return void 0;
|
|
189
|
-
const { id,
|
|
184
|
+
const { id, path, kind, oldText, newText, updatedAt, sessionId, sessionIds } = value;
|
|
190
185
|
if (typeof id !== "string" || id.length === 0) return void 0;
|
|
191
|
-
if (typeof sessionId !== "string" || sessionId.length === 0) return void 0;
|
|
192
186
|
if (typeof path !== "string" || path.length === 0) return void 0;
|
|
193
187
|
if (kind !== "edit" && kind !== "create") return void 0;
|
|
194
188
|
if (typeof oldText !== "string" || typeof newText !== "string") return void 0;
|
|
195
189
|
if (typeof updatedAt !== "number") return void 0;
|
|
190
|
+
if (typeof sessionId !== "string" || sessionId.length === 0) return void 0;
|
|
191
|
+
const ids = Array.isArray(sessionIds) ? sessionIds.filter((value) => typeof value === "string" && value.length > 0) : [];
|
|
196
192
|
return {
|
|
197
|
-
id,
|
|
198
|
-
sessionId,
|
|
193
|
+
id: path,
|
|
199
194
|
path,
|
|
200
195
|
kind,
|
|
201
196
|
oldText,
|
|
202
197
|
newText,
|
|
203
|
-
updatedAt
|
|
198
|
+
updatedAt,
|
|
199
|
+
sessionId,
|
|
200
|
+
sessionIds: ids.length > 0 ? ids : [sessionId]
|
|
204
201
|
};
|
|
205
202
|
}
|
|
206
|
-
/**
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
* @param file - the file path, for the error message.
|
|
211
|
-
* @param value - the parsed JSON value.
|
|
212
|
-
* @returns the validated file, or `undefined` when the file does not exist.
|
|
213
|
-
*/
|
|
214
|
-
function workspaceFileOf(file, value) {
|
|
215
|
-
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error(`pending persistence file '${file}' is not a workspace file`);
|
|
216
|
-
const { version, sessions } = value;
|
|
203
|
+
/** Validate a parsed global file. */
|
|
204
|
+
function globalFileOf(file, value) {
|
|
205
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error(`pending persistence file '${file}' is not a global file`);
|
|
206
|
+
const { version, entries } = value;
|
|
217
207
|
if (version !== FILE_VERSION) throw new Error(`pending persistence file '${file}' has unsupported version ${JSON.stringify(version)}`);
|
|
218
|
-
if (
|
|
208
|
+
if (!Array.isArray(entries)) throw new Error(`pending persistence file '${file}' has no entry list`);
|
|
219
209
|
return {
|
|
220
210
|
version,
|
|
221
|
-
|
|
211
|
+
entries
|
|
222
212
|
};
|
|
223
213
|
}
|
|
214
|
+
/** Read one file's content; `undefined` when absent (the normal empty state). */
|
|
215
|
+
async function readJson(file) {
|
|
216
|
+
let raw;
|
|
217
|
+
try {
|
|
218
|
+
raw = await readFile(file, "utf8");
|
|
219
|
+
} catch (error) {
|
|
220
|
+
if (error.code === "ENOENT") return void 0;
|
|
221
|
+
throw error;
|
|
222
|
+
}
|
|
223
|
+
try {
|
|
224
|
+
return JSON.parse(raw);
|
|
225
|
+
} catch (error) {
|
|
226
|
+
throw new Error(`pending persistence file '${file}' is not valid JSON: ${error instanceof Error ? error.message : String(error)}`);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
/** Stage a JSON envelope as a sibling temp file and atomically rename it into place. */
|
|
230
|
+
async function writeJson(file, value) {
|
|
231
|
+
await mkdir(dirName(file), { recursive: true });
|
|
232
|
+
const tmp = `${file}.${process.pid}.${Date.now()}.tmp`;
|
|
233
|
+
await writeFile(tmp, JSON.stringify(value), "utf8");
|
|
234
|
+
try {
|
|
235
|
+
await rename(tmp, file);
|
|
236
|
+
} catch (error) {
|
|
237
|
+
await rm(tmp, { force: true }).catch(() => {});
|
|
238
|
+
throw error;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
function dirName(file) {
|
|
242
|
+
const index = file.lastIndexOf("/");
|
|
243
|
+
return index < 0 ? "." : file.slice(0, index);
|
|
244
|
+
}
|
|
224
245
|
/**
|
|
225
|
-
* File-backed pending entries keyed by
|
|
246
|
+
* File-backed pending entries, one global file keyed by path.
|
|
226
247
|
*/
|
|
227
248
|
var PendingPersistence = class {
|
|
228
249
|
root;
|
|
229
250
|
tails = /* @__PURE__ */ new Map();
|
|
251
|
+
globalFile;
|
|
230
252
|
/**
|
|
231
|
-
* @param root - directory holding
|
|
253
|
+
* @param root - directory holding the global `pending.json` (and, pre-migration,
|
|
254
|
+
* one JSON file per workspace).
|
|
232
255
|
*/
|
|
233
256
|
constructor(root) {
|
|
234
257
|
this.root = root;
|
|
235
|
-
|
|
236
|
-
fileOf(workspaceId) {
|
|
237
|
-
return join(this.root, `${workspaceId}.json`);
|
|
238
|
-
}
|
|
239
|
-
/** Read one workspace file; a missing file reads as empty. */
|
|
240
|
-
async readWorkspace(file) {
|
|
241
|
-
let raw;
|
|
242
|
-
try {
|
|
243
|
-
raw = await readFile(file, "utf8");
|
|
244
|
-
} catch (error) {
|
|
245
|
-
if (error.code === "ENOENT") return void 0;
|
|
246
|
-
throw error;
|
|
247
|
-
}
|
|
248
|
-
let parsed;
|
|
249
|
-
try {
|
|
250
|
-
parsed = JSON.parse(raw);
|
|
251
|
-
} catch (error) {
|
|
252
|
-
throw new Error(`pending persistence file '${file}' is not valid JSON: ${error instanceof Error ? error.message : String(error)}`);
|
|
253
|
-
}
|
|
254
|
-
return workspaceFileOf(file, parsed);
|
|
255
|
-
}
|
|
256
|
-
/** Stage the envelope as a sibling temp file and atomically rename it into place. */
|
|
257
|
-
async writeWorkspace(file, envelope) {
|
|
258
|
-
await mkdir(this.root, { recursive: true });
|
|
259
|
-
const tmp = `${file}.${process.pid}.${Date.now()}.tmp`;
|
|
260
|
-
await writeFile(tmp, JSON.stringify(envelope), "utf8");
|
|
261
|
-
try {
|
|
262
|
-
await rename(tmp, file);
|
|
263
|
-
} catch (error) {
|
|
264
|
-
await rm(tmp, { force: true }).catch(() => {});
|
|
265
|
-
throw error;
|
|
266
|
-
}
|
|
258
|
+
this.globalFile = join(root, "pending.json");
|
|
267
259
|
}
|
|
268
260
|
/**
|
|
269
|
-
* Load
|
|
270
|
-
*
|
|
271
|
-
*
|
|
272
|
-
* @returns the
|
|
261
|
+
* Load every persisted entry. Returns the global file's entries, or — when the
|
|
262
|
+
* global file is absent — flattens the legacy per-workspace files so the
|
|
263
|
+
* caller can fold them once and then save (which clears the legacy files).
|
|
264
|
+
* @returns the entries plus whether they came from the legacy layout (awaiting
|
|
265
|
+
* a save to finalize the migration).
|
|
273
266
|
*/
|
|
274
|
-
async
|
|
275
|
-
const
|
|
276
|
-
if (
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
for (const row of rows) {
|
|
281
|
-
const entry = pendingEntryOf(row);
|
|
282
|
-
if (entry !== void 0) entries.push(entry);
|
|
283
|
-
}
|
|
284
|
-
return entries.sort((left, right) => left.updatedAt - right.updatedAt);
|
|
285
|
-
}
|
|
286
|
-
/**
|
|
287
|
-
* Load every session's entries for one workspace, oldest capture first.
|
|
288
|
-
* The list shows a workspace's pending changes across sessions — a session
|
|
289
|
-
* that restarted carries a fresh id while its earlier entries sit under the
|
|
290
|
-
* original session ids in the same workspace file — so hydration reads the
|
|
291
|
-
* whole workspace, not one session.
|
|
292
|
-
* @param workspaceId - the owning workspace's stable id.
|
|
293
|
-
* @returns the persisted entries across all sessions; empty when none were saved.
|
|
294
|
-
*/
|
|
295
|
-
async loadWorkspace(workspaceId) {
|
|
296
|
-
const envelope = await this.readWorkspace(this.fileOf(workspaceId));
|
|
297
|
-
if (envelope === void 0) return [];
|
|
298
|
-
const entries = [];
|
|
299
|
-
for (const sessionId of Object.keys(envelope.sessions)) {
|
|
300
|
-
const rows = envelope.sessions[sessionId];
|
|
301
|
-
if (!Array.isArray(rows)) continue;
|
|
302
|
-
for (const row of rows) {
|
|
267
|
+
async loadAll() {
|
|
268
|
+
const global = await readJson(this.globalFile);
|
|
269
|
+
if (global !== void 0) {
|
|
270
|
+
const parsed = globalFileOf(this.globalFile, global);
|
|
271
|
+
const entries = [];
|
|
272
|
+
for (const row of parsed.entries) {
|
|
303
273
|
const entry = pendingEntryOf(row);
|
|
304
274
|
if (entry !== void 0) entries.push(entry);
|
|
305
275
|
}
|
|
276
|
+
return {
|
|
277
|
+
entries: entries.sort((l, r) => l.updatedAt - r.updatedAt),
|
|
278
|
+
migratedLegacy: false
|
|
279
|
+
};
|
|
306
280
|
}
|
|
307
|
-
|
|
281
|
+
const legacy = await collectLegacy(this.root);
|
|
282
|
+
if (legacy.length > 0) return {
|
|
283
|
+
entries: legacy.sort((l, r) => l.updatedAt - r.updatedAt),
|
|
284
|
+
migratedLegacy: true
|
|
285
|
+
};
|
|
286
|
+
return {
|
|
287
|
+
entries: [],
|
|
288
|
+
migratedLegacy: false
|
|
289
|
+
};
|
|
308
290
|
}
|
|
309
291
|
/**
|
|
310
|
-
* Replace
|
|
311
|
-
* a previous save's failure does not block the next one.
|
|
312
|
-
*
|
|
313
|
-
* @param
|
|
314
|
-
* @param entries - the session's complete entry list, possibly empty.
|
|
292
|
+
* Replace the persisted entry set durably. Saves to the one file are
|
|
293
|
+
* serialized; a previous save's failure does not block the next one. On a
|
|
294
|
+
* legacy migration this also removes the per-workspace files.
|
|
295
|
+
* @param entries - the complete entry list, possibly empty.
|
|
315
296
|
* @returns resolution after the file is durable.
|
|
316
297
|
*/
|
|
317
|
-
save(
|
|
318
|
-
const file = this.fileOf(workspaceId);
|
|
298
|
+
save(entries) {
|
|
319
299
|
const task = async () => {
|
|
320
|
-
|
|
321
|
-
if (entries.length === 0) {
|
|
322
|
-
if (envelope === void 0) return;
|
|
323
|
-
delete envelope.sessions[String(sessionId)];
|
|
324
|
-
if (Object.keys(envelope.sessions).length === 0) {
|
|
325
|
-
await rm(file, { force: true });
|
|
326
|
-
return;
|
|
327
|
-
}
|
|
328
|
-
await this.writeWorkspace(file, envelope);
|
|
329
|
-
return;
|
|
330
|
-
}
|
|
331
|
-
const next = envelope ?? {
|
|
300
|
+
await writeJson(this.globalFile, {
|
|
332
301
|
version: FILE_VERSION,
|
|
333
|
-
|
|
334
|
-
};
|
|
335
|
-
|
|
336
|
-
await this.writeWorkspace(file, next);
|
|
302
|
+
entries
|
|
303
|
+
});
|
|
304
|
+
await removeLegacy(this.root);
|
|
337
305
|
};
|
|
338
|
-
const run = (this.tails.get(
|
|
339
|
-
this.tails.set(
|
|
306
|
+
const run = (this.tails.get(this.globalFile) ?? Promise.resolve()).then(task, task);
|
|
307
|
+
this.tails.set(this.globalFile, run.catch(() => {}));
|
|
340
308
|
return run;
|
|
341
309
|
}
|
|
342
310
|
};
|
|
311
|
+
/** Flatten every legacy per-workspace file's entries (adding `sessionIds`). */
|
|
312
|
+
async function collectLegacy(root) {
|
|
313
|
+
let names;
|
|
314
|
+
try {
|
|
315
|
+
names = await readdir(root);
|
|
316
|
+
} catch (error) {
|
|
317
|
+
if (error.code === "ENOENT") return [];
|
|
318
|
+
throw error;
|
|
319
|
+
}
|
|
320
|
+
const entries = [];
|
|
321
|
+
for (const name of names) {
|
|
322
|
+
if (name === "pending.json" || !name.endsWith(".json")) continue;
|
|
323
|
+
const value = await readJson(join(root, name));
|
|
324
|
+
if (value === void 0) continue;
|
|
325
|
+
if (typeof value !== "object" || value === null) continue;
|
|
326
|
+
const { version, sessions } = value;
|
|
327
|
+
if (version !== LEGACY_FILE_VERSION) continue;
|
|
328
|
+
if (typeof sessions !== "object" || sessions === null) continue;
|
|
329
|
+
for (const sessionId of Object.keys(sessions)) {
|
|
330
|
+
const rows = sessions[sessionId];
|
|
331
|
+
if (!Array.isArray(rows)) continue;
|
|
332
|
+
for (const row of rows) {
|
|
333
|
+
const entry = pendingEntryOf(row);
|
|
334
|
+
if (entry !== void 0) entries.push(entry);
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
return entries;
|
|
339
|
+
}
|
|
340
|
+
/** Delete the legacy per-workspace files (post-migration cleanup). */
|
|
341
|
+
async function removeLegacy(root) {
|
|
342
|
+
let names;
|
|
343
|
+
try {
|
|
344
|
+
names = await readdir(root);
|
|
345
|
+
} catch {
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
for (const name of names) {
|
|
349
|
+
if (name === "pending.json" || !name.endsWith(".json")) continue;
|
|
350
|
+
await rm(join(root, name), { force: true }).catch(() => {});
|
|
351
|
+
}
|
|
352
|
+
}
|
|
343
353
|
//#endregion
|
|
344
354
|
//#region lib/types/open.js
|
|
345
355
|
/**
|
|
@@ -819,11 +829,20 @@ function apply(ctx, config) {
|
|
|
819
829
|
const store = new PendingDiffStore();
|
|
820
830
|
const persistence = new PendingPersistence(resolve(expandHomePath(storageDir ?? defaultStorageDir())));
|
|
821
831
|
const launchPath = config?.openPath ?? defaultOpenPath;
|
|
822
|
-
/**
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
832
|
+
/** Hydrate the globally-unique store once from the single persistence file. */
|
|
833
|
+
let loadPromise;
|
|
834
|
+
const ensureLoaded = () => {
|
|
835
|
+
loadPromise ??= (async () => {
|
|
836
|
+
try {
|
|
837
|
+
const { entries, migratedLegacy } = await persistence.loadAll();
|
|
838
|
+
if (entries.length > 0) store.hydrate(entries);
|
|
839
|
+
if (migratedLegacy) await persistence.save(store.all());
|
|
840
|
+
} catch (error) {
|
|
841
|
+
ctx.logger.warn(`diff-approval: loading persisted state failed: ${errorMessage(error)}`);
|
|
842
|
+
}
|
|
843
|
+
})();
|
|
844
|
+
return loadPromise;
|
|
845
|
+
};
|
|
827
846
|
/** Pre-write bases captured at the intent seams, keyed by the tool call id. */
|
|
828
847
|
const editorIntents = /* @__PURE__ */ new Map();
|
|
829
848
|
/**
|
|
@@ -881,17 +900,19 @@ function apply(ctx, config) {
|
|
|
881
900
|
}
|
|
882
901
|
if (basis.kind === "edit" && after === basis.before) return;
|
|
883
902
|
const sessionId = basis.sessionId;
|
|
903
|
+
const path = basis.target.displayPath;
|
|
884
904
|
const entry = {
|
|
885
|
-
id:
|
|
905
|
+
id: path,
|
|
886
906
|
sessionId,
|
|
887
|
-
path
|
|
907
|
+
path,
|
|
888
908
|
kind: basis.kind,
|
|
889
909
|
oldText: basis.before,
|
|
890
910
|
newText: after,
|
|
891
|
-
updatedAt: Date.now()
|
|
911
|
+
updatedAt: Date.now(),
|
|
912
|
+
sessionIds: [sessionId]
|
|
892
913
|
};
|
|
893
|
-
await ensureLoaded(
|
|
894
|
-
if (store.fold(entry)) await persistSession(
|
|
914
|
+
await ensureLoaded();
|
|
915
|
+
if (store.fold(entry)) await persistSession();
|
|
895
916
|
}
|
|
896
917
|
/** The stable `FsError.code`, when the thrown value carries one. */
|
|
897
918
|
function fsErrorCodeOf(error) {
|
|
@@ -935,20 +956,20 @@ function apply(ctx, config) {
|
|
|
935
956
|
* @param entries - the store's entries for one session.
|
|
936
957
|
* @returns entries with `missing` and `diverged` set from the live file.
|
|
937
958
|
*/
|
|
938
|
-
/** Drop every undo/redo pair that belongs to a removed
|
|
959
|
+
/** Drop every undo/redo pair that belongs to a removed file, so the LIFO
|
|
939
960
|
* queue stays traversable without ever trying to restore an unreadable file. */
|
|
940
|
-
function purgeForEntry(
|
|
941
|
-
const
|
|
942
|
-
|
|
943
|
-
const stack = stacks.get(key);
|
|
944
|
-
if (stack === void 0) continue;
|
|
945
|
-
stacks.set(key, stack.filter((pair) => {
|
|
946
|
-
const touches = (state) => state.id === entryId || state.path === path;
|
|
961
|
+
function purgeForEntry(path) {
|
|
962
|
+
const clean = (stack) => {
|
|
963
|
+
const kept = stack.filter((pair) => {
|
|
947
964
|
const batch = pair.before.batch ?? pair.after.batch;
|
|
948
|
-
if (batch !== void 0) return !batch.some((item) => item.
|
|
949
|
-
return
|
|
950
|
-
})
|
|
951
|
-
|
|
965
|
+
if (batch !== void 0) return !batch.some((item) => item.path === path);
|
|
966
|
+
return pair.before.path !== path && pair.after.path !== path;
|
|
967
|
+
});
|
|
968
|
+
stack.length = 0;
|
|
969
|
+
stack.push(...kept);
|
|
970
|
+
};
|
|
971
|
+
clean(undoStack);
|
|
972
|
+
clean(redoStack);
|
|
952
973
|
}
|
|
953
974
|
/**
|
|
954
975
|
* Settle each listed entry against its live file. Existence is decided by the
|
|
@@ -956,82 +977,70 @@ function apply(ctx, config) {
|
|
|
956
977
|
* (Ctrl+Z recreates it and restores the entry), an unavailable one leaves the
|
|
957
978
|
* list and has its undo/redo records dropped, and externally changed content
|
|
958
979
|
* is adopted as the new baseline with its own checkpoint.
|
|
959
|
-
* @param sessionId - the session being listed.
|
|
960
|
-
* @param entries - the store's entries for one session.
|
|
980
|
+
* @param sessionId - the session being listed (its session-scoped view).
|
|
961
981
|
* @returns the listed entries plus whether an external change cleared redo.
|
|
962
982
|
*/
|
|
963
|
-
async function listWithState(sessionId
|
|
964
|
-
const byPath = /* @__PURE__ */ new Map();
|
|
965
|
-
for (const entry of entries) {
|
|
966
|
-
const group = byPath.get(entry.path);
|
|
967
|
-
if (group === void 0) byPath.set(entry.path, [entry]);
|
|
968
|
-
else group.push(entry);
|
|
969
|
-
}
|
|
983
|
+
async function listWithState(sessionId) {
|
|
970
984
|
const listed = [];
|
|
971
985
|
let redoCleared = false;
|
|
972
|
-
for (const
|
|
973
|
-
const
|
|
974
|
-
if (newest === void 0) continue;
|
|
975
|
-
const live = await liveStateOf(newest.path);
|
|
986
|
+
for (const entry of store.list(sessionId)) {
|
|
987
|
+
const live = await liveStateOf(entry.path);
|
|
976
988
|
if (live.kind === "deleted") {
|
|
977
|
-
store.remove(
|
|
989
|
+
store.remove(entry.path);
|
|
978
990
|
pushUndo(sessionId, {
|
|
979
|
-
id:
|
|
980
|
-
path:
|
|
981
|
-
entry
|
|
982
|
-
fileText:
|
|
991
|
+
id: entry.path,
|
|
992
|
+
path: entry.path,
|
|
993
|
+
entry,
|
|
994
|
+
fileText: entry.newText
|
|
983
995
|
}, {
|
|
984
|
-
id:
|
|
985
|
-
path:
|
|
996
|
+
id: entry.path,
|
|
997
|
+
path: entry.path,
|
|
986
998
|
entry: void 0,
|
|
987
999
|
fileText: void 0
|
|
988
1000
|
});
|
|
989
|
-
await persistSession(
|
|
1001
|
+
await persistSession();
|
|
990
1002
|
continue;
|
|
991
1003
|
}
|
|
992
1004
|
if (live.kind === "unavailable") {
|
|
993
|
-
store.remove(
|
|
994
|
-
purgeForEntry(
|
|
995
|
-
await persistSession(
|
|
1005
|
+
store.remove(entry.path);
|
|
1006
|
+
purgeForEntry(entry.path);
|
|
1007
|
+
await persistSession();
|
|
996
1008
|
continue;
|
|
997
1009
|
}
|
|
998
1010
|
const content = live.content;
|
|
999
|
-
let adopted =
|
|
1011
|
+
let adopted = entry.newText;
|
|
1000
1012
|
const hasContent = typeof content === "string";
|
|
1001
|
-
if (hasContent && content !==
|
|
1013
|
+
if (hasContent && content !== entry.newText) {
|
|
1002
1014
|
adopted = content;
|
|
1003
|
-
const beforeText =
|
|
1004
|
-
const redoWasPresent =
|
|
1005
|
-
store.update(
|
|
1015
|
+
const beforeText = entry.newText;
|
|
1016
|
+
const redoWasPresent = redoStack.length > 0;
|
|
1017
|
+
store.update(entry.path, { newText: content });
|
|
1006
1018
|
pushUndo(sessionId, {
|
|
1007
|
-
id:
|
|
1008
|
-
path:
|
|
1009
|
-
entry
|
|
1019
|
+
id: entry.path,
|
|
1020
|
+
path: entry.path,
|
|
1021
|
+
entry,
|
|
1010
1022
|
fileText: beforeText
|
|
1011
1023
|
}, {
|
|
1012
|
-
id:
|
|
1013
|
-
path:
|
|
1024
|
+
id: entry.path,
|
|
1025
|
+
path: entry.path,
|
|
1014
1026
|
entry: {
|
|
1015
|
-
...
|
|
1027
|
+
...entry,
|
|
1016
1028
|
newText: content,
|
|
1017
1029
|
updatedAt: Date.now()
|
|
1018
1030
|
},
|
|
1019
1031
|
fileText: content
|
|
1020
1032
|
});
|
|
1021
|
-
await persistSession(
|
|
1033
|
+
await persistSession();
|
|
1022
1034
|
if (redoWasPresent) redoCleared = true;
|
|
1023
1035
|
}
|
|
1024
1036
|
const state = {
|
|
1025
1037
|
missing: false,
|
|
1026
1038
|
diverged: hasContent ? content !== adopted : true
|
|
1027
1039
|
};
|
|
1028
|
-
|
|
1040
|
+
listed.push({
|
|
1029
1041
|
...entry,
|
|
1030
1042
|
newText: adopted,
|
|
1031
1043
|
...state
|
|
1032
|
-
} : {
|
|
1033
|
-
...entry,
|
|
1034
|
-
...state
|
|
1035
1044
|
});
|
|
1036
1045
|
}
|
|
1037
1046
|
return {
|
|
@@ -1083,18 +1092,15 @@ function apply(ctx, config) {
|
|
|
1083
1092
|
const policy = sandboxPolicyOf(sessionId);
|
|
1084
1093
|
return policy === void 0 ? ctx.fs.writeText(target, content, void 0, signal) : ctx.fs.writeText(target, content, void 0, signal, policy);
|
|
1085
1094
|
}
|
|
1086
|
-
const
|
|
1087
|
-
const
|
|
1095
|
+
const undoStack = [];
|
|
1096
|
+
const redoStack = [];
|
|
1088
1097
|
function pushUndo(sessionId, before, after) {
|
|
1089
|
-
|
|
1090
|
-
const stack = undoStacks.get(key) ?? [];
|
|
1091
|
-
stack.push({
|
|
1098
|
+
undoStack.push({
|
|
1092
1099
|
sessionId,
|
|
1093
1100
|
before,
|
|
1094
1101
|
after
|
|
1095
1102
|
});
|
|
1096
|
-
|
|
1097
|
-
redoStacks.delete(key);
|
|
1103
|
+
redoStack.length = 0;
|
|
1098
1104
|
}
|
|
1099
1105
|
/**
|
|
1100
1106
|
* Restore one snapshot (the before/after side of an undo pair). File writes
|
|
@@ -1115,116 +1121,26 @@ function apply(ctx, config) {
|
|
|
1115
1121
|
await writeRevert(resolved, state.fileText, sessionId, signal);
|
|
1116
1122
|
}
|
|
1117
1123
|
if (state.batch !== void 0) {
|
|
1118
|
-
for (const item of state.batch) if (item.entry !== void 0) store.restore(
|
|
1119
|
-
else store.remove(
|
|
1124
|
+
for (const item of state.batch) if (item.entry !== void 0) store.restore(item.entry);
|
|
1125
|
+
else store.remove(item.path);
|
|
1120
1126
|
return;
|
|
1121
1127
|
}
|
|
1122
|
-
if (state.entry !== void 0) store.restore(
|
|
1123
|
-
else store.remove(
|
|
1128
|
+
if (state.entry !== void 0) store.restore(state.entry);
|
|
1129
|
+
else store.remove(state.path);
|
|
1124
1130
|
}
|
|
1125
1131
|
/**
|
|
1126
|
-
*
|
|
1127
|
-
*
|
|
1128
|
-
*
|
|
1129
|
-
*
|
|
1130
|
-
* @param sessionId - the session to register.
|
|
1131
|
-
* @returns the owning workspace, or `undefined` when none accounts it.
|
|
1132
|
-
*/
|
|
1133
|
-
function registerSession(sessionId) {
|
|
1134
|
-
const workspace = workspaceOf(sessionId);
|
|
1135
|
-
if (workspace === void 0) return void 0;
|
|
1136
|
-
const key = String(workspace.id);
|
|
1137
|
-
const sessions = sessionsByWorkspace.get(key);
|
|
1138
|
-
if (sessions === void 0) sessionsByWorkspace.set(key, /* @__PURE__ */ new Set([String(sessionId)]));
|
|
1139
|
-
else sessions.add(String(sessionId));
|
|
1140
|
-
return workspace;
|
|
1141
|
-
}
|
|
1142
|
-
/**
|
|
1143
|
-
* Merge one workspace's persisted entries into the store, once per
|
|
1144
|
-
* workspace. Hydration is workspace-scoped: after a restart the current
|
|
1145
|
-
* session has a fresh id while the persisted entries live under their
|
|
1146
|
-
* original session ids in the same workspace file, so the whole workspace
|
|
1147
|
-
* is loaded and every persisted session is accounted. Concurrent callers
|
|
1148
|
-
* share the in-flight load, and folds arriving while the load runs stay
|
|
1149
|
-
* safe: `hydrate` never overwrites a live entry.
|
|
1150
|
-
* @param workspace - the workspace whose persisted state to merge.
|
|
1151
|
-
* @returns resolution after the workspace's persisted state is merged (or skipped).
|
|
1152
|
-
*/
|
|
1153
|
-
function ensureWorkspaceLoaded(workspace) {
|
|
1154
|
-
const key = String(workspace.id);
|
|
1155
|
-
if (loadedWorkspaces.has(key)) return Promise.resolve();
|
|
1156
|
-
const pending = loadingWorkspaces.get(key);
|
|
1157
|
-
if (pending !== void 0) return pending;
|
|
1158
|
-
const task = (async () => {
|
|
1159
|
-
try {
|
|
1160
|
-
const persisted = await persistence.loadWorkspace(key);
|
|
1161
|
-
const bySession = /* @__PURE__ */ new Map();
|
|
1162
|
-
for (const entry of persisted) {
|
|
1163
|
-
const sessionKey = String(entry.sessionId);
|
|
1164
|
-
const group = bySession.get(sessionKey);
|
|
1165
|
-
if (group === void 0) bySession.set(sessionKey, [entry]);
|
|
1166
|
-
else group.push(entry);
|
|
1167
|
-
}
|
|
1168
|
-
for (const [sessionKey, entries] of bySession) {
|
|
1169
|
-
const sessions = sessionsByWorkspace.get(key) ?? /* @__PURE__ */ new Set();
|
|
1170
|
-
sessions.add(sessionKey);
|
|
1171
|
-
sessionsByWorkspace.set(key, sessions);
|
|
1172
|
-
store.hydrate(SessionId(sessionKey), entries);
|
|
1173
|
-
}
|
|
1174
|
-
} catch (error) {
|
|
1175
|
-
ctx.logger.warn(`diff-approval: loading persisted state for workspace ${key} failed: ${errorMessage(error)}`);
|
|
1176
|
-
}
|
|
1177
|
-
loadedWorkspaces.add(key);
|
|
1178
|
-
loadingWorkspaces.delete(key);
|
|
1179
|
-
})();
|
|
1180
|
-
loadingWorkspaces.set(key, task);
|
|
1181
|
-
return task;
|
|
1182
|
-
}
|
|
1183
|
-
/**
|
|
1184
|
-
* Merge the session's workspace's persisted state into the store (a session
|
|
1185
|
-
* with no workspace is the memory-only edge and has nothing to load).
|
|
1186
|
-
* @param sessionId - the session to hydrate for.
|
|
1187
|
-
* @returns resolution after the workspace's persisted state is merged.
|
|
1188
|
-
*/
|
|
1189
|
-
function ensureLoaded(sessionId) {
|
|
1190
|
-
const workspace = registerSession(sessionId);
|
|
1191
|
-
if (workspace === void 0) return Promise.resolve();
|
|
1192
|
-
return ensureWorkspaceLoaded(workspace);
|
|
1193
|
-
}
|
|
1194
|
-
/**
|
|
1195
|
-
* All entries visible to one session: every registered session of its
|
|
1196
|
-
* workspace, merged oldest capture first. This is what makes an unhandled
|
|
1197
|
-
* change survive a restart — the new session lists the workspace's whole
|
|
1198
|
-
* pending set, its own live folds plus the earlier sessions' persisted
|
|
1199
|
-
* entries.
|
|
1200
|
-
* @param sessionId - the viewing session.
|
|
1201
|
-
* @returns the merged entries; a session with no workspace lists only itself.
|
|
1202
|
-
*/
|
|
1203
|
-
async function workspaceEntries(sessionId) {
|
|
1204
|
-
await ensureLoaded(sessionId);
|
|
1205
|
-
const workspace = workspaceOf(sessionId);
|
|
1206
|
-
if (workspace === void 0) return store.list(sessionId);
|
|
1207
|
-
const sessions = sessionsByWorkspace.get(String(workspace.id));
|
|
1208
|
-
if (sessions === void 0) return store.list(sessionId);
|
|
1209
|
-
const entries = [];
|
|
1210
|
-
for (const sessionKey of sessions) entries.push(...store.list(SessionId(sessionKey)));
|
|
1211
|
-
return entries.sort((left, right) => left.updatedAt - right.updatedAt);
|
|
1212
|
-
}
|
|
1213
|
-
/**
|
|
1214
|
-
* Mirror one session's entries to disk. A write fault logs a warning and
|
|
1215
|
-
* leaves the in-memory view intact: the review flow must not break on a
|
|
1216
|
-
* storage fault, and the next successful mutation rewrites the whole file.
|
|
1217
|
-
* @param sessionId - the session whose complete entry list to save.
|
|
1132
|
+
* Mirror the whole (globally-unique) entry set to disk. A write fault logs a
|
|
1133
|
+
* warning and leaves the in-memory view intact: the review flow must not
|
|
1134
|
+
* break on a storage fault, and the next successful mutation rewrites the
|
|
1135
|
+
* file.
|
|
1218
1136
|
* @returns resolution after the write settles (successful or logged).
|
|
1219
1137
|
*/
|
|
1220
|
-
async function persistSession(
|
|
1221
|
-
await ensureLoaded(
|
|
1222
|
-
const workspace = workspaceOf(sessionId);
|
|
1223
|
-
if (workspace === void 0) return;
|
|
1138
|
+
async function persistSession() {
|
|
1139
|
+
await ensureLoaded();
|
|
1224
1140
|
try {
|
|
1225
|
-
await persistence.save(
|
|
1141
|
+
await persistence.save(store.all());
|
|
1226
1142
|
} catch (error) {
|
|
1227
|
-
ctx.logger.warn(`diff-approval: persisting
|
|
1143
|
+
ctx.logger.warn(`diff-approval: persisting pending changes failed: ${errorMessage(error)}`);
|
|
1228
1144
|
}
|
|
1229
1145
|
}
|
|
1230
1146
|
ctx.on("tools/result", (exec, result) => {
|
|
@@ -1237,22 +1153,21 @@ function apply(ctx, config) {
|
|
|
1237
1153
|
if (outcome === void 0 || outcome.oldText === outcome.newText) return;
|
|
1238
1154
|
const sessionId = exec.agent.id;
|
|
1239
1155
|
const entry = {
|
|
1240
|
-
id:
|
|
1156
|
+
id: outcome.path,
|
|
1241
1157
|
sessionId,
|
|
1242
1158
|
...outcome,
|
|
1243
|
-
updatedAt: Date.now()
|
|
1159
|
+
updatedAt: Date.now(),
|
|
1160
|
+
sessionIds: [sessionId]
|
|
1244
1161
|
};
|
|
1245
|
-
(
|
|
1246
|
-
await ensureLoaded(sessionId);
|
|
1247
|
-
if (store.fold(entry)) await persistSession(sessionId);
|
|
1248
|
-
})();
|
|
1162
|
+
if (store.fold(entry)) persistSession();
|
|
1249
1163
|
});
|
|
1250
1164
|
const handle = async (endpoint, payload, signal) => {
|
|
1251
1165
|
switch (endpoint) {
|
|
1252
1166
|
case "list": {
|
|
1253
1167
|
const sessionId = sessionOf(payload);
|
|
1254
1168
|
if (sessionId === void 0) return rpcError("sessionId must be a non-empty string");
|
|
1255
|
-
|
|
1169
|
+
await ensureLoaded();
|
|
1170
|
+
const { files, redoCleared } = await listWithState(sessionId);
|
|
1256
1171
|
return {
|
|
1257
1172
|
ok: true,
|
|
1258
1173
|
value: {
|
|
@@ -1265,25 +1180,28 @@ function apply(ctx, config) {
|
|
|
1265
1180
|
case "keep": {
|
|
1266
1181
|
const target = targetOf(payload);
|
|
1267
1182
|
if (target === void 0) return rpcError("sessionId and id must be non-empty strings");
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1183
|
+
let entry = store.get(target.id);
|
|
1184
|
+
if (entry === void 0) {
|
|
1185
|
+
await ensureLoaded();
|
|
1186
|
+
entry = store.get(target.id);
|
|
1187
|
+
if (entry === void 0) return {
|
|
1188
|
+
ok: true,
|
|
1189
|
+
value: { outcome: "missing" }
|
|
1190
|
+
};
|
|
1191
|
+
}
|
|
1192
|
+
store.remove(target.id);
|
|
1275
1193
|
pushUndo(target.sessionId, {
|
|
1276
|
-
id: entry.
|
|
1194
|
+
id: entry.path,
|
|
1277
1195
|
path: entry.path,
|
|
1278
1196
|
entry,
|
|
1279
1197
|
fileText: void 0
|
|
1280
1198
|
}, {
|
|
1281
|
-
id: entry.
|
|
1199
|
+
id: entry.path,
|
|
1282
1200
|
path: entry.path,
|
|
1283
1201
|
entry: void 0,
|
|
1284
1202
|
fileText: void 0
|
|
1285
1203
|
});
|
|
1286
|
-
await persistSession(
|
|
1204
|
+
await persistSession();
|
|
1287
1205
|
return {
|
|
1288
1206
|
ok: true,
|
|
1289
1207
|
value: { outcome: "kept" }
|
|
@@ -1292,12 +1210,15 @@ function apply(ctx, config) {
|
|
|
1292
1210
|
case "revert": {
|
|
1293
1211
|
const target = targetOf(payload);
|
|
1294
1212
|
if (target === void 0) return rpcError("sessionId and id must be non-empty strings");
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1213
|
+
let entry = store.get(target.id);
|
|
1214
|
+
if (entry === void 0) {
|
|
1215
|
+
await ensureLoaded();
|
|
1216
|
+
entry = store.get(target.id);
|
|
1217
|
+
if (entry === void 0) return {
|
|
1218
|
+
ok: true,
|
|
1219
|
+
value: { outcome: "missing" }
|
|
1220
|
+
};
|
|
1221
|
+
}
|
|
1301
1222
|
let undo;
|
|
1302
1223
|
try {
|
|
1303
1224
|
const resolved = await ctx.fs.resolve(entry.path, { signal });
|
|
@@ -1308,13 +1229,13 @@ function apply(ctx, config) {
|
|
|
1308
1229
|
await writeRevert(resolved, content, target.sessionId, signal);
|
|
1309
1230
|
undo = {
|
|
1310
1231
|
before: {
|
|
1311
|
-
id: entry.
|
|
1232
|
+
id: entry.path,
|
|
1312
1233
|
path: entry.path,
|
|
1313
1234
|
entry,
|
|
1314
1235
|
fileText: preWrite
|
|
1315
1236
|
},
|
|
1316
1237
|
after: {
|
|
1317
|
-
id: entry.
|
|
1238
|
+
id: entry.path,
|
|
1318
1239
|
path: entry.path,
|
|
1319
1240
|
entry: void 0,
|
|
1320
1241
|
fileText: content
|
|
@@ -1324,9 +1245,9 @@ function apply(ctx, config) {
|
|
|
1324
1245
|
} catch (error) {
|
|
1325
1246
|
return rpcError(`revert failed: ${errorMessage(error)}`);
|
|
1326
1247
|
}
|
|
1327
|
-
store.remove(target.
|
|
1248
|
+
store.remove(target.id);
|
|
1328
1249
|
if (undo !== void 0) pushUndo(target.sessionId, undo.before, undo.after);
|
|
1329
|
-
await persistSession(
|
|
1250
|
+
await persistSession();
|
|
1330
1251
|
return {
|
|
1331
1252
|
ok: true,
|
|
1332
1253
|
value: { outcome: "reverted" }
|
|
@@ -1335,15 +1256,15 @@ function apply(ctx, config) {
|
|
|
1335
1256
|
case "block-keep": {
|
|
1336
1257
|
const blockTarget = blockTargetOf(payload);
|
|
1337
1258
|
if (blockTarget === void 0) return rpcError("sessionId, id, and block must be valid");
|
|
1338
|
-
await ensureLoaded(
|
|
1339
|
-
const entry = store.get(blockTarget.
|
|
1259
|
+
await ensureLoaded();
|
|
1260
|
+
const entry = store.get(blockTarget.id);
|
|
1340
1261
|
if (entry === void 0) return {
|
|
1341
1262
|
ok: true,
|
|
1342
1263
|
value: { outcome: "missing" }
|
|
1343
1264
|
};
|
|
1344
1265
|
const accepted = contentRangeOf(entry.newText, blockTarget.block.newStart, blockTarget.block.newEnd);
|
|
1345
1266
|
const updatedOld = replaceContentLines(entry.oldText, blockTarget.block.oldStart, blockTarget.block.oldEnd, accepted);
|
|
1346
|
-
store.update(blockTarget.
|
|
1267
|
+
store.update(blockTarget.id, { oldText: updatedOld });
|
|
1347
1268
|
const afterEntry = {
|
|
1348
1269
|
...entry,
|
|
1349
1270
|
oldText: updatedOld,
|
|
@@ -1360,7 +1281,7 @@ function apply(ctx, config) {
|
|
|
1360
1281
|
entry: afterEntry,
|
|
1361
1282
|
fileText: void 0
|
|
1362
1283
|
});
|
|
1363
|
-
await persistSession(
|
|
1284
|
+
await persistSession();
|
|
1364
1285
|
return {
|
|
1365
1286
|
ok: true,
|
|
1366
1287
|
value: updatedOld === entry.newText ? {
|
|
@@ -1372,8 +1293,8 @@ function apply(ctx, config) {
|
|
|
1372
1293
|
case "block-revert": {
|
|
1373
1294
|
const blockTarget = blockTargetOf(payload);
|
|
1374
1295
|
if (blockTarget === void 0) return rpcError("sessionId, id, and block must be valid");
|
|
1375
|
-
await ensureLoaded(
|
|
1376
|
-
const entry = store.get(blockTarget.
|
|
1296
|
+
await ensureLoaded();
|
|
1297
|
+
const entry = store.get(blockTarget.id);
|
|
1377
1298
|
if (entry === void 0) return {
|
|
1378
1299
|
ok: true,
|
|
1379
1300
|
value: { outcome: "missing" }
|
|
@@ -1381,7 +1302,7 @@ function apply(ctx, config) {
|
|
|
1381
1302
|
const restored = contentRangeOf(entry.oldText, blockTarget.block.oldStart, blockTarget.block.oldEnd);
|
|
1382
1303
|
const updatedNew = replaceContentLines(entry.newText, blockTarget.block.newStart, blockTarget.block.newEnd, restored);
|
|
1383
1304
|
const content = reencodeEol(updatedNew, detectEol(entry.newText));
|
|
1384
|
-
store.update(blockTarget.
|
|
1305
|
+
store.update(blockTarget.id, { newText: content });
|
|
1385
1306
|
const afterEntry = {
|
|
1386
1307
|
...entry,
|
|
1387
1308
|
newText: content,
|
|
@@ -1413,7 +1334,7 @@ function apply(ctx, config) {
|
|
|
1413
1334
|
return rpcError(`block revert failed: ${errorMessage(error)}`);
|
|
1414
1335
|
}
|
|
1415
1336
|
if (undo !== void 0) pushUndo(blockTarget.sessionId, undo.before, undo.after);
|
|
1416
|
-
await persistSession(
|
|
1337
|
+
await persistSession();
|
|
1417
1338
|
return {
|
|
1418
1339
|
ok: true,
|
|
1419
1340
|
value: normalizeEol(updatedNew) === normalizeEol(entry.oldText) ? {
|
|
@@ -1425,9 +1346,7 @@ function apply(ctx, config) {
|
|
|
1425
1346
|
case "undo": {
|
|
1426
1347
|
const sessionId = sessionOf(payload);
|
|
1427
1348
|
if (sessionId === void 0) return rpcError("sessionId must be a non-empty string");
|
|
1428
|
-
const
|
|
1429
|
-
const stack = undoStacks.get(key) ?? [];
|
|
1430
|
-
const pair = stack.pop();
|
|
1349
|
+
const pair = undoStack.pop();
|
|
1431
1350
|
if (pair === void 0) return {
|
|
1432
1351
|
ok: true,
|
|
1433
1352
|
value: { outcome: "nothing" }
|
|
@@ -1435,13 +1354,11 @@ function apply(ctx, config) {
|
|
|
1435
1354
|
try {
|
|
1436
1355
|
await restoreState(sessionId, pair.before, pair.after, signal);
|
|
1437
1356
|
} catch (error) {
|
|
1438
|
-
|
|
1357
|
+
undoStack.push(pair);
|
|
1439
1358
|
return rpcError(`undo failed: ${errorMessage(error)}`);
|
|
1440
1359
|
}
|
|
1441
|
-
const redoStack = redoStacks.get(key) ?? [];
|
|
1442
1360
|
redoStack.push(pair);
|
|
1443
|
-
|
|
1444
|
-
await persistSession(sessionId);
|
|
1361
|
+
await persistSession();
|
|
1445
1362
|
return {
|
|
1446
1363
|
ok: true,
|
|
1447
1364
|
value: {
|
|
@@ -1453,9 +1370,7 @@ function apply(ctx, config) {
|
|
|
1453
1370
|
case "redo": {
|
|
1454
1371
|
const sessionId = sessionOf(payload);
|
|
1455
1372
|
if (sessionId === void 0) return rpcError("sessionId must be a non-empty string");
|
|
1456
|
-
const
|
|
1457
|
-
const stack = redoStacks.get(key) ?? [];
|
|
1458
|
-
const pair = stack.pop();
|
|
1373
|
+
const pair = redoStack.pop();
|
|
1459
1374
|
if (pair === void 0) return {
|
|
1460
1375
|
ok: true,
|
|
1461
1376
|
value: { outcome: "nothing" }
|
|
@@ -1463,13 +1378,11 @@ function apply(ctx, config) {
|
|
|
1463
1378
|
try {
|
|
1464
1379
|
await restoreState(sessionId, pair.after, pair.before, signal);
|
|
1465
1380
|
} catch (error) {
|
|
1466
|
-
|
|
1381
|
+
redoStack.push(pair);
|
|
1467
1382
|
return rpcError(`redo failed: ${errorMessage(error)}`);
|
|
1468
1383
|
}
|
|
1469
|
-
const undoStack = undoStacks.get(key) ?? [];
|
|
1470
1384
|
undoStack.push(pair);
|
|
1471
|
-
|
|
1472
|
-
await persistSession(sessionId);
|
|
1385
|
+
await persistSession();
|
|
1473
1386
|
return {
|
|
1474
1387
|
ok: true,
|
|
1475
1388
|
value: {
|
|
@@ -1509,19 +1422,20 @@ function apply(ctx, config) {
|
|
|
1509
1422
|
} catch (error) {
|
|
1510
1423
|
return rpcError(`import failed: ${errorMessage(error)}`);
|
|
1511
1424
|
}
|
|
1512
|
-
await ensureLoaded(
|
|
1425
|
+
await ensureLoaded();
|
|
1513
1426
|
const before = new Map(store.list(sessionId).map((entry) => [entry.path, entry]));
|
|
1514
1427
|
let imported = 0;
|
|
1515
1428
|
const changedPaths = [];
|
|
1516
1429
|
for (const change of changes) {
|
|
1517
1430
|
const entry = {
|
|
1518
|
-
id:
|
|
1431
|
+
id: change.path,
|
|
1519
1432
|
sessionId,
|
|
1520
1433
|
path: change.path,
|
|
1521
1434
|
kind: change.kind,
|
|
1522
1435
|
oldText: change.oldText,
|
|
1523
1436
|
newText: change.newText,
|
|
1524
|
-
updatedAt: Date.now()
|
|
1437
|
+
updatedAt: Date.now(),
|
|
1438
|
+
sessionIds: [sessionId]
|
|
1525
1439
|
};
|
|
1526
1440
|
if (store.fold(entry)) {
|
|
1527
1441
|
imported += 1;
|
|
@@ -1529,7 +1443,7 @@ function apply(ctx, config) {
|
|
|
1529
1443
|
}
|
|
1530
1444
|
}
|
|
1531
1445
|
if (imported > 0) {
|
|
1532
|
-
await persistSession(
|
|
1446
|
+
await persistSession();
|
|
1533
1447
|
const after = new Map(store.list(sessionId).map((entry) => [entry.path, entry]));
|
|
1534
1448
|
const batchBefore = [];
|
|
1535
1449
|
const batchAfter = [];
|
|
@@ -1575,8 +1489,8 @@ function apply(ctx, config) {
|
|
|
1575
1489
|
case "open": {
|
|
1576
1490
|
const target = openTargetOf(payload);
|
|
1577
1491
|
if (target === void 0) return rpcError("sessionId, id, and action must be valid");
|
|
1578
|
-
await ensureLoaded(
|
|
1579
|
-
const entry = store.get(target.
|
|
1492
|
+
await ensureLoaded();
|
|
1493
|
+
const entry = store.get(target.id);
|
|
1580
1494
|
if (entry === void 0) return {
|
|
1581
1495
|
ok: true,
|
|
1582
1496
|
value: { outcome: "missing" }
|