import { type AcquiredSession, type SessionStore, type SessionRepo, type SessionTreeEntry } from "@sema-agent/core"; import type { SessionSummary } from "./store-contracts.js"; import { type StagingHandle } from "../session-sync-kernel.js"; export declare class LocalSessionStore implements SessionStore { private readonly repo; /** [ref] 车1:托管留存声明 —— **诚实的 none**。本店的行没有任何按期删除的腿(留存 lane 的三方法只实现在 * SQL 聚合店上),所以 locked policy + 本店 ⇒ core 的 `assertRetentionCapability` 拒启,这是契约的 * **正确行为**不是缺口(要留存治理先上 DB 后端;设计稿 §1「不做清单」逐字)。缺席也会被读成 none, * 显式写出来是文档义务:沉默表达不出「我核对过我的介质就是删不了」。 */ readonly retention: import("@sema-agent/core").RetentionDeclaration; /** In-flight acquisitions keyed by id, so concurrent acquire(sameId) in one process share one (mirrors TiDBSessionStore). */ private readonly pending; /** Service-side owner scope per session (core's repo has no owner column). Registered-ownerless ⇒ null; absent ⇒ undefined. */ private readonly owners; /** Last-activity marker (touch) for ordering — the keyset sort key for {@link listSessions}. PERSISTED to a JSON * sidecar under {@link dataDir} (when given) so the /resume picker's newest-first order SURVIVES a service restart: * without persistence the map starts EMPTY on boot and every session degrades to `createdAt` ordering (the picker * shows the wrong "most recent" — TOC-review #10). In-memory only (no sidecar) when `dataDir` is absent (the * process-local `InMemorySessionRepo` case, where the sessions themselves are lost on restart anyway). */ private readonly lastActivity; /** K-5c (core 1.155 SessionStore.noteTaskRun seam): the latest task run per session, recorded by core's Runner at * runTask START (so a running/suspended run surfaces too) → projected as `lastRunId` on listSessions. The cloud * TiDB/PG twins derive it from `task_run`; the local backend has no runs ledger, so this is an in-memory map. * ⚠️ IN-MEMORY BEST-EFFORT (honest degrade, NOT sidecar-persisted — review Q6): it covers the PRIMARY /resume use * case (re-attach to a RUNNING run — recorded at the run's start, the process can't have restarted while that run * is still live), and is LOST on a process restart. That's acceptable: a session whose last run predates a restart * has only TERMINAL runs (no live tail to re-attach), so `lastRunId` falls back to null and the shell uses its * no-anchor path — same posture as the other local degrades here (runCount:0, lastStatus:""). Persisting it would * need a sidecar-format change (the #10 clobber area); deferred until a real local-file-backed need. */ /** K-5c/[ref]/[ref]:per-session 最近一次 run 的 {taskId, runId?}(noteTaskRun 席;runId omit ⇒ 清座)。 */ private readonly lastTaskRun; /** 2c P1d-β — live staged-import handles keyed by stagingId, so a local staging's in-memory buffer is readable via * readStagedEntries (the durable twins re-read the staged session_event rows; local has no such rows). */ private readonly stagings; /** Lazy one-shot load of the persisted lastActivity sidecar (memoized; only the first reader pays the read). */ private loaded?; /** A pending debounced sidecar flush (coalesces a burst of touch() into one write). undefined ⇒ no flush queued. */ private flushTimer?; /** Serializes sidecar writes (bug B): the in-flight write promise. A flush() while one is running CHAINS after it * (re-checking the dirty flag) so an older snapshot can never rename after a newer one (lost-update). */ private writeChain; /** Set by touch/delete when the map mutated; cleared by a write that captured the change. A write that ran while this * is set re-runs (a touch that landed during an active write isn't dropped). Drives the chain's re-run guard (bug B). */ private dirty; /** Monotonic per-write counter — feeds a UNIQUE tmp filename per write so overlapping writes can't share one tmp path * and tear each other's bytes (bug B). */ private writeSeq; /** Last time a sidecar-write error was warned (bug D): throttle the best-effort warn so a persistently-failing disk * doesn't spam the log on every debounced flush. 0 ⇒ never warned. */ private lastWarnAt; /** Absolute path to the lastActivity JSON sidecar, or undefined when not persisting (in-memory-only deployment). */ private readonly sidecarPath?; /** Auto-title map + its own LIGHT sidecar (`session-titles.json`). Separate file from the * lastActivity sidecar ON PURPOSE (that format is a #10 clobber area); titles are write-once + ultra-low-rate * (one per session, ever), so a plain read-modify-write with tmp+rename per write is enough — no debounce. * * 🔴 CROSS-INSTANCE (fixed 2026-07-25, blackboard [ref] — core's "per-instance state" defect class): * these three used to be INSTANCE fields, which is exactly the shape core described — «the backend is shared * more widely than the object, but the state and the lock hang off the object». Two `LocalSessionStore`s over * the SAME dataDir each held their own `titles` map and their own write chain, so a real concurrency probe * (`test/file-stores-cross-instance-concurrency.test.ts`) showed **BOTH winning** the write-once IS-NULL gate * (`wins=2`): each checked its own map, both saw absent, both returned `true`, and the second whole-map * snapshot silently clobbered the first title. That is core's A-class (CAS double-win) and B-class * (lost update) stacked. Fix per core's prescription: state + lock live at MODULE level, keyed by the resolved * sidecar path, so any number of instances over one dataDir collapse to a single authority. * Sharing key = the resolved path; NO sidecar (in-memory deployment) ⇒ no shared backend exists ⇒ that case * keeps genuinely per-instance state (see {@link titleState}). */ private readonly titleSidecarPath?; /** Per-instance fallback used ONLY when there is no sidecar path (nothing is shared, so nothing to collapse). */ private readonly ownTitleState; /** @param repo the core SessionRepo this store projects into the §0.5 SessionStore shape. * @param dataDir the SAME data dir the file-backed repo persists under (LocalBackend passes `fileBackend.root`). * When given, lastActivity is persisted to `/session-last-activity.json` so it survives a * restart (the file-backed deployment); omit it for the in-memory deployment (nothing survives). */ constructor(repo: SessionRepo, dataDir?: string); /** 该实例应当使用的 title 状态:有 sidecar ⇒ 按路径共享的那份;无 sidecar ⇒ 自己那份(无共享后端)。 */ private get titleState(); /** Lazy one-shot hydrate of the persisted titles (mirrors ensureLoaded's posture: missing file silent, torn warn). */ private ensureTitlesLoaded; /** Chained snapshot write of the titles sidecar (the chain guarantees the LAST rename carries the * NEWEST map state: each link re-snapshots the SHARED map at write time, so an overtaken writer is harmless). */ private persistTitles; /** Cheap pre-LLM probe (local twin). The map write needs no session_meta row, so there is * no "none" (register-lag) dimension here — two states only. */ probeTitle(sessionId: string): Promise<"none" | "untitled" | "titled">; /** Write-once auto-title (the local twin of the SQL setTitleIfNull). Persists the whole * (small) map via a SERIALIZED tmp+rename snapshot; a write failure keeps the in-memory title (degrades to * re-title after restart). */ setTitleIfNull(sessionId: string, title: string): Promise; /** Best-effort, one-shot hydrate of the persisted lastActivity map (no-op when not persisting / no sidecar yet). A * read/parse error degrades to an empty map (createdAt ordering) rather than failing the list — the picker still * works, just without restart-persisted recency. Memoized so concurrent listSessions share one read. */ private ensureLoaded; /** Queue a debounced, best-effort atomic write of the lastActivity map to the sidecar (coalesces touch bursts). The * write is fire-and-forget: a persistence failure must NEVER break the run path (touch is on the hot acquire path), * so errors are swallowed — the worst case is a restart falling back to createdAt ordering for un-flushed touches. */ private scheduleFlush; /** Atomic best-effort write of the lastActivity map. SERIALIZED via {@link writeChain} (bug B): overlapping flushes * chain instead of racing, so an older snapshot can never rename after a newer one (lost-update), and a unique tmp * name per write means two writes can't tear each other's bytes. Returns the chain so callers (dispose) can await the * final write (bug C). Each link HYDRATES the disk sidecar first (bug A) and writes the UNION of disk + in-process — * the `has(id)` live-wins guard in ensureLoaded() keeps in-process touches authoritative — so a fresh-boot flush * before any list never CLOBBERS the prior boot's recency for not-yet-touched sessions. */ private flush; /** One serialized sidecar write: hydrate-then-merge (bug A), snapshot, atomic tmp+rename with a UNIQUE tmp name (bug * B). Clears {@link dirty} for the change it captured; re-runs once if a touch landed mid-write so it's not dropped. */ private doFlush; /** Throttled best-effort warn for a sidecar I/O failure (bug D): never throws, at most once per ~minute so a * persistently-failing disk doesn't spam the log on every debounced flush. */ private warnSidecar; acquire(sessionId?: string, opts?: { requireExisting?: boolean; }): Promise; private load; /** Idempotently record a session's owner — never overwrites an existing owner (mirrors TiDBSessionStore.register). * TiDB parity: register INSERTs a session_meta row so the session EXISTS (forkable) even before its * first acquire/run — so we also ensure an EMPTY repo session exists (open if present → never overwrite history; * create if not), else `ownerOf` would say a registered-but-never-acquired session exists while `fork` 404s it. */ register(sessionId: string, owner: string | null): Promise; /** Owner of a session: `null` if registered ownerless, `undefined` if no such session (mirrors TiDBSessionStore.ownerOf). * * S3 修(clay 裁 2026-07-26:**local=单用户,多租户走云** ⇒ 跨重启不承诺 owner 隔离):`owners` 是纯内存 * Map,重启后失忆——曾把「失忆」答成 `undefined`(=「无此会话」),让 DELETE /v1/sessions 的 route 门短路成 * already-gone、checkpoint 的 EXISTS 镜像门 return 0 ⇒ **E21 对盘上老会话整体 no-op 且无人知道**。 * 现在 Map miss 时探 repo:盘上在 ⇒ 按 load() 两处懒回填早已写下的同一判据回填 `null`(registered * ownerless);repo 真没有才 `undefined`。SQL 孪生无此形(owner 在 session_meta 行,重启无损)。 */ ownerOf(sessionId: string): Promise; /** [ref] 六轮复审:owner+leafId 原子快照——单线程 JS 内两同步/顺序读之间无 await 打断 owner 判定 * (owner 先取快照,leaf 读后 owner 复核,变了=undefined 视作删除中,调用方按删处理)。 */ getHead(sessionId: string): Promise<{ owner: string | null; leafId: string | null; } | undefined>; /** 2c P1d-β — re-stamp the service-side owner scope WITHOUT rewriting entries (the staged-commit `identical` path: * the log is already present & equal, so only the owner needs re-stamping to the importing principal, §9). */ restampOwner(sessionId: string, owner: string | null): void; touch(sessionId: string): Promise; /** E18 — the session's current leaf SessionTreeEntry.id (@see TiDBSessionStore.getLeafId). Reads the repo's storage * leaf (no acquire-lock; InMemory/File repo reads don't lock). null when the session doesn't exist / has no leaf. */ getLeafId(sessionId: string): Promise; /** Durable history is retained by the repo; just drop the in-flight cache entry (mirrors TiDBSessionStore.release). */ release(sessionId: string): Promise; /** * P0.5 variant-2 (E16, clay 2026-06-26) — enumerate the caller's sessions (CC /resume picker) from the SESSION * abstraction (`repo.list()`), keyset-paginated newest-first by last activity. Session IDENTITY comes from the * repo (one entry per persisted session); a {@link SessionSummary} is SYNTHESIZED per id so the wire shape is * byte-identical to {@link TiDBSessionStore.listSessions} (the run picker doesn't drift): * - `owner` — the service-side owner map (process-local; null when unknown/ownerless). Owner FILTERING * is best-effort here (the in-memory map IS the source of truth; a single-user local * deployment runs REQUIRE_PRINCIPAL=off so the principal path passes owner=null and every * null-owner session lists). The route already owner-gates at the HTTP layer. * - `firstActivityAt` — the session's `createdAt` (repo metadata). * - `lastActivityAt` — the touch marker (falls back to `createdAt`); the keyset sort key. * - `objectivePreview`— the last USER-text entry, synthesized from the session tree (no run ledger). * - `runCount`/`lastStatus` — DEGRADED (0 / ""): the session abstraction has no runs ledger; the TiDB twin * LEFT-JOINs `task_run` for these, which the local backend (MemoryRunStore, separate * ledger) intentionally does NOT correlate here (variant-2 is session-abstraction-only). * * Keyset matches the contract: STRICTLY older than the cursor, ties broken by id DESC; `limit` is honored after * the in-memory sort. Cheap (a local single-user store holds few sessions). Errors reading one session's entries * degrade that row's preview to null rather than failing the whole list. */ listSessions(opts: { owner?: string; /** [ref] 窄互认第二臂的第三条件(SQL 孪生里是 `owner IS NULL` 谓词;此处是同义的 `owner === null` 放行)。 */ includeUnowned?: boolean; cursor?: { lastActivityAt: string; sessionId: string; }; limit: number; q?: string; }): Promise; /** K-5c (core 1.155 SessionStore seam): core's Runner calls this at runTask START with the run's taskId, so the * latest run surfaces as `lastRunId` on listSessions even while it is still running/suspended (the local backend * has no runs ledger to derive it from, unlike the TiDB/PG twins). In-memory, last-write-wins per session. * [ref] → [ref]([ref] 兑现;上一版「有意缓议」注即本批债锚,兑现随批撤):第三参 engine runId * 自本批起**消费** —— map 值 {taskId, runId?} + 行投影 `lastTaskRunId` + **两座同动清座**(omit ⇒ * 清 runId 座,对齐 core TtlSessionStore.noteTaskRun 的 omit⇒clear 契约,core session.d.ts * 亲读:旧 run 的 runId 挂在新 task 旁 = 把上一条 run 的身份错标给新 run)。存储与行投影同批落 * (codex 5.65 R1-[medium]「存储先行、投影另批」之驳仍成立:本店 runId 唯一读点即行投影)。 * wire 半场:HTTP 面零投影透传(sessions-list.ts,additive 键随行上 wire);sema-sdk spec * `SessionSummary` 闭集候 SDK 开键([ref]/.82 班车)——SDK 落键即端到端闭合。 */ noteTaskRun(sessionId: string, taskId: string, runId?: string): void; /** Synthesize a session's objective preview = the most-recent USER text message (the objective the run last saw). * Reads the session's entries through the repo; any read error degrades to null (never fails the list). */ private previewOf; /** E17 — fork a session's WHOLE history to a NEW id owned by `owner` (core SessionRepo.fork). Returns the new id, * or null if the source does not exist (the route maps that to 404). */ fork(sourceId: string, owner: string | null): Promise; /** 2c session-sync — EXPORT a session's full durable entry log (core `SessionRepo.exportEntries`; the * in-memory/file repo holds the whole log so there is no floor to bypass). null if the source is unknown / the * underlying repo lacks the optional seam. The owner is NOT exported — import re-stamps it. */ exportEntries(sessionId: string): Promise; /** 2c session-sync P1d-α (PULL streaming) — the IDS-ONLY projection of the full durable log (@see * TiDBSessionStore.listEntryIds). The local repo holds the whole log in memory/file so there is no floor to bypass; * ids are the `exportEntries` ids in order. null if the source is unknown / the repo lacks the export seam. */ listEntryIds(sessionId: string): Promise; /** 2c session-sync P1d-α (PULL streaming) — STREAM the full durable log as an async generator (@see * TiDBSessionStore.exportEntriesStream). The LOCAL twin has no cross-instance buffering hazard (an in-memory map / * single-process file repo), so it reads the whole log via the repo's `exportEntries` then yields it lazily, SLICED * after `opts.afterSeq` (the LOCAL backend's native seq = the 0-based dense ARRAY INDEX of the entry in the * oldest-first log; afterSeq=k → entries at index > k = `slice(k+1)`). afterSeq is a BACKEND-NATIVE seq cursor (a * resume is always same-backend), so the local 0-based-index base needn't match the durable backends' `seq` column * base. Resolves to null (NOT an empty iterable) for an unknown session — mirrors {@link exportEntries}. * `opts.batchSize` is accepted for API parity but has no effect locally (the whole log is already in hand). */ exportEntriesStream(sessionId: string, opts?: { afterSeq?: number; batchSize?: number; }): Promise | null>; /** 2c session-sync — IMPORT a verbatim entry log into `sessionId` via core `SessionRepo.importEntries` * (which runs the `validateEntriesForImport` fail-closed gate before writing). `owner` is the authenticated * importing principal (re-stamped into the service-side owner map; core's local repo has no owner column, §9). * `owner=null` (single-user local) is passed as `undefined` to the core seam (its owner param is `string | undefined`). */ importEntries(sessionId: string, owner: string | null, entries: SessionTreeEntry[]): Promise; /** 2c session-sync (§7/§8) — IDEMPOTENT REPLACE. The local twin's replace is DELETE-then-IMPORT: core's * InMemorySessionRepo / FileSessionRepo expose no single replace primitive, but the local backend is single-writer * with NO compaction floor / cross-instance race (a process-local in-memory map / a single-process file repo), so * deleteSession-then-importEntries is effectively atomic for the local case (no concurrent reader can observe the * gap, unlike the durable cross-instance backends which do it in ONE SQL txn). Both halves are best-effort feature- * gated: a repo lacking either seam throws the same clear error importEntries does (no silent half-write). */ replaceEntries(sessionId: string, owner: string | null, entries: SessionTreeEntry[]): Promise; /** 2c session-sync P1d-β (PUSH streaming) — begin a STAGED import. The LOCAL backend is single-writer with no * cross-instance / cross-replica concurrency story (the durable twins carry that), so the staging handle simply * ACCUMULATES the streamed batches in memory then, on commit, runs the SAME validate-first → replaceEntries the * whole-bundle local import uses (effectively atomic locally — no concurrent reader can observe the gap). Keep it * simple per §1: the durable twins are where the SHADOW-id swap + bounded-memory + concurrency matter. */ beginImportStaging(realSessionId: string, token: string): StagingHandle; /** 2c P1d-β (staged-row inspection) — the in-memory staged entries for a local staging id. No longer the import gate * (the per-line StreamingImportValidator validates during the Phase-B stream); a tests/diagnostics seam. [] for an * unknown/finished staging id (mirrors the durable twins' empty-set return). */ readStagedEntries(stagingId: string): Promise; /** Drop a finished/aborted local staging handle from the registry (called by the handle on commit/abort). */ forgetStaging(stagingId: string): void; /** E21 — purge a session's conversation history (core SessionRepo.delete), owner-guarded (single-user: owner=null * matches). Idempotent: a missing/wrong-owner session is a no-op returning false. The runs-ledger / checkpoint / * tool-result rows are purged separately by the route's purgeSession coordinator. */ deleteSession(sessionId: string, owner: string | null): Promise; get size(): number; /** Graceful shutdown: AWAIT the final sidecar write (bug C) so a restart doesn't lose the last debounced-but-unwritten * touch. Cancels any queued debounce, then awaits a flush (which chains after any in-flight write and merges disk). */ dispose(): Promise; } //# sourceMappingURL=local-session-store.d.ts.map