opencode-self-improvement 0.8.2 → 0.8.4
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/package.json +1 -1
- package/src/index.ts +67 -44
- package/src/inject.ts +20 -11
- package/src/schema.ts +1 -1
- package/src/undo.ts +123 -25
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-self-improvement",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.4",
|
|
4
4
|
"description": "Durable memory for opencode: remember facts, decisions, and commands across sessions, plus a one-command per-repo setup (/repo-init).",
|
|
5
5
|
"main": "index.ts",
|
|
6
6
|
"exports": {
|
package/src/index.ts
CHANGED
|
@@ -399,7 +399,7 @@ const impl = {
|
|
|
399
399
|
editor.add(validated(UndoArgs, {
|
|
400
400
|
name: "memory_undo",
|
|
401
401
|
description:
|
|
402
|
-
"List memory snapshots, or restore one (rollback).
|
|
402
|
+
"List memory snapshots, or restore one (rollback). Only snapshots inside this store's snapshots directory are accepted: pass a bare name from the list or its full path. SAFE BY DEFAULT: restore refuses if the current file has rows added or edited since the snapshot, and audited human/tool deletions stay deleted. Re-run with merge=true to restore AND keep divergent rows, or force=true for an explicit exact rollback. Blocklisted rows remain inactive in every mode.",
|
|
403
403
|
options: direct,
|
|
404
404
|
async execute(args): Promise<{ content: string }> {
|
|
405
405
|
if (args.snapshot) {
|
|
@@ -420,7 +420,10 @@ const impl = {
|
|
|
420
420
|
? ` Kept ${res.concurrentAdded.length} added / ${res.concurrentChanged.length} changed row(s) written after the snapshot.`
|
|
421
421
|
: "";
|
|
422
422
|
const safety = res.safety ? ` Pre-restore file snapshotted -> ${res.safety}.` : "";
|
|
423
|
-
|
|
423
|
+
const omitted = res.deletedOmitted.length
|
|
424
|
+
? ` Omitted ${res.deletedOmitted.length} deliberately deleted row(s); use force=true to recover them.`
|
|
425
|
+
: "";
|
|
426
|
+
return { content: `Restored ${basename(res.target)} from ${res.mode} snapshot -> ${res.target}.${kept}${safety}${omitted}` };
|
|
424
427
|
}
|
|
425
428
|
const snaps = await listSnapshots();
|
|
426
429
|
if (!snaps.length) return { content: "No snapshots" };
|
|
@@ -602,10 +605,13 @@ const impl = {
|
|
|
602
605
|
|
|
603
606
|
// Per-session frozen injection epochs: the relevance block plus the store
|
|
604
607
|
// fingerprint it was computed against. Recomputed ONLY on the first prompt
|
|
605
|
-
// of a session, when
|
|
606
|
-
// prompt/compaction hooks) — so the system prefix, and with it the
|
|
607
|
-
// provider prompt cache, survives ordinary
|
|
608
|
+
// of a session, when injectable rows change, or after a compaction (see the
|
|
609
|
+
// prompt/context/compaction hooks) — so the system prefix, and with it the
|
|
610
|
+
// provider prompt cache, survives ordinary requests.
|
|
608
611
|
const epoch = new Map<string, { version: string; block: string; at: string; reason: "first" | "store_changed" }>();
|
|
612
|
+
// Latest user prompt per session. A changed store is re-scored against it
|
|
613
|
+
// even when the change happened during a tool loop with no new prompt.
|
|
614
|
+
const latestPrompt = new Map<string, string>();
|
|
609
615
|
// Recomputes since server start: every one invalidated a prompt cache, so
|
|
610
616
|
// it is the honest cost number the surfaces show (status files / tools).
|
|
611
617
|
let epochRecomputes = 0;
|
|
@@ -654,6 +660,47 @@ const impl = {
|
|
|
654
660
|
const flushStatus = (sid: string): Promise<void> =>
|
|
655
661
|
buildPayload(sid).then((p) => (p ? writeStatus(p) : undefined));
|
|
656
662
|
|
|
663
|
+
// Called when a prompt arrives (to keep status surfaces current) and again
|
|
664
|
+
// at the injection decision on every model request. The latter keeps
|
|
665
|
+
// removals fail-closed: an out-of-band redaction or a block during a tool
|
|
666
|
+
// loop cannot ride one more request waiting for another user prompt.
|
|
667
|
+
const injectableRows = async (): Promise<Memory[]> =>
|
|
668
|
+
(await loadAll()).filter((m) => isActive(m) && isVisibleHere(m));
|
|
669
|
+
const refreshEpoch = async (sid: string, rows?: Memory[]): Promise<void> => {
|
|
670
|
+
if (!latestPrompt.has(sid)) return;
|
|
671
|
+
const mems = rows ?? await injectableRows();
|
|
672
|
+
const version = await storeVersion(mems);
|
|
673
|
+
const cached = epoch.get(sid);
|
|
674
|
+
if (cached?.version === version) return;
|
|
675
|
+
const raw = await relevantKnowledge(latestPrompt.get(sid) ?? "", 5, mems); // widen candidate pool
|
|
676
|
+
const block = raw ? capToBudget(raw, budget) : "";
|
|
677
|
+
const at = new Date().toISOString();
|
|
678
|
+
const reason: "first" | "store_changed" = cached ? "store_changed" : "first";
|
|
679
|
+
epoch.set(sid, { version, block, at, reason });
|
|
680
|
+
await record(
|
|
681
|
+
"epoch_recompute",
|
|
682
|
+
{
|
|
683
|
+
session: sid,
|
|
684
|
+
reason,
|
|
685
|
+
chars: block.length,
|
|
686
|
+
},
|
|
687
|
+
false,
|
|
688
|
+
);
|
|
689
|
+
epochRecomputes++;
|
|
690
|
+
// Ledger: a store_changed recompute of a live epoch is an
|
|
691
|
+
// invalidation (the first epoch of a session busts nothing). The
|
|
692
|
+
// epoch request count restarts with the new block.
|
|
693
|
+
const st = statsFor(sid);
|
|
694
|
+
st.epochRequests = 0;
|
|
695
|
+
if (reason === "store_changed") {
|
|
696
|
+
st.invalidations++;
|
|
697
|
+
st.lastInvalidation = { at, reason };
|
|
698
|
+
}
|
|
699
|
+
// Status hand-off for the terminal panel (src/tui.tsx) and any
|
|
700
|
+
// out-of-process surface. Best-effort; never breaks injection.
|
|
701
|
+
await flushStatus(sid);
|
|
702
|
+
};
|
|
703
|
+
|
|
657
704
|
// ---- hooks (all fail-closed) -------------------------------------------
|
|
658
705
|
// Bookkeeping: track the active session/tool/call before a tool runs.
|
|
659
706
|
await ctx.tool?.hook("execute.before", (event: any) =>
|
|
@@ -691,12 +738,14 @@ const impl = {
|
|
|
691
738
|
guarded("session.context (systemDigest)", async () => {
|
|
692
739
|
// Route store reads in this hook through the right session's scope.
|
|
693
740
|
setCurrent({ sessionID: event?.sessionID });
|
|
741
|
+
const sid = event?.sessionID;
|
|
742
|
+
const mems = await injectableRows();
|
|
743
|
+
if (typeof sid === "string") await refreshEpoch(sid, mems);
|
|
694
744
|
const parts: string[] = [];
|
|
695
|
-
await systemDigest({ sessionID: event?.sessionID }, { system: parts });
|
|
745
|
+
await systemDigest({ sessionID: event?.sessionID }, { system: parts }, mems);
|
|
696
746
|
for (const text of parts) event?.system?.push?.({ type: "text", text });
|
|
697
747
|
// Deliver this epoch's relevance-ranked memory via the system channel.
|
|
698
748
|
// The UI never renders system parts, unlike appended prompt text.
|
|
699
|
-
const sid = event?.sessionID;
|
|
700
749
|
const knowledge = typeof sid === "string" ? epoch.get(sid)?.block : undefined;
|
|
701
750
|
if (knowledge) event?.system?.push?.({ type: "text", text: knowledge });
|
|
702
751
|
// Count AT THE INJECTION DECISION so badge/panel numbers match exactly
|
|
@@ -712,10 +761,11 @@ const impl = {
|
|
|
712
761
|
}),
|
|
713
762
|
);
|
|
714
763
|
|
|
715
|
-
// Memory-first injection, frozen per epoch (cache-safe).
|
|
716
|
-
//
|
|
717
|
-
//
|
|
718
|
-
//
|
|
764
|
+
// Memory-first injection, frozen per epoch (cache-safe). Remember the
|
|
765
|
+
// latest user prompt here; the request-time context hook builds or
|
|
766
|
+
// invalidates the epoch immediately before delivery. Relevance staleness
|
|
767
|
+
// is accepted while the injectable rows stay unchanged, preserving a
|
|
768
|
+
// stable system prefix across ordinary turns.
|
|
719
769
|
// Never mutate event.prompt.text — it is stored verbatim and rendered in
|
|
720
770
|
// the UI.
|
|
721
771
|
await ctx.session?.hook("prompt", (event: any) =>
|
|
@@ -726,38 +776,8 @@ const impl = {
|
|
|
726
776
|
markTurn(sid);
|
|
727
777
|
await checkNewSession(sid);
|
|
728
778
|
await bindSessionFromMessages(sid);
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
if (!cached || cached.version !== version) {
|
|
732
|
-
const text = event?.prompt?.text ?? "";
|
|
733
|
-
const raw = await relevantKnowledge(text, 5); // widen candidate pool
|
|
734
|
-
const block = raw ? capToBudget(raw, budget) : "";
|
|
735
|
-
const at = new Date().toISOString();
|
|
736
|
-
const reason: "first" | "store_changed" = cached ? "store_changed" : "first";
|
|
737
|
-
epoch.set(sid, { version, block, at, reason });
|
|
738
|
-
await record(
|
|
739
|
-
"epoch_recompute",
|
|
740
|
-
{
|
|
741
|
-
session: sid,
|
|
742
|
-
reason,
|
|
743
|
-
chars: block.length,
|
|
744
|
-
},
|
|
745
|
-
false,
|
|
746
|
-
);
|
|
747
|
-
epochRecomputes++;
|
|
748
|
-
// Ledger: a store_changed recompute of a live epoch is an
|
|
749
|
-
// invalidation (the first epoch of a session busts nothing). The
|
|
750
|
-
// epoch request count restarts with the new block.
|
|
751
|
-
const st = statsFor(sid);
|
|
752
|
-
st.epochRequests = 0;
|
|
753
|
-
if (reason === "store_changed") {
|
|
754
|
-
st.invalidations++;
|
|
755
|
-
st.lastInvalidation = { at, reason };
|
|
756
|
-
}
|
|
757
|
-
// Status hand-off for the terminal panel (src/tui.tsx) and any
|
|
758
|
-
// out-of-process surface. Best-effort; never breaks injection.
|
|
759
|
-
await flushStatus(sid);
|
|
760
|
-
}
|
|
779
|
+
latestPrompt.set(sid, event?.prompt?.text ?? "");
|
|
780
|
+
await refreshEpoch(sid);
|
|
761
781
|
}
|
|
762
782
|
}),
|
|
763
783
|
);
|
|
@@ -772,7 +792,10 @@ const impl = {
|
|
|
772
792
|
guarded("session.compaction (onCompacting)", async () => {
|
|
773
793
|
setCurrent({ sessionID: event?.sessionID });
|
|
774
794
|
await onCompacting({ sessionID: event?.sessionID });
|
|
775
|
-
if (typeof event?.sessionID === "string")
|
|
795
|
+
if (typeof event?.sessionID === "string") {
|
|
796
|
+
epoch.delete(event.sessionID);
|
|
797
|
+
latestPrompt.delete(event.sessionID);
|
|
798
|
+
}
|
|
776
799
|
const anchor = await compactionAnchor();
|
|
777
800
|
if (anchor) event?.system?.push?.({ type: "text", text: anchor });
|
|
778
801
|
}),
|
package/src/inject.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
// results. Evidence is never injected. Open threads (status=
|
|
6
6
|
// "open") get a single capped frontier block in the digest so planning sessions
|
|
7
7
|
// start at the previous session's frontier instead of re-deriving it.
|
|
8
|
+
import { createHash } from "node:crypto";
|
|
8
9
|
import { isActive, loadAll, visibleHere, type Memory } from "./memory.ts";
|
|
9
10
|
import { record } from "./evidence.ts";
|
|
10
11
|
import { dateStr } from "./config.ts";
|
|
@@ -141,9 +142,10 @@ const frontierInjectedSessions = new Set<string>();
|
|
|
141
142
|
export async function systemDigest(
|
|
142
143
|
_input: unknown,
|
|
143
144
|
output: TransformOutput,
|
|
145
|
+
rows?: Memory[],
|
|
144
146
|
): Promise<void> {
|
|
145
147
|
// Repo-scoped: model-facing reads see this repo + global rows.
|
|
146
|
-
const mems = visibleHere((await loadAll()).filter(isActive));
|
|
148
|
+
const mems = rows ?? visibleHere((await loadAll()).filter(isActive));
|
|
147
149
|
if (!mems.length) return;
|
|
148
150
|
const latest = flattenMemoryText(mems.map((m) => m.ts).sort().pop() ?? "?");
|
|
149
151
|
output.system.push(
|
|
@@ -190,26 +192,33 @@ export async function systemDigest(
|
|
|
190
192
|
);
|
|
191
193
|
}
|
|
192
194
|
|
|
193
|
-
export async function relevantKnowledge(message: string, limit = 3): Promise<string> {
|
|
195
|
+
export async function relevantKnowledge(message: string, limit = 3, rows?: Memory[]): Promise<string> {
|
|
194
196
|
const text = message.trim();
|
|
195
197
|
if (!text) return "";
|
|
196
198
|
// Repo-scoped: model-facing reads see this repo + global rows.
|
|
197
|
-
const mems = visibleHere((await loadAll()).filter(isActive));
|
|
199
|
+
const mems = rows ?? visibleHere((await loadAll()).filter(isActive));
|
|
198
200
|
const top = scoreMemories(text, mems, limit);
|
|
199
201
|
if (!top.length) return "";
|
|
200
202
|
return `## Relevant durable memory${FRAME}\n${top.map(formatMemory).join("\n")}`;
|
|
201
203
|
}
|
|
202
204
|
|
|
203
|
-
// Deterministic fingerprint of the
|
|
204
|
-
//
|
|
205
|
-
//
|
|
206
|
-
//
|
|
207
|
-
|
|
205
|
+
// Deterministic fingerprint of the rows that can affect injection. Hash the
|
|
206
|
+
// exact rendered row plus its full id, in load order: content edits, blocklist
|
|
207
|
+
// changes and supersession all invalidate an epoch, while evidence, hidden
|
|
208
|
+
// rows and non-rendered metadata do not. This is only a linear hash over rows
|
|
209
|
+
// loadAll() already had to read and parse for the request.
|
|
210
|
+
export async function storeVersion(rows?: Memory[]): Promise<string> {
|
|
208
211
|
// Repo-scoped: model-facing reads see this repo + global rows.
|
|
209
|
-
const mems = visibleHere((await loadAll()).filter(isActive));
|
|
212
|
+
const mems = rows ?? visibleHere((await loadAll()).filter(isActive));
|
|
213
|
+
if (!mems.length) return "0:";
|
|
210
214
|
let max = "";
|
|
211
|
-
|
|
212
|
-
|
|
215
|
+
const hash = createHash("sha256");
|
|
216
|
+
for (const m of mems) {
|
|
217
|
+
if (m.ts > max) max = m.ts;
|
|
218
|
+
hash.update(JSON.stringify([m.ts, formatMemory(m)]));
|
|
219
|
+
hash.update("\n");
|
|
220
|
+
}
|
|
221
|
+
return `${mems.length}:${max}:${hash.digest("hex")}`;
|
|
213
222
|
}
|
|
214
223
|
|
|
215
224
|
// Trim an injection block to `budget` characters at WHOLE-line boundaries:
|
package/src/schema.ts
CHANGED
|
@@ -94,7 +94,7 @@ export const UndoArgs = z.object({
|
|
|
94
94
|
list: z.boolean().describe("List snapshots").optional(),
|
|
95
95
|
snapshot: z
|
|
96
96
|
.string()
|
|
97
|
-
.describe("
|
|
97
|
+
.describe("Store-owned snapshot to restore (bare name from list, or its full path)")
|
|
98
98
|
.optional(),
|
|
99
99
|
merge: z
|
|
100
100
|
.boolean()
|
package/src/undo.ts
CHANGED
|
@@ -10,12 +10,15 @@
|
|
|
10
10
|
// MERGE replays them on top of the snapshot; FORCE is the old exact rollback,
|
|
11
11
|
// and additionally snapshots the file it replaces, so the state it drops stays
|
|
12
12
|
// undoable (result.safety).
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
13
|
+
import { lstat, realpath, stat } from "node:fs/promises";
|
|
14
|
+
import { basename, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
15
|
+
import { deletionsFile, snapshotDir } from "./config.ts";
|
|
16
|
+
import { ensureDir, parseLine, readLines, writeAtomic } from "./logfmt.ts";
|
|
16
17
|
import { withStoreLock, LOCK_RESTORE } from "./lock.ts";
|
|
17
18
|
import { snapshotFiles } from "./memory.ts";
|
|
18
19
|
|
|
20
|
+
const SNAPSHOT_NAME = /^(\d+)-(\d{4}-\d{2}-\d{2}\.logfmt)$/;
|
|
21
|
+
|
|
19
22
|
export interface SnapshotInfo {
|
|
20
23
|
file: string;
|
|
21
24
|
name: string;
|
|
@@ -28,6 +31,7 @@ export async function listSnapshots(): Promise<SnapshotInfo[]> {
|
|
|
28
31
|
const out: SnapshotInfo[] = [];
|
|
29
32
|
try {
|
|
30
33
|
for await (const f of glob.scan(snapshotDir())) {
|
|
34
|
+
if (!SNAPSHOT_NAME.test(f)) continue;
|
|
31
35
|
const info = Bun.file(join(snapshotDir(), f));
|
|
32
36
|
out.push({
|
|
33
37
|
file: join(snapshotDir(), f),
|
|
@@ -55,6 +59,9 @@ export interface RestoreResult {
|
|
|
55
59
|
// (added after it — or in-place edits, listed under their original ts).
|
|
56
60
|
concurrentAdded: string[];
|
|
57
61
|
concurrentChanged: string[];
|
|
62
|
+
// ts of rows intentionally deleted after this snapshot. CHECK and MERGE
|
|
63
|
+
// leave them deleted; FORCE is the explicit recovery path.
|
|
64
|
+
deletedOmitted: string[];
|
|
58
65
|
}
|
|
59
66
|
|
|
60
67
|
// Rows are keyed by their ts= field; unparseable lines key on the whole line.
|
|
@@ -66,33 +73,113 @@ function rows(text: string): string[] {
|
|
|
66
73
|
return text.split("\n").filter((l) => l.trim());
|
|
67
74
|
}
|
|
68
75
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
76
|
+
function isWithin(parent: string, child: string): boolean {
|
|
77
|
+
const rel = relative(parent, child);
|
|
78
|
+
return rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function resolveSnapshot(
|
|
82
|
+
snapshot: string,
|
|
83
|
+
memoryRootDir: string,
|
|
84
|
+
): Promise<{ path: string; original: string }> {
|
|
85
|
+
const name = basename(snapshot);
|
|
86
|
+
const match = SNAPSHOT_NAME.exec(name);
|
|
87
|
+
const original = match?.[2];
|
|
88
|
+
const stamp = match ? Number(match[1]) : Number.NaN;
|
|
89
|
+
if (!original || !Number.isSafeInteger(stamp)) {
|
|
90
|
+
throw new Error(`invalid snapshot name: ${name}`);
|
|
91
|
+
}
|
|
92
|
+
if (!isAbsolute(snapshot) && snapshot !== name) {
|
|
93
|
+
throw new Error("snapshot must be a bare name from memory_undo list or a full path");
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const rootResolved = resolve(memoryRootDir);
|
|
97
|
+
const snapshotsResolved = resolve(rootResolved, "snapshots");
|
|
98
|
+
const requestedResolved = isAbsolute(snapshot)
|
|
99
|
+
? resolve(snapshot)
|
|
100
|
+
: resolve(snapshotsResolved, name);
|
|
101
|
+
if (!isWithin(snapshotsResolved, requestedResolved)) {
|
|
102
|
+
throw new Error(`snapshot is outside the snapshots directory: ${snapshot}`);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
let rootReal: string;
|
|
106
|
+
let snapshotsReal: string;
|
|
107
|
+
let snapshotReal: string;
|
|
108
|
+
try {
|
|
109
|
+
[rootReal, snapshotsReal, snapshotReal] = await Promise.all([
|
|
110
|
+
realpath(rootResolved),
|
|
111
|
+
realpath(snapshotsResolved),
|
|
112
|
+
realpath(requestedResolved),
|
|
113
|
+
]);
|
|
114
|
+
} catch {
|
|
115
|
+
throw new Error(`snapshot not found: ${snapshot}`);
|
|
116
|
+
}
|
|
117
|
+
if (!isWithin(rootReal, snapshotsReal)) {
|
|
118
|
+
throw new Error("snapshots directory escapes the memory root");
|
|
119
|
+
}
|
|
120
|
+
if (!isWithin(snapshotsReal, snapshotReal)) {
|
|
121
|
+
throw new Error(`snapshot is outside the snapshots directory: ${snapshot}`);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const [linkInfo, fileInfo] = await Promise.all([
|
|
125
|
+
lstat(requestedResolved),
|
|
126
|
+
stat(snapshotReal),
|
|
127
|
+
]);
|
|
128
|
+
if (linkInfo.isSymbolicLink() || !fileInfo.isFile()) {
|
|
129
|
+
throw new Error(`snapshot is not a regular file: ${snapshot}`);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return { path: snapshotReal, original };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// ts of every row memory_forget or a viewer delete has removed. Ordering
|
|
136
|
+
// against the snapshot is not needed: a snapshot row with a deletion audit
|
|
137
|
+
// that is absent from the live file was removed on purpose.
|
|
138
|
+
async function auditedDeletions(): Promise<Set<string>> {
|
|
139
|
+
const deleted = new Set<string>();
|
|
140
|
+
for (const line of await readLines(deletionsFile())) {
|
|
141
|
+
const fields = parseLine(line);
|
|
142
|
+
if (fields?.action !== "deleted" || typeof fields.original_ts !== "string") continue;
|
|
143
|
+
deleted.add(fields.original_ts);
|
|
144
|
+
}
|
|
145
|
+
return deleted;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Restore `snapshot` over its original file. Only `<stamp>-<YYYY-MM-DD>.logfmt`
|
|
149
|
+
// files inside <memory-root>/snapshots/ are accepted (the viewer restore's
|
|
150
|
+
// grammar); the stamp is stripped to recover the target day file. A bare name
|
|
151
|
+
// (as printed by the list) resolves against <memory-root>/snapshots/.
|
|
72
152
|
export async function restoreSnapshot(
|
|
73
153
|
snapshot: string,
|
|
74
154
|
memoryRootDir: string,
|
|
75
155
|
mode: RestoreMode = "check",
|
|
76
156
|
): Promise<RestoreResult> {
|
|
77
|
-
const
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
157
|
+
const snap = await resolveSnapshot(snapshot, memoryRootDir);
|
|
158
|
+
const rootReal = await realpath(resolve(memoryRootDir));
|
|
159
|
+
const realTarget = resolve(rootReal, snap.original);
|
|
160
|
+
if (!isWithin(rootReal, realTarget)) {
|
|
161
|
+
throw new Error(`snapshot target escapes the memory root: ${snap.original}`);
|
|
82
162
|
}
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
// carries no separators — but a restore must never write outside the memory
|
|
89
|
-
// root no matter what a future caller passes.
|
|
90
|
-
const rootAbs = resolve(memoryRootDir);
|
|
91
|
-
const target = resolve(rootAbs, original);
|
|
92
|
-
if (!target.startsWith(rootAbs + sep)) {
|
|
93
|
-
throw new Error(`snapshot target escapes the memory root: ${original}`);
|
|
163
|
+
let targetInfo: Awaited<ReturnType<typeof lstat>> | undefined;
|
|
164
|
+
try {
|
|
165
|
+
targetInfo = await lstat(realTarget);
|
|
166
|
+
} catch (err) {
|
|
167
|
+
if (!(err instanceof Error && "code" in err && err.code === "ENOENT")) throw err;
|
|
94
168
|
}
|
|
95
|
-
|
|
169
|
+
if (targetInfo) {
|
|
170
|
+
if (targetInfo.isSymbolicLink()) {
|
|
171
|
+
throw new Error(`snapshot target must not be a symbolic link: ${snap.original}`);
|
|
172
|
+
}
|
|
173
|
+
const targetReal = await realpath(realTarget);
|
|
174
|
+
if (relative(realTarget, targetReal) !== "") {
|
|
175
|
+
throw new Error(`snapshot target must not be a symbolic link: ${snap.original}`);
|
|
176
|
+
}
|
|
177
|
+
if (!targetInfo.isFile()) throw new Error(`snapshot target is not a file: ${snap.original}`);
|
|
178
|
+
}
|
|
179
|
+
// Checks above ran on real paths; lock and write through the same lexical
|
|
180
|
+
// path every other writer uses, so the in-process lock queue is shared.
|
|
181
|
+
const target = resolve(memoryRootDir, snap.original);
|
|
182
|
+
const rawSnapLines = rows(await Bun.file(snap.path).text());
|
|
96
183
|
|
|
97
184
|
// Comparing and publishing happen under the same lock: a divergence check on
|
|
98
185
|
// a stale read would wave through a row that landed after it. A restore waits
|
|
@@ -104,6 +191,17 @@ export async function restoreSnapshot(
|
|
|
104
191
|
async () => {
|
|
105
192
|
const cur = Bun.file(target);
|
|
106
193
|
const curLines = (await cur.exists()) ? rows(await cur.text()) : [];
|
|
194
|
+
// Deliberate deletions are read inside the lock too, so a forget that
|
|
195
|
+
// finished just before this restore is honoured.
|
|
196
|
+
const audited = mode !== "force" ? await auditedDeletions() : new Set<string>();
|
|
197
|
+
const live = new Set(curLines.map(rowKey));
|
|
198
|
+
const deletedOmitted: string[] = [];
|
|
199
|
+
const snapLines = rawSnapLines.filter((line) => {
|
|
200
|
+
const key = rowKey(line);
|
|
201
|
+
if (!audited.has(key) || live.has(key)) return true;
|
|
202
|
+
deletedOmitted.push(key);
|
|
203
|
+
return false;
|
|
204
|
+
});
|
|
107
205
|
|
|
108
206
|
const snapByKey = new Map(snapLines.map((l) => [rowKey(l), l]));
|
|
109
207
|
const concurrentAdded: string[] = [];
|
|
@@ -118,7 +216,7 @@ export async function restoreSnapshot(
|
|
|
118
216
|
}
|
|
119
217
|
|
|
120
218
|
if (mode === "check" && (concurrentAdded.length || concurrentChanged.length)) {
|
|
121
|
-
return { target, restored: false, mode, concurrentAdded, concurrentChanged };
|
|
219
|
+
return { target, restored: false, mode, concurrentAdded, concurrentChanged, deletedOmitted };
|
|
122
220
|
}
|
|
123
221
|
|
|
124
222
|
// FORCE is the mode that drops rows, so the file it replaces is
|
|
@@ -140,7 +238,7 @@ export async function restoreSnapshot(
|
|
|
140
238
|
}
|
|
141
239
|
await ensureDir(memoryRootDir);
|
|
142
240
|
await writeAtomic(target, out.length ? out.join("\n") + "\n" : "");
|
|
143
|
-
return { target, restored: true, mode, safety, concurrentAdded, concurrentChanged };
|
|
241
|
+
return { target, restored: true, mode, safety, concurrentAdded, concurrentChanged, deletedOmitted };
|
|
144
242
|
},
|
|
145
243
|
LOCK_RESTORE,
|
|
146
244
|
);
|