@oh-my-pi/pi-coding-agent 15.5.9 → 15.5.11

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,481 @@
1
+ import { logger, toError } from "@oh-my-pi/pi-utils";
2
+ import type { SessionStorage, SessionStorageStat, SessionStorageWriter } from "./session-storage";
3
+
4
+ /**
5
+ * Minimal subset of the `bun:redis` `RedisClient` surface used by
6
+ * {@link RedisSessionStorage}. Keeping the contract narrow (and accepting any
7
+ * client that conforms) lets callers swap in test doubles or shared clients
8
+ * without dragging the entire Bun typings into this module.
9
+ */
10
+ export interface RedisSessionStorageClient {
11
+ get(key: string): Promise<string | null>;
12
+ set(key: string, value: string): Promise<unknown>;
13
+ append(key: string, value: string): Promise<number>;
14
+ del(...keys: string[]): Promise<number>;
15
+ rename(src: string, dst: string): Promise<unknown>;
16
+ scan(cursor: string, ...args: string[]): Promise<[string, string[]]>;
17
+ hset(key: string, field: string, value: string): Promise<unknown>;
18
+ hgetall(key: string): Promise<Record<string, string>>;
19
+ hdel(key: string, ...fields: string[]): Promise<unknown>;
20
+ }
21
+
22
+ export interface RedisSessionStorageOptions {
23
+ /** A connected `bun:redis` RedisClient (or any compatible adapter). */
24
+ client: RedisSessionStorageClient;
25
+ /**
26
+ * Key prefix applied to every Redis key this storage owns. Default `omp:sessions:`.
27
+ * Trailing colon is preserved verbatim — set to a project-scoped prefix to share
28
+ * one Redis instance between multiple agents.
29
+ */
30
+ prefix?: string;
31
+ /**
32
+ * Maximum number of keys returned per SCAN batch when warming the mirror.
33
+ * Default 500.
34
+ */
35
+ scanCount?: number;
36
+ }
37
+
38
+ interface MirrorEntry {
39
+ content: string;
40
+ mtimeMs: number;
41
+ }
42
+
43
+ const DEFAULT_PREFIX = "omp:sessions:";
44
+ const DEFAULT_SCAN_COUNT = 500;
45
+
46
+ function enoent(p: string): NodeJS.ErrnoException {
47
+ const err = new Error(`ENOENT: no such file, '${p}'`) as NodeJS.ErrnoException;
48
+ err.code = "ENOENT";
49
+ err.errno = -2;
50
+ err.path = p;
51
+ err.syscall = "open";
52
+ return err;
53
+ }
54
+
55
+ function matchesGlob(name: string, pattern: string): boolean {
56
+ if (pattern === "*") return true;
57
+ if (pattern.startsWith("*.")) return name.endsWith(pattern.slice(1));
58
+ return name === pattern;
59
+ }
60
+
61
+ /**
62
+ * Redis-backed implementation of {@link SessionStorage}. Each session JSONL
63
+ * file maps to a Redis STRING key, with per-key metadata (mtime) tracked in a
64
+ * single sibling HASH. An in-memory mirror is loaded on construction so the
65
+ * interface's synchronous methods (`existsSync`, `statSync`, `listFilesSync`,
66
+ * `readTextSync`, `writeTextSync`) keep their contracts — Bun's Redis client
67
+ * is async only, and the persist hot path (`writer.writeLineSync`) cannot
68
+ * wait on a network round-trip.
69
+ *
70
+ * Trade-offs vs `FileSessionStorage`:
71
+ * - Mirror state is process-local. Two processes writing the same session key
72
+ * will diverge until one of them reloads via {@link refresh}. This matches
73
+ * `FileSessionStorage`'s existing single-writer assumption.
74
+ * - `writeLineSync` updates the mirror synchronously and queues an async
75
+ * `APPEND`. The promise is awaited by `flush()` / `close()` / {@link drain}.
76
+ * A SIGKILL landing between the sync mirror update and the network round
77
+ * trip loses the last line; the file-backed implementation survives that
78
+ * window because bytes are handed to the kernel page cache before
79
+ * returning.
80
+ * - Blobs (image data) and tool artifact files still live on disk via
81
+ * `BlobStore` / `ArtifactManager`. Those are out of scope for this storage.
82
+ */
83
+ export class RedisSessionStorage implements SessionStorage {
84
+ readonly #client: RedisSessionStorageClient;
85
+ readonly #prefix: string;
86
+ readonly #scanCount: number;
87
+ readonly #mirror = new Map<string, MirrorEntry>();
88
+ readonly #writers = new Set<RedisSessionStorageWriter>();
89
+ #nextMtimeMs = 0;
90
+ #pendingTail: Promise<void> = Promise.resolve();
91
+
92
+ private constructor(options: RedisSessionStorageOptions) {
93
+ this.#client = options.client;
94
+ this.#prefix = options.prefix ?? DEFAULT_PREFIX;
95
+ this.#scanCount = options.scanCount ?? DEFAULT_SCAN_COUNT;
96
+ }
97
+
98
+ /**
99
+ * Warm the in-memory mirror with every existing session key under the
100
+ * configured prefix and return the ready-to-use storage. Must be awaited
101
+ * before passing the storage into `SessionManager.create()` so synchronous
102
+ * lookups (session resume, recent sessions, EPERM-backup recovery) see
103
+ * the existing keyspace.
104
+ */
105
+ static async create(options: RedisSessionStorageOptions): Promise<RedisSessionStorage> {
106
+ const storage = new RedisSessionStorage(options);
107
+ await storage.refresh();
108
+ return storage;
109
+ }
110
+
111
+ /**
112
+ * Re-scan Redis and replace the mirror's contents. Call this from a
113
+ * different process that took over a session keyspace, or after an
114
+ * out-of-band write made by another agent.
115
+ */
116
+ async refresh(): Promise<void> {
117
+ this.#mirror.clear();
118
+ const filePrefix = this.#fileKey("");
119
+ const metaRaw = await this.#client.hgetall(this.#metaKey());
120
+ const meta: Record<string, string> = metaRaw ?? {};
121
+
122
+ const seen = new Set<string>();
123
+ let cursor = "0";
124
+ do {
125
+ const [next, batch] = await this.#client.scan(
126
+ cursor,
127
+ "MATCH",
128
+ `${filePrefix}*`,
129
+ "COUNT",
130
+ String(this.#scanCount),
131
+ );
132
+ cursor = next;
133
+ for (const key of batch) seen.add(key);
134
+ } while (cursor !== "0");
135
+
136
+ await Promise.all(
137
+ Array.from(seen, async key => {
138
+ const path = key.slice(filePrefix.length);
139
+ const content = await this.#client.get(key);
140
+ if (content === null) return;
141
+ const mtimeRaw = meta[path];
142
+ const mtimeMs = mtimeRaw ? Number(mtimeRaw) : Date.now();
143
+ this.#mirror.set(path, { content, mtimeMs });
144
+ if (mtimeMs > this.#nextMtimeMs) this.#nextMtimeMs = mtimeMs;
145
+ }),
146
+ );
147
+ }
148
+
149
+ /**
150
+ * Resolve once every pending background write (issued via `writeTextSync`
151
+ * or `writer.writeLineSync`) has been acknowledged by Redis. Throws if any
152
+ * background write failed since the last drain.
153
+ *
154
+ * Call this on graceful shutdown to avoid losing the last unflushed line.
155
+ * The session-manager's own `flush()` / `close()` already drain through
156
+ * the writer chain — this method exists for callers (test harnesses,
157
+ * subprocess-style consumers) that bypass the writer.
158
+ */
159
+ async drain(): Promise<void> {
160
+ // Take ownership of the current tail, then reset so subsequent
161
+ // operations start from a clean (resolved) chain. Without the reset,
162
+ // any failure observed here would also be re-thrown by every later
163
+ // write that piggybacks on the tail via `#trackPending`.
164
+ const tail = this.#pendingTail;
165
+ this.#pendingTail = Promise.resolve();
166
+ await tail;
167
+ }
168
+
169
+ #fileKey(path: string): string {
170
+ return `${this.#prefix}file:${path}`;
171
+ }
172
+
173
+ #metaKey(): string {
174
+ return `${this.#prefix}meta`;
175
+ }
176
+
177
+ /**
178
+ * Allocate a strictly monotonic mtime. Multiple writes within the same
179
+ * millisecond would otherwise yield identical `mtimeMs` values and break
180
+ * `getSortedSessions`' newest-first ordering.
181
+ */
182
+ #allocMtimeMs(): number {
183
+ const now = Date.now();
184
+ const next = now > this.#nextMtimeMs ? now : this.#nextMtimeMs + 1;
185
+ this.#nextMtimeMs = next;
186
+ return next;
187
+ }
188
+
189
+ #trackPending(promise: Promise<void>): void {
190
+ // `Promise.all` rejects if either input rejects, which is exactly
191
+ // what we want for `drain()`. The follow-up `.catch(() => {})` is
192
+ // attached only to silence the unhandled-rejection signal on the
193
+ // shared tail — `drain()` keeps its own handler chain and still
194
+ // observes the original error, because rejection delivery is
195
+ // per-handler-chain, not per-promise.
196
+ this.#pendingTail = Promise.all([this.#pendingTail, promise]).then(() => {});
197
+ this.#pendingTail.catch(() => {});
198
+ }
199
+
200
+ // --- sync surface ---------------------------------------------------------
201
+
202
+ ensureDirSync(_dir: string): void {
203
+ // Redis is flat: directories are derived from key prefixes.
204
+ }
205
+
206
+ existsSync(path: string): boolean {
207
+ return this.#mirror.has(path);
208
+ }
209
+
210
+ writeTextSync(path: string, content: string): void {
211
+ const mtimeMs = this.#allocMtimeMs();
212
+ this.#mirror.set(path, { content, mtimeMs });
213
+ this.#trackPending(this.#writeRemote(path, content, mtimeMs));
214
+ }
215
+
216
+ readTextSync(path: string): string {
217
+ const entry = this.#mirror.get(path);
218
+ if (!entry) throw enoent(path);
219
+ return entry.content;
220
+ }
221
+
222
+ statSync(path: string): SessionStorageStat {
223
+ const entry = this.#mirror.get(path);
224
+ if (!entry) throw enoent(path);
225
+ return {
226
+ size: Buffer.byteLength(entry.content, "utf-8"),
227
+ mtimeMs: entry.mtimeMs,
228
+ mtime: new Date(entry.mtimeMs),
229
+ };
230
+ }
231
+
232
+ listFilesSync(dir: string, pattern: string): string[] {
233
+ const prefix = dir.endsWith("/") ? dir : `${dir}/`;
234
+ const out: string[] = [];
235
+ for (const path of this.#mirror.keys()) {
236
+ if (!path.startsWith(prefix)) continue;
237
+ const name = path.slice(prefix.length);
238
+ if (name.includes("/")) continue;
239
+ if (!matchesGlob(name, pattern)) continue;
240
+ out.push(path);
241
+ }
242
+ return out;
243
+ }
244
+
245
+ // --- async surface --------------------------------------------------------
246
+
247
+ async exists(path: string): Promise<boolean> {
248
+ // Mirror is the source of truth; checking Redis would only diverge
249
+ // when a peer process mutated the key, which is outside the
250
+ // storage's contract (see class JSDoc).
251
+ return this.#mirror.has(path);
252
+ }
253
+
254
+ async readText(path: string): Promise<string> {
255
+ const entry = this.#mirror.get(path);
256
+ if (!entry) throw enoent(path);
257
+ return entry.content;
258
+ }
259
+
260
+ async readTextPrefix(path: string, maxBytes: number): Promise<string> {
261
+ const entry = this.#mirror.get(path);
262
+ if (!entry) throw enoent(path);
263
+ if (maxBytes <= 0) return "";
264
+ // `entry.content` is a JS string (UTF-16 code units), but the prefix
265
+ // contract is byte-oriented. Encode to UTF-8, slice, then decode —
266
+ // matching `peekFile`'s behaviour for the file-backed storage.
267
+ const bytes = Buffer.from(entry.content, "utf-8");
268
+ const slice = bytes.subarray(0, Math.min(maxBytes, bytes.byteLength));
269
+ return slice.toString("utf-8");
270
+ }
271
+
272
+ async writeText(path: string, content: string): Promise<void> {
273
+ const mtimeMs = this.#allocMtimeMs();
274
+ this.#mirror.set(path, { content, mtimeMs });
275
+ await this.#writeRemote(path, content, mtimeMs);
276
+ }
277
+
278
+ async rename(src: string, dst: string): Promise<void> {
279
+ const entry = this.#mirror.get(src);
280
+ if (!entry) throw enoent(src);
281
+ // Update the mirror first so a synchronous existsSync() right after
282
+ // the await resolves consistently. If RENAME fails the mirror is
283
+ // rolled back below.
284
+ this.#mirror.delete(src);
285
+ this.#mirror.set(dst, entry);
286
+
287
+ try {
288
+ await this.#client.rename(this.#fileKey(src), this.#fileKey(dst));
289
+ } catch (err) {
290
+ this.#mirror.delete(dst);
291
+ this.#mirror.set(src, entry);
292
+ throw toError(err);
293
+ }
294
+
295
+ // Move the mtime hash entry too. Failures here cause meta drift but
296
+ // the mirror cache keeps statSync accurate, so log and continue.
297
+ try {
298
+ await this.#client.hdel(this.#metaKey(), src);
299
+ await this.#client.hset(this.#metaKey(), dst, String(entry.mtimeMs));
300
+ } catch (err) {
301
+ logger.warn("Redis session storage meta rename failed", {
302
+ src,
303
+ dst,
304
+ error: toError(err).message,
305
+ });
306
+ }
307
+ }
308
+
309
+ async unlink(path: string): Promise<void> {
310
+ const existed = this.#mirror.delete(path);
311
+ await this.#client.del(this.#fileKey(path));
312
+ await this.#client.hdel(this.#metaKey(), path);
313
+ if (!existed) {
314
+ throw enoent(path);
315
+ }
316
+ }
317
+
318
+ async deleteSessionWithArtifacts(sessionPath: string): Promise<void> {
319
+ await this.unlink(sessionPath);
320
+
321
+ // Mirror artifacts live under `<sessionPath without .jsonl>/...`. The
322
+ // Redis storage doesn't actually persist tool artifact bytes — those
323
+ // stay on disk via `ArtifactManager` — but a draft sidecar may have
324
+ // been written through `writeText`. Sweep any keys under that prefix.
325
+ const artifactsDir = sessionPath.slice(0, -6);
326
+ const prefix = artifactsDir.endsWith("/") ? artifactsDir : `${artifactsDir}/`;
327
+ const victims: string[] = [];
328
+ for (const key of this.#mirror.keys()) {
329
+ if (key.startsWith(prefix)) victims.push(key);
330
+ }
331
+ if (victims.length === 0) return;
332
+
333
+ for (const key of victims) this.#mirror.delete(key);
334
+ await this.#client.del(...victims.map(v => this.#fileKey(v)));
335
+ await this.#client.hdel(this.#metaKey(), ...victims);
336
+ }
337
+
338
+ openWriter(path: string, options?: { flags?: "a" | "w"; onError?: (err: Error) => void }): SessionStorageWriter {
339
+ const writer = new RedisSessionStorageWriter(this, path, options);
340
+ this.#writers.add(writer);
341
+ return writer;
342
+ }
343
+
344
+ // --- writer support -------------------------------------------------------
345
+
346
+ _writerClosed(writer: RedisSessionStorageWriter): void {
347
+ this.#writers.delete(writer);
348
+ }
349
+
350
+ /** Mirror-only mutation, no Redis call. Used by writers to update local state synchronously. */
351
+ _mirrorAppend(path: string, line: string): void {
352
+ const existing = this.#mirror.get(path);
353
+ const content = existing ? existing.content + line : line;
354
+ this.#mirror.set(path, { content, mtimeMs: this.#allocMtimeMs() });
355
+ }
356
+
357
+ /** Mirror-only mutation, no Redis call. Used by writers opened with `flags: "w"` to truncate. */
358
+ _mirrorTruncate(path: string): void {
359
+ this.#mirror.set(path, { content: "", mtimeMs: this.#allocMtimeMs() });
360
+ }
361
+
362
+ async _remoteTruncate(path: string): Promise<void> {
363
+ const entry = this.#mirror.get(path);
364
+ const mtimeMs = entry?.mtimeMs ?? Date.now();
365
+ await this.#client.set(this.#fileKey(path), "");
366
+ await this.#client.hset(this.#metaKey(), path, String(mtimeMs));
367
+ }
368
+
369
+ async _remoteAppend(path: string, line: string): Promise<void> {
370
+ await this.#client.append(this.#fileKey(path), line);
371
+ const entry = this.#mirror.get(path);
372
+ if (entry) {
373
+ await this.#client.hset(this.#metaKey(), path, String(entry.mtimeMs));
374
+ }
375
+ }
376
+
377
+ /** Record a writer's pending promise on the storage-level tail so `drain()` waits for it. */
378
+ _attachPending(promise: Promise<void>): void {
379
+ this.#trackPending(promise);
380
+ }
381
+
382
+ async #writeRemote(path: string, content: string, mtimeMs: number): Promise<void> {
383
+ await this.#client.set(this.#fileKey(path), content);
384
+ await this.#client.hset(this.#metaKey(), path, String(mtimeMs));
385
+ }
386
+ }
387
+
388
+ class RedisSessionStorageWriter implements SessionStorageWriter {
389
+ #storage: RedisSessionStorage;
390
+ #path: string;
391
+ #closed = false;
392
+ #error: Error | undefined;
393
+ #onError: ((err: Error) => void) | undefined;
394
+ #pendingChain: Promise<void> = Promise.resolve();
395
+
396
+ constructor(
397
+ storage: RedisSessionStorage,
398
+ path: string,
399
+ options?: { flags?: "a" | "w"; onError?: (err: Error) => void },
400
+ ) {
401
+ this.#storage = storage;
402
+ this.#path = path;
403
+ this.#onError = options?.onError;
404
+ const flags = options?.flags ?? "a";
405
+ if (flags === "w") {
406
+ // "w" mirrors FileSessionStorageWriter passing `"w"` to
407
+ // `fs.openSync`: start from empty content. Materialize the
408
+ // truncate in the mirror synchronously so an immediate reader
409
+ // can't observe stale content, then queue the remote SET.
410
+ storage._mirrorTruncate(path);
411
+ this.#enqueueRaw(() => storage._remoteTruncate(path));
412
+ }
413
+ }
414
+
415
+ #recordError(err: unknown): Error {
416
+ const error = toError(err);
417
+ if (!this.#error) this.#error = error;
418
+ this.#onError?.(error);
419
+ return error;
420
+ }
421
+
422
+ #enqueueRaw(task: () => Promise<void>): Promise<void> {
423
+ const next = this.#pendingChain.then(async () => {
424
+ if (this.#error) throw this.#error;
425
+ try {
426
+ await task();
427
+ } catch (err) {
428
+ throw this.#recordError(err);
429
+ }
430
+ });
431
+ this.#pendingChain = next.catch(() => {
432
+ // Errors are recorded on `this.#error`; subsequent enqueues
433
+ // throw from inside the wrapper above. The outer chain swallows
434
+ // to avoid surfacing as an unhandled promise rejection.
435
+ });
436
+ // Storage-level drain() waits for every writer's pending work too.
437
+ this.#storage._attachPending(next);
438
+ return next;
439
+ }
440
+
441
+ writeLineSync(line: string): void {
442
+ if (this.#closed) throw new Error("Writer closed");
443
+ if (this.#error) throw this.#error;
444
+ this.#storage._mirrorAppend(this.#path, line);
445
+ this.#enqueueRaw(() => this.#storage._remoteAppend(this.#path, line));
446
+ }
447
+
448
+ async writeLine(line: string): Promise<void> {
449
+ if (this.#closed) throw new Error("Writer closed");
450
+ if (this.#error) throw this.#error;
451
+ this.#storage._mirrorAppend(this.#path, line);
452
+ await this.#enqueueRaw(() => this.#storage._remoteAppend(this.#path, line));
453
+ }
454
+
455
+ async flush(): Promise<void> {
456
+ if (this.#error) throw this.#error;
457
+ await this.#enqueueRaw(async () => {});
458
+ if (this.#error) throw this.#error;
459
+ }
460
+
461
+ async fsync(): Promise<void> {
462
+ // Bun's `RedisClient` has no fsync equivalent; APPEND/SET return only
463
+ // after the server has acknowledged the write. `flush()` already
464
+ // awaits that ack, so this collapses into a drain.
465
+ await this.flush();
466
+ }
467
+
468
+ async close(): Promise<void> {
469
+ if (this.#closed) return;
470
+ this.#closed = true;
471
+ try {
472
+ await this.flush();
473
+ } finally {
474
+ this.#storage._writerClosed(this);
475
+ }
476
+ }
477
+
478
+ getError(): Error | undefined {
479
+ return this.#error;
480
+ }
481
+ }