/** * 2c session-sync — P1d-α PULL manifest export (`exportSessionManifest`) + §7 dry-run planning (`planSync`) over a * `StoreBackend` (sema-internal server/docs/DESIGN-session-sync.md §5–§10, §15). The PUSH half is the two-phase * staged streamed import in `src/http/routes/session-sync.ts` (§8 fanout re-implemented there, entries-off-the-wire). * * 🪦 **TOMBSTONE (2026-08-06, mechanical removal car, [ref] 2-i precondition + #51 死码判定)**: this module used to * ALSO export a whole-bundle `exportSession`/`importSession` orchestrator pair (the P1b one-shot in-memory move: * read entries+snapshots+policy+anchors into one {@link SessionBundle}, replay them across backends in the §8 * atomic order). #51's audit (src git history around this date; see `test/session-sync-routes.test.ts`'s former * "死码判定的常驻门" describe, now a removal-proof tombstone test) established BOTH functions had **zero production * call sites** — PULL uses `exportSessionManifest` below + the keyset-paged NDJSON `/sync/entries` stream; PUSH uses * the two-phase staging route, which reimplements the §8 fanout itself (`handle.commit` / `importManifest` / * `putRules` / `resumeAnchorStore().put`) and never called `importSession`. The only consumers were three test * files. [ref] 2-i's precondition for removal (B4/G1 re-anchored to the live streaming path) was met, so this car * deleted both functions + the tests that existed solely to exercise them. * * ⚠️ **Two invariants the deleted `importSession` uniquely protected have NO live-route equivalent** (verified by * reading the current code, not assumed) — flagging rather than silently dropping the coverage: * 1. **Policy-rev stability on idempotent retry/duplicate sync** (the deleted ③ step's fix: skip `putRules` when * the incoming record's content already equals the dst row, so a retried/duplicated sync never bumps `rev` — * `rev` is the operator's optimistic-lock CAS token). The LIVE two-phase PUSH route (`src/http/routes/ * session-sync.ts` lines ~382 and ~608) calls `sessionPolicyStore.putRules(...)` UNCONDITIONALLY on every * policy-record replay (identical-branch short-circuit AND the post-commit replay) — no content-equality skip. * A crash-retry or a harmless duplicate sync on the live PUSH route WILL bump every policy row's rev, unlike * the now-deleted `importSession`. The deleted tests (fanout-characterization §A A4/A5/A6, §F F1/F1c) pinned * this ONLY on the dead function; no wire/live-route test exercises it. * 2. **overwrite-dst subsystem wipe** (the deleted ⓪ step: before replaying policy/anchors/snapshots under a * consented `overwrite-dst`, purge the destination's stale rows for principals/keys NOT in the incoming * bundle, so an abandoned fork branch's policy/anchor/snapshot rows don't survive as a MIXED state). The LIVE * two-phase PUSH route has NO equivalent wipe step — its overwrite-dst commit only purges/renames * `session_event` (entries); the post-commit policy/anchor replay (lines ~606–611) only `put`s the records the * bundle carries, never deletes a dst-only row. A dangling anchor pointing at a purged entry, or a * dst-only-principal policy row from the abandoned branch, can survive an overwrite-dst PUSH on the live * route. The deleted tests (fanout-characterization §C C1/C1b, `session-sync-routes.test.ts`'s deleted * "[ref] 同族: overwrite-dst wipe" describe) pinned this ONLY on the dead function. * Neither gap is fixed by this car (mechanical removal, no behavior changes) — they are reported here + in the * removal's final report for separate triage (port the fix to the live route, or explicitly accept the regression). * * ✅ **STATUS UPDATE (2026-08-06 同日,[ref] 红先修绿)**:上面两条「NO live-route equivalent」已作废—— * 两个保护都已移植到活路由(本文件尾部 [ref] 段:`sameRulesContent`/`replayPolicyRecords`/ * `overwriteWipeIncapable`/`wipeSessionAttendants`;路由接线=src/http/routes/session-sync.ts 的两条重放腿 * + commit 后擦除步 + commit 前能力预检 501)。判据钉=fanout-characterization「[ref] live 路由无等价物 * 缺口」describe(L1 rev 零漂移+反向臂 / L2 三子系统擦除+共享 key 换源端字节 / L3 缺 seam fail-loud), * L1/L2 先红后绿,两处变异抽验(擦除步禁用⇒L2 红、等值跳过禁用⇒L1 红)。原文保留供考古。 */ import type { StoreBackend } from "./plugins/store-backend.js"; import { type SyncRelation } from "./session-sync-kernel.js"; import { type SessionTreeEntry, type SessionRulesRecord, type FileHistoryExport, type FileHistoryResult, type SessionPermissionRules, type StoredSessionRules, type SessionPolicyStore } from "@sema-agent/core"; import type { ServiceFileHistoryStore, ServiceSessionPolicyStore, ResumeAnchorStore } from "./plugins/store-backend.js"; /** A blob getter: content-addressed bytes for a sha256 hash, or `undefined` if the source can't supply them. * Returned by {@link exportSessionManifest} (closes over the source store); consumed wherever a caller pulls * content-addressed history bytes lazily (the live PUSH route's `importHistory` replay). */ export type BlobGetter = (hash: string) => Promise; /** The history-import capability face of a `FileHistoryStore` (`importHistory` is OPTIONAL on the core * contract — a store without it cannot be a 2c history sink). */ export interface FileHistoryImportFace { importHistory(scope: string, data: FileHistoryExport, srcGetBlob: BlobGetter): Promise; } /** Single-point capability probe ([ref] R7): does `fs` carry the optional `importHistory` seam? * Returns the bound method (never a naked function reference — `this` inside must resolve to `fs`) or * `undefined` when absent. Callers gate on the RETURN VALUE, not a separate `typeof` check, so there is * exactly one place that knows the shape of the probe. */ export declare function fileHistoryImportFace(fs: unknown): FileHistoryImportFace | undefined; /** * The 2c PUSH replay of a session's file-history graph — keep-SOURCE convergence over core's * non-destructive-target law. `importHistory` refuses a target that already holds a DIFFERENT graph * (typed `conflict`; the identical envelope re-publishes idempotently). On that arm, when the store has * the purge seam, the sync's documented semantic (the destination converges to the source's state, same * posture as the policy/anchor replays) is applied: wipe the scope, then import once more. A store * WITHOUT `deleteBySession` (the local-lane core file store) surfaces the typed conflict unchanged — * an honest capability limit, never a silent half-state. * * Honest residual (recorded, not hidden): if the RE-import after the wipe fails (a blob corrupted in * transit past the Phase-A presence check), the destination's old graph is already gone and the target * is left EMPTY — worse than the moment before this call, but strictly self-healing: the client re-PUTs * the blob and re-POSTs, and the retry lands on an empty target (importHistory's first-import arm). * The alternative (keeping the old graph) would wedge every retry on the same typed conflict forever. */ export declare function replayFileHistory(store: ServiceFileHistoryStore, scope: string, envelope: FileHistoryExport, srcGetBlob: BlobGetter, opts: { allowReplace: boolean; }): Promise; /** * The portable state of ONE session, ready to replay into another backend. Blobs are deliberately NOT inlined * (§5: a turn's working-tree blobs can be tens of MiB → OOM/413 if buffered); each snapshot carries only its * `manifest` (relPath → blobHash) and the bytes are streamed through the paired {@link BlobGetter}. */ export interface SessionBundle { sessionId: string; /** The FULL durable conversation log (compaction floor bypassed), verbatim ids/parents/payload — `exportEntries`. */ entries: SessionTreeEntry[]; /** The session's per-edited-file rewind history graph (core's versioned `FileHistoryExport` envelope — * tracked set + version chains + boundaries; blob BYTES travel separately, content-addressed). `null` = * no history / the source store cannot export. Replaces the retired E19 `snapshots` manifests ([ref]). */ fileHistory: FileHistoryExport | null; /** Every (principal, rules) record for the session, across ALL principals (E6 `listBySession`). */ policy: SessionRulesRecord[]; /** Every E18 resume-at anchor (eventId→entryId + its source owner; owner is RE-KEYED on import, §9). */ anchors: Array<{ eventId: string; entryId: string; owner: string | null; }>; } /** * P1d-α (PULL streaming, §1.5/§5/§15) — a {@link SessionBundle} with the ENTRIES LIFTED OFF THE WIRE: the HTTP PULL * `GET /sync/manifest` returns THIS (small, bounded), and the entries flow separately through the NDJSON * `GET /sync/entries` stream (bounded memory regardless of session size — the §1.5 ruling: entries are small text * rows, so streaming is about ROW COUNT, not blob size). It carries the entries' IDS (`entryIds`, oldest-first — the * local peer feeds these to {@link classifySyncRelationshipByIds} to decide fast-forward / fork BEFORE pulling the * stream) + the count + the current leaf, and the snapshots/policy/anchors EXACTLY as {@link SessionBundle} does. */ export interface SessionManifest { sessionId: string; /** The full durable log's entry IDS, oldest-first (verbatim, cross-backend stable) — NOT the entry payloads. */ entryIds: string[]; /** `entryIds.length` — the number of entries the paired `/sync/entries` stream will yield (the trailer's count). */ entryCount: number; /** The session's current leaf `SessionTreeEntry.id` (session_meta.leaf_id), or null if it has none. */ leafId: string | null; /** The per-edited-file rewind history envelope ([ref]; see {@link SessionBundle.fileHistory}). EXACTLY as the bundle. */ fileHistory: FileHistoryExport | null; /** Every (principal, rules) record across ALL principals (E6 `listBySession`). EXACTLY as the bundle. */ policy: SessionRulesRecord[]; /** Every E18 resume-at anchor (eventId→entryId + source owner; RE-KEYED on import, §9). EXACTLY as the bundle. */ anchors: Array<{ eventId: string; entryId: string; owner: string | null; }>; } /** * P1d-α (PULL streaming, §15) — EXPORT a session's portable state as a {@link SessionManifest} (entries LIFTED OFF * the wire), so it does NOT load entry payloads: it reads the * IDS-only projection via `listEntryIds` (`null` ⇒ no such session ⇒ this returns `null`), counts them, reads the * current `leafId`, and builds snapshots/policy/anchors. The paired NDJSON * `GET /sync/entries` route streams the entry payloads separately (keyset-paged → bounded memory), so a 1M-entry * session's manifest stays small (ids + counts + the snapshot/policy/anchor metadata). * * The OWNER is NOT exported (import re-stamps it, §9). The owner-scope GATE is the CALLER's (route P1d): this returns * `null` ONLY for "session does not exist". (Named exportSessionManifest, NOT exportManifest, to avoid the collision * with the history store's own export verb.) */ export declare function exportSessionManifest(sessionId: string, srcBackend: StoreBackend): Promise<{ manifest: SessionManifest; getBlob: BlobGetter; } | null>; /** * §7 DRY-RUN — classify what importing `sessionId` from `srcBackend` into `dstBackend` WOULD do, WITHOUT writing * anything (the shell can present a keep-local / keep-cloud / fork-new choice up front). * * Returns `null` when the SOURCE session does not exist (no bundle to plan); otherwise the {@link SyncRelation} * between the source's full durable log and the destination's current log (a `null` dst log ⇒ `fresh`). No writes. */ export declare function planSync(sessionId: string, srcBackend: StoreBackend, dstBackend: StoreBackend): Promise; /** [ref] ①(死码 A6 修的移植):同内容判据 —— 比较「归一化、去 rev」后的字节形。putRules 每次 * 无条件写都 rev+1,而 rev 是 operator 乐观锁的 CAS 令牌(`PutRulesOptions.expectedRev`), * 幂等重试/无害重复同步不该把在途 CAS 打失败。归一化用 core 的 `normalizeRules`(store 落盘 * 前走同一函数 ⇒ 两侧同坐标);`stripRev` 去掉记录内嵌的 rev(`listBySession` 的 rules 含 rev, * 它不是内容)。 */ export declare function sameRulesContent(stored: StoredSessionRules | null, incoming: SessionPermissionRules): boolean; /** [ref] ① 的重放腿共享实现:PUSH 路由的两条 policy 重放腿(Phase A identical 短路支 / Phase B * commit 后支)都从这里走 —— 等内容跳过(rev 零漂移),真变更照常落地(E6 tighten-only 门在 * putRules 内部,此处不重述)。 */ export declare function replayPolicyRecords(store: Pick, sessionId: string, records: readonly SessionRulesRecord[], operatorOk: boolean): Promise; /** [ref] ② 能力预检(fail-loud 方向,[ref] 判据:保护型能力缺席=fail-closed):consented * overwrite-dst 需要 policy 店的 `deleteBySession` purge seam 才能不留混合态;缺席=true,路由在 * commit **之前**用它干净中止(此时零写入落地),wire 拒因字面量归路由所有(api-error-text-freeze * 锚字面站点)。anchor 店的 deleteBySession 是类型必填、history 店有 core 必填的 `reap` 兜底(+ * {@link replayFileHistory} 的 conflict 收敛臂),都不构成缺席面;唯一可缺的就是 policy 这条 * (core 接口上是可选方法)。 */ export declare function overwriteWipeIncapable(policyStore: ServiceSessionPolicyStore | undefined): boolean; /** [ref] ②(死码 ⓪ 擦除步的移植):consented overwrite-dst = keep-source,结果必须是**源端那 * 一份**,不得混成「源端对话 + 目的端遗留 policy/anchor/历史」(混合态比丢历史更危险:悬空 * anchor 指向已 purge 的 entry、被放弃分支的 policy 行继续 gating、目的端旧历史图挡住源端图的 * importHistory 空目标法则)。调用时点=commit(原子 swap)成功**之后**、附随物重放**之前**: * 死码在任何写入前擦(其 entries 写本身非原子);活路由有真 commit point,擦在 commit 前会在 * 「commit 失败」时留下反向混合态(目的端对话完好、附随物已毁)——方向更坏。 * 诚实残余:进程在 commit 与本函数之间崩溃时,重试落 Phase A identical 支(无 wipe)⇒ 遗留行 * 可存活;该窗口窄于修前世界(修前=遗留行**恒**存活),与 §8「历史重放失败 ⇒ 422 重试自愈」 * 同款接受,记录于此不装没有。 * 各店姿势:file history=deleteBySession(整 scope 清空;blob 走异步孤儿 GC)缺席则 core 必填 * `reap(scope, [])`(丢全部 boundary;v1 基线按 core 契约留存 —— 随后 {@link replayFileHistory} * 的 conflict 臂在有 purge 缝的店上收敛,无缝的店如实 422);anchor=直调(类型必填); * policy=deleteBySession(路由已用 {@link overwriteWipeIncapable} 预检过,此处缺席=类型外的 * JS 层残缺实现,直调抛 TypeError 即 fail-loud)。 */ export declare function wipeSessionAttendants(stores: { fileHistoryStore?: ServiceFileHistoryStore | undefined; sessionPolicyStore?: ServiceSessionPolicyStore | undefined; resumeAnchorStore?: Pick | undefined; }, sessionId: string): Promise; //# sourceMappingURL=session-sync.d.ts.map