opencode-self-improvement 0.8.0 → 0.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +15 -6
- package/package.json +1 -1
- package/src/blocklist.ts +26 -11
- package/src/index.ts +2 -1
- package/src/lock.ts +202 -0
- package/src/logfmt.ts +23 -1
- package/src/memory.ts +149 -119
- package/src/undo.ts +61 -37
- package/src/viewer/mutate.ts +19 -9
- package/src/viewer/server.ts +6 -6
package/README.md
CHANGED
|
@@ -41,6 +41,12 @@ LLMs waste a lot of tokens reading and re-reading your code and asking you for c
|
|
|
41
41
|
superseded memories are rejected. Blocked writes are logged with a reason
|
|
42
42
|
(`memory_evidence`), and nothing is ever deleted — updates supersede the old row and
|
|
43
43
|
`memory_undo` rolls back.
|
|
44
|
+
- **Safe across concurrent sessions** — the daily store file is shared by every
|
|
45
|
+
opencode session on the machine plus the viewer, so the whole-file rewrites
|
|
46
|
+
(update, forget, undo, viewer delete) hold a per-file lock that is honored
|
|
47
|
+
across processes, and hand the new file over with an atomic rename. Another
|
|
48
|
+
session's write is never overwritten, and a reader never sees a half-written
|
|
49
|
+
store.
|
|
44
50
|
- Automatically looks for insights when you complete the verification gates (tests, linting, etc)
|
|
45
51
|
- Always defaults to memories first before sending a prompt to the LLM, which saves tokens!
|
|
46
52
|
|
|
@@ -182,7 +188,7 @@ Add a `plugins` entry to your opencode config —
|
|
|
182
188
|
```json
|
|
183
189
|
{
|
|
184
190
|
"$schema": "https://opencode.ai/config.json",
|
|
185
|
-
"plugins": ["opencode-self-improvement
|
|
191
|
+
"plugins": ["opencode-self-improvement"]
|
|
186
192
|
}
|
|
187
193
|
```
|
|
188
194
|
|
|
@@ -190,13 +196,15 @@ Opencode installs the package (and its dependencies) automatically with Bun at
|
|
|
190
196
|
startup. Quit and restart opencode; the plugin loads and `/repo-init` installs
|
|
191
197
|
itself. Nothing else to do.
|
|
192
198
|
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
199
|
+
An unpinned entry resolves `@latest` exactly once and is never re-resolved
|
|
200
|
+
afterwards, so updates are deliberate: `opencode plugin update
|
|
201
|
+
opencode-self-improvement`. To install a specific release, append
|
|
202
|
+
`@<version>` — see [npm](https://www.npmjs.com/package/opencode-self-improvement)
|
|
203
|
+
for what's current — and bump it when you want to move. On Opencode v2 you
|
|
204
|
+
can also manage it with the CLI:
|
|
197
205
|
|
|
198
206
|
```sh
|
|
199
|
-
opencode plugin add opencode-self-improvement
|
|
207
|
+
opencode plugin add opencode-self-improvement
|
|
200
208
|
opencode plugin update opencode-self-improvement
|
|
201
209
|
```
|
|
202
210
|
|
|
@@ -398,6 +406,7 @@ Run `bun memory-view` in your terminal and visit http://127.0.0.1:8787 in your
|
|
|
398
406
|
| `memory_remember` says blocked | A gate rejected the write (stale code reference, a cited `path/to/file.ts` that doesn't exist in the repo, overlap with a superseded memory, or an ambiguous claim). The reason is logged — check `memory_evidence`, then rephrase against current code and retry. |
|
|
399
407
|
| `invalid arguments to memory_*` | The tool call didn't match the tool's input schema — nothing was done, and the message names the offending fields. Schema validation lives at the tool boundary; gate blocks look different (`blocked at GATE (…)`). |
|
|
400
408
|
| STORE shows `N malformed` | Rows in a daily file have a `ts=` that is not a valid ISO instant (typically a hand-edit gone wrong). Every reader skips them; fix the `ts=` value or restore the file with `memory_undo`. |
|
|
409
|
+
| `memory store is busy`, or a stray `*.logfmt.lock` | Another session or the viewer has held that store file for over 5s. Ordinary writes proceed anyway; only `memory_undo` and the viewer's restore refuse, because a forced rollback under a live writer would drop rows nobody agreed to drop. Retry. A lock left behind by a killed process is taken over after 10s; `*.lock` and `*.tmp` files in the store are write bookkeeping, safe to delete while nothing is writing. |
|
|
401
410
|
| `/repo-init` missing from the command list | The self-install couldn't write to `~/.config/opencode/commands/` (e.g. read-only config dir). Create it, restart, or just ask the model to "init memory for this repo" — it calls the same tool. |
|
|
402
411
|
| Init says `Graph: unavailable` | `codebase-memory-mcp` couldn't start (not installed, or its once-only download is blocked). Install pinned `0.11.0` via the one-liner above, or point `OPENCODE_CBM_BIN` at the binary. Scripts are still remembered without it. |
|
|
403
412
|
| Stale plugin after an update | Bump the pinned version in the `plugin` entry (or `opencode plugin update opencode-self-improvement` on v2) and restart opencode. |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-self-improvement",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.1",
|
|
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/blocklist.ts
CHANGED
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
// memory scan must never pick it up.
|
|
6
6
|
import { join } from "node:path";
|
|
7
7
|
import { memoryRoot } from "./config.ts";
|
|
8
|
-
import { appendLog, formatLine, parseLine, readLines } from "./logfmt.ts";
|
|
8
|
+
import { appendLog, formatLine, parseLine, readLines, writeAtomic } from "./logfmt.ts";
|
|
9
|
+
import { withStoreLock, LOCK_WRITE } from "./lock.ts";
|
|
9
10
|
|
|
10
11
|
const BLOCKLIST_FILENAME = "blocklist.logfmt";
|
|
11
12
|
|
|
@@ -40,17 +41,31 @@ export async function blockedTs(): Promise<Set<string>> {
|
|
|
40
41
|
|
|
41
42
|
export async function addBlock(ts: string, reason: string): Promise<string> {
|
|
42
43
|
if (!reason.trim()) throw new Error("reason is required to block a memory");
|
|
43
|
-
|
|
44
|
+
const file = blocklistFile();
|
|
45
|
+
await withStoreLock(
|
|
46
|
+
file,
|
|
47
|
+
() => appendLog(file, formatLine({ ts, reason, blocked_at: new Date().toISOString() })),
|
|
48
|
+
LOCK_WRITE,
|
|
49
|
+
);
|
|
44
50
|
return ts;
|
|
45
51
|
}
|
|
46
52
|
|
|
53
|
+
// Unblocking filters the file down, so it republishes it whole: both paths hold
|
|
54
|
+
// the blocklist lock or the slower one loses the other's entries.
|
|
47
55
|
export async function removeBlock(ts: string): Promise<boolean> {
|
|
48
|
-
const file =
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
56
|
+
const file = blocklistFile();
|
|
57
|
+
return withStoreLock(
|
|
58
|
+
file,
|
|
59
|
+
async () => {
|
|
60
|
+
const store = Bun.file(file);
|
|
61
|
+
if (!(await store.exists())) return false;
|
|
62
|
+
const kept = (await store.text()).split("\n").filter((l) => {
|
|
63
|
+
const row = parseLine(l);
|
|
64
|
+
return !(row && row.ts === ts);
|
|
65
|
+
});
|
|
66
|
+
await writeAtomic(file, kept.join("\n") + (kept.length ? "\n" : ""));
|
|
67
|
+
return true;
|
|
68
|
+
},
|
|
69
|
+
LOCK_WRITE,
|
|
70
|
+
);
|
|
71
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -415,7 +415,8 @@ const impl = {
|
|
|
415
415
|
const kept = res.mode === "merge" && (res.concurrentAdded.length || res.concurrentChanged.length)
|
|
416
416
|
? ` Kept ${res.concurrentAdded.length} added / ${res.concurrentChanged.length} changed row(s) written after the snapshot.`
|
|
417
417
|
: "";
|
|
418
|
-
|
|
418
|
+
const safety = res.safety ? ` Pre-restore file snapshotted -> ${res.safety}.` : "";
|
|
419
|
+
return { content: `Restored ${basename(res.target)} from ${res.mode} snapshot -> ${res.target}.${kept}${safety}` };
|
|
419
420
|
}
|
|
420
421
|
const snaps = await listSnapshots();
|
|
421
422
|
if (!snaps.length) return { content: "No snapshots" };
|
package/src/lock.ts
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
// Serialization for the store's write paths.
|
|
2
|
+
//
|
|
3
|
+
// Daily store files are shared by every opencode session on the machine plus
|
|
4
|
+
// the viewer, which is a separate process. Appending is atomic on its own
|
|
5
|
+
// (O_APPEND, see logfmt.ts), but supersede, prune, viewer delete and snapshot
|
|
6
|
+
// restore each REWRITE a whole file: a writer that read before someone else
|
|
7
|
+
// wrote publishes a stale copy and silently drops the other's rows. Measured
|
|
8
|
+
// before this module existed: 40 concurrent appends against 40 supersedes left
|
|
9
|
+
// 40 rows on disk; four processes writing one store kept 20 of 120.
|
|
10
|
+
//
|
|
11
|
+
// One lock per store file, held across the whole read-modify-write. Appends take
|
|
12
|
+
// the same lock so they cannot land inside an RMW window. Two layers, sharing
|
|
13
|
+
// one timeout budget:
|
|
14
|
+
// - a FIFO hand-off between async tasks in THIS process, so contending
|
|
15
|
+
// writers take their turn instead of spinning;
|
|
16
|
+
// - a lock FILE created with O_EXCL, which is atomic across processes (and
|
|
17
|
+
// exclusive against our own tasks too, since the check is in the kernel).
|
|
18
|
+
// A killed holder is recovered by age. A writer that waits past the budget
|
|
19
|
+
// gives up waiting — see onTimeout.
|
|
20
|
+
//
|
|
21
|
+
// Readers take NO lock: publishers hand over the new file with a rename
|
|
22
|
+
// (writeAtomic in logfmt.ts), so a reader always sees a complete store, never
|
|
23
|
+
// the middle of a rewrite.
|
|
24
|
+
import { mkdir, open, stat, unlink } from "node:fs/promises";
|
|
25
|
+
import { basename, dirname } from "node:path";
|
|
26
|
+
import { record } from "./evidence.ts";
|
|
27
|
+
|
|
28
|
+
const LOCK_SUFFIX = ".lock";
|
|
29
|
+
// A lock older than this belongs to a process that died holding it.
|
|
30
|
+
const STALE_MS = 10_000;
|
|
31
|
+
const DEFAULT_TIMEOUT_MS = 5_000;
|
|
32
|
+
const MIN_BACKOFF_MS = 1;
|
|
33
|
+
const MAX_BACKOFF_MS = 25;
|
|
34
|
+
|
|
35
|
+
export function lockFileFor(file: string): string {
|
|
36
|
+
return file + LOCK_SUFFIX;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Waiting past the timeout, or taking over a dead holder's lock. */
|
|
40
|
+
export type LockEvent = "steal" | "timeout";
|
|
41
|
+
|
|
42
|
+
export interface LockOptions {
|
|
43
|
+
timeoutMs?: number;
|
|
44
|
+
/**
|
|
45
|
+
* What waiting past `timeoutMs` means. "fail" refuses the mutation (for
|
|
46
|
+
* callers where proceeding unlocked could destroy more than it saves, e.g.
|
|
47
|
+
* snapshot restore). "proceed" runs it anyway: strictly no worse than the
|
|
48
|
+
* pre-lock behaviour, and it never costs a user their write.
|
|
49
|
+
*/
|
|
50
|
+
onTimeout?: "proceed" | "fail";
|
|
51
|
+
onEvent?: (event: LockEvent, waitedMs: number) => void;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// How each store caller treats contention. An ordinary write PROCEEDS past the
|
|
55
|
+
// timeout rather than lose the user's memory, and the wait is audited; restore
|
|
56
|
+
// fails closed, because "force" drops rows and must not guess under a racing
|
|
57
|
+
// writer.
|
|
58
|
+
export const LOCK_WRITE: LockOptions = {
|
|
59
|
+
onEvent: (event, waitedMs) => {
|
|
60
|
+
void record("store_lock", { event, waited_ms: waitedMs, op: "write" });
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
export const LOCK_RESTORE: LockOptions = {
|
|
65
|
+
onTimeout: "fail",
|
|
66
|
+
onEvent: (event, waitedMs) => {
|
|
67
|
+
void record("store_lock", { event, waited_ms: waitedMs, op: "restore" });
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
|
72
|
+
|
|
73
|
+
// Resolves when the task stored as this key has finished, handing the turn to
|
|
74
|
+
// whoever queued after it.
|
|
75
|
+
const turns = new Map<string, Promise<void>>();
|
|
76
|
+
|
|
77
|
+
async function timed(prev: Promise<void>, ms: number): Promise<boolean> {
|
|
78
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
79
|
+
const gained = await Promise.race([
|
|
80
|
+
prev.then(() => true),
|
|
81
|
+
new Promise<boolean>((res) => (timer = setTimeout(() => res(false), ms))),
|
|
82
|
+
]);
|
|
83
|
+
clearTimeout(timer);
|
|
84
|
+
return gained;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function stealIfStale(lockPath: string, now: number): Promise<boolean> {
|
|
88
|
+
let age = -1;
|
|
89
|
+
try {
|
|
90
|
+
age = now - (await stat(lockPath)).mtimeMs;
|
|
91
|
+
} catch {
|
|
92
|
+
return true; // vanished between EEXIST and stat: retry the create
|
|
93
|
+
}
|
|
94
|
+
if (age < STALE_MS) return false;
|
|
95
|
+
try {
|
|
96
|
+
await unlink(lockPath);
|
|
97
|
+
} catch {
|
|
98
|
+
return false; // another stealer won; its create decides
|
|
99
|
+
}
|
|
100
|
+
return true;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Cross-process half: hold the lock file while `fn` runs. */
|
|
104
|
+
async function holdFileLock<T>(
|
|
105
|
+
lockPath: string,
|
|
106
|
+
file: string,
|
|
107
|
+
fn: () => Promise<T>,
|
|
108
|
+
opts: LockOptions,
|
|
109
|
+
budgetMs: number,
|
|
110
|
+
): Promise<T> {
|
|
111
|
+
await mkdir(dirname(lockPath), { recursive: true });
|
|
112
|
+
const token = `${process.pid}-${Math.random().toString(36).slice(2)}`;
|
|
113
|
+
const started = Date.now();
|
|
114
|
+
let backoff = MIN_BACKOFF_MS;
|
|
115
|
+
for (;;) {
|
|
116
|
+
try {
|
|
117
|
+
const handle = await open(lockPath, "wx");
|
|
118
|
+
try {
|
|
119
|
+
await handle.write(token);
|
|
120
|
+
} finally {
|
|
121
|
+
await handle.close();
|
|
122
|
+
}
|
|
123
|
+
break;
|
|
124
|
+
} catch (err) {
|
|
125
|
+
const code = (err as NodeJS.ErrnoException).code;
|
|
126
|
+
if (code !== "EEXIST") throw err;
|
|
127
|
+
if (await stealIfStale(lockPath, Date.now())) {
|
|
128
|
+
opts.onEvent?.("steal", Date.now() - started);
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
if (Date.now() - started >= budgetMs) {
|
|
132
|
+
opts.onEvent?.("timeout", Date.now() - started);
|
|
133
|
+
if (opts.onTimeout === "fail") {
|
|
134
|
+
throw new Error(
|
|
135
|
+
`memory store is busy: ${basename(file)} has been locked for ${Date.now() - started}ms — retry`,
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
return fn();
|
|
139
|
+
}
|
|
140
|
+
await sleep(backoff);
|
|
141
|
+
backoff = Math.min(backoff * 2, MAX_BACKOFF_MS);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
try {
|
|
145
|
+
return await fn();
|
|
146
|
+
} finally {
|
|
147
|
+
// Never release a lock that is no longer ours: if we ran long and someone
|
|
148
|
+
// stole it, unlinking would clobber the new holder.
|
|
149
|
+
try {
|
|
150
|
+
if ((await Bun.file(lockPath).text()) === token) await unlink(lockPath);
|
|
151
|
+
} catch {
|
|
152
|
+
// already gone
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Run `fn` as the only writer of `file`, in this process and every other one.
|
|
159
|
+
* Sections run flat: calling this again for the same file from inside `fn`
|
|
160
|
+
* waits for its own lock.
|
|
161
|
+
*/
|
|
162
|
+
export async function withStoreLock<T>(
|
|
163
|
+
file: string,
|
|
164
|
+
fn: () => Promise<T>,
|
|
165
|
+
opts: LockOptions = {},
|
|
166
|
+
): Promise<T> {
|
|
167
|
+
const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
168
|
+
const started = Date.now();
|
|
169
|
+
const lockPath = lockFileFor(file);
|
|
170
|
+
|
|
171
|
+
let releaseTurn!: () => void;
|
|
172
|
+
const myTurnEnded = new Promise<void>((res) => (releaseTurn = res));
|
|
173
|
+
const queuedBehind = turns.get(file);
|
|
174
|
+
turns.set(file, myTurnEnded);
|
|
175
|
+
|
|
176
|
+
const finish = () => {
|
|
177
|
+
releaseTurn();
|
|
178
|
+
if (turns.get(file) === myTurnEnded) turns.delete(file);
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
if (queuedBehind) {
|
|
182
|
+
const gained = await timed(queuedBehind, timeoutMs);
|
|
183
|
+
if (!gained) {
|
|
184
|
+
// Give up our place in line rather than park everyone behind us on a
|
|
185
|
+
// writer that is not going to finish.
|
|
186
|
+
finish();
|
|
187
|
+
opts.onEvent?.("timeout", Date.now() - started);
|
|
188
|
+
if (opts.onTimeout === "fail") {
|
|
189
|
+
throw new Error(
|
|
190
|
+
`memory store is busy: ${basename(file)} has been locked for ${Date.now() - started}ms — retry`,
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
return fn();
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
try {
|
|
198
|
+
return await holdFileLock(lockPath, file, fn, opts, timeoutMs - (Date.now() - started));
|
|
199
|
+
} finally {
|
|
200
|
+
finish();
|
|
201
|
+
}
|
|
202
|
+
}
|
package/src/logfmt.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Minimal logfmt reader/writer shared by the durable store and the evidence
|
|
2
2
|
// log. Field order is preserved on write so chains are diffable.
|
|
3
3
|
import { dirname } from "node:path";
|
|
4
|
-
import { appendFile, mkdir } from "node:fs/promises";
|
|
4
|
+
import { appendFile, mkdir, rename, unlink } from "node:fs/promises";
|
|
5
5
|
|
|
6
6
|
export type Fields = Record<string, unknown>;
|
|
7
7
|
|
|
@@ -88,9 +88,31 @@ export async function appendLog(file: string, line: string): Promise<void> {
|
|
|
88
88
|
// covering interleaved async tasks in the service AND a second process
|
|
89
89
|
// (the viewer) appending the same day file. Do NOT "simplify" back to
|
|
90
90
|
// Bun.write with an options object: on Bun 1.4.3 that silently TRUNCATES.
|
|
91
|
+
// Appending is safe against other appenders only. Callers writing a file that
|
|
92
|
+
// is ALSO rewritten whole (a daily memory file, the blocklist) hold the store
|
|
93
|
+
// lock around this — see lock.ts.
|
|
91
94
|
await appendFile(file, next);
|
|
92
95
|
}
|
|
93
96
|
|
|
97
|
+
// Publish whole-file content so no reader can observe a partial store. Recall,
|
|
98
|
+
// injection and the viewer read these files without taking the lock, so the
|
|
99
|
+
// handover is a same-directory rename (atomic on POSIX): a reader sees either
|
|
100
|
+
// the previous file or the new one, never a truncated middle. Callers hold the
|
|
101
|
+
// store lock for the whole read-modify-write.
|
|
102
|
+
export async function writeAtomic(file: string, content: string): Promise<void> {
|
|
103
|
+
await ensureDir(dirname(file));
|
|
104
|
+
// One writer per file (the store lock), so the temp name is stable per
|
|
105
|
+
// process and a crash leaves one leftover instead of one per attempt.
|
|
106
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
107
|
+
try {
|
|
108
|
+
await Bun.write(tmp, content);
|
|
109
|
+
await rename(tmp, file);
|
|
110
|
+
} catch (err) {
|
|
111
|
+
await unlink(tmp).catch(() => undefined);
|
|
112
|
+
throw err;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
94
116
|
export async function readLines(file: string): Promise<string[]> {
|
|
95
117
|
const f = Bun.file(file);
|
|
96
118
|
if (!(await f.exists())) return [];
|
package/src/memory.ts
CHANGED
|
@@ -3,11 +3,13 @@
|
|
|
3
3
|
// vendor/opencode-memory/LICENSE) and extends it with the fields the write-gate
|
|
4
4
|
// needs: supersession markers, snapshots and audited deletion.
|
|
5
5
|
import { existsSync } from "node:fs";
|
|
6
|
+
import { open } from "node:fs/promises";
|
|
6
7
|
import { join, basename } from "node:path";
|
|
7
|
-
import { memoryRoot, deletionsFile, snapshotDir } from "./config.ts";
|
|
8
|
+
import { dateStr, memoryRoot, deletionsFile, snapshotDir } from "./config.ts";
|
|
8
9
|
import { repoScopeFor } from "./repokey.ts";
|
|
9
10
|
import { getCurrent } from "./state.ts";
|
|
10
|
-
import { appendLog, ensureDir, formatLine, parseLine, readLines } from "./logfmt.ts";
|
|
11
|
+
import { appendLog, ensureDir, formatLine, parseLine, readLines, writeAtomic, type Fields } from "./logfmt.ts";
|
|
12
|
+
import { withStoreLock, LOCK_WRITE } from "./lock.ts";
|
|
11
13
|
import { blockedTs } from "./blocklist.ts";
|
|
12
14
|
|
|
13
15
|
export type MemoryStatus = "open" | "settled";
|
|
@@ -143,16 +145,15 @@ export interface AppendMemoryInput {
|
|
|
143
145
|
repoPath?: string;
|
|
144
146
|
}
|
|
145
147
|
|
|
146
|
-
//
|
|
147
|
-
//
|
|
148
|
-
//
|
|
149
|
-
|
|
148
|
+
// ts values already in `file`. An appended row's ts is "now", so the only rows
|
|
149
|
+
// it can collide with are ones this same file received in the same millisecond;
|
|
150
|
+
// scanning just it keeps the locked section to one read of one file. (A row
|
|
151
|
+
// hand-placed with today's ts in another day's file is outside this promise.)
|
|
152
|
+
async function existingTs(file: string): Promise<Set<string>> {
|
|
150
153
|
const set = new Set<string>();
|
|
151
|
-
for (const
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
if (f && typeof f.ts === "string") set.add(f.ts);
|
|
155
|
-
}
|
|
154
|
+
for (const line of await readLines(file)) {
|
|
155
|
+
const f = parseLine(line);
|
|
156
|
+
if (f && typeof f.ts === "string") set.add(f.ts);
|
|
156
157
|
}
|
|
157
158
|
return set;
|
|
158
159
|
}
|
|
@@ -166,17 +167,11 @@ export async function appendMemory(input: AppendMemoryInput): Promise<string> {
|
|
|
166
167
|
if (input.content.length > MAX_CONTENT_CHARS) {
|
|
167
168
|
throw new Error(`memory content exceeds ${MAX_CONTENT_CHARS} chars (${input.content.length})`);
|
|
168
169
|
}
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
}
|
|
175
|
-
// Repo stamp at the single write choke point: gated writes,
|
|
176
|
-
// viewer restores and edits all pass through here. Explicit "global"
|
|
177
|
-
// survives; an unscoped process (viewer) stamps nothing = global. Session
|
|
178
|
-
// identity wins over the module scope so a server hosting several workspaces
|
|
179
|
-
// stamps the writing session's repo.
|
|
170
|
+
// Repo stamp at the single write choke point: gated writes, viewer restores
|
|
171
|
+
// and edits all pass through here. Explicit "global" survives; an unscoped
|
|
172
|
+
// process (viewer) stamps nothing = global. Session identity wins over the
|
|
173
|
+
// module scope so a server hosting several workspaces stamps the writing
|
|
174
|
+
// session's repo.
|
|
180
175
|
let repo = input.repo;
|
|
181
176
|
let repoPath = input.repoPath;
|
|
182
177
|
if (repo === undefined) {
|
|
@@ -186,22 +181,36 @@ export async function appendMemory(input: AppendMemoryInput): Promise<string> {
|
|
|
186
181
|
repoPath = cur.path;
|
|
187
182
|
}
|
|
188
183
|
}
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
|
|
184
|
+
// Day file chosen before the lock (it IS the lock key). A ts bumped across
|
|
185
|
+
// midnight therefore lands under the previous day's name; readers glob every
|
|
186
|
+
// file and decay compares ts values, so the grouping is cosmetic.
|
|
187
|
+
const file = join(memoryRoot(), `${dateStr()}.logfmt`);
|
|
188
|
+
// Locked end to end: seeding ts from a scan another process can race yields
|
|
189
|
+
// two rows with the same identity, and an append that lands inside someone
|
|
190
|
+
// else's whole-file rewrite is simply not there afterwards.
|
|
191
|
+
return withStoreLock(file, async () => {
|
|
192
|
+
const taken = await existingTs(file);
|
|
193
|
+
let ts = new Date().toISOString();
|
|
194
|
+
let guard = 0;
|
|
195
|
+
while (taken.has(ts) && guard++ < 1000) {
|
|
196
|
+
ts = new Date(new Date(ts).getTime() + 1).toISOString();
|
|
197
|
+
}
|
|
198
|
+
await appendLog(
|
|
199
|
+
file,
|
|
200
|
+
formatLine({
|
|
201
|
+
ts,
|
|
202
|
+
type: input.type,
|
|
203
|
+
scope: input.scope,
|
|
204
|
+
content: input.content,
|
|
205
|
+
issue: input.issue,
|
|
206
|
+
tags: input.tags?.length ? input.tags.join(",") : undefined,
|
|
207
|
+
status: input.status,
|
|
208
|
+
repo,
|
|
209
|
+
repoPath,
|
|
210
|
+
}),
|
|
211
|
+
);
|
|
212
|
+
return ts;
|
|
213
|
+
}, LOCK_WRITE);
|
|
205
214
|
}
|
|
206
215
|
|
|
207
216
|
export interface SupersedeFields {
|
|
@@ -213,44 +222,52 @@ export interface SupersedeFields {
|
|
|
213
222
|
entity?: string;
|
|
214
223
|
}
|
|
215
224
|
|
|
216
|
-
// Copy `file` into snapshots/ before a mutation.
|
|
217
|
-
// `<stamp>-<original>` because /undo strips the stamp to find its target, so a
|
|
218
|
-
// same-millisecond collision would clobber an earlier, still-undoable snapshot
|
|
219
|
-
// (supersede + clear in one merge_review can land in the same millisecond).
|
|
225
|
+
// Copy `file` into snapshots/ before a mutation.
|
|
220
226
|
async function snapshotBefore(file: string): Promise<void> {
|
|
221
|
-
await
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
227
|
+
await snapshotFiles([file]);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Read-modify-write ONE row of a store file: read, rewrite the line, publish
|
|
231
|
+
// atomically, all under the store lock, so an append or another rewrite by a
|
|
232
|
+
// concurrent session cannot be overwritten by our stale copy. Resolves to null
|
|
233
|
+
// when the row is not in this file, otherwise the mutation's outcome.
|
|
234
|
+
async function stampRow(
|
|
235
|
+
file: string,
|
|
236
|
+
ts: string,
|
|
237
|
+
mutate: (fields: Fields) => string | null,
|
|
238
|
+
): Promise<boolean | null> {
|
|
239
|
+
return withStoreLock(
|
|
240
|
+
file,
|
|
241
|
+
async () => {
|
|
242
|
+
const lines = await readLines(file);
|
|
243
|
+
const idx = lines.findIndex((l) => {
|
|
244
|
+
const f = parseLine(l);
|
|
245
|
+
return f && f.ts === ts;
|
|
246
|
+
});
|
|
247
|
+
if (idx === -1) return null;
|
|
248
|
+
const parsed = parseLine(lines[idx]);
|
|
249
|
+
if (!parsed) return false;
|
|
250
|
+
const next = mutate(parsed);
|
|
251
|
+
if (next === null) return false;
|
|
252
|
+
lines[idx] = next;
|
|
253
|
+
await snapshotBefore(file);
|
|
254
|
+
await writeAtomic(file, lines.join("\n") + "\n");
|
|
255
|
+
return true;
|
|
256
|
+
},
|
|
257
|
+
LOCK_WRITE,
|
|
258
|
+
);
|
|
226
259
|
}
|
|
227
260
|
|
|
228
261
|
// Mark an existing memory as superseded IN PLACE (entry retained, recoverable).
|
|
229
262
|
// Idempotent: a second call is a no-op, so no double `superseded_by`.
|
|
230
263
|
export async function markSuperseded(ts: string, fields: SupersedeFields): Promise<boolean> {
|
|
231
264
|
for (const file of await memoryFiles()) {
|
|
232
|
-
|
|
233
|
-
const idx = lines.findIndex((l) => {
|
|
234
|
-
const f = parseLine(l);
|
|
235
|
-
return f && f.ts === ts;
|
|
236
|
-
});
|
|
237
|
-
if (idx === -1) continue;
|
|
238
|
-
const parsed = parseLine(lines[idx]);
|
|
239
|
-
if (parsed && typeof parsed.superseded_by === "string") return false;
|
|
240
|
-
// Rewrite the whole line rather than appending to it, so an old-format line
|
|
265
|
+
// The whole line is rewritten rather than extended, so an old-format line
|
|
241
266
|
// comes out in the current format with its values unchanged.
|
|
242
|
-
|
|
243
|
-
...parsed,
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
symbol: fields.symbol,
|
|
247
|
-
from: fields.from,
|
|
248
|
-
to: fields.to,
|
|
249
|
-
entity: fields.entity,
|
|
250
|
-
});
|
|
251
|
-
await snapshotBefore(file);
|
|
252
|
-
await Bun.write(file, lines.join("\n") + "\n");
|
|
253
|
-
return true;
|
|
267
|
+
const outcome = await stampRow(file, ts, (parsed) =>
|
|
268
|
+
typeof parsed.superseded_by === "string" ? null : formatLine({ ...parsed, ...fields }),
|
|
269
|
+
);
|
|
270
|
+
if (outcome !== null) return outcome;
|
|
254
271
|
}
|
|
255
272
|
return false;
|
|
256
273
|
}
|
|
@@ -260,25 +277,18 @@ export async function markSuperseded(ts: string, fields: SupersedeFields): Promi
|
|
|
260
277
|
// even when an earlier row — now the loser — had superseded it first.
|
|
261
278
|
export async function clearSuperseded(ts: string): Promise<boolean> {
|
|
262
279
|
for (const file of await memoryFiles()) {
|
|
263
|
-
const
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
280
|
+
const outcome = await stampRow(file, ts, (parsed) => {
|
|
281
|
+
if (typeof parsed.superseded_by !== "string") return null;
|
|
282
|
+
// These five keys are written only by markSuperseded (drift, canonical,
|
|
283
|
+
// update and merge_review), never by appendMemory, so they are all part of
|
|
284
|
+
// the marker and go with it. Field order round-trips because parseLine
|
|
285
|
+
// inserts keys in source order.
|
|
286
|
+
for (const key of ["superseded_by", "reason", "symbol", "from", "to", "entity"]) {
|
|
287
|
+
delete parsed[key];
|
|
288
|
+
}
|
|
289
|
+
return formatLine(parsed);
|
|
267
290
|
});
|
|
268
|
-
if (
|
|
269
|
-
const parsed = parseLine(lines[idx]);
|
|
270
|
-
if (!parsed || typeof parsed.superseded_by !== "string") return false;
|
|
271
|
-
// These five keys are written only by markSuperseded (drift, canonical,
|
|
272
|
-
// update and merge_review), never by appendMemory, so they are all part of
|
|
273
|
-
// the marker and go with it. Field order round-trips because parseLine
|
|
274
|
-
// inserts keys in source order.
|
|
275
|
-
for (const key of ["superseded_by", "reason", "symbol", "from", "to", "entity"]) {
|
|
276
|
-
delete parsed[key];
|
|
277
|
-
}
|
|
278
|
-
lines[idx] = formatLine(parsed);
|
|
279
|
-
await snapshotBefore(file);
|
|
280
|
-
await Bun.write(file, lines.join("\n") + "\n");
|
|
281
|
-
return true;
|
|
291
|
+
if (outcome !== null) return outcome;
|
|
282
292
|
}
|
|
283
293
|
return false;
|
|
284
294
|
}
|
|
@@ -334,24 +344,35 @@ export interface DeletedMemory {
|
|
|
334
344
|
reason: string;
|
|
335
345
|
}
|
|
336
346
|
|
|
347
|
+
// Claim a snapshot name with O_EXCL so "never overwrite a snapshot" holds
|
|
348
|
+
// across processes, not just inside one: two sessions mutating the same file in
|
|
349
|
+
// one millisecond both bump instead of one clobbering the other's still-undoable
|
|
350
|
+
// copy. Names are `<epoch-ms>-<original>` because /undo strips the leading stamp
|
|
351
|
+
// to find its target, so nothing may go in front of it.
|
|
352
|
+
async function claimSnapshot(file: string): Promise<string> {
|
|
353
|
+
await ensureDir(snapshotDir());
|
|
354
|
+
let stamp = Date.now();
|
|
355
|
+
for (;;) {
|
|
356
|
+
const snap = join(snapshotDir(), `${stamp}-${basename(file)}`);
|
|
357
|
+
try {
|
|
358
|
+
const handle = await open(snap, "wx");
|
|
359
|
+
await handle.close();
|
|
360
|
+
return snap;
|
|
361
|
+
} catch (err) {
|
|
362
|
+
if ((err as NodeJS.ErrnoException).code !== "EEXIST") throw err;
|
|
363
|
+
stamp++;
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
337
368
|
// Snapshot-before-mutate: copy each affected file into snapshots/ so /undo can
|
|
338
369
|
// restore it. Returns the snapshot paths keyed by original file.
|
|
339
370
|
export async function snapshotFiles(files: string[]): Promise<string[]> {
|
|
340
|
-
await ensureDir(snapshotDir());
|
|
341
|
-
let stamp = Date.now();
|
|
342
371
|
const snaps: string[] = [];
|
|
343
372
|
for (const file of files) {
|
|
344
373
|
const f = Bun.file(file);
|
|
345
374
|
if (!(await f.exists())) continue;
|
|
346
|
-
|
|
347
|
-
// mutations happen in the same millisecond, and a clobbered snapshot once
|
|
348
|
-
// let a restore read post-edit content. Bump until free; never overwrite
|
|
349
|
-
// a snapshot.
|
|
350
|
-
let snap = join(snapshotDir(), `${stamp}-${basename(file)}`);
|
|
351
|
-
while (await Bun.file(snap).exists()) {
|
|
352
|
-
stamp++;
|
|
353
|
-
snap = join(snapshotDir(), `${stamp}-${basename(file)}`);
|
|
354
|
-
}
|
|
375
|
+
const snap = await claimSnapshot(file);
|
|
355
376
|
await Bun.write(snap, await f.text());
|
|
356
377
|
snaps.push(snap);
|
|
357
378
|
}
|
|
@@ -379,30 +400,39 @@ export interface PruneResult {
|
|
|
379
400
|
}
|
|
380
401
|
|
|
381
402
|
// Remove matching memories (used by memory_forget), snapshotting first and
|
|
382
|
-
// logging every deletion with its reason.
|
|
403
|
+
// logging every deletion with its reason. Each file is filtered and republished
|
|
404
|
+
// under the store lock, so a row another session appended while we were reading
|
|
405
|
+
// is not quietly filtered out along with the targets.
|
|
383
406
|
export async function prune(scope: string, type: string, reason: string): Promise<PruneResult> {
|
|
384
|
-
const files = await memoryFiles();
|
|
385
|
-
const snapshot = await snapshotFiles(files);
|
|
386
407
|
const deleted: DeletedMemory[] = [];
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
408
|
+
const snapshot: string[] = [];
|
|
409
|
+
for (const file of await memoryFiles()) {
|
|
410
|
+
await withStoreLock(
|
|
411
|
+
file,
|
|
412
|
+
async () => {
|
|
413
|
+
const lines = await readLines(file);
|
|
414
|
+
const kept: string[] = [];
|
|
415
|
+
let changed = false;
|
|
416
|
+
for (const line of lines) {
|
|
417
|
+
const parsed = parseLine(line);
|
|
418
|
+
const m = parsed ? toMemory(parsed, file, 0) : null;
|
|
419
|
+
// Repo floor: forgetting scope X here must never delete
|
|
420
|
+
// another repo's row for scope X — same basename collisions are real
|
|
421
|
+
// (a fork next to its upstream).
|
|
422
|
+
if (m && m.scope === scope && m.type === type && isVisibleHere(m)) {
|
|
423
|
+
deleted.push({ memory: m, reason });
|
|
424
|
+
await logDeletion(m, reason);
|
|
425
|
+
changed = true;
|
|
426
|
+
continue;
|
|
427
|
+
}
|
|
428
|
+
kept.push(line);
|
|
429
|
+
}
|
|
430
|
+
if (!changed) return;
|
|
431
|
+
snapshot.push(...(await snapshotFiles([file])));
|
|
432
|
+
await writeAtomic(file, kept.join("\n") + (kept.length ? "\n" : ""));
|
|
433
|
+
},
|
|
434
|
+
LOCK_WRITE,
|
|
435
|
+
);
|
|
406
436
|
}
|
|
407
437
|
return { deleted, snapshot };
|
|
408
438
|
}
|
package/src/undo.ts
CHANGED
|
@@ -3,15 +3,18 @@
|
|
|
3
3
|
//
|
|
4
4
|
// Daily logfmt files are SHARED across concurrent sessions, so a naive
|
|
5
5
|
// whole-file write can silently clobber rows another session appended after
|
|
6
|
-
// the snapshot. Restore is therefore
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
6
|
+
// the snapshot. Restore is therefore conflict-aware: the snapshot cannot tell
|
|
7
|
+
// "rows I added since the snapshot, which undo should remove" apart from
|
|
8
|
+
// "rows another session added, which must survive", so that call belongs to
|
|
9
|
+
// the caller. The default mode CHECK refuses and lists the divergent rows;
|
|
10
|
+
// MERGE replays them on top of the snapshot; FORCE is the old exact rollback,
|
|
11
|
+
// and additionally snapshots the file it replaces, so the state it drops stays
|
|
12
|
+
// undoable (result.safety).
|
|
12
13
|
import { basename, join, resolve, sep } from "node:path";
|
|
13
14
|
import { snapshotDir } from "./config.ts";
|
|
14
|
-
import { ensureDir } from "./logfmt.ts";
|
|
15
|
+
import { ensureDir, writeAtomic } from "./logfmt.ts";
|
|
16
|
+
import { withStoreLock, LOCK_RESTORE } from "./lock.ts";
|
|
17
|
+
import { snapshotFiles } from "./memory.ts";
|
|
15
18
|
|
|
16
19
|
export interface SnapshotInfo {
|
|
17
20
|
file: string;
|
|
@@ -45,6 +48,9 @@ export interface RestoreResult {
|
|
|
45
48
|
target: string;
|
|
46
49
|
restored: boolean;
|
|
47
50
|
mode: RestoreMode;
|
|
51
|
+
// Snapshot of the file as it stood the moment the restore held the lock —
|
|
52
|
+
// present on a force restore, which is the mode that drops rows.
|
|
53
|
+
safety?: string;
|
|
48
54
|
// ts of rows present in the CURRENT file but absent from the snapshot
|
|
49
55
|
// (added after it — or in-place edits, listed under their original ts).
|
|
50
56
|
concurrentAdded: string[];
|
|
@@ -87,37 +93,55 @@ export async function restoreSnapshot(
|
|
|
87
93
|
throw new Error(`snapshot target escapes the memory root: ${original}`);
|
|
88
94
|
}
|
|
89
95
|
const snapLines = rows(await f.text());
|
|
90
|
-
const cur = Bun.file(target);
|
|
91
|
-
const curLines = (await cur.exists()) ? rows(await cur.text()) : [];
|
|
92
96
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
}
|
|
97
|
+
// Comparing and publishing happen under the same lock: a divergence check on
|
|
98
|
+
// a stale read would wave through a row that landed after it. A restore waits
|
|
99
|
+
// for the store rather than proceeding unlocked, because "force" deliberately
|
|
100
|
+
// drops rows — doing that on top of a racing writer would drop rows nobody
|
|
101
|
+
// agreed to drop.
|
|
102
|
+
return withStoreLock(
|
|
103
|
+
target,
|
|
104
|
+
async () => {
|
|
105
|
+
const cur = Bun.file(target);
|
|
106
|
+
const curLines = (await cur.exists()) ? rows(await cur.text()) : [];
|
|
104
107
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
+
const snapByKey = new Map(snapLines.map((l) => [rowKey(l), l]));
|
|
109
|
+
const concurrentAdded: string[] = [];
|
|
110
|
+
const concurrentChanged: string[] = [];
|
|
111
|
+
const curByKey = new Map<string, string>();
|
|
112
|
+
for (const l of curLines) {
|
|
113
|
+
const k = rowKey(l);
|
|
114
|
+
curByKey.set(k, l);
|
|
115
|
+
const s = snapByKey.get(k);
|
|
116
|
+
if (s === undefined) concurrentAdded.push(k);
|
|
117
|
+
else if (s !== l) concurrentChanged.push(k);
|
|
118
|
+
}
|
|
108
119
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
120
|
+
if (mode === "check" && (concurrentAdded.length || concurrentChanged.length)) {
|
|
121
|
+
return { target, restored: false, mode, concurrentAdded, concurrentChanged };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// FORCE is the mode that drops rows, so the file it replaces is
|
|
125
|
+
// snapshotted HERE — inside the lock, which is what makes rows a
|
|
126
|
+
// concurrent session appended moments earlier part of the copy. A caller
|
|
127
|
+
// that snapshots before this call races the very write it means to save.
|
|
128
|
+
const safety = mode === "force" ? (await snapshotFiles([target]))[0] : undefined;
|
|
129
|
+
|
|
130
|
+
let out = snapLines;
|
|
131
|
+
if (mode === "merge") {
|
|
132
|
+
// Keep the CURRENT version of rows the snapshot also has (in-place edits —
|
|
133
|
+
// e.g. a supersede stamp — made after the snapshot win), then append the
|
|
134
|
+
// rows the snapshot never saw. Merge is a deliberate superset: rows the
|
|
135
|
+
// snapshotter wrote after the snapshot are NOT dropped here; choose FORCE
|
|
136
|
+
// for that.
|
|
137
|
+
out = snapLines.map((l) => curByKey.get(rowKey(l)) ?? l).concat(
|
|
138
|
+
curLines.filter((l) => !snapByKey.has(rowKey(l))),
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
await ensureDir(memoryRootDir);
|
|
142
|
+
await writeAtomic(target, out.length ? out.join("\n") + "\n" : "");
|
|
143
|
+
return { target, restored: true, mode, safety, concurrentAdded, concurrentChanged };
|
|
144
|
+
},
|
|
145
|
+
LOCK_RESTORE,
|
|
146
|
+
);
|
|
123
147
|
}
|
package/src/viewer/mutate.ts
CHANGED
|
@@ -9,7 +9,8 @@
|
|
|
9
9
|
import { deletionsFile } from "../config.ts";
|
|
10
10
|
import { addBlock, removeBlock } from "../blocklist.ts";
|
|
11
11
|
import { record, chainId } from "../evidence.ts";
|
|
12
|
-
import { appendLog, formatLine, parseLine, readLines } from "../logfmt.ts";
|
|
12
|
+
import { appendLog, formatLine, parseLine, readLines, writeAtomic } from "../logfmt.ts";
|
|
13
|
+
import { withStoreLock, LOCK_WRITE } from "../lock.ts";
|
|
13
14
|
import {
|
|
14
15
|
appendMemory,
|
|
15
16
|
loadAll,
|
|
@@ -99,14 +100,23 @@ export async function deleteMemory(input: DeleteInput): Promise<MutationResult>
|
|
|
99
100
|
const chain = chainId();
|
|
100
101
|
const target = await findMemory(input.ts);
|
|
101
102
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
103
|
+
// Snapshot, filter and republish happen as one locked step, so a row another
|
|
104
|
+
// session appended while we read cannot be filtered out with the target.
|
|
105
|
+
let snapshot: string[] = [];
|
|
106
|
+
await withStoreLock(
|
|
107
|
+
target.file,
|
|
108
|
+
async () => {
|
|
109
|
+
const lines = await readLines(target.file);
|
|
110
|
+
const kept = lines.filter((l) => {
|
|
111
|
+
const f = parseLine(l);
|
|
112
|
+
return !(f && f.ts === input.ts);
|
|
113
|
+
});
|
|
114
|
+
if (kept.length === lines.length) throw new Error(`memory line not found: ${input.ts}`);
|
|
115
|
+
snapshot = await snapshotFiles([target.file]);
|
|
116
|
+
await writeAtomic(target.file, kept.join("\n") + (kept.length ? "\n" : ""));
|
|
117
|
+
},
|
|
118
|
+
LOCK_WRITE,
|
|
119
|
+
);
|
|
110
120
|
|
|
111
121
|
await audit("deleted", target, input.ts, undefined, input.reason);
|
|
112
122
|
await record("prune", {
|
package/src/viewer/server.ts
CHANGED
|
@@ -11,7 +11,7 @@ import { BlockBody, DeleteBody, EditBody, RestoreEntryBody, UnblockBody } from "
|
|
|
11
11
|
import { deletionsFile, memoryRoot, snapshotDir } from "../config.ts";
|
|
12
12
|
import { blockedEntries } from "../blocklist.ts";
|
|
13
13
|
import { listSnapshots, restoreSnapshot } from "../undo.ts";
|
|
14
|
-
import { appendMemory, loadAll, memoryFiles
|
|
14
|
+
import { appendMemory, loadAll, memoryFiles } from "../memory.ts";
|
|
15
15
|
import { parseLine, readLines } from "../logfmt.ts";
|
|
16
16
|
import { deleteMemory, editMemory, addBlock, removeBlock, type MutationResult } from "./mutate.ts";
|
|
17
17
|
|
|
@@ -269,14 +269,14 @@ async function restoreRoute(body: Record<string, unknown>): Promise<Response> {
|
|
|
269
269
|
if (!/^\d+-\d{4}-\d{2}-\d{2}\.logfmt$/.test(name)) {
|
|
270
270
|
return error("invalid snapshot name", 400);
|
|
271
271
|
}
|
|
272
|
-
|
|
273
|
-
//
|
|
274
|
-
//
|
|
275
|
-
//
|
|
276
|
-
await snapshotFiles([join(memoryRoot(), original)]);
|
|
272
|
+
// Explicit human admin action: exact rollback. The pre-restore snapshot the
|
|
273
|
+
// dropped rows can be recovered from is taken by restoreSnapshot itself,
|
|
274
|
+
// under the store lock — snapshotting here instead would miss anything
|
|
275
|
+
// another session appended between this call and the rollback.
|
|
277
276
|
const res = await restoreSnapshot(join(snapshotDir(), name), memoryRoot(), "force");
|
|
278
277
|
return json({
|
|
279
278
|
restored: res.target,
|
|
279
|
+
safety: res.safety ?? null,
|
|
280
280
|
rolledBack: res.concurrentAdded.length + res.concurrentChanged.length,
|
|
281
281
|
});
|
|
282
282
|
}
|