@cjhyy/code-shell-core 0.8.8 → 0.8.10
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/dist/automation/scheduler.js +49 -0
- package/dist/automation/store.d.ts +1 -1
- package/dist/automation/store.js +184 -10
- package/dist/cli/agent-server-stdio.js +7 -0
- package/dist/credentials/store.d.ts +14 -0
- package/dist/credentials/store.js +245 -42
- package/dist/engine/engine.js +81 -27
- package/dist/engine/file-history-hook.js +24 -5
- package/dist/engine/run-finalize.js +8 -6
- package/dist/engine/run-session-open.js +1 -1
- package/dist/engine/run-setup.d.ts +4 -0
- package/dist/engine/run-setup.js +5 -1
- package/dist/engine/run-tooling.js +7 -1
- package/dist/engine/run-types.d.ts +33 -0
- package/dist/engine/run-types.js +21 -0
- package/dist/engine/run-workspace.js +11 -5
- package/dist/engine/turn-loop.js +9 -8
- package/dist/goal/lifecycle.d.ts +2 -0
- package/dist/goal/lifecycle.js +56 -33
- package/dist/index.d.ts +2 -3
- package/dist/index.internal.d.ts +1 -0
- package/dist/index.internal.js +1 -0
- package/dist/index.js +2 -2
- package/dist/links/cli.d.ts +2 -0
- package/dist/links/cli.js +11 -4
- package/dist/model-catalog/index.js +19 -4
- package/dist/model-catalog/save-entry.js +122 -61
- package/dist/model-catalog/types.js +27 -23
- package/dist/panel-apps/installer.js +27 -14
- package/dist/panel-apps/manifest.d.ts +11 -11
- package/dist/panel-apps/manifest.js +3 -1
- package/dist/panel-apps/registry.js +60 -12
- package/dist/plugins/installedPlugins.d.ts +4 -0
- package/dist/plugins/installedPlugins.js +70 -30
- package/dist/plugins/installer/types.d.ts +12 -12
- package/dist/plugins/installer/update.js +37 -38
- package/dist/plugins/knownMarketplaces.d.ts +7 -3
- package/dist/plugins/knownMarketplaces.js +127 -23
- package/dist/plugins/pluginCatalog.js +18 -4
- package/dist/plugins/pluginHookApproval.js +56 -60
- package/dist/plugins/pluginMcpApproval.js +50 -52
- package/dist/profile/catalog-store.js +39 -4
- package/dist/profile/catalog.js +55 -15
- package/dist/profile/store.js +51 -21
- package/dist/prompt/composer.d.ts +8 -0
- package/dist/prompt/composer.js +8 -0
- package/dist/protocol/chat-session-manager.d.ts +9 -0
- package/dist/protocol/chat-session-manager.js +22 -0
- package/dist/protocol/chat-session.d.ts +5 -0
- package/dist/protocol/chat-session.js +1 -0
- package/dist/protocol/server.d.ts +2 -0
- package/dist/protocol/server.js +112 -29
- package/dist/protocol/types.d.ts +20 -0
- package/dist/run/FileRunStore.d.ts +2 -0
- package/dist/run/FileRunStore.js +153 -18
- package/dist/run/Heartbeat.js +63 -4
- package/dist/services/auto-dream.js +39 -17
- package/dist/services/session-memory.js +107 -8
- package/dist/session/file-history.d.ts +63 -2
- package/dist/session/file-history.js +593 -86
- package/dist/session/session-manager.d.ts +4 -1
- package/dist/session/session-manager.js +94 -47
- package/dist/session/transcript.js +33 -3
- package/dist/session/undo-target.d.ts +15 -6
- package/dist/session/undo-target.js +26 -9
- package/dist/settings/manager.d.ts +22 -3
- package/dist/settings/manager.js +185 -50
- package/dist/settings/schema.d.ts +3 -3
- package/dist/sources/adapters/local-files.js +49 -4
- package/dist/sources/catalog.js +64 -18
- package/dist/sources/types.d.ts +3 -3
- package/dist/sources/types.js +7 -4
- package/dist/themes/installer.js +192 -28
- package/dist/tool-system/builtin/add-marketplace.js +21 -1
- package/dist/tool-system/builtin/cron.d.ts +2 -1
- package/dist/tool-system/builtin/cron.js +20 -6
- package/dist/tool-system/builtin/index.js +48 -1
- package/dist/tool-system/builtin/install-capability.d.ts +52 -0
- package/dist/tool-system/builtin/install-capability.js +1057 -0
- package/dist/tool-system/builtin/skill.js +3 -1
- package/dist/tool-system/executor.js +1 -0
- package/dist/tool-system/path-policy.js +25 -33
- package/dist/tool-system/registry.js +5 -0
- package/dist/utils/file-mutex.d.ts +2 -0
- package/dist/utils/file-mutex.js +29 -4
- package/package.json +2 -1
|
@@ -4,12 +4,15 @@
|
|
|
4
4
|
* Stores snapshots in the session directory under file-history/.
|
|
5
5
|
* Supports restoring files to a previous state.
|
|
6
6
|
*/
|
|
7
|
-
import { mkdirSync, existsSync, readFileSync,
|
|
8
|
-
import { join, resolve } from "node:path";
|
|
9
|
-
import { createHash } from "node:crypto";
|
|
10
|
-
import { earliestSnapshotsPerFile } from "./undo-target.js";
|
|
7
|
+
import { mkdirSync, existsSync, readFileSync, copyFileSync, chmodSync, lstatSync, realpathSync, rmSync, } from "node:fs";
|
|
8
|
+
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
9
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
10
|
+
import { earliestSnapshotsPerFile, latestRedoTargets } from "./undo-target.js";
|
|
11
|
+
import { acquireFileLock, writeFileAtomic } from "../utils/file-mutex.js";
|
|
12
|
+
const LEGACY_TURN = Number.MIN_SAFE_INTEGER;
|
|
11
13
|
export class FileHistory {
|
|
12
14
|
historyDir;
|
|
15
|
+
lockDepth = 0;
|
|
13
16
|
snapshots = [];
|
|
14
17
|
/**
|
|
15
18
|
* Redo material for undone turns, kept SEPARATE from `snapshots` so it never
|
|
@@ -19,8 +22,10 @@ export class FileHistory {
|
|
|
19
22
|
/** Per-turn "this file was created" markers (see CreatedMarker). */
|
|
20
23
|
created = [];
|
|
21
24
|
constructor(sessionDir) {
|
|
22
|
-
this.historyDir =
|
|
23
|
-
mkdirSync(this.historyDir, { recursive: true });
|
|
25
|
+
this.historyDir = resolve(sessionDir, "file-history");
|
|
26
|
+
mkdirSync(this.historyDir, { recursive: true, mode: 0o700 });
|
|
27
|
+
if (process.platform !== "win32")
|
|
28
|
+
chmodSync(this.historyDir, 0o700);
|
|
24
29
|
}
|
|
25
30
|
/**
|
|
26
31
|
* Save a snapshot of a file before it is modified.
|
|
@@ -31,10 +36,19 @@ export class FileHistory {
|
|
|
31
36
|
* latestTurnUndoTargets). Omit it for callers without turn context.
|
|
32
37
|
*/
|
|
33
38
|
saveSnapshot(filePath, turnSeq) {
|
|
39
|
+
return this.withCurrentIndex(() => this.saveSnapshotUnlocked(filePath, turnSeq));
|
|
40
|
+
}
|
|
41
|
+
saveSnapshotUnlocked(filePath, turnSeq) {
|
|
42
|
+
if (turnSeq !== undefined && !isFiniteTurn(turnSeq))
|
|
43
|
+
return null;
|
|
34
44
|
const absPath = resolve(filePath);
|
|
35
45
|
if (!existsSync(absPath))
|
|
36
46
|
return null;
|
|
37
47
|
try {
|
|
48
|
+
const sourceInfo = lstatSync(absPath);
|
|
49
|
+
if (!sourceInfo.isFile() || sourceInfo.isSymbolicLink())
|
|
50
|
+
return null;
|
|
51
|
+
const realPath = realpathSync(absPath);
|
|
38
52
|
const content = readFileSync(absPath);
|
|
39
53
|
const hash = createHash("md5").update(content).digest("hex");
|
|
40
54
|
// Check if we already have this exact (path, content) for this turn. Dedup
|
|
@@ -43,7 +57,7 @@ export class FileHistory {
|
|
|
43
57
|
// miss it. Within a turn, re-snapshotting unchanged content is a no-op.
|
|
44
58
|
const existing = this.snapshots.find((s) => s.filePath === absPath && s.hash === hash && s.turnSeq === turnSeq);
|
|
45
59
|
if (existing)
|
|
46
|
-
return existing;
|
|
60
|
+
return { ...existing };
|
|
47
61
|
// Create backup. The filename must uniquely identify (path, content): a
|
|
48
62
|
// bare 100-char tail of the path can collide for two different files
|
|
49
63
|
// whose tails coincide, and with the same ms timestamp the second
|
|
@@ -56,18 +70,30 @@ export class FileHistory {
|
|
|
56
70
|
const safeName = absPath.replace(/[/\\:]/g, "_").slice(-80);
|
|
57
71
|
const backupPath = join(this.historyDir, `${timestamp}_${pathHash}_${hash.slice(0, 8)}_${safeName}`);
|
|
58
72
|
copyFileSync(absPath, backupPath);
|
|
73
|
+
if (process.platform !== "win32")
|
|
74
|
+
chmodSync(backupPath, 0o600);
|
|
59
75
|
const snapshot = {
|
|
60
76
|
filePath: absPath,
|
|
61
77
|
timestamp,
|
|
62
78
|
backupPath,
|
|
63
79
|
hash,
|
|
64
80
|
size: content.length,
|
|
81
|
+
mode: sourceInfo.mode & 0o7777,
|
|
82
|
+
realPath,
|
|
65
83
|
...(turnSeq === undefined ? {} : { turnSeq }),
|
|
66
84
|
};
|
|
67
85
|
this.snapshots.push(snapshot);
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
86
|
+
if (!this.saveIndex()) {
|
|
87
|
+
this.snapshots.pop();
|
|
88
|
+
try {
|
|
89
|
+
rmSync(backupPath, { force: true });
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
// The unreferenced private backup is harmless; preserve the write failure result.
|
|
93
|
+
}
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
return { ...snapshot };
|
|
71
97
|
}
|
|
72
98
|
catch {
|
|
73
99
|
return null;
|
|
@@ -82,16 +108,101 @@ export class FileHistory {
|
|
|
82
108
|
* re-armed) — only fresh turns create new markers.
|
|
83
109
|
*/
|
|
84
110
|
recordCreated(filePath, turnSeq) {
|
|
111
|
+
this.withCurrentIndex(() => this.recordCreatedUnlocked(filePath, turnSeq));
|
|
112
|
+
}
|
|
113
|
+
recordCreatedUnlocked(filePath, turnSeq) {
|
|
114
|
+
if (!isFiniteTurn(turnSeq))
|
|
115
|
+
return;
|
|
85
116
|
const absPath = resolve(filePath);
|
|
86
117
|
const existing = this.created.find((c) => c.filePath === absPath && c.turnSeq === turnSeq);
|
|
87
118
|
if (existing)
|
|
88
119
|
return;
|
|
89
|
-
this.
|
|
90
|
-
|
|
120
|
+
const realPath = this.canonicalizeNewPath(absPath);
|
|
121
|
+
const marker = {
|
|
122
|
+
filePath: absPath,
|
|
123
|
+
turnSeq,
|
|
124
|
+
...(realPath ? { realPath } : {}),
|
|
125
|
+
};
|
|
126
|
+
this.created.push(marker);
|
|
127
|
+
if (!this.saveIndex())
|
|
128
|
+
this.created.pop();
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Capture a create marker before the tool runs, but do not persist it yet.
|
|
132
|
+
* `lstat` (rather than `existsSync`) deliberately treats dangling symlinks as
|
|
133
|
+
* existing: a failed/no-follow Write must never turn such a path into a
|
|
134
|
+
* create marker that a later undo would remove.
|
|
135
|
+
*/
|
|
136
|
+
prepareCreated(filePath, turnSeq) {
|
|
137
|
+
if (!isFiniteTurn(turnSeq))
|
|
138
|
+
return null;
|
|
139
|
+
const absPath = resolve(filePath);
|
|
140
|
+
try {
|
|
141
|
+
lstatSync(absPath);
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
catch (err) {
|
|
145
|
+
if (err.code !== "ENOENT")
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
const realPath = this.canonicalizeNewPath(absPath);
|
|
149
|
+
return {
|
|
150
|
+
filePath: absPath,
|
|
151
|
+
turnSeq,
|
|
152
|
+
...(realPath ? { realPath } : {}),
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
/** Persist a marker previously returned by prepareCreated after tool success. */
|
|
156
|
+
commitCreated(marker) {
|
|
157
|
+
this.withCurrentIndex(() => this.commitCreatedUnlocked(marker));
|
|
158
|
+
}
|
|
159
|
+
commitCreatedUnlocked(marker) {
|
|
160
|
+
if (!isCreatedMarker(marker))
|
|
161
|
+
return;
|
|
162
|
+
const existing = this.created.find((candidate) => candidate.filePath === marker.filePath && candidate.turnSeq === marker.turnSeq);
|
|
163
|
+
if (existing)
|
|
164
|
+
return;
|
|
165
|
+
this.created.push({ ...marker, undone: undefined });
|
|
166
|
+
if (!this.saveIndex())
|
|
167
|
+
this.created.pop();
|
|
91
168
|
}
|
|
92
169
|
/** Redo material captured by past undos (see RedoRecord). Returns a copy. */
|
|
93
170
|
getRedoRecords() {
|
|
94
|
-
return
|
|
171
|
+
return this.redoRecords.map((record) => ({ ...record }));
|
|
172
|
+
}
|
|
173
|
+
/** Latest redo records after accounting for create-only live turns. */
|
|
174
|
+
getLatestRedoRecords() {
|
|
175
|
+
const records = latestRedoTargets(this.redoRecords, this.snapshots);
|
|
176
|
+
if (records.length === 0)
|
|
177
|
+
return [];
|
|
178
|
+
const redoTurn = records[0].turnSeq;
|
|
179
|
+
const snapshots = this.snapshots.filter((snapshot) => this.turnKey(snapshot.turnSeq) === redoTurn);
|
|
180
|
+
const markers = this.created.filter((marker) => marker.turnSeq === redoTurn);
|
|
181
|
+
if (snapshots.length + markers.length === 0 ||
|
|
182
|
+
snapshots.some((snapshot) => !snapshot.undone) ||
|
|
183
|
+
markers.some((marker) => !marker.undone)) {
|
|
184
|
+
return [];
|
|
185
|
+
}
|
|
186
|
+
if (this.created.some((marker) => !marker.undone && marker.turnSeq > redoTurn)) {
|
|
187
|
+
return [];
|
|
188
|
+
}
|
|
189
|
+
return records.map((record) => ({ ...record }));
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* Authoritative latest-turn plan, including turns that only created files.
|
|
193
|
+
* Keeping this decision in FileHistory prevents UI callers from accidentally
|
|
194
|
+
* selecting an older snapshot-only turn.
|
|
195
|
+
*/
|
|
196
|
+
getLatestTurnUndoPlan() {
|
|
197
|
+
const plan = this.latestTurnUndoPlanInternal();
|
|
198
|
+
if (!plan)
|
|
199
|
+
return null;
|
|
200
|
+
return {
|
|
201
|
+
turnSeq: plan.turnSeq,
|
|
202
|
+
snapshots: plan.snapshots.map((snapshot) => ({ ...snapshot })),
|
|
203
|
+
createdPaths: [...plan.createdPaths],
|
|
204
|
+
filePaths: [...plan.filePaths],
|
|
205
|
+
};
|
|
95
206
|
}
|
|
96
207
|
/**
|
|
97
208
|
* Get all snapshots for a specific file.
|
|
@@ -100,32 +211,28 @@ export class FileHistory {
|
|
|
100
211
|
const absPath = resolve(filePath);
|
|
101
212
|
return this.snapshots
|
|
102
213
|
.filter((s) => s.filePath === absPath)
|
|
214
|
+
.map((snapshot) => ({ ...snapshot }))
|
|
103
215
|
.sort((a, b) => b.timestamp - a.timestamp);
|
|
104
216
|
}
|
|
105
217
|
/**
|
|
106
218
|
* Restore a file to a specific snapshot.
|
|
107
219
|
*/
|
|
108
220
|
restore(snapshot) {
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
this.saveSnapshot(snapshot.filePath);
|
|
114
|
-
copyFileSync(snapshot.backupPath, snapshot.filePath);
|
|
115
|
-
return true;
|
|
116
|
-
}
|
|
117
|
-
catch {
|
|
118
|
-
return false;
|
|
119
|
-
}
|
|
221
|
+
return this.withCurrentIndex(() => {
|
|
222
|
+
const authoritative = this.findSnapshot(snapshot);
|
|
223
|
+
return authoritative ? this.restoreSnapshot(authoritative, true) : false;
|
|
224
|
+
});
|
|
120
225
|
}
|
|
121
226
|
/**
|
|
122
227
|
* Restore a file to its most recent snapshot.
|
|
123
228
|
*/
|
|
124
229
|
restoreLatest(filePath) {
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
230
|
+
return this.withCurrentIndex(() => {
|
|
231
|
+
const snapshots = this.getSnapshots(filePath);
|
|
232
|
+
if (snapshots.length === 0)
|
|
233
|
+
return false;
|
|
234
|
+
return this.restore(snapshots[0]);
|
|
235
|
+
});
|
|
129
236
|
}
|
|
130
237
|
/**
|
|
131
238
|
* Get all tracked files.
|
|
@@ -141,8 +248,10 @@ export class FileHistory {
|
|
|
141
248
|
* hide which files reverted.
|
|
142
249
|
*/
|
|
143
250
|
restoreAllToEarliest() {
|
|
144
|
-
|
|
145
|
-
|
|
251
|
+
return this.withCurrentIndex(() => {
|
|
252
|
+
const targets = earliestSnapshotsPerFile(this.snapshots);
|
|
253
|
+
return targets.map((snap) => ({ filePath: snap.filePath, ok: this.restore(snap) }));
|
|
254
|
+
});
|
|
146
255
|
}
|
|
147
256
|
/**
|
|
148
257
|
* Capture the file's CURRENT on-disk content into a fresh redo backup and
|
|
@@ -152,16 +261,44 @@ export class FileHistory {
|
|
|
152
261
|
*/
|
|
153
262
|
captureRedoBackup(filePath, turnSeq, existedBefore) {
|
|
154
263
|
try {
|
|
155
|
-
if (
|
|
264
|
+
if (this.redoRecords.some((record) => record.filePath === filePath && record.turnSeq === turnSeq)) {
|
|
265
|
+
return true;
|
|
266
|
+
}
|
|
267
|
+
if (!existsSync(filePath)) {
|
|
268
|
+
const snapshot = this.snapshots.find((candidate) => candidate.filePath === filePath && this.turnKey(candidate.turnSeq) === turnSeq);
|
|
269
|
+
const backupPath = join(this.historyDir, `redo_absent_${Date.now()}_${randomUUID()}`);
|
|
270
|
+
writeFileAtomic(backupPath, "", 0o600);
|
|
271
|
+
this.redoRecords.push({
|
|
272
|
+
filePath,
|
|
273
|
+
turnSeq,
|
|
274
|
+
backupPath,
|
|
275
|
+
existedBefore,
|
|
276
|
+
existedAfter: false,
|
|
277
|
+
...(snapshot?.realPath ? { realPath: snapshot.realPath } : {}),
|
|
278
|
+
});
|
|
279
|
+
return true;
|
|
280
|
+
}
|
|
281
|
+
const sourceInfo = lstatSync(filePath);
|
|
282
|
+
if (!sourceInfo.isFile() || sourceInfo.isSymbolicLink())
|
|
156
283
|
return false;
|
|
157
284
|
const content = readFileSync(filePath);
|
|
158
285
|
const hash = createHash("md5").update(content).digest("hex");
|
|
159
286
|
const timestamp = Date.now();
|
|
160
287
|
const pathHash = createHash("md5").update(filePath).digest("hex").slice(0, 8);
|
|
161
288
|
const safeName = filePath.replace(/[/\\:]/g, "_").slice(-80);
|
|
162
|
-
const backupPath = join(this.historyDir, `redo_${timestamp}_${pathHash}_${hash.slice(0, 8)}_${safeName}`);
|
|
289
|
+
const backupPath = join(this.historyDir, `redo_${timestamp}_${pathHash}_${hash.slice(0, 8)}_${randomUUID()}_${safeName}`);
|
|
163
290
|
copyFileSync(filePath, backupPath);
|
|
164
|
-
|
|
291
|
+
if (process.platform !== "win32")
|
|
292
|
+
chmodSync(backupPath, 0o600);
|
|
293
|
+
this.redoRecords.push({
|
|
294
|
+
filePath,
|
|
295
|
+
turnSeq,
|
|
296
|
+
backupPath,
|
|
297
|
+
existedBefore,
|
|
298
|
+
existedAfter: true,
|
|
299
|
+
mode: sourceInfo.mode & 0o7777,
|
|
300
|
+
realPath: realpathSync(filePath),
|
|
301
|
+
});
|
|
165
302
|
return true;
|
|
166
303
|
}
|
|
167
304
|
catch {
|
|
@@ -181,20 +318,76 @@ export class FileHistory {
|
|
|
181
318
|
* latestTurnUndoTargets so preview and restore agree. Returns per-file results.
|
|
182
319
|
*/
|
|
183
320
|
undoLatestTurn(targets) {
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
const
|
|
188
|
-
if (
|
|
321
|
+
return this.withCurrentIndex(() => this.undoLatestTurnUnlocked(targets));
|
|
322
|
+
}
|
|
323
|
+
undoLatestTurnUnlocked(targets) {
|
|
324
|
+
const plan = this.latestTurnUndoPlanInternal();
|
|
325
|
+
if (!plan)
|
|
189
326
|
return [];
|
|
327
|
+
if (!this.isExactSnapshotSet(targets, plan.snapshots)) {
|
|
328
|
+
return plan.filePaths.map((filePath) => ({ filePath, ok: false }));
|
|
329
|
+
}
|
|
330
|
+
const undoneTurn = plan.turnKey;
|
|
190
331
|
const results = [];
|
|
191
332
|
// Files created this turn: delete them (after stashing redo material). Skip
|
|
192
333
|
// files that are also a restore target — created wins (the file didn't exist
|
|
193
334
|
// pre-turn, so its "pre-turn state" is "absent" → delete, not restore).
|
|
194
335
|
const createdThisTurn = this.created.filter((c) => !c.undone && c.turnSeq === undoneTurn);
|
|
195
336
|
const createdPaths = new Set(createdThisTurn.map((c) => c.filePath));
|
|
337
|
+
// Validate every destination against the location recorded BEFORE the
|
|
338
|
+
// creating/editing tool ran. Redo backups describe the current location,
|
|
339
|
+
// so comparing only against them is tautological and would miss a parent
|
|
340
|
+
// directory swapped to a symlink after the edit.
|
|
341
|
+
const safeToCapture = plan.filePaths.every((filePath) => {
|
|
342
|
+
if (!this.isSafeDestination(filePath))
|
|
343
|
+
return false;
|
|
344
|
+
const marker = createdThisTurn.find((candidate) => candidate.filePath === filePath);
|
|
345
|
+
const snapshot = plan.snapshots.find((candidate) => candidate.filePath === filePath);
|
|
346
|
+
const expectedRealPath = marker?.realPath ?? snapshot?.realPath;
|
|
347
|
+
if (!this.matchesRecordedLocation(filePath, expectedRealPath))
|
|
348
|
+
return false;
|
|
349
|
+
return !snapshot || this.isSafeBackup(snapshot.backupPath);
|
|
350
|
+
});
|
|
351
|
+
if (!safeToCapture) {
|
|
352
|
+
return plan.filePaths.map((filePath) => ({ filePath, ok: false }));
|
|
353
|
+
}
|
|
354
|
+
// Capture every post-turn state before mutating any file. A missing file is
|
|
355
|
+
// a legitimate post-turn state (delete), represented by an absence marker.
|
|
356
|
+
// If any capture fails, leave all working files untouched and keep the turn
|
|
357
|
+
// live so the user can retry.
|
|
358
|
+
const captured = plan.filePaths.map((filePath) => this.captureRedoBackup(filePath, undoneTurn, !createdPaths.has(filePath)));
|
|
359
|
+
if (captured.some((ok) => !ok)) {
|
|
360
|
+
this.discardRedoTurn(undoneTurn);
|
|
361
|
+
this.saveIndex();
|
|
362
|
+
return plan.filePaths.map((filePath) => ({ filePath, ok: false }));
|
|
363
|
+
}
|
|
364
|
+
const redoForTurn = new Map(this.redoRecords
|
|
365
|
+
.filter((record) => record.turnSeq === undoneTurn)
|
|
366
|
+
.map((record) => [record.filePath, record]));
|
|
367
|
+
const canApply = plan.filePaths.every((filePath) => {
|
|
368
|
+
const redo = redoForTurn.get(filePath);
|
|
369
|
+
if (!redo || !this.isSafeBackup(redo.backupPath))
|
|
370
|
+
return false;
|
|
371
|
+
if (!this.isSafeDestination(filePath))
|
|
372
|
+
return false;
|
|
373
|
+
if (!this.matchesRecordedLocation(filePath, redo.realPath))
|
|
374
|
+
return false;
|
|
375
|
+
const snapshot = plan.snapshots.find((candidate) => candidate.filePath === filePath);
|
|
376
|
+
return !snapshot || this.isSafeBackup(snapshot.backupPath);
|
|
377
|
+
});
|
|
378
|
+
if (!canApply) {
|
|
379
|
+
this.discardRedoTurn(undoneTurn);
|
|
380
|
+
this.saveIndex();
|
|
381
|
+
return plan.filePaths.map((filePath) => ({ filePath, ok: false }));
|
|
382
|
+
}
|
|
383
|
+
// Commit the complete recovery plan before touching working files. If the
|
|
384
|
+
// process crashes during a restore, the next load still has the original
|
|
385
|
+
// post-turn material and can safely retry the idempotent operation.
|
|
386
|
+
if (!this.saveIndex()) {
|
|
387
|
+
this.discardRedoTurn(undoneTurn);
|
|
388
|
+
return plan.filePaths.map((filePath) => ({ filePath, ok: false }));
|
|
389
|
+
}
|
|
196
390
|
for (const c of createdThisTurn) {
|
|
197
|
-
this.captureRedoBackup(c.filePath, undoneTurn, false);
|
|
198
391
|
let ok = true;
|
|
199
392
|
try {
|
|
200
393
|
if (existsSync(c.filePath))
|
|
@@ -207,35 +400,37 @@ export class FileHistory {
|
|
|
207
400
|
}
|
|
208
401
|
// Modified (pre-existing) files: stash the current result as redo material,
|
|
209
402
|
// then restore the pre-turn content.
|
|
210
|
-
for (const snap of
|
|
403
|
+
for (const snap of plan.snapshots) {
|
|
211
404
|
if (createdPaths.has(snap.filePath))
|
|
212
405
|
continue;
|
|
213
|
-
|
|
214
|
-
|
|
406
|
+
results.push({ filePath: snap.filePath, ok: this.restoreSnapshot(snap, false) });
|
|
407
|
+
}
|
|
408
|
+
if (results.some((result) => !result.ok)) {
|
|
409
|
+
this.saveIndex();
|
|
410
|
+
return results;
|
|
215
411
|
}
|
|
216
412
|
// Mark the turn undone (snapshots + created markers) — keep them on disk so
|
|
217
413
|
// redo has material and the turn is re-applyable.
|
|
218
414
|
for (const s of this.snapshots) {
|
|
219
|
-
if (s.turnSeq === undoneTurn)
|
|
415
|
+
if (this.turnKey(s.turnSeq) === undoneTurn)
|
|
220
416
|
s.undone = true;
|
|
221
417
|
}
|
|
222
418
|
for (const c of this.created) {
|
|
223
419
|
if (c.turnSeq === undoneTurn)
|
|
224
420
|
c.undone = true;
|
|
225
421
|
}
|
|
226
|
-
this.saveIndex()
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
max = c.turnSeq;
|
|
422
|
+
if (!this.saveIndex()) {
|
|
423
|
+
for (const s of this.snapshots) {
|
|
424
|
+
if (this.turnKey(s.turnSeq) === undoneTurn)
|
|
425
|
+
s.undone = false;
|
|
426
|
+
}
|
|
427
|
+
for (const c of this.created) {
|
|
428
|
+
if (c.turnSeq === undoneTurn)
|
|
429
|
+
c.undone = false;
|
|
430
|
+
}
|
|
431
|
+
return results.map((result) => ({ ...result, ok: false }));
|
|
237
432
|
}
|
|
238
|
-
return
|
|
433
|
+
return results;
|
|
239
434
|
}
|
|
240
435
|
/**
|
|
241
436
|
* Turn-level redo: re-apply a previously undone turn. For each redo target,
|
|
@@ -247,14 +442,40 @@ export class FileHistory {
|
|
|
247
442
|
* latest still-undone turn). Returns per-file results.
|
|
248
443
|
*/
|
|
249
444
|
redoLatestTurn(redoTargets) {
|
|
445
|
+
return this.withCurrentIndex(() => this.redoLatestTurnUnlocked(redoTargets));
|
|
446
|
+
}
|
|
447
|
+
redoLatestTurnUnlocked(redoTargets) {
|
|
250
448
|
if (redoTargets.length === 0)
|
|
251
449
|
return [];
|
|
252
|
-
const
|
|
253
|
-
const
|
|
450
|
+
const expected = this.getLatestRedoRecords();
|
|
451
|
+
const authoritative = redoTargets.map((record) => this.findRedoRecord(record));
|
|
452
|
+
const expectedBackups = new Set(expected.map((record) => record.backupPath));
|
|
453
|
+
const suppliedBackups = new Set(authoritative.flatMap((record) => (record ? [record.backupPath] : [])));
|
|
454
|
+
const exactCurrentSet = authoritative.every((record) => record !== undefined) &&
|
|
455
|
+
suppliedBackups.size === authoritative.length &&
|
|
456
|
+
suppliedBackups.size === expectedBackups.size &&
|
|
457
|
+
[...suppliedBackups].every((backupPath) => expectedBackups.has(backupPath));
|
|
458
|
+
// Treat caller-provided records as untrusted values. Besides protecting the
|
|
459
|
+
// destination path, this prevents a stale preview from consuming a newer
|
|
460
|
+
// turn's only redo material.
|
|
461
|
+
if (!exactCurrentSet) {
|
|
462
|
+
return redoTargets.map((record) => ({ filePath: record.filePath, ok: false }));
|
|
463
|
+
}
|
|
464
|
+
const turn = authoritative[0].turnSeq;
|
|
465
|
+
const results = authoritative.map((rec) => {
|
|
254
466
|
let ok = false;
|
|
255
467
|
try {
|
|
256
|
-
if (
|
|
257
|
-
|
|
468
|
+
if (this.isSafeBackup(rec.backupPath) &&
|
|
469
|
+
this.isSafeDestination(rec.filePath) &&
|
|
470
|
+
this.matchesRecordedLocation(rec.filePath, rec.realPath)) {
|
|
471
|
+
if (rec.existedAfter === false) {
|
|
472
|
+
if (existsSync(rec.filePath))
|
|
473
|
+
rmSync(rec.filePath, { force: true });
|
|
474
|
+
}
|
|
475
|
+
else {
|
|
476
|
+
copyFileSync(rec.backupPath, rec.filePath);
|
|
477
|
+
this.restoreMode(rec.filePath, rec.mode);
|
|
478
|
+
}
|
|
258
479
|
ok = true;
|
|
259
480
|
}
|
|
260
481
|
}
|
|
@@ -263,31 +484,48 @@ export class FileHistory {
|
|
|
263
484
|
}
|
|
264
485
|
return { filePath: rec.filePath, ok };
|
|
265
486
|
});
|
|
266
|
-
//
|
|
487
|
+
// A partial filesystem failure must not consume redo history. Successful
|
|
488
|
+
// copies are safe to repeat when the user retries after fixing the failed
|
|
489
|
+
// path, while deleting the records here would make recovery impossible.
|
|
490
|
+
if (results.some((result) => !result.ok)) {
|
|
491
|
+
return results;
|
|
492
|
+
}
|
|
493
|
+
// Un-mark the turn and remove redo references in the index first. Backup
|
|
494
|
+
// files are deleted only after that commit succeeds; deleting them first
|
|
495
|
+
// creates a crash window where the durable index points at missing redo.
|
|
496
|
+
const previousRedoRecords = this.redoRecords;
|
|
267
497
|
for (const s of this.snapshots) {
|
|
268
|
-
if (s.turnSeq === turn)
|
|
498
|
+
if (this.turnKey(s.turnSeq) === turn)
|
|
269
499
|
s.undone = false;
|
|
270
500
|
}
|
|
271
501
|
for (const c of this.created) {
|
|
272
502
|
if (c.turnSeq === turn)
|
|
273
503
|
c.undone = false;
|
|
274
504
|
}
|
|
275
|
-
|
|
276
|
-
const
|
|
277
|
-
this.redoRecords = this.redoRecords.filter((
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
}
|
|
283
|
-
catch {
|
|
284
|
-
// best-effort cleanup
|
|
285
|
-
}
|
|
286
|
-
return false;
|
|
505
|
+
const consumedBackups = new Set(authoritative.map((record) => record.backupPath));
|
|
506
|
+
const consumedRecords = this.redoRecords.filter((record) => consumedBackups.has(record.backupPath) || record.turnSeq === turn);
|
|
507
|
+
this.redoRecords = this.redoRecords.filter((record) => !consumedBackups.has(record.backupPath) && record.turnSeq !== turn);
|
|
508
|
+
if (!this.saveIndex()) {
|
|
509
|
+
for (const s of this.snapshots) {
|
|
510
|
+
if (this.turnKey(s.turnSeq) === turn)
|
|
511
|
+
s.undone = true;
|
|
287
512
|
}
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
513
|
+
for (const c of this.created) {
|
|
514
|
+
if (c.turnSeq === turn)
|
|
515
|
+
c.undone = true;
|
|
516
|
+
}
|
|
517
|
+
this.redoRecords = previousRedoRecords;
|
|
518
|
+
return results.map((result) => ({ ...result, ok: false }));
|
|
519
|
+
}
|
|
520
|
+
for (const record of consumedRecords) {
|
|
521
|
+
try {
|
|
522
|
+
if (this.isSafeBackup(record.backupPath))
|
|
523
|
+
rmSync(record.backupPath, { force: true });
|
|
524
|
+
}
|
|
525
|
+
catch {
|
|
526
|
+
// The index no longer references this file; cleanup remains best effort.
|
|
527
|
+
}
|
|
528
|
+
}
|
|
291
529
|
return results;
|
|
292
530
|
}
|
|
293
531
|
/**
|
|
@@ -296,7 +534,7 @@ export class FileHistory {
|
|
|
296
534
|
* Returns a copy so callers can't mutate the internal list.
|
|
297
535
|
*/
|
|
298
536
|
getAllSnapshots() {
|
|
299
|
-
return
|
|
537
|
+
return this.snapshots.map((snapshot) => ({ ...snapshot }));
|
|
300
538
|
}
|
|
301
539
|
saveIndex() {
|
|
302
540
|
try {
|
|
@@ -306,33 +544,302 @@ export class FileHistory {
|
|
|
306
544
|
redoRecords: this.redoRecords,
|
|
307
545
|
created: this.created,
|
|
308
546
|
};
|
|
309
|
-
|
|
547
|
+
writeFileAtomic(indexPath, `${JSON.stringify(index, null, 2)}\n`, 0o600);
|
|
548
|
+
return true;
|
|
310
549
|
}
|
|
311
550
|
catch {
|
|
312
|
-
|
|
551
|
+
return false;
|
|
313
552
|
}
|
|
314
553
|
}
|
|
315
554
|
static loadFromDir(sessionDir) {
|
|
316
555
|
const history = new FileHistory(sessionDir);
|
|
317
|
-
|
|
556
|
+
history.reloadIndex(false);
|
|
557
|
+
return history;
|
|
558
|
+
}
|
|
559
|
+
/** Serialize every read-modify-write and refresh after taking the lock. */
|
|
560
|
+
withCurrentIndex(operation) {
|
|
561
|
+
if (this.lockDepth > 0)
|
|
562
|
+
return operation();
|
|
563
|
+
const indexPath = join(this.historyDir, "index.json");
|
|
564
|
+
const release = acquireFileLock(indexPath);
|
|
565
|
+
this.lockDepth = 1;
|
|
566
|
+
try {
|
|
567
|
+
this.reloadIndex(true);
|
|
568
|
+
return operation();
|
|
569
|
+
}
|
|
570
|
+
finally {
|
|
571
|
+
this.lockDepth = 0;
|
|
572
|
+
release();
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
reloadIndex(throwOnInvalid) {
|
|
576
|
+
const indexPath = join(this.historyDir, "index.json");
|
|
577
|
+
this.snapshots = [];
|
|
578
|
+
this.redoRecords = [];
|
|
579
|
+
this.created = [];
|
|
318
580
|
if (existsSync(indexPath)) {
|
|
319
581
|
try {
|
|
320
582
|
const parsed = JSON.parse(readFileSync(indexPath, "utf-8"));
|
|
321
583
|
if (Array.isArray(parsed)) {
|
|
322
584
|
// Legacy v1: bare FileSnapshot[]. No redo/created material existed yet.
|
|
323
|
-
|
|
585
|
+
this.snapshots = parsed.filter((value) => this.isSnapshot(value));
|
|
324
586
|
}
|
|
325
587
|
else {
|
|
326
588
|
const index = parsed;
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
589
|
+
this.snapshots = Array.isArray(index.snapshots)
|
|
590
|
+
? index.snapshots.filter((value) => this.isSnapshot(value))
|
|
591
|
+
: [];
|
|
592
|
+
this.redoRecords = Array.isArray(index.redoRecords)
|
|
593
|
+
? index.redoRecords.filter((value) => this.isRedoRecord(value))
|
|
594
|
+
: [];
|
|
595
|
+
this.created = Array.isArray(index.created)
|
|
596
|
+
? index.created.filter(isCreatedMarker)
|
|
597
|
+
: [];
|
|
330
598
|
}
|
|
331
599
|
}
|
|
600
|
+
catch (err) {
|
|
601
|
+
if (throwOnInvalid)
|
|
602
|
+
throw err;
|
|
603
|
+
// Read-only load keeps the historical recovery behavior. A later
|
|
604
|
+
// mutation reloads under lock and fails closed instead of overwriting
|
|
605
|
+
// the malformed index with an empty one.
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
isSafeBackup(backupPath) {
|
|
610
|
+
if (!this.isContainedBackupPath(backupPath))
|
|
611
|
+
return false;
|
|
612
|
+
try {
|
|
613
|
+
const info = lstatSync(backupPath);
|
|
614
|
+
return info.isFile() && !info.isSymbolicLink();
|
|
615
|
+
}
|
|
616
|
+
catch {
|
|
617
|
+
return false;
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
isContainedBackupPath(backupPath) {
|
|
621
|
+
if (typeof backupPath !== "string" || !isAbsolute(backupPath))
|
|
622
|
+
return false;
|
|
623
|
+
const rel = relative(this.historyDir, resolve(backupPath));
|
|
624
|
+
return Boolean(rel && rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel));
|
|
625
|
+
}
|
|
626
|
+
/** Never follow a destination symlink while restoring an old file version. */
|
|
627
|
+
isSafeDestination(filePath) {
|
|
628
|
+
if (typeof filePath !== "string" || !isAbsolute(filePath))
|
|
629
|
+
return false;
|
|
630
|
+
if (!existsSync(filePath))
|
|
631
|
+
return true;
|
|
632
|
+
try {
|
|
633
|
+
const info = lstatSync(filePath);
|
|
634
|
+
return info.isFile() && !info.isSymbolicLink();
|
|
635
|
+
}
|
|
636
|
+
catch {
|
|
637
|
+
return false;
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
canonicalizeNewPath(filePath) {
|
|
641
|
+
const absPath = resolve(filePath);
|
|
642
|
+
let candidate = absPath;
|
|
643
|
+
const missingSegments = [];
|
|
644
|
+
for (let depth = 0; depth < 256; depth += 1) {
|
|
645
|
+
try {
|
|
646
|
+
return join(realpathSync(candidate), ...missingSegments.reverse());
|
|
647
|
+
}
|
|
332
648
|
catch {
|
|
333
|
-
|
|
649
|
+
const parent = dirname(candidate);
|
|
650
|
+
if (parent === candidate)
|
|
651
|
+
return undefined;
|
|
652
|
+
missingSegments.push(basename(candidate));
|
|
653
|
+
candidate = parent;
|
|
334
654
|
}
|
|
335
655
|
}
|
|
336
|
-
return
|
|
656
|
+
return undefined;
|
|
657
|
+
}
|
|
658
|
+
matchesRecordedLocation(filePath, realPath) {
|
|
659
|
+
if (!realPath)
|
|
660
|
+
return true; // Legacy index written before canonical metadata.
|
|
661
|
+
try {
|
|
662
|
+
const current = existsSync(filePath) ? realpathSync(filePath) : this.canonicalizeNewPath(filePath);
|
|
663
|
+
return current === realPath;
|
|
664
|
+
}
|
|
665
|
+
catch {
|
|
666
|
+
return false;
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
restoreSnapshot(snapshot, saveCurrent) {
|
|
670
|
+
try {
|
|
671
|
+
if (!this.isSafeBackup(snapshot.backupPath))
|
|
672
|
+
return false;
|
|
673
|
+
if (!this.isSafeDestination(snapshot.filePath))
|
|
674
|
+
return false;
|
|
675
|
+
if (!this.matchesRecordedLocation(snapshot.filePath, snapshot.realPath))
|
|
676
|
+
return false;
|
|
677
|
+
const currentMode = this.readMode(snapshot.filePath);
|
|
678
|
+
if (saveCurrent && existsSync(snapshot.filePath) && !this.saveSnapshot(snapshot.filePath)) {
|
|
679
|
+
return false;
|
|
680
|
+
}
|
|
681
|
+
copyFileSync(snapshot.backupPath, snapshot.filePath);
|
|
682
|
+
this.restoreMode(snapshot.filePath, snapshot.mode ?? currentMode);
|
|
683
|
+
return true;
|
|
684
|
+
}
|
|
685
|
+
catch {
|
|
686
|
+
return false;
|
|
687
|
+
}
|
|
337
688
|
}
|
|
689
|
+
readMode(filePath) {
|
|
690
|
+
if (process.platform === "win32" || !existsSync(filePath))
|
|
691
|
+
return undefined;
|
|
692
|
+
try {
|
|
693
|
+
return lstatSync(filePath).mode & 0o7777;
|
|
694
|
+
}
|
|
695
|
+
catch {
|
|
696
|
+
return undefined;
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
restoreMode(filePath, mode) {
|
|
700
|
+
if (process.platform === "win32" || mode === undefined)
|
|
701
|
+
return;
|
|
702
|
+
chmodSync(filePath, mode);
|
|
703
|
+
}
|
|
704
|
+
discardRedoTurn(turnSeq) {
|
|
705
|
+
this.redoRecords = this.redoRecords.filter((record) => {
|
|
706
|
+
if (record.turnSeq !== turnSeq)
|
|
707
|
+
return true;
|
|
708
|
+
try {
|
|
709
|
+
if (this.isSafeBackup(record.backupPath))
|
|
710
|
+
rmSync(record.backupPath, { force: true });
|
|
711
|
+
}
|
|
712
|
+
catch {
|
|
713
|
+
// Best-effort cleanup; dropping the index reference is the safety boundary.
|
|
714
|
+
}
|
|
715
|
+
return false;
|
|
716
|
+
});
|
|
717
|
+
}
|
|
718
|
+
turnKey(turnSeq) {
|
|
719
|
+
return turnSeq ?? LEGACY_TURN;
|
|
720
|
+
}
|
|
721
|
+
latestTurnUndoPlanInternal() {
|
|
722
|
+
let latest = -Infinity;
|
|
723
|
+
let found = false;
|
|
724
|
+
for (const snapshot of this.snapshots) {
|
|
725
|
+
if (snapshot.undone)
|
|
726
|
+
continue;
|
|
727
|
+
latest = Math.max(latest, this.turnKey(snapshot.turnSeq));
|
|
728
|
+
found = true;
|
|
729
|
+
}
|
|
730
|
+
for (const marker of this.created) {
|
|
731
|
+
if (marker.undone)
|
|
732
|
+
continue;
|
|
733
|
+
latest = Math.max(latest, marker.turnSeq);
|
|
734
|
+
found = true;
|
|
735
|
+
}
|
|
736
|
+
if (!found)
|
|
737
|
+
return null;
|
|
738
|
+
const earliest = new Map();
|
|
739
|
+
for (const snapshot of this.snapshots) {
|
|
740
|
+
if (snapshot.undone || this.turnKey(snapshot.turnSeq) !== latest)
|
|
741
|
+
continue;
|
|
742
|
+
const current = earliest.get(snapshot.filePath);
|
|
743
|
+
if (!current || snapshot.timestamp < current.timestamp) {
|
|
744
|
+
earliest.set(snapshot.filePath, snapshot);
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
const snapshots = [...earliest.values()].sort((a, b) => a.timestamp - b.timestamp);
|
|
748
|
+
const createdPaths = this.created
|
|
749
|
+
.filter((marker) => !marker.undone && marker.turnSeq === latest)
|
|
750
|
+
.map((marker) => marker.filePath);
|
|
751
|
+
const filePaths = [...new Set([...createdPaths, ...snapshots.map((item) => item.filePath)])];
|
|
752
|
+
return {
|
|
753
|
+
turnKey: latest,
|
|
754
|
+
...(latest === LEGACY_TURN ? {} : { turnSeq: latest }),
|
|
755
|
+
snapshots,
|
|
756
|
+
createdPaths,
|
|
757
|
+
filePaths,
|
|
758
|
+
};
|
|
759
|
+
}
|
|
760
|
+
isExactSnapshotSet(candidates, expected) {
|
|
761
|
+
if (candidates.length !== expected.length)
|
|
762
|
+
return false;
|
|
763
|
+
const remaining = [...expected];
|
|
764
|
+
for (const candidate of candidates) {
|
|
765
|
+
const index = remaining.findIndex((snapshot) => this.sameSnapshot(snapshot, candidate));
|
|
766
|
+
if (index < 0)
|
|
767
|
+
return false;
|
|
768
|
+
remaining.splice(index, 1);
|
|
769
|
+
}
|
|
770
|
+
return remaining.length === 0;
|
|
771
|
+
}
|
|
772
|
+
sameSnapshot(a, b) {
|
|
773
|
+
return (a.filePath === b.filePath &&
|
|
774
|
+
a.timestamp === b.timestamp &&
|
|
775
|
+
a.backupPath === b.backupPath &&
|
|
776
|
+
a.hash === b.hash &&
|
|
777
|
+
a.size === b.size &&
|
|
778
|
+
a.turnSeq === b.turnSeq &&
|
|
779
|
+
a.undone === b.undone);
|
|
780
|
+
}
|
|
781
|
+
findSnapshot(candidate) {
|
|
782
|
+
return this.snapshots.find((snapshot) => this.sameSnapshot(snapshot, candidate));
|
|
783
|
+
}
|
|
784
|
+
findRedoRecord(candidate) {
|
|
785
|
+
return this.redoRecords.find((record) => record.filePath === candidate.filePath &&
|
|
786
|
+
record.turnSeq === candidate.turnSeq &&
|
|
787
|
+
record.backupPath === candidate.backupPath &&
|
|
788
|
+
record.existedBefore === candidate.existedBefore &&
|
|
789
|
+
record.existedAfter === candidate.existedAfter);
|
|
790
|
+
}
|
|
791
|
+
isSnapshot(value) {
|
|
792
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
793
|
+
return false;
|
|
794
|
+
const snapshot = value;
|
|
795
|
+
return (typeof snapshot.filePath === "string" &&
|
|
796
|
+
isAbsolute(snapshot.filePath) &&
|
|
797
|
+
typeof snapshot.timestamp === "number" &&
|
|
798
|
+
Number.isFinite(snapshot.timestamp) &&
|
|
799
|
+
typeof snapshot.hash === "string" &&
|
|
800
|
+
typeof snapshot.size === "number" &&
|
|
801
|
+
Number.isFinite(snapshot.size) &&
|
|
802
|
+
(snapshot.mode === undefined || isFileMode(snapshot.mode)) &&
|
|
803
|
+
(snapshot.realPath === undefined ||
|
|
804
|
+
(typeof snapshot.realPath === "string" && isAbsolute(snapshot.realPath))) &&
|
|
805
|
+
(snapshot.turnSeq === undefined || isFiniteTurn(snapshot.turnSeq)) &&
|
|
806
|
+
(snapshot.undone === undefined || typeof snapshot.undone === "boolean") &&
|
|
807
|
+
this.isSafeBackup(snapshot.backupPath));
|
|
808
|
+
}
|
|
809
|
+
isRedoRecord(value) {
|
|
810
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
811
|
+
return false;
|
|
812
|
+
const record = value;
|
|
813
|
+
return (typeof record.filePath === "string" &&
|
|
814
|
+
isAbsolute(record.filePath) &&
|
|
815
|
+
typeof record.turnSeq === "number" &&
|
|
816
|
+
isHistoryTurn(record.turnSeq) &&
|
|
817
|
+
typeof record.existedBefore === "boolean" &&
|
|
818
|
+
(record.existedAfter === undefined || typeof record.existedAfter === "boolean") &&
|
|
819
|
+
(record.mode === undefined || isFileMode(record.mode)) &&
|
|
820
|
+
(record.realPath === undefined ||
|
|
821
|
+
(typeof record.realPath === "string" && isAbsolute(record.realPath))) &&
|
|
822
|
+
this.isContainedBackupPath(record.backupPath));
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
function isCreatedMarker(value) {
|
|
826
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
827
|
+
return false;
|
|
828
|
+
const marker = value;
|
|
829
|
+
return (typeof marker.filePath === "string" &&
|
|
830
|
+
isAbsolute(marker.filePath) &&
|
|
831
|
+
typeof marker.turnSeq === "number" &&
|
|
832
|
+
isFiniteTurn(marker.turnSeq) &&
|
|
833
|
+
(marker.realPath === undefined ||
|
|
834
|
+
(typeof marker.realPath === "string" && isAbsolute(marker.realPath))) &&
|
|
835
|
+
(marker.undone === undefined || typeof marker.undone === "boolean"));
|
|
836
|
+
}
|
|
837
|
+
function isFiniteTurn(value) {
|
|
838
|
+
return typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
|
|
839
|
+
}
|
|
840
|
+
function isHistoryTurn(value) {
|
|
841
|
+
return value === LEGACY_TURN || isFiniteTurn(value);
|
|
842
|
+
}
|
|
843
|
+
function isFileMode(value) {
|
|
844
|
+
return typeof value === "number" && Number.isInteger(value) && value >= 0 && value <= 0o7777;
|
|
338
845
|
}
|