/** * TiDB-backed Session event-log center (L1) — the durable "persistence center". * * Implements core's `SessionStore`. `acquire` rebuilds a session from its append-only event log * (= wake/resume), so any stateless runner instance can continue it. Cross-instance write safety is * enforced by the storage layer's compare-and-set (core's F2 seam); crash consistency (orphan * tool-call after a mid-turn crash) is handled by the Runner calling `reconcileInterruptedSession` * on acquire (core's F1). * * Persistence only. **Tenant authorization (who may wake which session) lives in the service layer** * (`src/security.ts`), which uses `ownerOf` / `register` below. The store does not infer identity. */ import { type AcquiredSession, type PlacedSessionRow, type SessionPlacement, type SessionPlacementRecord, type SessionStore, type SessionTreeEntry } from "@sema-agent/core"; import type { Pool } from "mysql2/promise"; import type { SessionSummary } from "./store-contracts.js"; import { type PlacementAcquireOpts } from "./session-placement.js"; import { type StagingHandle } from "../session-sync-kernel.js"; export declare class TiDBSessionStore implements SessionStore { private readonly pool; /** [ref] 车1:托管留存声明。读法与「为什么 SQL 店答 managed 而实现体在别处」逐字见 * `retention-store-sql.ts` 的 {@link MANAGED_RETENTION}。 */ readonly retention: import("@sema-agent/core").RetentionDeclaration; /** [ref] C18(core 5.48.0 [ref]):placement **声明**。六条义务的实装点逐条见 * {@link SQL_SESSION_PLACEMENTS} 顶注与本类的 acquire / listSessions / release / listPlaced / * placementOf 各处的 `[ref]` 注。 */ readonly placements: { readonly subagent: { readonly durability: "durable"; }; }; /** In-flight acquisitions keyed by id, so concurrent acquire(sameId) in one process share one. */ private readonly pending; constructor(pool: Pool); acquire(sessionId?: string, opts?: PlacementAcquireOpts): Promise; private load; /** Idempotently create a session_meta row with an owner. Existing owner is never overwritten. * [ref]:`placement` 在场时与行同一条 INSERT 落六列;ON DUPLICATE 臂是 no-op ⇒ 已存在的行的元组 * **永不被重写**(first-write 不可变,义务 5 的写侧半场)。 */ register(sessionId: string, owner: string | null, placement?: SessionPlacement): Promise; /** * [ref] 义务 6 的廉价探针 —— 一行 PK SELECT:`undefined` = 普通会话或无此行。 * (release 的冷缓存腿、包装店的暖命中准入判、诊断面共用这一只。) */ placementOf(sessionId: string): Promise; /** * [ref] 义务 5 —— placed 分区的**自有枚举**(联合 reap 的分区腿输入)。 * * 年龄锚 = `COALESCE(updated_at, placed_at)`:`updated_at` 是本店的「最后活动」标记(register 时置、 * 每次 append/touch 推进),取不到才回落 `placed_at` —— 与 core File 店「mtime 优先、placedAt 回落」 * 的读法同义。行按年龄升序、上界 {@link LIST_PLACED_BATCH}(理由见那里)。 * join 半缺(scope/handle 任一为 NULL)⇒ `tupleIncomplete` 诚实变体,消费方对它 fail-closed。 */ listPlaced(kind: "subagent", opts?: { olderThanMs?: number; scope?: string; }): Promise; /** Owner principal of a session, `null` if registered ownerless, `undefined` if no such session. */ ownerOf(sessionId: string): Promise; touch(sessionId: string): Promise; /** E18 — the session's current leaf `SessionTreeEntry.id` (`session_meta.leaf_id`, set transactionally on every * persist). A single SELECT, NO acquire/lock/CAS — safe to call mid-run (write-safety is the append-time CAS, not * an acquire lease) and cache-bypassing (the resume-at turn-capture reads it without touching the warm session). * Returns the leaf id, or null when the session has none / no such session. */ getLeafId(sessionId: string): Promise; /** [ref] 六轮复审:owner+leafId **单行原子快照**(SSE watch 探针的租户围栏读)——两独立 SELECT 在 * delete/reclaim 缝里可拼出「旧 owner+新 leaf」混世代组合;单 SELECT 单行=天然一致。undefined=无此会话。 */ getHead(sessionId: string): Promise<{ owner: string | null; leafId: string | null; } | undefined>; /** * Durable history is retained; just drop any in-flight cache entry. * * 🔴 [ref] 义务 4 —— **placed 会话例外:release 是真删除**(durable 史一并)。联合 reap 先按 rev 条件删 * 掉 agent 行、赢了才调本方法终结转录;若这里还按「归还租约」走,一条 30 天的转录就会失去属主行地 * 悬着(正是这条义务要关死的 strand)。判据取**持久化元组**(冷缓存/重启后照样成立,不依赖任何进程 * 内状态);删除本身是 ABA 安全的**条件删**(`placement_kind IS NOT NULL`)——探针与删除之间若真有 * 「行被删掉又以同 id 重建成普通会话」的世代翻转,条件删当场落空,不会误杀那只无辜的新会话。 * 删除失败**响亮上浮**(`SessionError("storage")`):吞掉失败等于让 reap 数一条「已终结」而字节还在 * (core File 店同批的 rmSync 失败上浮是同一条法)。 */ release(sessionId: string): Promise; /** * core B-17 的 `forget` 面 —— **丢引用,不动 durable 史**(与 {@link release} 的分工:release 是 * 生命周期终结口,forget 是「我用完了这只句柄」)。[ref] 之后这条分工是承重的:release 对 placed * 会话已是真删除,于是每一处「只为验存在 / 写完收工」的收尾都必须落在 forget 上(preflight 纪律, * [ref] 义务①)。本店的进程内状态只有 in-flight 去重表,故实现就是丢那一格。 */ forget(sessionId: string): void; /** 真删除一只 placed 会话的两张表(条件删门在 `placement_kind IS NOT NULL`;事务内 meta 先删=提交点, * 与 {@link deleteSession} 同序)。任何 I/O 失败上浮成 `SessionError("storage")`,绝不吞。 */ private deletePlacedSession; /** * E16 — list the caller's sessions (CC /resume picker), keyset-paginated newest-first by last activity. * Session IDENTITY/enumeration comes from `session_meta` (owner-filtered on the service's OWN owner column); * a LEFT JOIN onto the `task_run` runs ledger supplies run-level PREVIEW (latest run's objective/status, * run count) but never gates which sessions exist — a session with zero runs still lists (preview null/0). * Keyset on `(updated_at, session_id)`; `updated_at` is the session's last-activity marker (touch on every * run/append). The shipped {@link SessionSummary} shape is preserved (additive): `lastActivityAt` = * `session_meta.updated_at`, `firstActivityAt` = `session_meta.created_at`. */ listSessions(opts: { owner?: string; includeUnowned?: boolean; cursor?: { lastActivityAt: string; sessionId: string; }; limit: number; q?: string; }): Promise; /** Cheap pre-LLM probe: "none" = session_meta row not yet REGISTERED (createRun fires * before the Runner's session acquire — the titler retries), "untitled" / "titled" as read. */ probeTitle(sessionId: string): Promise<"none" | "untitled" | "titled">; /** Write-once auto-title: lands ONLY when title IS NULL (a concurrent/prior title wins; * the user-facing rename layer is BFF-side). Returns whether this call set it. */ setTitleIfNull(sessionId: string, title: string): Promise; /** * E17 — fork a session: copy its WHOLE durable history to a NEW uuidv7 session owned by `owner`, mirroring * core's `PgSessionRepo.fork`. Entry ids / parent links are per-session and copy VERBATIM; only `seq` is * re-densified from 0 in the new session. The new leaf = the source leaf (session_meta.leaf_id). * Returns the new sessionId, or null if the source does not exist (the route maps that to 404). * * 🔴 FULL history, NOT `wake().getEntries()` (adversarial-review MEDIUM): `wake` loads only the bounded tail * `[floor..leaf]` from the latest compaction floor (F3), so forking via it would copy only the active tail of a * COMPACTED session and SILENTLY LOSE all pre-compaction history (contradicting the whole-history-copy intent). * We instead SELECT the entire `session_event` log ordered by seq — every durable entry, compaction floor * bypassed — and re-seq it dense from 0 into the new session, ids/parents/payload byte-verbatim. */ fork(sourceId: string, owner: string | null): Promise; /** * 2c session-sync — EXPORT a session's full durable entry log for cross-backend migration. This is the * READ half of {@link fork}, surfaced as the core `SessionRepo.exportEntries` seam: the WHOLE `session_event` log, * oldest-first, ids/parents/payload/ts VERBATIM, with the compaction floor BYPASSED (NOT `wake().getEntries()`, * which returns only the bounded `[floor..leaf]` tail and would silently truncate pre-floor history — see fork's * note at :149-152). Returns `null` if the source does not exist (no `session_meta` row), mirroring fork's null * semantics; an existing-but-empty session returns `[]`. The owner is NOT exported — import re-stamps it (§9). */ exportEntries(sessionId: string): Promise; /** * 2c session-sync P1d-α (PULL streaming) — the IDS-ONLY projection of {@link exportEntries}: the entry ids of the * WHOLE durable log, oldest-first (`SELECT entry_id … ORDER BY seq ASC`), with no payloads. Small even for a huge * session, so the PULL `GET /sync/manifest` can carry it (and feed it to `classifySyncRelationshipByIds`) instead of * the entries. Returns `null` if the source does not exist (no `session_meta` row), mirroring {@link exportEntries}. */ listEntryIds(sessionId: string): Promise; /** * 2c session-sync P1d-α (PULL streaming) — STREAM the WHOLE durable log (compaction floor bypassed), oldest-first, * as an async generator that KEYSET-PAGES the `session_event` table: mysql2 buffers a plain SELECT, so a 1M-row * session would OOM if read in one shot. Instead the generator loops `… WHERE seq > cursor ORDER BY seq ASC LIMIT K` * (K = `opts.batchSize ?? 500`), advancing the cursor to the last `seq` of each batch, until a SHORT page (fewer than * K rows) ends the stream. Each batch is its OWN `pool.query` (a pooled connection acquired + released per batch), so * a slow NDJSON consumer can NEVER pin one connection across the whole stream (the clay-mandated concurrency * discipline). Start cursor = `opts.afterSeq ?? -1` (seq is 0-based dense, so `> -1` = from seq 0). * * Resolves to `null` (NOT an empty async-iterable) when the session does not exist — checked via `session_meta` * EAGERLY here (before returning the iterable), exactly like {@link exportEntries}, so a missing session is distinct * from a real-but-empty one (an empty stream). payload is parsed like {@link exportEntries} (string → JSON.parse, * object passthrough). The check is eager (the method is async) because a generator body is lazy — it can't decide * `null` vs. iterable on its own first pull. */ exportEntriesStream(sessionId: string, opts?: { afterSeq?: number; batchSize?: number; }): Promise | null>; /** * 2c session-sync — IMPORT a verbatim entry log (from another backend's {@link exportEntries}) into a * (typically NEW) `sessionId`. This is the WRITE half of {@link fork}, surfaced as `SessionRepo.importEntries`, * with two 2c-mandated differences from fork: * (a) OWNER RE-STAMP (§9): `owner` is the AUTHENTICATED importing principal passed by the caller; the bundle's * own owner is NEVER trusted (a local push is always owner=null → would become an ownerless cloud session * any anonymous caller could attach to). Mirrors the fork route's server-side `scope.gateOwner`. * (b) the single INVARIANT GATE is core's {@link validateEntriesForImport} (unique ids / parent-before-child / * exactly-one-root / leaf-resolvable / structural refs present) — we persist its VERBATIM output and do NOT * re-implement any of those checks in SQL ([[core-service-boundary]]; the same discipline fork's hand-SQL * was warned about). A violation throws `SessionError("invalid_session")` and NOTHING is written. * The write itself mirrors fork: re-seq dense from 0, entry_id/parent_id/payload/ts verbatim, in ONE txn with * `session_meta` LAST as the commit point (a mid-import crash leaves orphan events with no meta = invisible to * wake, never a half-session). The leaf is the replayed leaf (core's `leafIdAfterEntry`), matching core's import. */ importEntries(sessionId: string, owner: string | null, entries: SessionTreeEntry[]): Promise; /** * 2c session-sync (§7/§8) — IDEMPOTENT REPLACE: purge any existing conversation log for `sessionId` then re-import * `entries` verbatim under `owner`, ALL in ONE txn. Unlike {@link importEntries} (a plain INSERT that crashes on a * duplicate PK when the session already exists at the destination), replace is safe to run OVER an existing session * — the §7 fast-forward path and a §8 retry-to-completion both land on it. The validate gate runs FIRST (fail-closed, * before any write), then DELETE events + the OWNER-GUARDED meta delete (defense-in-depth, mirrors {@link deleteSession} * — a wrong-owner caller can never replace another tenant's session), then the SAME shared insert body importEntries * uses (so there is ONE source of the insert logic, no copy-paste drift). session_meta is still written LAST = commit * point, so a mid-replace crash (post-delete, pre-insert) leaves orphan-free state (no meta = invisible to wake). */ replaceEntries(sessionId: string, owner: string | null, entries: SessionTreeEntry[]): Promise; /** * Shared insert half of {@link importEntries} / {@link replaceEntries} (ONE source of the insert logic, no drift): * write the validated entries re-seq dense from 0 (entry_id/parent_id/type/ts verbatim), THEN session_meta LAST as * the commit point. Runs inside the caller's open transaction (`conn`) so the caller owns begin/commit/rollback. * `validated` is the OUTPUT of {@link validateEntriesForImport} (the caller runs the gate before opening the txn). */ private insertValidatedLog; /** * 2c session-sync P1d-β (PUSH streaming, §15) — begin a STAGED import into `realSessionId` under the shadow id * `${realSessionId}#stg-${token}`. The route mints the uuidv7 `token` (the store never calls uuidv7/Date.now itself * → the staging id is deterministic + the route owns the lease key). Returns a {@link TiDBStagingHandle} the route * streams entry batches into ({@link StagingHandle.appendBatch}) then commits ({@link StagingHandle.commit}, the * atomic swap) or aborts. No row is touched here — the handle is a stateless cursor over this pool. */ beginImportStaging(realSessionId: string, token: string): StagingHandle; /** [ref]:「这个 id 是 placed ⇒ 该写路径不适用」的共用拒绝(session-sync 的两条换装腿共用)。 */ private assertNotPlaced; /** * 2c session-sync P1d-β (staged-row inspection) — read the rows STAGED under `stagingId` (NO session_meta probe — a * staging id has no meta row by construction, so {@link exportEntries} would return null), oldest-first. The Phase-B * import gate is now the per-line {@link StreamingImportValidator} (core 1.143.0) run during the stream, so this * is no longer the validation path; it remains the staged-row inspection seam (tests/diagnostics). Returns `[]` for an * empty/unknown staging id. */ readStagedEntries(stagingId: string): Promise; /** * 2c session-sync P1d-β (§4) — reaper for ABANDONED staging sessions: DELETE session_event rows under a staging id * (`session_id LIKE '%#stg-%'`) that has NO session_meta (never committed → invisible orphans) AND whose OLDEST * staged row is older than the grace window (an IN-FLIGHT stream keeps appending fresh `ts` rows → its MIN(ts) is * recent → it is NOT reaped, exactly like sweepOrphanBlobs' created_at grace). Per-staging-id, so a slow but live * stream is never reaped while another abandoned one is. Returns the affected row count. Driven by main.ts's reaper. */ sweepStagingSessions(): Promise; /** * E21 — purge a session's CONVERSATION HISTORY (session_meta + session_event). Idempotent: a missing * session is a no-op (returns false). The service's OWN run-ledger / checkpoint / tool-result rows are * purged separately by the route's `purgeSession` coordinator — this method owns only the session * abstraction's tables (the `SessionRepo.delete` half). * * `owner` is the single-DB-fleet null-safe SQL owner guard on `session_meta.owner` (defense-in-depth — the * route already owner-gates; `<=>` so a legacy null-owner session matches owner=null). session_event carries * no owner column, so its purge is GATED on the owner-matched meta delete: if the meta DELETE matched 0 rows * (wrong owner / already gone) we skip the event delete — so a wrong-owner caller can never strand-delete * another tenant's events. Meta is deleted FIRST (mirrors core BUG-2: once it's gone, wake is `null` * regardless of leftover events; if a crash interleaves, orphan events with NO meta are invisible, never a * half-session), and the event purge only runs after that owner-matched commit point. */ deleteSession(sessionId: string, owner: string | null): Promise; get size(): number; dispose(): void; } //# sourceMappingURL=tidb-session-store.d.ts.map