tinker-agent 1.5.1 → 1.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +52 -1
- package/README.md +15 -7
- package/package.json +8 -7
- package/src/agent/assistant-text-delta.ts +10 -0
- package/src/agent/loop.ts +116 -22
- package/src/agent/runtime-session.ts +248 -1
- package/src/cli/command-line.ts +9 -1
- package/src/cli/config.ts +17 -4
- package/src/cli/main.ts +1 -0
- package/src/cli/public-cli-contract.ts +4 -0
- package/src/cli/public-config-contract.ts +25 -1
- package/src/cli/run-runner.ts +5 -0
- package/src/cli/runner-dependencies.ts +4 -1
- package/src/cli/tui-runner.tsx +17 -2
- package/src/events/bash-result-detail.ts +13 -6
- package/src/events/observation-text-log.ts +26 -1
- package/src/events/stdout-event-printer.ts +18 -2
- package/src/events/types.ts +14 -2
- package/src/model/fake-model-client.ts +177 -0
- package/src/model/model-client.ts +3 -0
- package/src/model/openai-chat-model-client.ts +54 -15
- package/src/model/openai-chat-stream.ts +95 -72
- package/src/observation/observation-builder.ts +54 -6
- package/src/session/session-catalog.ts +17 -11
- package/src/session/session-store.ts +2 -0
- package/src/tools/bash-guard.ts +131 -0
- package/src/tools/bash-task.ts +129 -90
- package/src/tools/bash.ts +75 -13
- package/src/tools/delete.ts +182 -0
- package/src/tools/edit.ts +68 -9
- package/src/tools/registry.ts +49 -3
- package/src/tools/shell-process.ts +296 -0
- package/src/tools/task-input.ts +229 -0
- package/src/tools/task-output-tool.ts +4 -1
- package/src/tools/terminal-screen.ts +105 -0
- package/src/tools/turn-undo-manager.ts +794 -0
- package/src/tools/types.ts +45 -0
- package/src/tools/write.ts +65 -14
- package/src/tui/app.tsx +161 -45
- package/src/tui/assistant-markdown-section-framer.ts +135 -0
- package/src/tui/components/background-tasks.tsx +3 -2
- package/src/tui/components/bash-confirmation.tsx +27 -0
- package/src/tui/components/context-status.tsx +11 -1
- package/src/tui/components/footer.tsx +8 -5
- package/src/tui/components/prompt-input.tsx +13 -1
- package/src/tui/components/resume-session-picker.tsx +292 -46
- package/src/tui/components/timeline.tsx +10 -0
- package/src/tui/context-format.ts +17 -0
- package/src/tui/event-store.ts +76 -4
- package/src/tui/slash-commands.ts +28 -0
- package/src/tui/tui-projection-store.ts +246 -7
- package/src/tui/tui-session-controller.ts +19 -1
|
@@ -0,0 +1,794 @@
|
|
|
1
|
+
import { Buffer } from "node:buffer";
|
|
2
|
+
import { lstat, readFile, unlink, writeFile } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import type { TurnId } from "../ids/runtime-id";
|
|
5
|
+
import type { TurnIdentity } from "../agent/types";
|
|
6
|
+
import { sha256Bytes } from "./hash";
|
|
7
|
+
import type { FileSnapshotStore } from "./types";
|
|
8
|
+
|
|
9
|
+
const MEBIBYTE = 1024 * 1024;
|
|
10
|
+
|
|
11
|
+
export const TURN_UNDO_LIMITS = Object.freeze({
|
|
12
|
+
maxFileBytes: 32 * MEBIBYTE,
|
|
13
|
+
maxRuntimeBytes: 64 * MEBIBYTE,
|
|
14
|
+
maxRecords: 20,
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
export type CapturedFileState =
|
|
18
|
+
| { state: "absent" }
|
|
19
|
+
| {
|
|
20
|
+
state: "present";
|
|
21
|
+
bytes: Buffer;
|
|
22
|
+
sha256: string;
|
|
23
|
+
byteLength: number;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export type FileStateFingerprint =
|
|
27
|
+
| { state: "absent" }
|
|
28
|
+
| { state: "present"; sha256: string };
|
|
29
|
+
|
|
30
|
+
export type TurnUndoBarrierReason =
|
|
31
|
+
| { kind: "file-too-large"; displayPath: string; byteLength: number }
|
|
32
|
+
| { kind: "turn-too-large" }
|
|
33
|
+
| { kind: "capture-unavailable"; displayPath: string; detail: string };
|
|
34
|
+
|
|
35
|
+
export type MutationCapture =
|
|
36
|
+
| {
|
|
37
|
+
kind: "tracked";
|
|
38
|
+
turnId: TurnId;
|
|
39
|
+
absolutePath: string;
|
|
40
|
+
generation: number;
|
|
41
|
+
beforeFingerprint: FileStateFingerprint;
|
|
42
|
+
}
|
|
43
|
+
| {
|
|
44
|
+
kind: "untracked";
|
|
45
|
+
turnId: TurnId;
|
|
46
|
+
absolutePath: string;
|
|
47
|
+
displayPath: string;
|
|
48
|
+
reason: TurnUndoBarrierReason;
|
|
49
|
+
beforeFingerprint?: FileStateFingerprint;
|
|
50
|
+
beforeKnownPresent?: true;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
export type TurnUndoConflict = {
|
|
54
|
+
readonly displayPath: string;
|
|
55
|
+
readonly detail: string;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
export type TurnUndoResult =
|
|
59
|
+
| { readonly status: "nothing" }
|
|
60
|
+
| {
|
|
61
|
+
readonly status: "unavailable";
|
|
62
|
+
readonly turnNumber: number;
|
|
63
|
+
readonly reason: TurnUndoBarrierReason;
|
|
64
|
+
}
|
|
65
|
+
| {
|
|
66
|
+
readonly status: "refused";
|
|
67
|
+
readonly turnNumber: number;
|
|
68
|
+
readonly conflicts: readonly TurnUndoConflict[];
|
|
69
|
+
}
|
|
70
|
+
| {
|
|
71
|
+
readonly status: "restored";
|
|
72
|
+
readonly turnNumber: number;
|
|
73
|
+
readonly restoredFileCount: number;
|
|
74
|
+
readonly deletedFileCount: number;
|
|
75
|
+
}
|
|
76
|
+
| {
|
|
77
|
+
readonly status: "incomplete";
|
|
78
|
+
readonly turnNumber: number;
|
|
79
|
+
readonly restoredFileCount: number;
|
|
80
|
+
readonly deletedFileCount: number;
|
|
81
|
+
readonly failedPath: string;
|
|
82
|
+
readonly detail: string;
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
export type BeforeMutationFileState =
|
|
86
|
+
| { state: "absent" }
|
|
87
|
+
| { state: "present"; bytes: Uint8Array };
|
|
88
|
+
|
|
89
|
+
type TurnUndoEntry = {
|
|
90
|
+
absolutePath: string;
|
|
91
|
+
displayPath: string;
|
|
92
|
+
before: CapturedFileState;
|
|
93
|
+
expectedAfter?: FileStateFingerprint;
|
|
94
|
+
mutationCount: number;
|
|
95
|
+
generation: number;
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
type ActiveTurnUndo = {
|
|
99
|
+
turnId: TurnId;
|
|
100
|
+
turnNumber: number;
|
|
101
|
+
entries: Map<string, TurnUndoEntry>;
|
|
102
|
+
retainedBytes: number;
|
|
103
|
+
unavailableReason?: TurnUndoBarrierReason;
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
type TurnUndoCheckpoint = {
|
|
107
|
+
kind: "checkpoint";
|
|
108
|
+
turnId: TurnId;
|
|
109
|
+
turnNumber: number;
|
|
110
|
+
entries: Map<string, TurnUndoEntry>;
|
|
111
|
+
retainedBytes: number;
|
|
112
|
+
completed: true;
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
type TurnUndoBarrier = {
|
|
116
|
+
kind: "barrier";
|
|
117
|
+
turnId: TurnId;
|
|
118
|
+
turnNumber: number;
|
|
119
|
+
reason: TurnUndoBarrierReason;
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
type TurnUndoRecord = TurnUndoCheckpoint | TurnUndoBarrier;
|
|
123
|
+
|
|
124
|
+
type CurrentFileState =
|
|
125
|
+
| FileStateFingerprint
|
|
126
|
+
| { state: "other"; kind: string }
|
|
127
|
+
| { state: "unavailable"; detail: string };
|
|
128
|
+
|
|
129
|
+
type TurnUndoFileSystem = {
|
|
130
|
+
lstat(filePath: string): Promise<{
|
|
131
|
+
isFile(): boolean;
|
|
132
|
+
isDirectory(): boolean;
|
|
133
|
+
isSymbolicLink(): boolean;
|
|
134
|
+
}>;
|
|
135
|
+
readFile(filePath: string): Promise<Buffer>;
|
|
136
|
+
writeFile(filePath: string, bytes: Buffer): Promise<void>;
|
|
137
|
+
unlink(filePath: string): Promise<void>;
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
type TurnUndoLimits = {
|
|
141
|
+
maxFileBytes: number;
|
|
142
|
+
maxRuntimeBytes: number;
|
|
143
|
+
maxRecords: number;
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
export type TurnUndoManagerOptions = {
|
|
147
|
+
snapshots: FileSnapshotStore;
|
|
148
|
+
limits?: TurnUndoLimits;
|
|
149
|
+
fileSystem?: TurnUndoFileSystem;
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
const DEFAULT_FILE_SYSTEM: TurnUndoFileSystem = {
|
|
153
|
+
lstat: async (filePath) => lstat(filePath),
|
|
154
|
+
readFile: async (filePath) => readFile(filePath),
|
|
155
|
+
writeFile: async (filePath, bytes) => writeFile(filePath, bytes),
|
|
156
|
+
unlink: async (filePath) => unlink(filePath),
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
export class TurnUndoManager {
|
|
160
|
+
private readonly records: TurnUndoRecord[] = [];
|
|
161
|
+
private readonly limits: TurnUndoLimits;
|
|
162
|
+
private readonly fileSystem: TurnUndoFileSystem;
|
|
163
|
+
private activeTurn?: ActiveTurnUndo;
|
|
164
|
+
private retainedBytes = 0;
|
|
165
|
+
private nextGeneration = 1;
|
|
166
|
+
|
|
167
|
+
constructor(private readonly options: TurnUndoManagerOptions) {
|
|
168
|
+
this.limits = options.limits ?? TURN_UNDO_LIMITS;
|
|
169
|
+
this.fileSystem = options.fileSystem ?? DEFAULT_FILE_SYSTEM;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
async captureBeforeMutation(input: {
|
|
173
|
+
turnId: TurnId;
|
|
174
|
+
turnNumber: number;
|
|
175
|
+
absolutePath: string;
|
|
176
|
+
displayPath: string;
|
|
177
|
+
knownByteLength?: number;
|
|
178
|
+
loadBefore: () => Promise<BeforeMutationFileState>;
|
|
179
|
+
}): Promise<MutationCapture> {
|
|
180
|
+
const turn = this.ensureActiveTurn(input);
|
|
181
|
+
const absolutePath = path.normalize(input.absolutePath);
|
|
182
|
+
const existing = turn.entries.get(absolutePath);
|
|
183
|
+
|
|
184
|
+
if (turn.unavailableReason !== undefined) {
|
|
185
|
+
return {
|
|
186
|
+
kind: "untracked",
|
|
187
|
+
turnId: turn.turnId,
|
|
188
|
+
absolutePath,
|
|
189
|
+
displayPath: input.displayPath,
|
|
190
|
+
reason: turn.unavailableReason,
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (existing !== undefined && existing.mutationCount > 0) {
|
|
195
|
+
if (existing.expectedAfter === undefined) {
|
|
196
|
+
throw new Error("A recorded undo mutation is missing its resulting state.");
|
|
197
|
+
}
|
|
198
|
+
return {
|
|
199
|
+
kind: "tracked",
|
|
200
|
+
turnId: turn.turnId,
|
|
201
|
+
absolutePath,
|
|
202
|
+
generation: existing.generation,
|
|
203
|
+
beforeFingerprint: existing.expectedAfter,
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
if (existing !== undefined) {
|
|
208
|
+
this.removeActiveEntry(turn, existing);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if (
|
|
212
|
+
input.knownByteLength !== undefined &&
|
|
213
|
+
input.knownByteLength > this.limits.maxFileBytes
|
|
214
|
+
) {
|
|
215
|
+
return this.untrackedCapture(input, absolutePath, {
|
|
216
|
+
kind: "file-too-large",
|
|
217
|
+
displayPath: input.displayPath,
|
|
218
|
+
byteLength: input.knownByteLength,
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
let loaded: BeforeMutationFileState;
|
|
223
|
+
try {
|
|
224
|
+
loaded = await input.loadBefore();
|
|
225
|
+
} catch (error) {
|
|
226
|
+
return this.untrackedCapture(input, absolutePath, {
|
|
227
|
+
kind: "capture-unavailable",
|
|
228
|
+
displayPath: input.displayPath,
|
|
229
|
+
detail: errorMessage(error),
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const loadedByteLength = loaded.state === "present" ? loaded.bytes.byteLength : 0;
|
|
234
|
+
const loadedFingerprint =
|
|
235
|
+
loaded.state === "present"
|
|
236
|
+
? { state: "present" as const, sha256: sha256Bytes(loaded.bytes) }
|
|
237
|
+
: { state: "absent" as const };
|
|
238
|
+
if (loadedByteLength > this.limits.maxFileBytes) {
|
|
239
|
+
return this.untrackedCapture(
|
|
240
|
+
input,
|
|
241
|
+
absolutePath,
|
|
242
|
+
{
|
|
243
|
+
kind: "file-too-large",
|
|
244
|
+
displayPath: input.displayPath,
|
|
245
|
+
byteLength: loadedByteLength,
|
|
246
|
+
},
|
|
247
|
+
loadedFingerprint,
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const addedBytes = loadedByteLength;
|
|
252
|
+
if (turn.retainedBytes + addedBytes > this.limits.maxRuntimeBytes) {
|
|
253
|
+
return this.untrackedCapture(
|
|
254
|
+
input,
|
|
255
|
+
absolutePath,
|
|
256
|
+
{ kind: "turn-too-large" },
|
|
257
|
+
loadedFingerprint,
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
this.evictCheckpointsForBytes(addedBytes);
|
|
262
|
+
const prepared = captureFileState(loaded, loadedFingerprint);
|
|
263
|
+
const entry: TurnUndoEntry = {
|
|
264
|
+
absolutePath,
|
|
265
|
+
displayPath: input.displayPath,
|
|
266
|
+
before: prepared,
|
|
267
|
+
mutationCount: 0,
|
|
268
|
+
generation: this.nextGeneration,
|
|
269
|
+
};
|
|
270
|
+
this.nextGeneration += 1;
|
|
271
|
+
turn.entries.set(absolutePath, entry);
|
|
272
|
+
turn.retainedBytes += addedBytes;
|
|
273
|
+
this.retainedBytes += addedBytes;
|
|
274
|
+
|
|
275
|
+
return {
|
|
276
|
+
kind: "tracked",
|
|
277
|
+
turnId: turn.turnId,
|
|
278
|
+
absolutePath,
|
|
279
|
+
generation: entry.generation,
|
|
280
|
+
beforeFingerprint: fingerprint(prepared),
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
recordMutationResult(capture: MutationCapture, after: FileStateFingerprint): void {
|
|
285
|
+
const turn = this.requireCaptureTurn(capture);
|
|
286
|
+
if (turn.unavailableReason !== undefined) {
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
if (capture.kind === "untracked") {
|
|
291
|
+
if (
|
|
292
|
+
capture.beforeFingerprint !== undefined &&
|
|
293
|
+
sameFingerprint(capture.beforeFingerprint, after)
|
|
294
|
+
) {
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
this.markTurnUnavailable(turn, capture.reason);
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const entry = turn.entries.get(capture.absolutePath);
|
|
302
|
+
if (entry === undefined || entry.generation !== capture.generation) {
|
|
303
|
+
throw new Error("Undo mutation capture is no longer current.");
|
|
304
|
+
}
|
|
305
|
+
entry.expectedAfter = after;
|
|
306
|
+
entry.mutationCount += 1;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
async recordMutationFailure(capture: MutationCapture): Promise<void> {
|
|
310
|
+
const turn = this.requireCaptureTurn(capture);
|
|
311
|
+
if (turn.unavailableReason !== undefined) {
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const current = await this.inspectCurrentState(capture.absolutePath);
|
|
316
|
+
if (isFingerprint(current)) {
|
|
317
|
+
if (
|
|
318
|
+
capture.beforeFingerprint !== undefined &&
|
|
319
|
+
sameFingerprint(capture.beforeFingerprint, current)
|
|
320
|
+
) {
|
|
321
|
+
this.discardMutationCapture(capture);
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
if (capture.kind === "untracked" && capture.beforeKnownPresent === true) {
|
|
325
|
+
if (current.state === "present") {
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
if (capture.kind === "tracked") {
|
|
330
|
+
this.recordMutationResult(capture, current);
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
this.markTurnUnavailable(turn, capture.reason);
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
this.markTurnUnavailable(
|
|
338
|
+
turn,
|
|
339
|
+
capture.kind === "untracked"
|
|
340
|
+
? capture.reason
|
|
341
|
+
: {
|
|
342
|
+
kind: "capture-unavailable",
|
|
343
|
+
displayPath: this.displayPathForCapture(turn, capture),
|
|
344
|
+
detail:
|
|
345
|
+
current.state === "unavailable"
|
|
346
|
+
? `could not determine the file state after a failed mutation: ${current.detail}`
|
|
347
|
+
: `path became ${current.kind} after a failed mutation`,
|
|
348
|
+
},
|
|
349
|
+
);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
discardMutationCapture(capture: MutationCapture): void {
|
|
353
|
+
if (capture.kind === "untracked") {
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
const turn = this.requireCaptureTurn(capture);
|
|
357
|
+
const entry = turn.entries.get(capture.absolutePath);
|
|
358
|
+
if (
|
|
359
|
+
entry !== undefined &&
|
|
360
|
+
entry.generation === capture.generation &&
|
|
361
|
+
entry.mutationCount === 0
|
|
362
|
+
) {
|
|
363
|
+
this.removeActiveEntry(turn, entry);
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
completeTurn(turn: Pick<TurnIdentity, "turnId" | "turnNumber">): void {
|
|
368
|
+
const active = this.activeTurn;
|
|
369
|
+
if (active === undefined) {
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
if (active.turnId !== turn.turnId || active.turnNumber !== turn.turnNumber) {
|
|
373
|
+
throw new Error("Cannot complete undo state for a different turn.");
|
|
374
|
+
}
|
|
375
|
+
this.activeTurn = undefined;
|
|
376
|
+
|
|
377
|
+
if (active.unavailableReason !== undefined) {
|
|
378
|
+
this.releaseActiveEntries(active);
|
|
379
|
+
this.records.push({
|
|
380
|
+
kind: "barrier",
|
|
381
|
+
turnId: active.turnId,
|
|
382
|
+
turnNumber: active.turnNumber,
|
|
383
|
+
reason: active.unavailableReason,
|
|
384
|
+
});
|
|
385
|
+
this.enforceRecordLimit();
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
for (const entry of [...active.entries.values()]) {
|
|
390
|
+
if (
|
|
391
|
+
entry.mutationCount === 0 ||
|
|
392
|
+
entry.expectedAfter === undefined ||
|
|
393
|
+
sameFingerprint(fingerprint(entry.before), entry.expectedAfter)
|
|
394
|
+
) {
|
|
395
|
+
this.removeActiveEntry(active, entry);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
if (active.entries.size === 0) {
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
this.records.push({
|
|
404
|
+
kind: "checkpoint",
|
|
405
|
+
turnId: active.turnId,
|
|
406
|
+
turnNumber: active.turnNumber,
|
|
407
|
+
entries: active.entries,
|
|
408
|
+
retainedBytes: active.retainedBytes,
|
|
409
|
+
completed: true,
|
|
410
|
+
});
|
|
411
|
+
this.enforceRecordLimit();
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
async undoLatest(): Promise<TurnUndoResult> {
|
|
415
|
+
if (this.activeTurn !== undefined) {
|
|
416
|
+
throw new Error("Cannot undo while a turn is active.");
|
|
417
|
+
}
|
|
418
|
+
const record = this.records.at(-1);
|
|
419
|
+
if (record === undefined) {
|
|
420
|
+
return { status: "nothing" };
|
|
421
|
+
}
|
|
422
|
+
if (record.kind === "barrier") {
|
|
423
|
+
return {
|
|
424
|
+
status: "unavailable",
|
|
425
|
+
turnNumber: record.turnNumber,
|
|
426
|
+
reason: record.reason,
|
|
427
|
+
};
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
const orderedEntries = [...record.entries.values()].sort((left, right) =>
|
|
431
|
+
left.absolutePath.localeCompare(right.absolutePath),
|
|
432
|
+
);
|
|
433
|
+
const pending: TurnUndoEntry[] = [];
|
|
434
|
+
const alreadyRestored: TurnUndoEntry[] = [];
|
|
435
|
+
const conflicts: TurnUndoConflict[] = [];
|
|
436
|
+
let restoredFileCount = 0;
|
|
437
|
+
let deletedFileCount = 0;
|
|
438
|
+
|
|
439
|
+
for (const entry of orderedEntries) {
|
|
440
|
+
if (entry.expectedAfter === undefined) {
|
|
441
|
+
throw new Error("A completed undo entry is missing its resulting state.");
|
|
442
|
+
}
|
|
443
|
+
const current = await this.inspectCurrentState(entry.absolutePath);
|
|
444
|
+
if (currentMatches(current, fingerprint(entry.before))) {
|
|
445
|
+
alreadyRestored.push(entry);
|
|
446
|
+
if (entry.before.state === "present") {
|
|
447
|
+
restoredFileCount += 1;
|
|
448
|
+
} else {
|
|
449
|
+
deletedFileCount += 1;
|
|
450
|
+
}
|
|
451
|
+
continue;
|
|
452
|
+
}
|
|
453
|
+
if (currentMatches(current, entry.expectedAfter)) {
|
|
454
|
+
pending.push(entry);
|
|
455
|
+
continue;
|
|
456
|
+
}
|
|
457
|
+
conflicts.push({
|
|
458
|
+
displayPath: entry.displayPath,
|
|
459
|
+
detail: conflictDetail(entry.expectedAfter, current),
|
|
460
|
+
});
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
if (conflicts.length > 0) {
|
|
464
|
+
return {
|
|
465
|
+
status: "refused",
|
|
466
|
+
turnNumber: record.turnNumber,
|
|
467
|
+
conflicts,
|
|
468
|
+
};
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
for (const entry of alreadyRestored) {
|
|
472
|
+
this.options.snapshots.delete(entry.absolutePath);
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
for (const entry of pending) {
|
|
476
|
+
try {
|
|
477
|
+
if (entry.before.state === "absent") {
|
|
478
|
+
await this.fileSystem.unlink(entry.absolutePath);
|
|
479
|
+
} else {
|
|
480
|
+
await this.fileSystem.writeFile(entry.absolutePath, entry.before.bytes);
|
|
481
|
+
}
|
|
482
|
+
const verified = await this.inspectCurrentState(entry.absolutePath);
|
|
483
|
+
if (!currentMatches(verified, fingerprint(entry.before))) {
|
|
484
|
+
throw new Error(
|
|
485
|
+
`restored state verification failed: ${describeCurrentState(verified)}`,
|
|
486
|
+
);
|
|
487
|
+
}
|
|
488
|
+
if (entry.before.state === "present") {
|
|
489
|
+
restoredFileCount += 1;
|
|
490
|
+
} else {
|
|
491
|
+
deletedFileCount += 1;
|
|
492
|
+
}
|
|
493
|
+
} catch (error) {
|
|
494
|
+
return {
|
|
495
|
+
status: "incomplete",
|
|
496
|
+
turnNumber: record.turnNumber,
|
|
497
|
+
restoredFileCount,
|
|
498
|
+
deletedFileCount,
|
|
499
|
+
failedPath: entry.displayPath,
|
|
500
|
+
detail: errorMessage(error),
|
|
501
|
+
};
|
|
502
|
+
} finally {
|
|
503
|
+
this.options.snapshots.delete(entry.absolutePath);
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
for (const entry of orderedEntries) {
|
|
508
|
+
const current = await this.inspectCurrentState(entry.absolutePath);
|
|
509
|
+
if (!currentMatches(current, fingerprint(entry.before))) {
|
|
510
|
+
return {
|
|
511
|
+
status: "incomplete",
|
|
512
|
+
turnNumber: record.turnNumber,
|
|
513
|
+
restoredFileCount,
|
|
514
|
+
deletedFileCount,
|
|
515
|
+
failedPath: entry.displayPath,
|
|
516
|
+
detail: `final restored state verification failed: ${describeCurrentState(current)}`,
|
|
517
|
+
};
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
const consumed = this.records.pop();
|
|
522
|
+
if (consumed !== record) {
|
|
523
|
+
throw new Error("Undo stack changed while a checkpoint was being restored.");
|
|
524
|
+
}
|
|
525
|
+
this.retainedBytes -= record.retainedBytes;
|
|
526
|
+
return {
|
|
527
|
+
status: "restored",
|
|
528
|
+
turnNumber: record.turnNumber,
|
|
529
|
+
restoredFileCount,
|
|
530
|
+
deletedFileCount,
|
|
531
|
+
};
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
private ensureActiveTurn(input: {
|
|
535
|
+
turnId: TurnId;
|
|
536
|
+
turnNumber: number;
|
|
537
|
+
}): ActiveTurnUndo {
|
|
538
|
+
if (this.activeTurn === undefined) {
|
|
539
|
+
this.activeTurn = {
|
|
540
|
+
turnId: input.turnId,
|
|
541
|
+
turnNumber: input.turnNumber,
|
|
542
|
+
entries: new Map(),
|
|
543
|
+
retainedBytes: 0,
|
|
544
|
+
};
|
|
545
|
+
return this.activeTurn;
|
|
546
|
+
}
|
|
547
|
+
if (
|
|
548
|
+
this.activeTurn.turnId !== input.turnId ||
|
|
549
|
+
this.activeTurn.turnNumber !== input.turnNumber
|
|
550
|
+
) {
|
|
551
|
+
throw new Error("Concurrent turns cannot share one TurnUndoManager.");
|
|
552
|
+
}
|
|
553
|
+
return this.activeTurn;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
private requireCaptureTurn(capture: MutationCapture): ActiveTurnUndo {
|
|
557
|
+
const turn = this.activeTurn;
|
|
558
|
+
if (turn === undefined || turn.turnId !== capture.turnId) {
|
|
559
|
+
throw new Error("Undo mutation capture does not belong to the active turn.");
|
|
560
|
+
}
|
|
561
|
+
return turn;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
private untrackedCapture(
|
|
565
|
+
input: {
|
|
566
|
+
turnId: TurnId;
|
|
567
|
+
displayPath: string;
|
|
568
|
+
knownByteLength?: number;
|
|
569
|
+
},
|
|
570
|
+
absolutePath: string,
|
|
571
|
+
reason: TurnUndoBarrierReason,
|
|
572
|
+
beforeFingerprint?: FileStateFingerprint,
|
|
573
|
+
): MutationCapture {
|
|
574
|
+
return {
|
|
575
|
+
kind: "untracked",
|
|
576
|
+
turnId: input.turnId,
|
|
577
|
+
absolutePath,
|
|
578
|
+
displayPath: input.displayPath,
|
|
579
|
+
reason,
|
|
580
|
+
...(beforeFingerprint === undefined ? {} : { beforeFingerprint }),
|
|
581
|
+
...(input.knownByteLength === undefined ? {} : { beforeKnownPresent: true }),
|
|
582
|
+
};
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
private markTurnUnavailable(
|
|
586
|
+
turn: ActiveTurnUndo,
|
|
587
|
+
reason: TurnUndoBarrierReason,
|
|
588
|
+
): void {
|
|
589
|
+
if (turn.unavailableReason !== undefined) {
|
|
590
|
+
return;
|
|
591
|
+
}
|
|
592
|
+
turn.unavailableReason = reason;
|
|
593
|
+
this.releaseActiveEntries(turn);
|
|
594
|
+
this.releaseAllRecords();
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
private removeActiveEntry(turn: ActiveTurnUndo, entry: TurnUndoEntry): void {
|
|
598
|
+
if (!turn.entries.delete(entry.absolutePath)) {
|
|
599
|
+
return;
|
|
600
|
+
}
|
|
601
|
+
const bytes = retainedByteLength(entry.before);
|
|
602
|
+
turn.retainedBytes -= bytes;
|
|
603
|
+
this.retainedBytes -= bytes;
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
private releaseActiveEntries(turn: ActiveTurnUndo): void {
|
|
607
|
+
for (const entry of turn.entries.values()) {
|
|
608
|
+
const bytes = retainedByteLength(entry.before);
|
|
609
|
+
turn.retainedBytes -= bytes;
|
|
610
|
+
this.retainedBytes -= bytes;
|
|
611
|
+
}
|
|
612
|
+
turn.entries.clear();
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
private releaseAllRecords(): void {
|
|
616
|
+
for (const record of this.records) {
|
|
617
|
+
if (record.kind === "checkpoint") {
|
|
618
|
+
this.retainedBytes -= record.retainedBytes;
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
this.records.length = 0;
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
private evictCheckpointsForBytes(addedBytes: number): void {
|
|
625
|
+
while (this.retainedBytes + addedBytes > this.limits.maxRuntimeBytes) {
|
|
626
|
+
const index = this.records.findIndex((record) => record.kind === "checkpoint");
|
|
627
|
+
if (index === -1) {
|
|
628
|
+
throw new Error("Undo byte accounting cannot satisfy the runtime limit.");
|
|
629
|
+
}
|
|
630
|
+
const [removed] = this.records.splice(index, 1);
|
|
631
|
+
if (removed?.kind === "checkpoint") {
|
|
632
|
+
this.retainedBytes -= removed.retainedBytes;
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
private enforceRecordLimit(): void {
|
|
638
|
+
while (this.records.length > this.limits.maxRecords) {
|
|
639
|
+
const removed = this.records.shift();
|
|
640
|
+
if (removed?.kind === "checkpoint") {
|
|
641
|
+
this.retainedBytes -= removed.retainedBytes;
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
private displayPathForCapture(
|
|
647
|
+
turn: ActiveTurnUndo,
|
|
648
|
+
capture: Extract<MutationCapture, { kind: "tracked" }>,
|
|
649
|
+
): string {
|
|
650
|
+
return turn.entries.get(capture.absolutePath)?.displayPath ?? capture.absolutePath;
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
private async inspectCurrentState(absolutePath: string): Promise<CurrentFileState> {
|
|
654
|
+
let info: Awaited<ReturnType<TurnUndoFileSystem["lstat"]>>;
|
|
655
|
+
try {
|
|
656
|
+
info = await this.fileSystem.lstat(absolutePath);
|
|
657
|
+
} catch (error) {
|
|
658
|
+
return isNotFound(error)
|
|
659
|
+
? { state: "absent" }
|
|
660
|
+
: { state: "unavailable", detail: errorMessage(error) };
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
if (!info.isFile()) {
|
|
664
|
+
return { state: "other", kind: fileKind(info) };
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
let bytes: Buffer;
|
|
668
|
+
try {
|
|
669
|
+
bytes = await this.fileSystem.readFile(absolutePath);
|
|
670
|
+
} catch (error) {
|
|
671
|
+
return isNotFound(error)
|
|
672
|
+
? { state: "absent" }
|
|
673
|
+
: { state: "unavailable", detail: errorMessage(error) };
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
try {
|
|
677
|
+
const verified = await this.fileSystem.lstat(absolutePath);
|
|
678
|
+
if (!verified.isFile()) {
|
|
679
|
+
return { state: "other", kind: fileKind(verified) };
|
|
680
|
+
}
|
|
681
|
+
} catch (error) {
|
|
682
|
+
return isNotFound(error)
|
|
683
|
+
? { state: "absent" }
|
|
684
|
+
: { state: "unavailable", detail: errorMessage(error) };
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
return { state: "present", sha256: sha256Bytes(bytes) };
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
function captureFileState(
|
|
692
|
+
state: BeforeMutationFileState,
|
|
693
|
+
preparedFingerprint: FileStateFingerprint,
|
|
694
|
+
): CapturedFileState {
|
|
695
|
+
if (state.state === "absent") {
|
|
696
|
+
return state;
|
|
697
|
+
}
|
|
698
|
+
const bytes = Buffer.from(state.bytes);
|
|
699
|
+
if (preparedFingerprint.state !== "present") {
|
|
700
|
+
throw new Error("Present undo bytes are missing their fingerprint.");
|
|
701
|
+
}
|
|
702
|
+
return {
|
|
703
|
+
state: "present",
|
|
704
|
+
bytes,
|
|
705
|
+
sha256: preparedFingerprint.sha256,
|
|
706
|
+
byteLength: bytes.byteLength,
|
|
707
|
+
};
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
function retainedByteLength(state: CapturedFileState): number {
|
|
711
|
+
return state.state === "present" ? state.byteLength : 0;
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
function fingerprint(state: CapturedFileState): FileStateFingerprint {
|
|
715
|
+
return state.state === "absent"
|
|
716
|
+
? { state: "absent" }
|
|
717
|
+
: { state: "present", sha256: state.sha256 };
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
function sameFingerprint(
|
|
721
|
+
left: FileStateFingerprint,
|
|
722
|
+
right: FileStateFingerprint,
|
|
723
|
+
): boolean {
|
|
724
|
+
return (
|
|
725
|
+
left.state === right.state &&
|
|
726
|
+
(left.state === "absent" ||
|
|
727
|
+
(right.state === "present" && left.sha256 === right.sha256))
|
|
728
|
+
);
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
function isFingerprint(state: CurrentFileState): state is FileStateFingerprint {
|
|
732
|
+
return state.state === "absent" || state.state === "present";
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
function currentMatches(
|
|
736
|
+
current: CurrentFileState,
|
|
737
|
+
expected: FileStateFingerprint,
|
|
738
|
+
): boolean {
|
|
739
|
+
return isFingerprint(current) && sameFingerprint(current, expected);
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
function conflictDetail(
|
|
743
|
+
expected: FileStateFingerprint,
|
|
744
|
+
current: CurrentFileState,
|
|
745
|
+
): string {
|
|
746
|
+
if (current.state === "unavailable") {
|
|
747
|
+
return `could not inspect file: ${current.detail}`;
|
|
748
|
+
}
|
|
749
|
+
if (current.state === "other") {
|
|
750
|
+
return `expected ${expected.state === "present" ? "file" : "missing"}, found ${current.kind}`;
|
|
751
|
+
}
|
|
752
|
+
if (expected.state === "present") {
|
|
753
|
+
return current.state === "absent"
|
|
754
|
+
? "expected file, found missing"
|
|
755
|
+
: "content changed";
|
|
756
|
+
}
|
|
757
|
+
return "expected missing, found file";
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
function describeCurrentState(current: CurrentFileState): string {
|
|
761
|
+
if (current.state === "absent") {
|
|
762
|
+
return "found missing";
|
|
763
|
+
}
|
|
764
|
+
if (current.state === "present") {
|
|
765
|
+
return "content changed";
|
|
766
|
+
}
|
|
767
|
+
if (current.state === "other") {
|
|
768
|
+
return `found ${current.kind}`;
|
|
769
|
+
}
|
|
770
|
+
return `could not inspect file: ${current.detail}`;
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
function fileKind(info: { isDirectory(): boolean; isSymbolicLink(): boolean }): string {
|
|
774
|
+
if (info.isSymbolicLink()) {
|
|
775
|
+
return "symbolic link";
|
|
776
|
+
}
|
|
777
|
+
if (info.isDirectory()) {
|
|
778
|
+
return "directory";
|
|
779
|
+
}
|
|
780
|
+
return "non-regular path";
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
function isNotFound(error: unknown): boolean {
|
|
784
|
+
return (
|
|
785
|
+
typeof error === "object" &&
|
|
786
|
+
error !== null &&
|
|
787
|
+
"code" in error &&
|
|
788
|
+
(error.code === "ENOENT" || error.code === "ENOTDIR")
|
|
789
|
+
);
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
function errorMessage(error: unknown): string {
|
|
793
|
+
return error instanceof Error ? error.message : String(error);
|
|
794
|
+
}
|