dsh-diff-approval 0.13.1 → 0.14.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/client.js +157 -114
- package/lib/index.js +388 -441
- package/lib/types/client/PendingPanel.d.ts +9 -0
- package/lib/types/client/reference.d.ts +9 -4
- 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))
|
|
914
|
+
await ensureLoaded();
|
|
915
|
+
if (store.fold(entry)) 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
|
-
|
|
1001
|
+
persistSession();
|
|
990
1002
|
continue;
|
|
991
1003
|
}
|
|
992
1004
|
if (live.kind === "unavailable") {
|
|
993
|
-
store.remove(
|
|
994
|
-
purgeForEntry(
|
|
995
|
-
|
|
1005
|
+
store.remove(entry.path);
|
|
1006
|
+
purgeForEntry(entry.path);
|
|
1007
|
+
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
|
-
|
|
1033
|
+
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,59 @@ 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(
|
|
1124
|
-
}
|
|
1125
|
-
/**
|
|
1126
|
-
* Record one session in its workspace's account. Every path that touches a
|
|
1127
|
-
* session registers it, so the list merges all of a workspace's sessions'
|
|
1128
|
-
* entries — a fresh session after restart still sees the workspace's
|
|
1129
|
-
* persisted pending changes.
|
|
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;
|
|
1128
|
+
if (state.entry !== void 0) store.restore(state.entry);
|
|
1129
|
+
else store.remove(state.path);
|
|
1141
1130
|
}
|
|
1142
1131
|
/**
|
|
1143
|
-
*
|
|
1144
|
-
*
|
|
1145
|
-
*
|
|
1146
|
-
*
|
|
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
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1138
|
+
const PERSIST_THROTTLE_MS = 1e3;
|
|
1139
|
+
let persistDirty = false;
|
|
1140
|
+
let persistScheduled = false;
|
|
1141
|
+
let lastPersistAt = 0;
|
|
1142
|
+
let persistTimer;
|
|
1143
|
+
/** Actually write the (dirty) store to disk; one coalesced write. */
|
|
1144
|
+
async function flushPersist() {
|
|
1145
|
+
if (!persistDirty) return;
|
|
1146
|
+
persistDirty = false;
|
|
1147
|
+
lastPersistAt = Date.now();
|
|
1224
1148
|
try {
|
|
1225
|
-
await
|
|
1149
|
+
await ensureLoaded();
|
|
1150
|
+
await persistence.save(store.all());
|
|
1226
1151
|
} catch (error) {
|
|
1227
|
-
ctx.logger.warn(`diff-approval: persisting
|
|
1152
|
+
ctx.logger.warn(`diff-approval: persisting pending changes failed: ${errorMessage(error)}`);
|
|
1153
|
+
}
|
|
1154
|
+
}
|
|
1155
|
+
/** Mark the store dirty and schedule one throttled, coalesced write. Pass
|
|
1156
|
+
* `force` to write immediately (user actions need durable, immediate results). */
|
|
1157
|
+
function persistSession(force = false) {
|
|
1158
|
+
persistDirty = true;
|
|
1159
|
+
if (force) {
|
|
1160
|
+
if (persistScheduled) {
|
|
1161
|
+
clearTimeout(persistTimer);
|
|
1162
|
+
persistScheduled = false;
|
|
1163
|
+
}
|
|
1164
|
+
flushPersist();
|
|
1165
|
+
return;
|
|
1166
|
+
}
|
|
1167
|
+
if (persistScheduled) return;
|
|
1168
|
+
const delay = PERSIST_THROTTLE_MS - (Date.now() - lastPersistAt);
|
|
1169
|
+
if (delay <= 0) flushPersist();
|
|
1170
|
+
else {
|
|
1171
|
+
persistScheduled = true;
|
|
1172
|
+
persistTimer = setTimeout(() => {
|
|
1173
|
+
persistScheduled = false;
|
|
1174
|
+
flushPersist();
|
|
1175
|
+
}, delay);
|
|
1176
|
+
persistTimer.unref?.();
|
|
1228
1177
|
}
|
|
1229
1178
|
}
|
|
1230
1179
|
ctx.on("tools/result", (exec, result) => {
|
|
@@ -1237,22 +1186,21 @@ function apply(ctx, config) {
|
|
|
1237
1186
|
if (outcome === void 0 || outcome.oldText === outcome.newText) return;
|
|
1238
1187
|
const sessionId = exec.agent.id;
|
|
1239
1188
|
const entry = {
|
|
1240
|
-
id:
|
|
1189
|
+
id: outcome.path,
|
|
1241
1190
|
sessionId,
|
|
1242
1191
|
...outcome,
|
|
1243
|
-
updatedAt: Date.now()
|
|
1192
|
+
updatedAt: Date.now(),
|
|
1193
|
+
sessionIds: [sessionId]
|
|
1244
1194
|
};
|
|
1245
|
-
(
|
|
1246
|
-
await ensureLoaded(sessionId);
|
|
1247
|
-
if (store.fold(entry)) await persistSession(sessionId);
|
|
1248
|
-
})();
|
|
1195
|
+
if (store.fold(entry)) persistSession();
|
|
1249
1196
|
});
|
|
1250
1197
|
const handle = async (endpoint, payload, signal) => {
|
|
1251
1198
|
switch (endpoint) {
|
|
1252
1199
|
case "list": {
|
|
1253
1200
|
const sessionId = sessionOf(payload);
|
|
1254
1201
|
if (sessionId === void 0) return rpcError("sessionId must be a non-empty string");
|
|
1255
|
-
|
|
1202
|
+
await ensureLoaded();
|
|
1203
|
+
const { files, redoCleared } = await listWithState(sessionId);
|
|
1256
1204
|
return {
|
|
1257
1205
|
ok: true,
|
|
1258
1206
|
value: {
|
|
@@ -1265,25 +1213,28 @@ function apply(ctx, config) {
|
|
|
1265
1213
|
case "keep": {
|
|
1266
1214
|
const target = targetOf(payload);
|
|
1267
1215
|
if (target === void 0) return rpcError("sessionId and id must be non-empty strings");
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1216
|
+
let entry = store.get(target.id);
|
|
1217
|
+
if (entry === void 0) {
|
|
1218
|
+
await ensureLoaded();
|
|
1219
|
+
entry = store.get(target.id);
|
|
1220
|
+
if (entry === void 0) return {
|
|
1221
|
+
ok: true,
|
|
1222
|
+
value: { outcome: "missing" }
|
|
1223
|
+
};
|
|
1224
|
+
}
|
|
1225
|
+
store.remove(target.id);
|
|
1275
1226
|
pushUndo(target.sessionId, {
|
|
1276
|
-
id: entry.
|
|
1227
|
+
id: entry.path,
|
|
1277
1228
|
path: entry.path,
|
|
1278
1229
|
entry,
|
|
1279
1230
|
fileText: void 0
|
|
1280
1231
|
}, {
|
|
1281
|
-
id: entry.
|
|
1232
|
+
id: entry.path,
|
|
1282
1233
|
path: entry.path,
|
|
1283
1234
|
entry: void 0,
|
|
1284
1235
|
fileText: void 0
|
|
1285
1236
|
});
|
|
1286
|
-
|
|
1237
|
+
persistSession(true);
|
|
1287
1238
|
return {
|
|
1288
1239
|
ok: true,
|
|
1289
1240
|
value: { outcome: "kept" }
|
|
@@ -1292,12 +1243,15 @@ function apply(ctx, config) {
|
|
|
1292
1243
|
case "revert": {
|
|
1293
1244
|
const target = targetOf(payload);
|
|
1294
1245
|
if (target === void 0) return rpcError("sessionId and id must be non-empty strings");
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1246
|
+
let entry = store.get(target.id);
|
|
1247
|
+
if (entry === void 0) {
|
|
1248
|
+
await ensureLoaded();
|
|
1249
|
+
entry = store.get(target.id);
|
|
1250
|
+
if (entry === void 0) return {
|
|
1251
|
+
ok: true,
|
|
1252
|
+
value: { outcome: "missing" }
|
|
1253
|
+
};
|
|
1254
|
+
}
|
|
1301
1255
|
let undo;
|
|
1302
1256
|
try {
|
|
1303
1257
|
const resolved = await ctx.fs.resolve(entry.path, { signal });
|
|
@@ -1308,13 +1262,13 @@ function apply(ctx, config) {
|
|
|
1308
1262
|
await writeRevert(resolved, content, target.sessionId, signal);
|
|
1309
1263
|
undo = {
|
|
1310
1264
|
before: {
|
|
1311
|
-
id: entry.
|
|
1265
|
+
id: entry.path,
|
|
1312
1266
|
path: entry.path,
|
|
1313
1267
|
entry,
|
|
1314
1268
|
fileText: preWrite
|
|
1315
1269
|
},
|
|
1316
1270
|
after: {
|
|
1317
|
-
id: entry.
|
|
1271
|
+
id: entry.path,
|
|
1318
1272
|
path: entry.path,
|
|
1319
1273
|
entry: void 0,
|
|
1320
1274
|
fileText: content
|
|
@@ -1324,9 +1278,9 @@ function apply(ctx, config) {
|
|
|
1324
1278
|
} catch (error) {
|
|
1325
1279
|
return rpcError(`revert failed: ${errorMessage(error)}`);
|
|
1326
1280
|
}
|
|
1327
|
-
store.remove(target.
|
|
1281
|
+
store.remove(target.id);
|
|
1328
1282
|
if (undo !== void 0) pushUndo(target.sessionId, undo.before, undo.after);
|
|
1329
|
-
|
|
1283
|
+
persistSession(true);
|
|
1330
1284
|
return {
|
|
1331
1285
|
ok: true,
|
|
1332
1286
|
value: { outcome: "reverted" }
|
|
@@ -1335,15 +1289,15 @@ function apply(ctx, config) {
|
|
|
1335
1289
|
case "block-keep": {
|
|
1336
1290
|
const blockTarget = blockTargetOf(payload);
|
|
1337
1291
|
if (blockTarget === void 0) return rpcError("sessionId, id, and block must be valid");
|
|
1338
|
-
await ensureLoaded(
|
|
1339
|
-
const entry = store.get(blockTarget.
|
|
1292
|
+
await ensureLoaded();
|
|
1293
|
+
const entry = store.get(blockTarget.id);
|
|
1340
1294
|
if (entry === void 0) return {
|
|
1341
1295
|
ok: true,
|
|
1342
1296
|
value: { outcome: "missing" }
|
|
1343
1297
|
};
|
|
1344
1298
|
const accepted = contentRangeOf(entry.newText, blockTarget.block.newStart, blockTarget.block.newEnd);
|
|
1345
1299
|
const updatedOld = replaceContentLines(entry.oldText, blockTarget.block.oldStart, blockTarget.block.oldEnd, accepted);
|
|
1346
|
-
store.update(blockTarget.
|
|
1300
|
+
store.update(blockTarget.id, { oldText: updatedOld });
|
|
1347
1301
|
const afterEntry = {
|
|
1348
1302
|
...entry,
|
|
1349
1303
|
oldText: updatedOld,
|
|
@@ -1360,7 +1314,7 @@ function apply(ctx, config) {
|
|
|
1360
1314
|
entry: afterEntry,
|
|
1361
1315
|
fileText: void 0
|
|
1362
1316
|
});
|
|
1363
|
-
|
|
1317
|
+
persistSession();
|
|
1364
1318
|
return {
|
|
1365
1319
|
ok: true,
|
|
1366
1320
|
value: updatedOld === entry.newText ? {
|
|
@@ -1372,8 +1326,8 @@ function apply(ctx, config) {
|
|
|
1372
1326
|
case "block-revert": {
|
|
1373
1327
|
const blockTarget = blockTargetOf(payload);
|
|
1374
1328
|
if (blockTarget === void 0) return rpcError("sessionId, id, and block must be valid");
|
|
1375
|
-
await ensureLoaded(
|
|
1376
|
-
const entry = store.get(blockTarget.
|
|
1329
|
+
await ensureLoaded();
|
|
1330
|
+
const entry = store.get(blockTarget.id);
|
|
1377
1331
|
if (entry === void 0) return {
|
|
1378
1332
|
ok: true,
|
|
1379
1333
|
value: { outcome: "missing" }
|
|
@@ -1381,7 +1335,7 @@ function apply(ctx, config) {
|
|
|
1381
1335
|
const restored = contentRangeOf(entry.oldText, blockTarget.block.oldStart, blockTarget.block.oldEnd);
|
|
1382
1336
|
const updatedNew = replaceContentLines(entry.newText, blockTarget.block.newStart, blockTarget.block.newEnd, restored);
|
|
1383
1337
|
const content = reencodeEol(updatedNew, detectEol(entry.newText));
|
|
1384
|
-
store.update(blockTarget.
|
|
1338
|
+
store.update(blockTarget.id, { newText: content });
|
|
1385
1339
|
const afterEntry = {
|
|
1386
1340
|
...entry,
|
|
1387
1341
|
newText: content,
|
|
@@ -1413,7 +1367,7 @@ function apply(ctx, config) {
|
|
|
1413
1367
|
return rpcError(`block revert failed: ${errorMessage(error)}`);
|
|
1414
1368
|
}
|
|
1415
1369
|
if (undo !== void 0) pushUndo(blockTarget.sessionId, undo.before, undo.after);
|
|
1416
|
-
|
|
1370
|
+
persistSession();
|
|
1417
1371
|
return {
|
|
1418
1372
|
ok: true,
|
|
1419
1373
|
value: normalizeEol(updatedNew) === normalizeEol(entry.oldText) ? {
|
|
@@ -1425,9 +1379,7 @@ function apply(ctx, config) {
|
|
|
1425
1379
|
case "undo": {
|
|
1426
1380
|
const sessionId = sessionOf(payload);
|
|
1427
1381
|
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();
|
|
1382
|
+
const pair = undoStack.pop();
|
|
1431
1383
|
if (pair === void 0) return {
|
|
1432
1384
|
ok: true,
|
|
1433
1385
|
value: { outcome: "nothing" }
|
|
@@ -1435,13 +1387,11 @@ function apply(ctx, config) {
|
|
|
1435
1387
|
try {
|
|
1436
1388
|
await restoreState(sessionId, pair.before, pair.after, signal);
|
|
1437
1389
|
} catch (error) {
|
|
1438
|
-
|
|
1390
|
+
undoStack.push(pair);
|
|
1439
1391
|
return rpcError(`undo failed: ${errorMessage(error)}`);
|
|
1440
1392
|
}
|
|
1441
|
-
const redoStack = redoStacks.get(key) ?? [];
|
|
1442
1393
|
redoStack.push(pair);
|
|
1443
|
-
|
|
1444
|
-
await persistSession(sessionId);
|
|
1394
|
+
persistSession(true);
|
|
1445
1395
|
return {
|
|
1446
1396
|
ok: true,
|
|
1447
1397
|
value: {
|
|
@@ -1453,9 +1403,7 @@ function apply(ctx, config) {
|
|
|
1453
1403
|
case "redo": {
|
|
1454
1404
|
const sessionId = sessionOf(payload);
|
|
1455
1405
|
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();
|
|
1406
|
+
const pair = redoStack.pop();
|
|
1459
1407
|
if (pair === void 0) return {
|
|
1460
1408
|
ok: true,
|
|
1461
1409
|
value: { outcome: "nothing" }
|
|
@@ -1463,13 +1411,11 @@ function apply(ctx, config) {
|
|
|
1463
1411
|
try {
|
|
1464
1412
|
await restoreState(sessionId, pair.after, pair.before, signal);
|
|
1465
1413
|
} catch (error) {
|
|
1466
|
-
|
|
1414
|
+
redoStack.push(pair);
|
|
1467
1415
|
return rpcError(`redo failed: ${errorMessage(error)}`);
|
|
1468
1416
|
}
|
|
1469
|
-
const undoStack = undoStacks.get(key) ?? [];
|
|
1470
1417
|
undoStack.push(pair);
|
|
1471
|
-
|
|
1472
|
-
await persistSession(sessionId);
|
|
1418
|
+
persistSession(true);
|
|
1473
1419
|
return {
|
|
1474
1420
|
ok: true,
|
|
1475
1421
|
value: {
|
|
@@ -1509,19 +1455,20 @@ function apply(ctx, config) {
|
|
|
1509
1455
|
} catch (error) {
|
|
1510
1456
|
return rpcError(`import failed: ${errorMessage(error)}`);
|
|
1511
1457
|
}
|
|
1512
|
-
await ensureLoaded(
|
|
1458
|
+
await ensureLoaded();
|
|
1513
1459
|
const before = new Map(store.list(sessionId).map((entry) => [entry.path, entry]));
|
|
1514
1460
|
let imported = 0;
|
|
1515
1461
|
const changedPaths = [];
|
|
1516
1462
|
for (const change of changes) {
|
|
1517
1463
|
const entry = {
|
|
1518
|
-
id:
|
|
1464
|
+
id: change.path,
|
|
1519
1465
|
sessionId,
|
|
1520
1466
|
path: change.path,
|
|
1521
1467
|
kind: change.kind,
|
|
1522
1468
|
oldText: change.oldText,
|
|
1523
1469
|
newText: change.newText,
|
|
1524
|
-
updatedAt: Date.now()
|
|
1470
|
+
updatedAt: Date.now(),
|
|
1471
|
+
sessionIds: [sessionId]
|
|
1525
1472
|
};
|
|
1526
1473
|
if (store.fold(entry)) {
|
|
1527
1474
|
imported += 1;
|
|
@@ -1529,7 +1476,7 @@ function apply(ctx, config) {
|
|
|
1529
1476
|
}
|
|
1530
1477
|
}
|
|
1531
1478
|
if (imported > 0) {
|
|
1532
|
-
|
|
1479
|
+
persistSession();
|
|
1533
1480
|
const after = new Map(store.list(sessionId).map((entry) => [entry.path, entry]));
|
|
1534
1481
|
const batchBefore = [];
|
|
1535
1482
|
const batchAfter = [];
|
|
@@ -1575,8 +1522,8 @@ function apply(ctx, config) {
|
|
|
1575
1522
|
case "open": {
|
|
1576
1523
|
const target = openTargetOf(payload);
|
|
1577
1524
|
if (target === void 0) return rpcError("sessionId, id, and action must be valid");
|
|
1578
|
-
await ensureLoaded(
|
|
1579
|
-
const entry = store.get(target.
|
|
1525
|
+
await ensureLoaded();
|
|
1526
|
+
const entry = store.get(target.id);
|
|
1580
1527
|
if (entry === void 0) return {
|
|
1581
1528
|
ok: true,
|
|
1582
1529
|
value: { outcome: "missing" }
|