import { type FileLockHandle } from "@oh-my-pi/pi-utils/file-lock"; import { type SessionTitleUpdate } from "./session-title-slot.js"; /** * Basename of a rewrite staged by {@link sessionStagingPath}; group 1 is the * target's basename. A live publish renames it away within milliseconds; * `omp gc` reclaims one a killed writer left behind. */ export declare const SESSION_STAGING_RE: RegExp; /** Fresh staging path beside `target`, matching {@link SESSION_STAGING_RE}. */ export declare function sessionStagingPath(target: string): string; /** * The file a rewrite must land in: the referent when `fpath` is a symlink. * * A rewrite stages a body beside the path and renames it over it, which * replaces a symlink rather than the transcript it points at. A transcript * staged into `--session-dir` as a symlink would then stop being one: the * staged file would keep every turn before the rewrite while a new regular * file beside it took the rest, and losing the staging directory would lose * them (#15878). Appends need none of this — they write through an open * descriptor, which follows the link. */ export declare function sessionPublishTarget(fpath: string): string; export interface SessionStorageStat { size: number; mtimeMs: number; mtime: Date; } export interface SessionStorageWriter { /** * Append one newline-terminated line. * * File and memory storage apply the line synchronously before the returned * promise settles, so a software crash after `append` returns (or after a * fire-and-forget call begins) still sees the entry on disk / in body. No * `fsync` — power loss may still drop the last page. Indexed backends update * the local index immediately and queue the remote publish in call order. * * `line` MUST include the trailing newline. */ append(line: string): Promise; /** * Synchronous append when the backend can apply the line before return. * File and memory implement this so {@link SessionManager} can latch the * first write failure before the appending call returns (surfaced by a later * flushSync/close/next append — the turn loop does not throw from append). * Indexed backends update the local index immediately and queue remote I/O. */ appendSync?(line: string): void; /** Resolve once all queued appends complete. No fsync. */ flush(): Promise; /** Drain synchronously flushable queued work when the backend supports it. No fsync. */ flushSync?(): void; /** False once close() has begun/finished. */ isOpen(): boolean; close(): Promise; getError(): Error | undefined; } /** Optimistic precondition for replacing a session file. */ export interface SessionStorageWriteOptions { /** Current UTF-8 byte length, or `null` when the target must not exist. */ expectedSize?: number | null; } /** * The session changed after a writer loaded it, so replacing it would discard * another writer's durable entries. */ export declare class SessionWriteConflictError extends Error { readonly path: string; readonly expectedSize: number | null; readonly actualSize: number | null; constructor(path: string, expectedSize: number | null, actualSize: number | null); } /** * The file publish lock is held by another live writer, so freshness cannot * be established. Fail-closed: the staged rewrite is discarded without * publishing. */ export declare class SessionLockError extends Error { readonly path: string; constructor(path: string, detail: string); } /** * Optional guards applied by {@link SessionStorage.writeTextAtomic}. The * backend MUST check `expectedSize` and call `commitGuard()` synchronously * immediately before it makes the staged content visible at `path`. Failed * preconditions leave the target untouched. Backends MUST NOT yield between * the checks and publishing the write. */ export interface WriteTextAtomicOptions extends SessionStorageWriteOptions { commitGuard?: () => boolean; } export interface SessionStorage { /** * `true` when synchronous writes ({@link writeTextSync} and a writer's * {@link SessionStorageWriter.appendSync}) have reached the backing store by * the time they return. File and memory backends apply them in-body and * leave this unset. Indexed backends only update the local index and queue * the remote publish, so a caller that tracks a durable byte size (such as * `SessionManager`) must wait for {@link drain} to confirm before it * advances that size. */ readonly defersSyncPublish?: boolean; /** * Resolve once every write this storage queued for `path` has been confirmed * by, or rejected by, the backing store. In-body backends never queue, so * they omit this; {@link defersSyncPublish} marks the backends that provide * it. */ confirmWrites?(path: string): Promise; ensureDirSync(dir: string): void; existsSync(path: string): boolean; /** * Whether the backing store has confirmed `path` exists. {@link existsSync} * on a deferring backend also reports queued, unconfirmed writes; a check * that must not outlive an aborted publish (an exit-time resume hint) uses * this instead. In-body backends omit it: their {@link existsSync} already * reports confirmed state. A {@link defersSyncPublish} backend that omits * it is treated as never confirmed. */ existsConfirmedSync?(path: string): boolean; writeTextSync(path: string, content: string, options?: SessionStorageWriteOptions): void; /** * Streaming {@link writeTextSync}: publishes the concatenation of `lines` * with the same staging, size check, and conflict semantics without * materializing it as one string. Optional; callers on backends without * it join the lines and call {@link writeTextSync}. */ writeLinesSync?(path: string, lines: readonly string[], options?: SessionStorageWriteOptions): void; /** * Update the current session title through the storage backend. * * File-like backends rewrite the fixed-width JSONL title slot; indexed * backends can store the semantic title fields and synthesize the slot when * reading. */ updateSessionTitle(path: string, update: SessionTitleUpdate): Promise; statSync(path: string): SessionStorageStat; listFilesSync(dir: string, pattern: string): string[]; exists(path: string): Promise; readText(path: string): Promise; /** * Synchronous {@link readText}. Optional: `SessionManager` reads the file * back to keep another writer's entries when a synchronous rewrite meets * them, and reports the conflict instead on backends without it. */ readTextSync?(path: string): string; /** Read the requested UTF-8 byte windows from the head and tail of the file. */ readTextSlices(path: string, prefixBytes: number, suffixBytes: number): Promise<[string, string]>; /** * Feed each line of the file to `visit`, in order, until it returns true or * the file ends. Lets callers find records that fall between the bounded * {@link readTextSlices} windows. Optional: backends without cheap full scans * omit it and callers keep the window evidence. */ scanLines?(path: string, visit: (line: string) => boolean): Promise; writeText(path: string, content: string): Promise; writeTextAtomic(path: string, content: string, options?: WriteTextAtomicOptions): Promise; /** Streaming {@link writeTextAtomic}, optional on the same terms as {@link writeLinesSync}. */ writeLinesAtomic?(path: string, lines: readonly string[], options?: WriteTextAtomicOptions): Promise; rename(path: string, nextPath: string): Promise; unlink(path: string): Promise; deleteSessionWithArtifacts(sessionPath: string): Promise; /** * Run a synchronous session mutation under the backend's cross-process * lock. Optional because only backends with a process-shared lock can * participate in close-time draft GC. */ withSessionFileLockSync?(sessionPath: string, operation: () => T): T; /** * Claim this process's ownership of the session `sessionId` it writes to * `sessionPath`, until the returned release callback runs. Returns `null` * while another live process holds the claim, or when `sessionPath` now * holds a different session (another session was moved onto it), so the * caller's appends would land in that session's journal; `SessionManager` * then moves its session to a sibling instead of writing that file. * Optional because only backends with a process-owned lock can tell that * another process writes a session. */ claimSession?(sessionId: string, sessionPath: string): (() => void) | null; /** * Atomically delete a session and its artifacts only when `shouldDelete` * accepts the current session content. Optional because backends without a * cross-process conditional-delete primitive must skip opportunistic GC. */ deleteSessionWithArtifactsIf?(sessionPath: string, shouldDelete: (content: string) => boolean): Promise; openWriter(path: string, options?: { flags?: "a" | "w"; onError?: (err: Error) => void; }): SessionStorageWriter; /** * Wait for every backing write scheduled by this storage to become durably * visible. Sync backends (file, memory) return immediately because their * writes complete in-body; async backends (Redis/SQL via * {@link IndexedSessionStorage}) await their per-path queues so a caller * driving a graceful shutdown does not exit while a fire-and-forget * `writeTextSync` publish is still on the wire. */ drain(): Promise; } /** * Take a session's ownership lease without waiting, or `null` while another * process holds it. A process writing the session holds it (see * `FileSessionStorage.claimSession`) until it exits; `omp gc` takes it to * delete a session no running process owns. Throws when the lock cannot be * probed. */ export declare function tryAcquireSessionLease(sessionId: string): FileLockHandle | null; /** * Whether a process, this one included, holds a session's ownership lease, * observed without taking it, so it never makes a concurrent claim fork the * session (see `isFileLockHeld`). `null` when * the lease cannot be probed. Never throws. */ export declare function isSessionLeaseHeld(sessionId: string): boolean | null; /** Session ids whose lease is held, and those whose lease could not be probed; the rest are free. */ export interface SessionLeaseReport { held: string[]; unknown: string[]; } /** `isSessionLeaseHeld` over many ids, for `omp session lease` and RPC `get_session_lease`. */ export declare function probeSessionLeases(sessionIds: readonly string[]): SessionLeaseReport; export declare class FileSessionStorage implements SessionStorage { #private; ensureDirSync(dir: string): void; existsSync(path: string): boolean; writeTextSync(fpath: string, content: string, options?: SessionStorageWriteOptions): void; /** * Streams `lines` into the staging file in bounded chunks. A subclass or * spy that intercepts {@link writeTextSync} still sees every full rewrite: * the lines are then joined and routed through it. */ writeLinesSync(fpath: string, lines: readonly string[], options?: SessionStorageWriteOptions): void; updateSessionTitle(fpath: string, update: SessionTitleUpdate): Promise; statSync(path: string): SessionStorageStat; listFilesSync(dir: string, pattern: string): string[]; exists(path: string): Promise; readText(path: string): Promise; readTextSync(path: string): string; readTextSlices(path: string, prefixBytes: number, suffixBytes: number): Promise<[string, string]>; scanLines(path: string, visit: (line: string) => boolean): Promise; writeText(path: string, content: string): Promise; writeTextAtomic(fpath: string, content: string, options?: WriteTextAtomicOptions): Promise; /** * Streams `lines` into the staging file through a buffered file sink. A * subclass or spy that intercepts {@link writeTextAtomic} still sees every * full rewrite: the lines are then joined and routed through it. */ writeLinesAtomic(fpath: string, lines: readonly string[], options?: WriteTextAtomicOptions): Promise; /** * Sync rename hook. Split from `rename` so `writeTextAtomic` can perform its * guard-then-publish step without a yield, and so tests can inject * Windows-style EPERM at the sync layer used by the atomic path. */ renameSync(source: string, target: string): void; rename(path: string, nextPath: string): Promise; unlink(path: string): Promise; drain(): Promise; openWriter(fpath: string, options?: { flags?: "a" | "w"; onError?: (err: Error) => void; }): SessionStorageWriter; /** Run a synchronous session mutation under its cross-process lock. */ withSessionFileLockSync(sessionPath: string, operation: () => T): T; /** * Rename `replacement` over the session file `fpath` under the * cross-process publish lock that serializes appends and rewrites, when * `unchanged` still accepts the file there. Returns whether it renamed. * Throws {@link SessionLockError} while another writer holds the lock. */ replaceSessionFileSync(fpath: string, replacement: string, unchanged: () => boolean): boolean; /** * The lease is an OS lock (abstract socket, named mutex, or `flock` sidecar * under ~/.omp/run/session-owners), so the kernel drops a dead owner's claim. * Never throws: a lock that cannot be taken for another reason counts as * owned, so it never moves a session off its file. */ claimSession(sessionId: string, sessionPath: string): (() => void) | null; /** * Conditionally delete under the same cross-process lock used by the first * durable append to a draft-only session. */ deleteSessionWithArtifactsIf(sessionPath: string, shouldDelete: (content: string) => boolean): Promise; /** * Delete a session file and its artifacts directory. * Artifacts are stored in a sibling directory with the same name minus .jsonl extension. */ deleteSessionWithArtifacts(sessionPath: string): Promise; } /** * Name of `key` when it sits directly inside `resolvedDir` (a `path.resolve`d * directory), else `undefined`. Key-indexed storages keep whatever spelling * callers wrote, so both sides are resolved: on Windows `/sessions/x` and the * `path.join`-built `\sessions\x\…` keys must list as the same directory. */ export declare function directChildKeyName(resolvedDir: string, key: string): string | undefined; export declare class MemorySessionStorage implements SessionStorage { #private; ensureDirSync(_dir: string): void; existsSync(path: string): boolean; writeTextSync(path: string, content: string, options?: SessionStorageWriteOptions): void; updateSessionTitle(path: string, update: SessionTitleUpdate): Promise; /** * Internal O(1) append used by {@link MemorySessionStorageWriter}. Lazily * creates the entry. External callers should go through `openWriter()` * rather than touching the mirror directly. */ appendSync(path: string, chunk: string): void; statSync(path: string): SessionStorageStat; listFilesSync(dir: string, pattern: string): string[]; exists(path: string): Promise; readText(path: string): Promise; readTextSync(path: string): string; readTextSlices(path: string, prefixBytes: number, suffixBytes: number): Promise<[string, string]>; scanLines(path: string, visit: (line: string) => boolean): Promise; writeText(path: string, content: string): Promise; writeTextAtomic(path: string, content: string, options?: WriteTextAtomicOptions): Promise; rename(path: string, nextPath: string): Promise; unlink(path: string): Promise; deleteSessionWithArtifacts(_sessionPath: string): Promise; drain(): Promise; openWriter(path: string, options?: { flags?: "a" | "w"; onError?: (err: Error) => void; }): SessionStorageWriter; }