opencode-context-tree 0.1.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.
@@ -0,0 +1,286 @@
1
+ /**
2
+ * Journal IO (DESIGN.md §4.1, §4.2, §8 "server/ journal IO (mtime cache)").
3
+ *
4
+ * Reads and appends `.opencode/context-tree/<treeId>.jsonl` plus its
5
+ * `registry.json` (sessionID → treeId), local to a worktree. This is the only
6
+ * place in the plugin that touches the filesystem for journal state.
7
+ */
8
+ import fs from "node:fs"
9
+ import path from "node:path"
10
+ import { foldJournal, parseJournal, type JournalActor, type JournalEntry, type TreeState } from "../core/journal.js"
11
+
12
+ export type StorageMode = "local" | "global"
13
+
14
+ export type JournalStoreOptions = {
15
+ /** The git worktree root (DESIGN.md §4.1's `local` storage default). */
16
+ worktree: string
17
+ /** `opencode`'s state directory, used only when `mode` is `"global"`. */
18
+ stateDir?: string
19
+ mode?: StorageMode
20
+ }
21
+
22
+ type CacheEntry = {
23
+ key: string
24
+ entries: JournalEntry[]
25
+ state: TreeState
26
+ }
27
+
28
+ const REGISTRY_FILE = "registry.json"
29
+ /** Past this, a lock is assumed to belong to a crashed writer: it is removed and taken over. */
30
+ const LOCK_TIMEOUT_MS = 250
31
+
32
+ export class JournalStore {
33
+ private readonly baseDir: string
34
+ private readonly cache = new Map<string, CacheEntry>()
35
+ private registryCache: { key: string; data: Record<string, string> } | undefined
36
+
37
+ constructor(options: JournalStoreOptions) {
38
+ // OpenCode reports worktree "/" for directories outside git; never write at the fs root
39
+ const local = options.mode !== "global" && !isFsRoot(options.worktree)
40
+ this.baseDir = local
41
+ ? path.join(options.worktree, ".opencode", "context-tree")
42
+ : path.join(options.stateDir ?? defaultStateDir(), "plugins", "opencode-context-tree")
43
+ }
44
+
45
+ /** Where this store keeps its files (for messages and tests). */
46
+ get dir(): string {
47
+ return this.baseDir
48
+ }
49
+
50
+ private ensureDir(): void {
51
+ if (fs.existsSync(this.baseDir)) return
52
+ fs.mkdirSync(this.baseDir, { recursive: true })
53
+ const gitignorePath = path.join(this.baseDir, ".gitignore")
54
+ if (!fs.existsSync(gitignorePath)) fs.writeFileSync(gitignorePath, "*\n")
55
+ }
56
+
57
+ private journalPath(treeId: string): string {
58
+ return path.join(this.baseDir, `${treeId}.jsonl`)
59
+ }
60
+
61
+ private registryPath(): string {
62
+ return path.join(this.baseDir, REGISTRY_FILE)
63
+ }
64
+
65
+ /** sessionID -> treeId, read fresh only when the registry file has changed. */
66
+ readRegistry(): Record<string, string> {
67
+ const registryPath = this.registryPath()
68
+ const key = statKey(registryPath)
69
+ // unreadable (removed, EACCES) must never take a hook down: keep the last good copy
70
+ if (key === undefined) return this.registryCache?.data ?? {}
71
+ if (this.registryCache && this.registryCache.key === key) return this.registryCache.data
72
+ let data: Record<string, string> = this.registryCache?.data ?? {}
73
+ try {
74
+ const parsed = JSON.parse(fs.readFileSync(registryPath, "utf8")) as unknown
75
+ if (parsed && typeof parsed === "object") data = Object.fromEntries(Object.entries(parsed as Record<string, unknown>).filter(([, v]) => typeof v === "string")) as Record<string, string>
76
+ } catch {
77
+ // a truncated/corrupt registry keeps the last good copy too
78
+ }
79
+ this.registryCache = { key, data }
80
+ return data
81
+ }
82
+
83
+ treeIdFor(sessionID: string): string | undefined {
84
+ return this.readRegistry()[sessionID]
85
+ }
86
+
87
+ /**
88
+ * Serialize the registry's read-modify-write against the other plugin half (DESIGN.md §8).
89
+ * Blocking is fine: the critical section is one small write, and a crashed holder's lock
90
+ * is broken rather than obeyed.
91
+ */
92
+ private withRegistryLock<T>(fn: () => T): T {
93
+ const lockPath = `${this.registryPath()}.lock`
94
+ const deadline = Date.now() + LOCK_TIMEOUT_MS
95
+ let fd: number | undefined
96
+ let broke = false
97
+ for (;;) {
98
+ try {
99
+ fd = fs.openSync(lockPath, "wx")
100
+ break
101
+ } catch {
102
+ // a lock older than the wait window belongs to a crashed writer: remove it once,
103
+ // rather than have every later write busy-wait the whole window inside a hook
104
+ if (!broke && lockAgeMs(lockPath) >= LOCK_TIMEOUT_MS) {
105
+ broke = true
106
+ try {
107
+ fs.unlinkSync(lockPath)
108
+ } catch {}
109
+ continue
110
+ }
111
+ if (Date.now() >= deadline) {
112
+ // the whole window has passed: a lock that predates our wait belongs to a dead
113
+ // holder (the critical section is one small write), so break it instead of
114
+ // leaving it to tax every later write with another full wait
115
+ // mtimeMs is sub-millisecond while Date.now() is not, so "predates our start" can
116
+ // miss a same-millisecond lock; "held longer than any critical section" cannot
117
+ if (lockMtimeMs(lockPath) <= Date.now() - 10) {
118
+ try {
119
+ fs.unlinkSync(lockPath)
120
+ } catch {}
121
+ }
122
+ break
123
+ }
124
+ sleepSync(5)
125
+ }
126
+ }
127
+ try {
128
+ return fn()
129
+ } finally {
130
+ if (fd !== undefined) {
131
+ try {
132
+ fs.closeSync(fd)
133
+ fs.unlinkSync(lockPath)
134
+ } catch {}
135
+ }
136
+ }
137
+ }
138
+
139
+ /** Registry read-modify-write; the caller must hold the registry lock. */
140
+ private putLocked(sessionID: string, treeId: string): void {
141
+ this.registryCache = undefined // the other half may have written while we waited for the lock
142
+ const registry = { ...this.readRegistry(), [sessionID]: treeId }
143
+ // atomic: temp file + rename, so a concurrent reader never sees a partial file
144
+ const tmp = `${this.registryPath()}.${process.pid}.${Date.now()}.tmp`
145
+ fs.writeFileSync(tmp, `${JSON.stringify(registry, null, 2)}\n`)
146
+ fs.renameSync(tmp, this.registryPath())
147
+ this.registryCache = undefined
148
+ }
149
+
150
+ /** Registers a session under a tree, creating the tree if this is its first session. */
151
+ registerSession(sessionID: string, treeId: string): void {
152
+ this.ensureDir()
153
+ if (this.readRegistry()[sessionID] === treeId) return
154
+ this.withRegistryLock(() => this.putLocked(sessionID, treeId))
155
+ }
156
+
157
+ /** Append one journal line. Append-only: existing lines are never rewritten. */
158
+ append(treeId: string, entry: JournalEntry): void {
159
+ this.ensureDir()
160
+ fs.appendFileSync(this.journalPath(treeId), `${JSON.stringify(entry)}\n`)
161
+ this.cache.delete(treeId) // our own append must be visible even inside one filesystem mtime tick
162
+ }
163
+
164
+ /** Read + parse a tree's journal, with a stat-checked cache so repeated reads within one
165
+ * transform hook (DESIGN.md §8's "sub-millisecond mtime check") are cheap. */
166
+ private readEntries(treeId: string): JournalEntry[] {
167
+ const cached = this.cache.get(treeId)
168
+ const key = statKey(this.journalPath(treeId))
169
+ if (key === undefined) return cached?.entries ?? []
170
+ if (cached && cached.key === key) return cached.entries
171
+ let raw: string
172
+ try {
173
+ raw = fs.readFileSync(this.journalPath(treeId), "utf8")
174
+ } catch {
175
+ return cached?.entries ?? []
176
+ }
177
+ const entries = parseJournal(raw)
178
+ const state = foldJournal(entries, treeId)
179
+ this.cache.set(treeId, { key, entries, state })
180
+ return entries
181
+ }
182
+
183
+ /** Raw journal entries of a tree in file order (for undo planning). */
184
+ entriesFor(treeId: string): JournalEntry[] {
185
+ return this.readEntries(treeId)
186
+ }
187
+
188
+ /** Folded tree state for a tree, from cache when the journal file hasn't changed. */
189
+ stateFor(treeId: string): TreeState {
190
+ this.readEntries(treeId) // populates/refreshes the cache as a side effect
191
+ const cached = this.cache.get(treeId)
192
+ return cached?.state ?? foldJournal([], treeId)
193
+ }
194
+
195
+ /** Build a journal envelope. IDs are time-sortable so a fold's tie-breaks are stable. */
196
+ static entry<T extends JournalEntry["type"]>(
197
+ type: T,
198
+ data: Extract<JournalEntry, { type: T }>["data"],
199
+ actor: JournalActor,
200
+ ): JournalEntry {
201
+ return { v: 1, id: `e_${Date.now().toString(36)}_${crypto.randomUUID().slice(0, 8)}`, ts: Date.now(), type, actor, data } as JournalEntry
202
+ }
203
+
204
+ /** Append a typed entry (envelope built here). Returns the entry so callers can reference its id. */
205
+ record<T extends JournalEntry["type"]>(
206
+ treeId: string,
207
+ type: T,
208
+ data: Extract<JournalEntry, { type: T }>["data"],
209
+ actor: JournalActor,
210
+ ): JournalEntry {
211
+ // generic forwarding trips TS's intersection of all data shapes; the public signature stays typed
212
+ const entry = JournalStore.entry(type, data as never, actor)
213
+ this.append(treeId, entry)
214
+ return entry
215
+ }
216
+
217
+ /** The tree a session belongs to, creating a fresh tree rooted at the session when it has none. */
218
+ ensureTree(sessionID: string, actor: JournalActor): string {
219
+ const existing = this.treeIdFor(sessionID)
220
+ if (existing) return existing
221
+ this.ensureDir()
222
+ // minting and registering must be one critical section: both halves adopt the same
223
+ // `session.created`, and an unlocked read-then-write mints two trees for one session
224
+ return this.withRegistryLock(() => {
225
+ this.registryCache = undefined
226
+ const raced = this.readRegistry()[sessionID]
227
+ if (raced) return raced
228
+ const treeId = `t_${Date.now().toString(36)}_${crypto.randomUUID().slice(0, 6)}`
229
+ this.putLocked(sessionID, treeId)
230
+ this.record(treeId, "tree.created", { rootSessionID: sessionID }, actor)
231
+ return treeId
232
+ })
233
+ }
234
+
235
+ /** Folded tree state for a session, or `undefined` if the session has no tree yet. */
236
+ stateForSession(sessionID: string): TreeState | undefined {
237
+ const treeId = this.treeIdFor(sessionID)
238
+ if (!treeId) return undefined
239
+ return this.stateFor(treeId)
240
+ }
241
+ }
242
+
243
+ /** Cache key: mtime alone cannot separate two writes inside one filesystem tick. */
244
+ function statKey(file: string): string | undefined {
245
+ try {
246
+ const st = fs.statSync(file)
247
+ return `${st.mtimeMs}:${st.size}`
248
+ } catch {
249
+ return undefined
250
+ }
251
+ }
252
+
253
+ /** When a lock file was created; `Infinity` when it is already gone (so "predates X" is false). */
254
+ function lockMtimeMs(lockPath: string): number {
255
+ try {
256
+ return fs.statSync(lockPath).mtimeMs
257
+ } catch {
258
+ return Infinity
259
+ }
260
+ }
261
+
262
+ /** How long a lock file has existed; `Infinity` when it is already gone. */
263
+ function lockAgeMs(lockPath: string): number {
264
+ try {
265
+ return Date.now() - fs.statSync(lockPath).mtimeMs
266
+ } catch {
267
+ return Infinity
268
+ }
269
+ }
270
+
271
+ const SLEEP_SLOT = new Int32Array(new SharedArrayBuffer(4))
272
+
273
+ function sleepSync(ms: number): void {
274
+ Atomics.wait(SLEEP_SLOT, 0, 0, ms)
275
+ }
276
+
277
+ function isFsRoot(p: string): boolean {
278
+ if (!p) return true
279
+ const abs = path.resolve(p)
280
+ return abs === path.parse(abs).root
281
+ }
282
+
283
+ function defaultStateDir(): string {
284
+ if (process.env["XDG_STATE_HOME"]) return path.join(process.env["XDG_STATE_HOME"], "opencode")
285
+ return path.join(process.env["HOME"] ?? "", ".local", "state", "opencode")
286
+ }