@sema-agent/server 6.5.0 → 6.7.0
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.
- package/dist/boot/resolve-spec.js +13 -2
- package/dist/http/routes/session-sync.js +28 -6
- package/dist/http/server.js +18 -0
- package/dist/leader/wire.d.ts +5 -1
- package/dist/session-sync.d.ts +82 -120
- package/dist/session-sync.js +67 -309
- package/dist/tool-approval.js +10 -9
- package/package.json +3 -3
|
@@ -280,6 +280,15 @@ export function createResolveSpec(ctx) {
|
|
|
280
280
|
if (rawAddDirs !== undefined && !cwdHonored(config)) {
|
|
281
281
|
logger.warn("task_additional_directories_ignored", { honored: false, sessionId: auth?.sessionId ?? null });
|
|
282
282
|
}
|
|
283
|
+
// core 5.11.0([2625] Added①,[2597] P0 案三决③A):`additionalReadDirectories` —— 只读围栏拓宽。
|
|
284
|
+
// 与 additionalDirectories 同门(host 单用户车道)、同形校验(parseAdditionalDirectories:绝对路径
|
|
285
|
+
// 数组 fail-loud)、同「忽略必留声」纪律;差异只在语义:只进 READ 面(classify 读边界 auto-allow +
|
|
286
|
+
// read_file/grep 等 containment),写面照旧拒——读授权永不静默变写授权(core 侧保证,我们只透传)。
|
|
287
|
+
const rawAddReadDirs = body.additionalReadDirectories;
|
|
288
|
+
const additionalReadDirectories = cwdHonored(config) ? parseAdditionalDirectories(rawAddReadDirs) : undefined;
|
|
289
|
+
if (rawAddReadDirs !== undefined && !cwdHonored(config)) {
|
|
290
|
+
logger.warn("task_additional_read_directories_ignored", { honored: false, sessionId: auth?.sessionId ?? null });
|
|
291
|
+
}
|
|
283
292
|
// body.model (explicit /model picker or run --model) WINS over settings.model (the config default); both gate
|
|
284
293
|
// against the catalog allow-list via resolveTaskModel (a settings.model not in the catalog is ignored, never injected).
|
|
285
294
|
// 🔴 TOB 档位联跑曾 FAIL(坐标级):the gate must see the TIER-EXPANDED catalog — expandTiers
|
|
@@ -320,7 +329,7 @@ export function createResolveSpec(ctx) {
|
|
|
320
329
|
metrics.inc("images_omitted_total", { model: picked.model });
|
|
321
330
|
logger.warn("images_omitted_no_vision_model", { model: picked.model, count: body.images.length });
|
|
322
331
|
}
|
|
323
|
-
return { taskHooks, additionalDirectories, wireCatalog, picked };
|
|
332
|
+
return { taskHooks, additionalDirectories, additionalReadDirectories, wireCatalog, picked };
|
|
324
333
|
};
|
|
325
334
|
/** 阶段③(anchors):吃 请求体 + auth,吐 两个历史锚解析后的 entryId(resumeAt / rewindFilesTo —— eventId→entryId
|
|
326
335
|
* 过 anchor store,未知锚在此 4xx 而不是交给 core)与 142-S4 项目登记簿种子 s4DefaultScopes。 */
|
|
@@ -374,7 +383,7 @@ export function createResolveSpec(ctx) {
|
|
|
374
383
|
/** 阶段④(spec 字面量):吃 请求体 + auth + `opts.leg` + 前三阶段的全部产出,吐**尚未过治理层**的 TaskSpec
|
|
375
384
|
* 字面量 —— 部署方拥有的 body→TaskSpec 映射本体(compactionModel 的 fresh 腿 400 也在这一段)。 */
|
|
376
385
|
const assembleSpecLiteral = (body, auth, opts, parts) => {
|
|
377
|
-
const { scenarioName, cap, centerDecls, acceptedAppend, parsedSettings, attachmentNotice, taskHooks, additionalDirectories, wireCatalog, picked, resumeAtEntryId, rewindFilesToEntryId, s4DefaultScopes, } = parts;
|
|
386
|
+
const { scenarioName, cap, centerDecls, acceptedAppend, parsedSettings, attachmentNotice, taskHooks, additionalDirectories, additionalReadDirectories, wireCatalog, picked, resumeAtEntryId, rewindFilesToEntryId, s4DefaultScopes, } = parts;
|
|
378
387
|
const spec = {
|
|
379
388
|
// D-1:附件告知随 objective 进 durable 流(只有名字/mime/尺寸——**内容永不进流**,这正是
|
|
380
389
|
// 「拼进 objective 是伪方案」的账要划清的线;文件名已消毒为安全字符集,无注入面)。
|
|
@@ -464,6 +473,8 @@ export function createResolveSpec(ctx) {
|
|
|
464
473
|
// design/119 (CC --add-dir): extra host dirs the FILE tools may access (core canonicalizes each into the
|
|
465
474
|
// containment allowlist + lists them in the `# Environment` block). Single-user host lane only (gated above).
|
|
466
475
|
additionalDirectories,
|
|
476
|
+
// core 5.11.0:只读围栏(见上方 parse 段注)。缺席=字节等价([2625] 自查③)。
|
|
477
|
+
additionalReadDirectories,
|
|
467
478
|
// EnterPlanMode (core 1.167): MODEL-DRIVEN plan — `enablePlanMode:true` alone (writable
|
|
468
479
|
// start, NO handsReadOnly) auto-mounts `enter_plan_mode`/`present_plan` so the model can self-enter read-only
|
|
469
480
|
// plan AT ANY TIME it judges a task needs planning. 🆕 DEFAULT ON (clay: resident; PLAN_MODE_ENABLED=false opts
|
|
@@ -2,7 +2,7 @@ import { once } from "node:events";
|
|
|
2
2
|
import { randomBytes, createHash } from "node:crypto";
|
|
3
3
|
import { sessionLogDigest, sessionLogDigestsComparable, StreamingImportValidator, SessionError, SessionPolicyError } from "@sema-agent/core";
|
|
4
4
|
import { StringDecoder } from "node:string_decoder";
|
|
5
|
-
import { exportSessionManifest, fileSnapshotImportFace } from "../../session-sync.js";
|
|
5
|
+
import { exportSessionManifest, fileSnapshotImportFace, replayPolicyRecords, overwriteWipeIncapable, wipeSessionAttendants } from "../../session-sync.js";
|
|
6
6
|
import { classifySyncRelationshipByIds, SyncConflictError } from "../../session-sync-kernel.js";
|
|
7
7
|
import { isUuidShape } from "../../security.js";
|
|
8
8
|
import { sendJson, sendError, msg } from "../send.js";
|
|
@@ -392,9 +392,10 @@ async function handleSessionSyncBody(req, res, url, ctx, miss) {
|
|
|
392
392
|
}
|
|
393
393
|
}
|
|
394
394
|
const opOk = explicitOperatorOk(gatedPrincipal(req, deps.config), deps.config.operatorPrincipals);
|
|
395
|
+
// #137 ①:等内容跳过(replayPolicyRecords)——这条腿在**每次** identical 重推上都跑,
|
|
396
|
+
// 无条件 putRules 会把 rev(operator 乐观锁 CAS 令牌)按重推次数推着走。
|
|
395
397
|
if (deps.sessionPolicyStore)
|
|
396
|
-
|
|
397
|
-
await deps.sessionPolicyStore.putRules(sessionId, rec.principal, rec.rules, { operator: opOk });
|
|
398
|
+
await replayPolicyRecords(deps.sessionPolicyStore, sessionId, policy, opOk);
|
|
398
399
|
const importingP = scope.fleetWide ? (owner ?? null) : scope.gateOwner;
|
|
399
400
|
if (deps.resumeAnchorStore)
|
|
400
401
|
for (const a of anchors)
|
|
@@ -647,9 +648,29 @@ async function handleSessionSyncBody(req, res, url, ctx, miss) {
|
|
|
647
648
|
sendError(res, 409, "session_active", "session started an active run while the import streamed; sync after it settles", { activeTaskId: activeAtCommit });
|
|
648
649
|
return;
|
|
649
650
|
}
|
|
650
|
-
//
|
|
651
|
+
// #137 ② 能力预检(fail-loud,[2195] 判据:保护型能力缺席=fail-closed):consent 了 overwrite
|
|
652
|
+
// 而 policy 店无 purge seam ⇒ 在 commit **之前**干净中止(此刻零写入落地),绝不静默留混合态。
|
|
653
|
+
// 只看 resolution 不看 relation(commit 的 in-txn re-classify 还没跑):保守方向——你要求了
|
|
654
|
+
// overwrite 而这套部署擦不动,哪怕最终 re-classify 判 fresh 也先拒,拒因里给了无损重跑姿势。
|
|
655
|
+
if (pending.resolution !== undefined && overwriteWipeIncapable(deps.sessionPolicyStore)) {
|
|
656
|
+
await pending.handle.abort().catch(() => undefined);
|
|
657
|
+
cleanupStaging(stagingId);
|
|
658
|
+
sendError(res, 501, "capability.policy_purge_required", "overwrite-dst cannot proceed: this deployment's session-policy store has no deleteBySession purge seam, so the destination's stale policy rows cannot be wiped and the import would land a MIXED state (the source's conversation plus the destination's leftover policy rows, which keep gating). Upgrade the policy store (every in-tree store carries deleteBySession since core 1.423), or re-run without resolution overwrite-dst");
|
|
659
|
+
return;
|
|
660
|
+
}
|
|
661
|
+
// ── commit (the atomic swap) → wipe (overwrite-dst) → replay snapshots/policy/anchors → cache evict → release lease ──
|
|
651
662
|
try {
|
|
652
663
|
const committed = await pending.handle.commit(pending.owner, pending.resolution ? { resolution: pending.resolution } : undefined);
|
|
664
|
+
// #137 ②(死码 ⓪ 擦除步的活路由移植,判据钉=fanout-characterization L2):consented
|
|
665
|
+
// overwrite-dst = keep-source ⇒ 在重放**之前**擦掉目的端遗留的 policy/anchor/快照行,否则
|
|
666
|
+
// 悬空 anchor 指向已 purge 的 entry、被放弃分支的 policy 行继续 gating、共享 key 快照因
|
|
667
|
+
// importManifest create-once 永远留着目的端字节。条件按 commit 权威 relation 收窄:fresh 不擦
|
|
668
|
+
// (B5 钉:注册过但零 entry 的目的端,原有附随物原地留存)、fast_forward 不擦(同支延长,无
|
|
669
|
+
// 被放弃分支)。崩溃残余(commit 与 wipe 之间死进程 ⇒ 重试落 identical 支无 wipe)在
|
|
670
|
+
// wipeSessionAttendants 的 doc 注如实记录——窗口窄于修前(修前=遗留行恒存活)。
|
|
671
|
+
if (pending.resolution === "overwrite-dst" && (committed.relation === "fork" || committed.relation === "stale")) {
|
|
672
|
+
await wipeSessionAttendants({ fileSnapshotStore: deps.fileSnapshotStore, sessionPolicyStore: deps.sessionPolicyStore, resumeAnchorStore: deps.resumeAnchorStore }, sessionId);
|
|
673
|
+
}
|
|
653
674
|
// ① snapshots — content-addressed import (idempotent). importManifest never throws → a {ok:false} is a
|
|
654
675
|
// fail-closed 422 (a blob the Phase-A presence check couldn't catch — corrupted in transit). Note: the
|
|
655
676
|
// ENTRIES are already committed (the swap), so a snapshot failure here means the conversation synced but a
|
|
@@ -666,10 +687,11 @@ async function handleSessionSyncBody(req, res, url, ctx, miss) {
|
|
|
666
687
|
}
|
|
667
688
|
}
|
|
668
689
|
// ② policy — replay each record; the E6 tighten-only gate applies unless the importer is a verified operator.
|
|
690
|
+
// #137 ①:等内容跳过(replayPolicyRecords)——crash-retry 会整段重跑本重放,无条件 put 让
|
|
691
|
+
// 每次重试都推 rev(operator 乐观锁 CAS 令牌)。
|
|
669
692
|
const policyStore = deps.sessionPolicyStore;
|
|
670
693
|
if (policyStore)
|
|
671
|
-
|
|
672
|
-
await policyStore.putRules(sessionId, rec.principal, rec.rules, { operator: pending.operatorOk });
|
|
694
|
+
await replayPolicyRecords(policyStore, sessionId, pending.policy, pending.operatorOk);
|
|
673
695
|
// ③ anchors — owner RE-KEYED to the importing principal (§9).
|
|
674
696
|
const anchorStore = deps.resumeAnchorStore;
|
|
675
697
|
if (anchorStore)
|
package/dist/http/server.js
CHANGED
|
@@ -691,6 +691,24 @@ export function createHttpServer(rawDeps) {
|
|
|
691
691
|
return null;
|
|
692
692
|
}
|
|
693
693
|
}
|
|
694
|
+
// core 5.11.0:additionalReadDirectories(只读围栏)—— 与 additionalDirectories 同形 fail-loud
|
|
695
|
+
// (绝对路径数组、无 `..`、同条数上限);语义差异(只进 READ 面)在 core,形校验在此对齐。
|
|
696
|
+
const addReadDirsRaw = body.additionalReadDirectories;
|
|
697
|
+
if (addReadDirsRaw !== undefined) {
|
|
698
|
+
if (!Array.isArray(addReadDirsRaw)) {
|
|
699
|
+
sendError(res, 400, "request.field_invalid", "additionalReadDirectories must be an array of absolute host paths");
|
|
700
|
+
return null;
|
|
701
|
+
}
|
|
702
|
+
if (addReadDirsRaw.length > MAX_ADDITIONAL_DIRS) {
|
|
703
|
+
sendError(res, 400, "request.field_invalid", `additionalReadDirectories must have at most ${MAX_ADDITIONAL_DIRS} entries`);
|
|
704
|
+
return null;
|
|
705
|
+
}
|
|
706
|
+
const badRead = addReadDirsRaw.find((d) => !isValidCwd(d));
|
|
707
|
+
if (badRead !== undefined) {
|
|
708
|
+
sendError(res, 400, "request.field_invalid", "each additionalReadDirectories entry must be a non-empty absolute host path (no '..' segments)");
|
|
709
|
+
return null;
|
|
710
|
+
}
|
|
711
|
+
}
|
|
694
712
|
// body.settings (client SemaSettings stamp) — fail-loud shape/size caps on submit, matching the sibling
|
|
695
713
|
// fields' posture (parseTaskSettings is also defensive on every path incl. resume; this 400s a bad submit early
|
|
696
714
|
// rather than silently coercing — adversarial review). The semantic projection (tighten-only) happens in resolveSpec.
|
package/dist/leader/wire.d.ts
CHANGED
|
@@ -131,7 +131,11 @@ export declare function leaderResourceConfig(env?: NodeJS.ProcessEnv): {
|
|
|
131
131
|
/** design/74 Slice 6: opt-in (LEADER_RESOURCE_SUSPEND=true) resource-slice suspend for workers. When set, a
|
|
132
132
|
* worker SUSPENDS at the per-slice window (`sliceMaxCostUsd`) instead of failing at the budget, and the
|
|
133
133
|
* leader auto-resumes (resumeWithVerification) until the TOTAL (`totalBudgetUsd` = workerBudgetUsd) is spent
|
|
134
|
-
* — a budget overrun becomes a resumable pause, not a worker failure.
|
|
134
|
+
* — a budget overrun becomes a resumable pause, not a worker failure. ⚠️ 语义更新(core 5.11.0 B3,
|
|
135
|
+
* 2026-08-06):`maxSuspends` 现在只数「连续无进展」park(批准+执行后链基归零),不再是总 park 数上限
|
|
136
|
+
* ——下面按预算切片总数放大的 sizing 公式因此从「必要」变「保守冗余」(合法的资源续跑链永不触 cap,
|
|
137
|
+
* cap 只剩防空转环职责);公式保留是无害的(上限只会更松不误杀),别再按「总数预算」理解它。
|
|
138
|
+
* 原注:`maxSuspends` is sized to the slice
|
|
135
139
|
* count (+margin) so the suspend-loop cap doesn't fire before the budget is legitimately spent. */
|
|
136
140
|
resourceSuspend?: {
|
|
137
141
|
totalBudgetUsd: number;
|
package/dist/session-sync.d.ts
CHANGED
|
@@ -1,23 +1,55 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* 2c session-sync —
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
* (a) reads them into a {@link SessionBundle} and (b) replays them across backends in the §8 fail-closed atomic order.
|
|
2
|
+
* 2c session-sync — P1d-α PULL manifest export (`exportSessionManifest`) + §7 dry-run planning (`planSync`) over a
|
|
3
|
+
* `StoreBackend` (sema-internal server/docs/DESIGN-session-sync.md §5–§10, §15). The PUSH half is the two-phase
|
|
4
|
+
* staged streamed import in `src/http/routes/session-sync.ts` (§8 fanout re-implemented there, entries-off-the-wire).
|
|
6
5
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
6
|
+
* 🪦 **TOMBSTONE (2026-08-06, mechanical removal car, [2557] 2-i precondition + #51 死码判定)**: this module used to
|
|
7
|
+
* ALSO export a whole-bundle `exportSession`/`importSession` orchestrator pair (the P1b one-shot in-memory move:
|
|
8
|
+
* read entries+snapshots+policy+anchors into one {@link SessionBundle}, replay them across backends in the §8
|
|
9
|
+
* atomic order). #51's audit (src git history around this date; see `test/session-sync-routes.test.ts`'s former
|
|
10
|
+
* "死码判定的常驻门" describe, now a removal-proof tombstone test) established BOTH functions had **zero production
|
|
11
|
+
* call sites** — PULL uses `exportSessionManifest` below + the keyset-paged NDJSON `/sync/entries` stream; PUSH uses
|
|
12
|
+
* the two-phase staging route, which reimplements the §8 fanout itself (`handle.commit` / `importManifest` /
|
|
13
|
+
* `putRules` / `resumeAnchorStore().put`) and never called `importSession`. The only consumers were three test
|
|
14
|
+
* files. [2557] 2-i's precondition for removal (B4/G1 re-anchored to the live streaming path) was met, so this car
|
|
15
|
+
* deleted both functions + the tests that existed solely to exercise them.
|
|
10
16
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
17
|
+
* ⚠️ **Two invariants the deleted `importSession` uniquely protected have NO live-route equivalent** (verified by
|
|
18
|
+
* reading the current code, not assumed) — flagging rather than silently dropping the coverage:
|
|
19
|
+
* 1. **Policy-rev stability on idempotent retry/duplicate sync** (the deleted ③ step's fix: skip `putRules` when
|
|
20
|
+
* the incoming record's content already equals the dst row, so a retried/duplicated sync never bumps `rev` —
|
|
21
|
+
* `rev` is the operator's optimistic-lock CAS token). The LIVE two-phase PUSH route (`src/http/routes/
|
|
22
|
+
* session-sync.ts` lines ~382 and ~608) calls `sessionPolicyStore.putRules(...)` UNCONDITIONALLY on every
|
|
23
|
+
* policy-record replay (identical-branch short-circuit AND the post-commit replay) — no content-equality skip.
|
|
24
|
+
* A crash-retry or a harmless duplicate sync on the live PUSH route WILL bump every policy row's rev, unlike
|
|
25
|
+
* the now-deleted `importSession`. The deleted tests (fanout-characterization §A A4/A5/A6, §F F1/F1c) pinned
|
|
26
|
+
* this ONLY on the dead function; no wire/live-route test exercises it.
|
|
27
|
+
* 2. **overwrite-dst subsystem wipe** (the deleted ⓪ step: before replaying policy/anchors/snapshots under a
|
|
28
|
+
* consented `overwrite-dst`, purge the destination's stale rows for principals/keys NOT in the incoming
|
|
29
|
+
* bundle, so an abandoned fork branch's policy/anchor/snapshot rows don't survive as a MIXED state). The LIVE
|
|
30
|
+
* two-phase PUSH route has NO equivalent wipe step — its overwrite-dst commit only purges/renames
|
|
31
|
+
* `session_event` (entries); the post-commit policy/anchor replay (lines ~606–611) only `put`s the records the
|
|
32
|
+
* bundle carries, never deletes a dst-only row. A dangling anchor pointing at a purged entry, or a
|
|
33
|
+
* dst-only-principal policy row from the abandoned branch, can survive an overwrite-dst PUSH on the live
|
|
34
|
+
* route. The deleted tests (fanout-characterization §C C1/C1b, `session-sync-routes.test.ts`'s deleted
|
|
35
|
+
* "[2195] 同族: overwrite-dst wipe" describe) pinned this ONLY on the dead function.
|
|
36
|
+
* Neither gap is fixed by this car (mechanical removal, no behavior changes) — they are reported here + in the
|
|
37
|
+
* removal's final report for separate triage (port the fix to the live route, or explicitly accept the regression).
|
|
38
|
+
*
|
|
39
|
+
* ✅ **STATUS UPDATE (2026-08-06 同日,#137 红先修绿)**:上面两条「NO live-route equivalent」已作废——
|
|
40
|
+
* 两个保护都已移植到活路由(本文件尾部 #137 段:`sameRulesContent`/`replayPolicyRecords`/
|
|
41
|
+
* `overwriteWipeIncapable`/`wipeSessionAttendants`;路由接线=src/http/routes/session-sync.ts 的两条重放腿
|
|
42
|
+
* + commit 后擦除步 + commit 前能力预检 501)。判据钉=fanout-characterization「#137 live 路由无等价物
|
|
43
|
+
* 缺口」describe(L1 rev 零漂移+反向臂 / L2 三子系统擦除+共享 key 换源端字节 / L3 缺 seam fail-loud),
|
|
44
|
+
* L1/L2 先红后绿,两处变异抽验(擦除步禁用⇒L2 红、等值跳过禁用⇒L1 红)。原文保留供考古。
|
|
15
45
|
*/
|
|
16
46
|
import type { StoreBackend } from "./plugins/store-backend.js";
|
|
17
47
|
import { type SyncRelation } from "./session-sync-kernel.js";
|
|
18
|
-
import type
|
|
48
|
+
import { type SessionTreeEntry, type SessionRulesRecord, type FileSnapshotResult, type SessionPermissionRules, type StoredSessionRules, type SessionPolicyStore } from "@sema-agent/core";
|
|
49
|
+
import type { ServiceFileSnapshotStore, ServiceSessionPolicyStore, ResumeAnchorStore } from "./plugins/store-backend.js";
|
|
19
50
|
/** A blob getter: content-addressed bytes for a sha256 hash, or `undefined` if the source can't supply them.
|
|
20
|
-
* Returned by {@link
|
|
51
|
+
* Returned by {@link exportSessionManifest} (closes over the source store); consumed wherever a caller pulls
|
|
52
|
+
* content-addressed snapshot bytes lazily (the live PUSH route's `importManifest` replay). */
|
|
21
53
|
export type BlobGetter = (hash: string) => Promise<Uint8Array | undefined>;
|
|
22
54
|
/** The snapshot-import capability face of a `FileSnapshotStore` — signature verbatim = the authoritative
|
|
23
55
|
* implementation, `FileSnapshotStoreSql.importManifest` (src/plugins/file-snapshot-store-sql.ts:201). */
|
|
@@ -83,58 +115,11 @@ export interface SessionManifest {
|
|
|
83
115
|
owner: string | null;
|
|
84
116
|
}>;
|
|
85
117
|
}
|
|
86
|
-
/**
|
|
87
|
-
* EXPORT a session's whole portable state from `srcBackend` into a {@link SessionBundle} + a {@link BlobGetter}.
|
|
88
|
-
*
|
|
89
|
-
* - entries: `exportEntries(sessionId)` — `null` ⇒ the session does not exist ⇒ this returns `null` (no partial bundle).
|
|
90
|
-
* - snapshots: `listKeys(sessionId)` → for each key `exportManifest(scope,key)` → `{key, [...manifest]}`. A key whose
|
|
91
|
-
* manifest reads back `null` (a concurrent reap between listKeys and exportManifest) is dropped, not exported empty.
|
|
92
|
-
* - policy: `listBySession(sessionId)` (empty `[]` if the store lacks the optional seam — honest degrade).
|
|
93
|
-
* - anchors: `listBySession(sessionId)` (empty `[]` if the store has no resumeAnchor seam).
|
|
94
|
-
* - getBlob: bound to the source `fileSnapshot().getBlob` so the importer can pull content-addressed bytes lazily.
|
|
95
|
-
*
|
|
96
|
-
* The OWNER is NOT exported (import re-stamps it, §9). The owner-scope GATE is the CALLER's (route P1d): this returns
|
|
97
|
-
* `null` ONLY for "session does not exist", never as an authz signal.
|
|
98
|
-
*
|
|
99
|
-
* NB (P1d-α): the HTTP PULL no longer uses this — it uses {@link exportSessionManifest} (entries lifted off the wire)
|
|
100
|
-
* + the keyset-paged `exportEntriesStream` NDJSON route so memory is bounded by ROW COUNT, not the whole-log array.
|
|
101
|
-
*
|
|
102
|
-
* 🔴 **TEST-ONLY(2026-07-29 死码判定,#51 / [2012] cli 反查;上一版这段 NB 的结论已过期)**。
|
|
103
|
-
* 上面那句「STAYS for the local same-process import path, P1d-β (PUSH), and the tests」**三条里两条已不成立**:
|
|
104
|
-
* 逐条亲验(`grep -rn 'exportSession\|importSession' src/`)——
|
|
105
|
-
* · **本地同进程 import 路径**:不存在这样的调用点(全仓零处);
|
|
106
|
-
* · **P1d-β(PUSH)**:路由改成了两阶段 staging + NDJSON 流,§8 扇出**在路由里自己重写了一遍**
|
|
107
|
-
* (`src/http/routes/session-sync.ts` 的 commit 链直接调 `handle.commit` / `importManifest` / `putRules` /
|
|
108
|
-
* `resumeAnchorStore`),**不经过** {@link importSession};
|
|
109
|
-
* · **tests**:成立,且是**唯一**成立的一条(三个测试文件在用)。
|
|
110
|
-
* 还有两处**只 import 不调用**的死接线(`src/http/server.ts` / `src/http/routes/session-sync.ts`)——本次已摘;
|
|
111
|
-
* 它们能活到今天是因为 tsconfig 没开 `noUnusedLocals`。这两个函数也**不在发布公面**上
|
|
112
|
-
* (`package.json` 的 exports 只有 `.` / `./main` / `./package.json`,`src/index.ts` 不 re-export 本模块)。
|
|
113
|
-
*
|
|
114
|
-
* **为什么本批只判定 + 上机器门,没有直接删**:两个仍在用它们的测试文件
|
|
115
|
-
* (`session-sync-fanout-characterization` / `session-sync-fanout-crash-recovery`)是 §8 崩溃/重试幂等语义的
|
|
116
|
-
* **唯一可执行验证**,并且其中 B4/G1 两笔 `it.fails` 记的是 `classifySyncRelationshipByIds` 的**分类缺口**——
|
|
117
|
-
* 而那个分类器**在活路由上仍被调用**(`routes/session-sync.ts:312` / `:632`)。直接删函数=连带删掉两套
|
|
118
|
-
* 覆盖与一笔**仍然有效**的欠账记账,那正是本仓 `vitest.config.ts` 里写死的判据「覆盖丢失伪装成绿,
|
|
119
|
-
* 比假红更坏」。⇒ **删除的前置条件**是先把 B4/G1 重锚到活的流式路径上;做完那件,这两个函数连同它们的
|
|
120
|
-
* 测试可一并摘除。判定与机器门见 `test/session-sync-routes.test.ts` 的「死码判定」describe。
|
|
121
|
-
*
|
|
122
|
-
* ✅ **前置条件已达成**(2026-08-05,[2557] 审计 2-i):B4 已重锚活路由(fanout-characterization §H ×3 +
|
|
123
|
-
* sync-commit-write-parity 三后端判据,commit 半场的 fast_forward 载荷缺口**已修**)并销账;G1 改性质为
|
|
124
|
-
* 「老客户端路现状钉」(wire 级、不依赖本模块)。⇒ 本模块 + 依赖它的测试段现在**可以整体摘除**——
|
|
125
|
-
* 剩余依赖=fanout-characterization §A/§B/§F 与 crash-recovery 全文件(§8 扇出切点矩阵的死码腿;活路由的
|
|
126
|
-
* 崩溃/重试语义已由 E 系列 + staging reaper 钉覆盖,摘除前需逐段核对该等价性)。单独排一班机械车做,
|
|
127
|
-
* 别混进行为修的车。
|
|
128
|
-
*/
|
|
129
|
-
export declare function exportSession(sessionId: string, srcBackend: StoreBackend): Promise<{
|
|
130
|
-
bundle: SessionBundle;
|
|
131
|
-
getBlob: BlobGetter;
|
|
132
|
-
} | null>;
|
|
133
118
|
/**
|
|
134
119
|
* P1d-α (PULL streaming, §15) — EXPORT a session's portable state as a {@link SessionManifest} (entries LIFTED OFF
|
|
135
|
-
* the wire)
|
|
120
|
+
* the wire), so it does NOT load entry payloads: it reads the
|
|
136
121
|
* IDS-only projection via `listEntryIds` (`null` ⇒ no such session ⇒ this returns `null`), counts them, reads the
|
|
137
|
-
* current `leafId`, and builds snapshots/policy/anchors
|
|
122
|
+
* current `leafId`, and builds snapshots/policy/anchors. The paired NDJSON
|
|
138
123
|
* `GET /sync/entries` route streams the entry payloads separately (keyset-paged → bounded memory), so a 1M-entry
|
|
139
124
|
* session's manifest stays small (ids + counts + the snapshot/policy/anchor metadata).
|
|
140
125
|
*
|
|
@@ -146,69 +131,46 @@ export declare function exportSessionManifest(sessionId: string, srcBackend: Sto
|
|
|
146
131
|
manifest: SessionManifest;
|
|
147
132
|
getBlob: BlobGetter;
|
|
148
133
|
} | null>;
|
|
149
|
-
/**
|
|
150
|
-
* IMPORT a {@link SessionBundle} (+ its {@link BlobGetter}) into `dstBackend` under `bundle.sessionId`, re-stamping
|
|
151
|
-
* ownership to `importingPrincipal`. Returns the snapshot tally (imported vs honestly-skipped on a destination whose
|
|
152
|
-
* FileSnapshotStore lacks the importManifest seam — pre-1.141.0; core's InMemory/File NOW carry it, so a local dst
|
|
153
|
-
* imports snapshots for real).
|
|
154
|
-
*
|
|
155
|
-
* §10 FAIL-CLOSED VALIDATION runs FIRST — BEFORE any write:
|
|
156
|
-
* 1. `validateEntriesForImport(bundle.entries)` (the single core invariant gate: unique ids / parent-before-child /
|
|
157
|
-
* one root / leaf-resolvable). Throws on violation; NOTHING is written.
|
|
158
|
-
* 2. Every blobHash referenced by every snapshot manifest MUST be supplied by `getBlob` — a missing blob would
|
|
159
|
-
* restore a PARTIAL tree (files half-written, conversation whole → breaks the entry↔file lockstep). A lightweight
|
|
160
|
-
* PRESENCE pre-check (fetch each distinct hash once, no hashing) fails FAST here; the AUTHORITATIVE content-address
|
|
161
|
-
* integrity check (`sha256(bytes)===hash`) is done ONCE inside `importManifest` (core 1.141.0), so we don't double-
|
|
162
|
-
* hash. (Runs only when the dst supports snapshot import; otherwise there is nothing to half-write.)
|
|
163
|
-
*
|
|
164
|
-
* §7 CLASSIFICATION GATE — BEFORE any write, the destination's CURRENT entry log is read and compared to the bundle
|
|
165
|
-
* via {@link classifySyncRelationship} (a sound entry-id SET comparison, not leaf_id). A `fork` (true divergence) or a
|
|
166
|
-
* `stale` source (the dst is strictly ahead) WOULD lose destination history → it throws {@link SyncConflictError}
|
|
167
|
-
* (route → 409) UNLESS the caller passes `{ resolution: "overwrite-dst" }`. An `identical` relation SKIPS the entries
|
|
168
|
-
* write entirely (the log is already present & equal; the idempotent snapshot/policy/anchor replay below still runs to
|
|
169
|
-
* heal anything missing). `fresh` / `fast_forward` (and an overwrite-resolved fork/stale) write the entries via the
|
|
170
|
-
* IDEMPOTENT {@link OwnerAwareSessionStore.replaceEntries} (purge-then-import) — NOT `importEntries`, whose plain
|
|
171
|
-
* INSERT crashes on a duplicate PK when the session already exists at the destination.
|
|
172
|
-
*
|
|
173
|
-
* §8 CROSS-STORE ATOMIC ORDER + IDEMPOTENCY — session_meta (written LAST inside `replaceEntries`) is the sole
|
|
174
|
-
* commit point, so a crash mid-import leaves orphan blobs/manifests/anchors with NO session_meta = invisible to
|
|
175
|
-
* wake (never a half-session, only collectable orphans). Order:
|
|
176
|
-
* ① snapshots (manifests + content-addressed blobs) — content-addressed + create-once = idempotent retry-to-completion.
|
|
177
|
-
* `importManifest` returns a {@link FileSnapshotResult} (NEVER throws — core 1.141.0); a `{ok:false}` (a
|
|
178
|
-
* rejecting/missing/hash-mismatched blob the §10 pre-check didn't catch — e.g. a blob corrupted in transit) is
|
|
179
|
-
* re-raised as a fail-closed THROW so the import aborts BEFORE the entries commit (no entry↔file split).
|
|
180
|
-
* ② entries (`replaceEntries` runs the core gate AGAIN and writes session_meta LAST = the commit point; idempotent
|
|
181
|
-
* over an existing session — skipped entirely when the relation is `identical`)
|
|
182
|
-
* ③ policy (replay each record via putRules; `opts.operatorOk` defaults FALSE → the E6 tighten-only gate APPLIES, so
|
|
183
|
-
* a replay that would LOOSEN an operator-tightened dst policy throws `SessionPolicyError("loosen_forbidden")` —
|
|
184
|
-
* `operator:true` is set ONLY when the authenticated importer is itself a verified operator)
|
|
185
|
-
* ④ anchors (owner RE-KEYED to `importingPrincipal`, §9)
|
|
186
|
-
* Steps ③/④ are post-commit; re-running them is idempotent — ③ SKIPS a put whose content already equals the dst row
|
|
187
|
-
* (a "no-op rev bump" is NOT idempotent: rev is the operator's optimistic-lock token — the A6 fix below), ④ is a
|
|
188
|
-
* same-value upsert — so a crash between ②–④ is recovered by a retry that converges to the same bytes.
|
|
189
|
-
*
|
|
190
|
-
* @param opts.resolution — `"overwrite-dst"` consents to a `fork`/`stale` import that overwrites destination history
|
|
191
|
-
* (the user's keep-source decision); omitted ⇒ such an import is refused with {@link SyncConflictError}.
|
|
192
|
-
* @param opts.operatorOk — when `true`, the policy replay (step ③) is an OPERATOR write (may loosen the dst rules); set
|
|
193
|
-
* ONLY when the AUTHENTICATED importer is a verified operator. DEFAULT false → the E6 tighten-only gate applies, so a
|
|
194
|
-
* non-operator import is TIGHTEN-ONLY (design §4): a replay that would loosen an operator-tightened dst session policy
|
|
195
|
-
* throws `SessionPolicyError("loosen_forbidden")`, which propagates (NOT swallowed) so the route can refuse the loosen.
|
|
196
|
-
*/
|
|
197
|
-
export declare function importSession(bundle: SessionBundle, getBlob: BlobGetter, dstBackend: StoreBackend, importingPrincipal: string | null, opts?: {
|
|
198
|
-
resolution?: "overwrite-dst";
|
|
199
|
-
operatorOk?: boolean;
|
|
200
|
-
}): Promise<{
|
|
201
|
-
snapshotsImported: number;
|
|
202
|
-
snapshotsSkipped: number;
|
|
203
|
-
relation: SyncRelation["relation"];
|
|
204
|
-
}>;
|
|
205
134
|
/**
|
|
206
135
|
* §7 DRY-RUN — classify what importing `sessionId` from `srcBackend` into `dstBackend` WOULD do, WITHOUT writing
|
|
207
|
-
* anything
|
|
208
|
-
* {@link importSession} (so the shell can present a keep-local / keep-cloud / fork-new choice up front).
|
|
136
|
+
* anything (the shell can present a keep-local / keep-cloud / fork-new choice up front).
|
|
209
137
|
*
|
|
210
138
|
* Returns `null` when the SOURCE session does not exist (no bundle to plan); otherwise the {@link SyncRelation}
|
|
211
139
|
* between the source's full durable log and the destination's current log (a `null` dst log ⇒ `fresh`). No writes.
|
|
212
140
|
*/
|
|
213
141
|
export declare function planSync(sessionId: string, srcBackend: StoreBackend, dstBackend: StoreBackend): Promise<SyncRelation | null>;
|
|
142
|
+
/** #137 ①(死码 A6 修的移植):同内容判据 —— 比较「归一化、去 rev」后的字节形。putRules 每次
|
|
143
|
+
* 无条件写都 rev+1,而 rev 是 operator 乐观锁的 CAS 令牌(`PutRulesOptions.expectedRev`),
|
|
144
|
+
* 幂等重试/无害重复同步不该把在途 CAS 打失败。归一化用 core 的 `normalizeRules`(store 落盘
|
|
145
|
+
* 前走同一函数 ⇒ 两侧同坐标);`stripRev` 去掉记录内嵌的 rev(`listBySession` 的 rules 含 rev,
|
|
146
|
+
* 它不是内容)。 */
|
|
147
|
+
export declare function sameRulesContent(stored: StoredSessionRules | null, incoming: SessionPermissionRules): boolean;
|
|
148
|
+
/** #137 ① 的重放腿共享实现:PUSH 路由的两条 policy 重放腿(Phase A identical 短路支 / Phase B
|
|
149
|
+
* commit 后支)都从这里走 —— 等内容跳过(rev 零漂移),真变更照常落地(E6 tighten-only 门在
|
|
150
|
+
* putRules 内部,此处不重述)。 */
|
|
151
|
+
export declare function replayPolicyRecords(store: Pick<SessionPolicyStore, "getRules" | "putRules">, sessionId: string, records: readonly SessionRulesRecord[], operatorOk: boolean): Promise<void>;
|
|
152
|
+
/** #137 ② 能力预检(fail-loud 方向,[2195] 判据:保护型能力缺席=fail-closed):consented
|
|
153
|
+
* overwrite-dst 需要 policy 店的 `deleteBySession` purge seam 才能不留混合态;缺席=true,路由在
|
|
154
|
+
* commit **之前**用它干净中止(此时零写入落地),wire 拒因字面量归路由所有(api-error-text-freeze
|
|
155
|
+
* 锚字面站点)。anchor 店的 deleteBySession 是类型必填、快照店有 core 必填的 `reap` 兜底,都不构成
|
|
156
|
+
* 缺席面;唯一可缺的就是 policy 这条(core 接口上是可选方法)。 */
|
|
157
|
+
export declare function overwriteWipeIncapable(policyStore: ServiceSessionPolicyStore | undefined): boolean;
|
|
158
|
+
/** #137 ②(死码 ⓪ 擦除步的移植):consented overwrite-dst = keep-source,结果必须是**源端那
|
|
159
|
+
* 一份**,不得混成「源端对话 + 目的端遗留 policy/anchor/快照」(混合态比丢历史更危险:悬空
|
|
160
|
+
* anchor 指向已 purge 的 entry、被放弃分支的 policy 行继续 gating、共享 key 快照因
|
|
161
|
+
* importManifest create-once 永远留着目的端字节)。调用时点=commit(原子 swap)成功**之后**、
|
|
162
|
+
* 附随物重放**之前**:死码在任何写入前擦(其 entries 写本身非原子);活路由有真 commit point,
|
|
163
|
+
* 擦在 commit 前会在「commit 失败」时留下反向混合态(目的端对话完好、附随物已毁)——方向更坏。
|
|
164
|
+
* 诚实残余:进程在 commit 与本函数之间崩溃时,重试落 Phase A identical 支(无 wipe)⇒ 遗留行
|
|
165
|
+
* 可存活;该窗口窄于修前世界(修前=遗留行**恒**存活),与 §8「快照重放失败 ⇒ 422 重试自愈」
|
|
166
|
+
* 同款接受,记录于此不装没有。
|
|
167
|
+
* 各店姿势(死码 verbatim 语义):快照=deleteBySession(SQL 孪生:manifest 即删、blob 走异步
|
|
168
|
+
* 孤儿 GC)缺席则 core 必填 `reap(scope, [])`(内容寻址,他 scope 仍引用的 blob 存活);
|
|
169
|
+
* anchor=直调(类型必填);policy=deleteBySession(路由已用 {@link overwriteWipeIncapable}
|
|
170
|
+
* 预检过,此处缺席=类型外的 JS 层残缺实现,直调抛 TypeError 即 fail-loud)。 */
|
|
171
|
+
export declare function wipeSessionAttendants(stores: {
|
|
172
|
+
fileSnapshotStore?: ServiceFileSnapshotStore | undefined;
|
|
173
|
+
sessionPolicyStore?: ServiceSessionPolicyStore | undefined;
|
|
174
|
+
resumeAnchorStore?: Pick<ResumeAnchorStore, "deleteBySession"> | undefined;
|
|
175
|
+
}, sessionId: string): Promise<void>;
|
|
214
176
|
//# sourceMappingURL=session-sync.d.ts.map
|
package/dist/session-sync.js
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import { validateEntriesForImport, normalizeRules, stripRev } from "@sema-agent/core";
|
|
1
|
+
import { classifySyncRelationship } from "./session-sync-kernel.js";
|
|
2
|
+
import { normalizeRules, stripRev } from "@sema-agent/core";
|
|
4
3
|
/** Single-point capability probe (design/158 R7): does `fs` carry the optional `importManifest` snapshot-import
|
|
5
4
|
* seam? Returns the bound method (never a naked function reference — `this` inside `importManifest` must resolve
|
|
6
5
|
* to `fs`, exactly like `asServiceDeps`'s other capability-probe-then-bind authorities) or `undefined` when the
|
|
@@ -17,96 +16,11 @@ export function fileSnapshotImportFace(fs) {
|
|
|
17
16
|
function ownerAware(backend) {
|
|
18
17
|
return backend.session();
|
|
19
18
|
}
|
|
20
|
-
/**
|
|
21
|
-
* EXPORT a session's whole portable state from `srcBackend` into a {@link SessionBundle} + a {@link BlobGetter}.
|
|
22
|
-
*
|
|
23
|
-
* - entries: `exportEntries(sessionId)` — `null` ⇒ the session does not exist ⇒ this returns `null` (no partial bundle).
|
|
24
|
-
* - snapshots: `listKeys(sessionId)` → for each key `exportManifest(scope,key)` → `{key, [...manifest]}`. A key whose
|
|
25
|
-
* manifest reads back `null` (a concurrent reap between listKeys and exportManifest) is dropped, not exported empty.
|
|
26
|
-
* - policy: `listBySession(sessionId)` (empty `[]` if the store lacks the optional seam — honest degrade).
|
|
27
|
-
* - anchors: `listBySession(sessionId)` (empty `[]` if the store has no resumeAnchor seam).
|
|
28
|
-
* - getBlob: bound to the source `fileSnapshot().getBlob` so the importer can pull content-addressed bytes lazily.
|
|
29
|
-
*
|
|
30
|
-
* The OWNER is NOT exported (import re-stamps it, §9). The owner-scope GATE is the CALLER's (route P1d): this returns
|
|
31
|
-
* `null` ONLY for "session does not exist", never as an authz signal.
|
|
32
|
-
*
|
|
33
|
-
* NB (P1d-α): the HTTP PULL no longer uses this — it uses {@link exportSessionManifest} (entries lifted off the wire)
|
|
34
|
-
* + the keyset-paged `exportEntriesStream` NDJSON route so memory is bounded by ROW COUNT, not the whole-log array.
|
|
35
|
-
*
|
|
36
|
-
* 🔴 **TEST-ONLY(2026-07-29 死码判定,#51 / [2012] cli 反查;上一版这段 NB 的结论已过期)**。
|
|
37
|
-
* 上面那句「STAYS for the local same-process import path, P1d-β (PUSH), and the tests」**三条里两条已不成立**:
|
|
38
|
-
* 逐条亲验(`grep -rn 'exportSession\|importSession' src/`)——
|
|
39
|
-
* · **本地同进程 import 路径**:不存在这样的调用点(全仓零处);
|
|
40
|
-
* · **P1d-β(PUSH)**:路由改成了两阶段 staging + NDJSON 流,§8 扇出**在路由里自己重写了一遍**
|
|
41
|
-
* (`src/http/routes/session-sync.ts` 的 commit 链直接调 `handle.commit` / `importManifest` / `putRules` /
|
|
42
|
-
* `resumeAnchorStore`),**不经过** {@link importSession};
|
|
43
|
-
* · **tests**:成立,且是**唯一**成立的一条(三个测试文件在用)。
|
|
44
|
-
* 还有两处**只 import 不调用**的死接线(`src/http/server.ts` / `src/http/routes/session-sync.ts`)——本次已摘;
|
|
45
|
-
* 它们能活到今天是因为 tsconfig 没开 `noUnusedLocals`。这两个函数也**不在发布公面**上
|
|
46
|
-
* (`package.json` 的 exports 只有 `.` / `./main` / `./package.json`,`src/index.ts` 不 re-export 本模块)。
|
|
47
|
-
*
|
|
48
|
-
* **为什么本批只判定 + 上机器门,没有直接删**:两个仍在用它们的测试文件
|
|
49
|
-
* (`session-sync-fanout-characterization` / `session-sync-fanout-crash-recovery`)是 §8 崩溃/重试幂等语义的
|
|
50
|
-
* **唯一可执行验证**,并且其中 B4/G1 两笔 `it.fails` 记的是 `classifySyncRelationshipByIds` 的**分类缺口**——
|
|
51
|
-
* 而那个分类器**在活路由上仍被调用**(`routes/session-sync.ts:312` / `:632`)。直接删函数=连带删掉两套
|
|
52
|
-
* 覆盖与一笔**仍然有效**的欠账记账,那正是本仓 `vitest.config.ts` 里写死的判据「覆盖丢失伪装成绿,
|
|
53
|
-
* 比假红更坏」。⇒ **删除的前置条件**是先把 B4/G1 重锚到活的流式路径上;做完那件,这两个函数连同它们的
|
|
54
|
-
* 测试可一并摘除。判定与机器门见 `test/session-sync-routes.test.ts` 的「死码判定」describe。
|
|
55
|
-
*
|
|
56
|
-
* ✅ **前置条件已达成**(2026-08-05,[2557] 审计 2-i):B4 已重锚活路由(fanout-characterization §H ×3 +
|
|
57
|
-
* sync-commit-write-parity 三后端判据,commit 半场的 fast_forward 载荷缺口**已修**)并销账;G1 改性质为
|
|
58
|
-
* 「老客户端路现状钉」(wire 级、不依赖本模块)。⇒ 本模块 + 依赖它的测试段现在**可以整体摘除**——
|
|
59
|
-
* 剩余依赖=fanout-characterization §A/§B/§F 与 crash-recovery 全文件(§8 扇出切点矩阵的死码腿;活路由的
|
|
60
|
-
* 崩溃/重试语义已由 E 系列 + staging reaper 钉覆盖,摘除前需逐段核对该等价性)。单独排一班机械车做,
|
|
61
|
-
* 别混进行为修的车。
|
|
62
|
-
*/
|
|
63
|
-
export async function exportSession(sessionId, srcBackend) {
|
|
64
|
-
const session = ownerAware(srcBackend);
|
|
65
|
-
// entries FIRST — null ⇒ no such session ⇒ no bundle at all (the route maps this to 404).
|
|
66
|
-
const exportEntries = session.exportEntries?.bind(session);
|
|
67
|
-
if (!exportEntries) {
|
|
68
|
-
// A backend whose session store lacks the export seam can't be a sync SOURCE (older/in-memory dev store). Treat as
|
|
69
|
-
// "nothing to export" rather than crash — the route already gates on the durable backend being present.
|
|
70
|
-
return null;
|
|
71
|
-
}
|
|
72
|
-
const entries = await exportEntries(sessionId);
|
|
73
|
-
if (entries === null)
|
|
74
|
-
return null; // session does not exist
|
|
75
|
-
// snapshots — listKeys → per-key manifest. exportManifest may be absent (no snapshot export seam) → no snapshots.
|
|
76
|
-
const fs = srcBackend.fileSnapshot();
|
|
77
|
-
const snapshots = [];
|
|
78
|
-
// [2373]B-5b:core 接口 listKeys 必填(恒真半支删);exportManifest? 可选——探测只留真可选的那半。
|
|
79
|
-
if (typeof fs.exportManifest === "function") {
|
|
80
|
-
const keys = await fs.listKeys(sessionId);
|
|
81
|
-
for (const key of keys) {
|
|
82
|
-
const manifest = await fs.exportManifest(sessionId, key);
|
|
83
|
-
if (manifest === null)
|
|
84
|
-
continue; // raced reap between listKeys and exportManifest — skip (never an empty snapshot)
|
|
85
|
-
snapshots.push({ key, manifest: [...manifest] });
|
|
86
|
-
}
|
|
87
|
-
}
|
|
88
|
-
// policy — every (principal, rules) record across ALL principals (E6 listBySession; optional → []).
|
|
89
|
-
const policyStore = srcBackend.sessionPolicy();
|
|
90
|
-
const policy = typeof policyStore.listBySession === "function"
|
|
91
|
-
? await policyStore.listBySession(sessionId)
|
|
92
|
-
: [];
|
|
93
|
-
// anchors — every E18 resume anchor (eventId→entryId + source owner). resumeAnchor is REQUIRED on all backends.
|
|
94
|
-
const anchorStore = srcBackend.resumeAnchor();
|
|
95
|
-
// [2373]B-5a:ResumeAnchorStore 是三实现的闭合联合、listBySession 全必填——feature-detect 恒真,
|
|
96
|
-
// `: []` 臂不可达([2195] deleteBySession 同判据,那轮漏了这对双胞胎)。直调。
|
|
97
|
-
const anchors = await anchorStore.listBySession(sessionId);
|
|
98
|
-
// getBlob — bound to the SOURCE snapshot store so the importer pulls bytes content-addressed (deduped), lazily.
|
|
99
|
-
// A source without getBlob (no blobs to move) supplies a getter that always returns undefined → snapshots with a
|
|
100
|
-
// missing blob fail the §10 validation below before any write (never a partial restore).
|
|
101
|
-
const getBlobFn = fs.getBlob?.bind(fs);
|
|
102
|
-
const getBlob = getBlobFn ? (hash) => getBlobFn(hash) : async () => undefined;
|
|
103
|
-
return { bundle: { sessionId, entries, snapshots, policy, anchors }, getBlob };
|
|
104
|
-
}
|
|
105
19
|
/**
|
|
106
20
|
* P1d-α (PULL streaming, §15) — EXPORT a session's portable state as a {@link SessionManifest} (entries LIFTED OFF
|
|
107
|
-
* the wire)
|
|
21
|
+
* the wire), so it does NOT load entry payloads: it reads the
|
|
108
22
|
* IDS-only projection via `listEntryIds` (`null` ⇒ no such session ⇒ this returns `null`), counts them, reads the
|
|
109
|
-
* current `leafId`, and builds snapshots/policy/anchors
|
|
23
|
+
* current `leafId`, and builds snapshots/policy/anchors. The paired NDJSON
|
|
110
24
|
* `GET /sync/entries` route streams the entry payloads separately (keyset-paged → bounded memory), so a 1M-entry
|
|
111
25
|
* session's manifest stays small (ids + counts + the snapshot/policy/anchor metadata).
|
|
112
26
|
*
|
|
@@ -127,7 +41,7 @@ export async function exportSessionManifest(sessionId, srcBackend) {
|
|
|
127
41
|
// leafId — the session's current leaf (cache-bypassing single read). Absent seam / no leaf ⇒ null.
|
|
128
42
|
const getLeafId = session.getLeafId?.bind(session);
|
|
129
43
|
const leafId = getLeafId ? await getLeafId(sessionId) : null;
|
|
130
|
-
// snapshots / policy / anchors —
|
|
44
|
+
// snapshots / policy / anchors — no entry payloads loaded anywhere here.
|
|
131
45
|
const fs = srcBackend.fileSnapshot();
|
|
132
46
|
const snapshots = [];
|
|
133
47
|
// [2373]B-5b:core 接口 listKeys 必填(恒真半支删);exportManifest? 可选——探测只留真可选的那半。
|
|
@@ -148,7 +62,7 @@ export async function exportSessionManifest(sessionId, srcBackend) {
|
|
|
148
62
|
// [2373]B-5a:ResumeAnchorStore 是三实现的闭合联合、listBySession 全必填——feature-detect 恒真,
|
|
149
63
|
// `: []` 臂不可达([2195] deleteBySession 同判据,那轮漏了这对双胞胎)。直调。
|
|
150
64
|
const anchors = await anchorStore.listBySession(sessionId);
|
|
151
|
-
// getBlob — bound to the SOURCE snapshot store (content-addressed, lazy)
|
|
65
|
+
// getBlob — bound to the SOURCE snapshot store (content-addressed, lazy).
|
|
152
66
|
const getBlobFn = fs.getBlob?.bind(fs);
|
|
153
67
|
const getBlob = getBlobFn ? (hash) => getBlobFn(hash) : async () => undefined;
|
|
154
68
|
return {
|
|
@@ -156,225 +70,9 @@ export async function exportSessionManifest(sessionId, srcBackend) {
|
|
|
156
70
|
getBlob,
|
|
157
71
|
};
|
|
158
72
|
}
|
|
159
|
-
/**
|
|
160
|
-
* IMPORT a {@link SessionBundle} (+ its {@link BlobGetter}) into `dstBackend` under `bundle.sessionId`, re-stamping
|
|
161
|
-
* ownership to `importingPrincipal`. Returns the snapshot tally (imported vs honestly-skipped on a destination whose
|
|
162
|
-
* FileSnapshotStore lacks the importManifest seam — pre-1.141.0; core's InMemory/File NOW carry it, so a local dst
|
|
163
|
-
* imports snapshots for real).
|
|
164
|
-
*
|
|
165
|
-
* §10 FAIL-CLOSED VALIDATION runs FIRST — BEFORE any write:
|
|
166
|
-
* 1. `validateEntriesForImport(bundle.entries)` (the single core invariant gate: unique ids / parent-before-child /
|
|
167
|
-
* one root / leaf-resolvable). Throws on violation; NOTHING is written.
|
|
168
|
-
* 2. Every blobHash referenced by every snapshot manifest MUST be supplied by `getBlob` — a missing blob would
|
|
169
|
-
* restore a PARTIAL tree (files half-written, conversation whole → breaks the entry↔file lockstep). A lightweight
|
|
170
|
-
* PRESENCE pre-check (fetch each distinct hash once, no hashing) fails FAST here; the AUTHORITATIVE content-address
|
|
171
|
-
* integrity check (`sha256(bytes)===hash`) is done ONCE inside `importManifest` (core 1.141.0), so we don't double-
|
|
172
|
-
* hash. (Runs only when the dst supports snapshot import; otherwise there is nothing to half-write.)
|
|
173
|
-
*
|
|
174
|
-
* §7 CLASSIFICATION GATE — BEFORE any write, the destination's CURRENT entry log is read and compared to the bundle
|
|
175
|
-
* via {@link classifySyncRelationship} (a sound entry-id SET comparison, not leaf_id). A `fork` (true divergence) or a
|
|
176
|
-
* `stale` source (the dst is strictly ahead) WOULD lose destination history → it throws {@link SyncConflictError}
|
|
177
|
-
* (route → 409) UNLESS the caller passes `{ resolution: "overwrite-dst" }`. An `identical` relation SKIPS the entries
|
|
178
|
-
* write entirely (the log is already present & equal; the idempotent snapshot/policy/anchor replay below still runs to
|
|
179
|
-
* heal anything missing). `fresh` / `fast_forward` (and an overwrite-resolved fork/stale) write the entries via the
|
|
180
|
-
* IDEMPOTENT {@link OwnerAwareSessionStore.replaceEntries} (purge-then-import) — NOT `importEntries`, whose plain
|
|
181
|
-
* INSERT crashes on a duplicate PK when the session already exists at the destination.
|
|
182
|
-
*
|
|
183
|
-
* §8 CROSS-STORE ATOMIC ORDER + IDEMPOTENCY — session_meta (written LAST inside `replaceEntries`) is the sole
|
|
184
|
-
* commit point, so a crash mid-import leaves orphan blobs/manifests/anchors with NO session_meta = invisible to
|
|
185
|
-
* wake (never a half-session, only collectable orphans). Order:
|
|
186
|
-
* ① snapshots (manifests + content-addressed blobs) — content-addressed + create-once = idempotent retry-to-completion.
|
|
187
|
-
* `importManifest` returns a {@link FileSnapshotResult} (NEVER throws — core 1.141.0); a `{ok:false}` (a
|
|
188
|
-
* rejecting/missing/hash-mismatched blob the §10 pre-check didn't catch — e.g. a blob corrupted in transit) is
|
|
189
|
-
* re-raised as a fail-closed THROW so the import aborts BEFORE the entries commit (no entry↔file split).
|
|
190
|
-
* ② entries (`replaceEntries` runs the core gate AGAIN and writes session_meta LAST = the commit point; idempotent
|
|
191
|
-
* over an existing session — skipped entirely when the relation is `identical`)
|
|
192
|
-
* ③ policy (replay each record via putRules; `opts.operatorOk` defaults FALSE → the E6 tighten-only gate APPLIES, so
|
|
193
|
-
* a replay that would LOOSEN an operator-tightened dst policy throws `SessionPolicyError("loosen_forbidden")` —
|
|
194
|
-
* `operator:true` is set ONLY when the authenticated importer is itself a verified operator)
|
|
195
|
-
* ④ anchors (owner RE-KEYED to `importingPrincipal`, §9)
|
|
196
|
-
* Steps ③/④ are post-commit; re-running them is idempotent — ③ SKIPS a put whose content already equals the dst row
|
|
197
|
-
* (a "no-op rev bump" is NOT idempotent: rev is the operator's optimistic-lock token — the A6 fix below), ④ is a
|
|
198
|
-
* same-value upsert — so a crash between ②–④ is recovered by a retry that converges to the same bytes.
|
|
199
|
-
*
|
|
200
|
-
* @param opts.resolution — `"overwrite-dst"` consents to a `fork`/`stale` import that overwrites destination history
|
|
201
|
-
* (the user's keep-source decision); omitted ⇒ such an import is refused with {@link SyncConflictError}.
|
|
202
|
-
* @param opts.operatorOk — when `true`, the policy replay (step ③) is an OPERATOR write (may loosen the dst rules); set
|
|
203
|
-
* ONLY when the AUTHENTICATED importer is a verified operator. DEFAULT false → the E6 tighten-only gate applies, so a
|
|
204
|
-
* non-operator import is TIGHTEN-ONLY (design §4): a replay that would loosen an operator-tightened dst session policy
|
|
205
|
-
* throws `SessionPolicyError("loosen_forbidden")`, which propagates (NOT swallowed) so the route can refuse the loosen.
|
|
206
|
-
*/
|
|
207
|
-
export async function importSession(bundle, getBlob, dstBackend, importingPrincipal, opts) {
|
|
208
|
-
const sessionId = bundle.sessionId;
|
|
209
|
-
const fs = dstBackend.fileSnapshot();
|
|
210
|
-
const importFace = fileSnapshotImportFace(fs);
|
|
211
|
-
const canImportSnapshots = importFace !== undefined;
|
|
212
|
-
// ── §7 classification gate, BEFORE any write ───────────────────────────────────────────────────────────────
|
|
213
|
-
// Read the destination's CURRENT entry log and classify the relationship (sound entry-id set comparison). A fork or
|
|
214
|
-
// a stale source would lose dst history → refuse with a typed 409 unless the caller explicitly resolved overwrite-dst.
|
|
215
|
-
const dstEntries = (await ownerAware(dstBackend).exportEntries?.(sessionId)) ?? null;
|
|
216
|
-
const rel = classifySyncRelationship(bundle.entries, dstEntries);
|
|
217
|
-
if ((rel.relation === "fork" || rel.relation === "stale") && opts?.resolution !== "overwrite-dst") {
|
|
218
|
-
throw new SyncConflictError(sessionId, rel); // 409 — surface the exclusive sets so the user picks keep-local/cloud
|
|
219
|
-
}
|
|
220
|
-
// ── §10 fail-closed validation, BEFORE any write ────────────────────────────────────────────────────────────
|
|
221
|
-
// (1) entries invariants (the core gate). validateEntriesForImport throws on any violation; we discard its result
|
|
222
|
-
// here (importEntries re-runs it as the authoritative write-time gate) — this is the early fail-closed check so
|
|
223
|
-
// we never start writing snapshots for a session whose entries the gate will reject.
|
|
224
|
-
validateEntriesForImport(bundle.entries);
|
|
225
|
-
// (2) every referenced blob must be fetchable — ONLY when the destination can actually import snapshots (otherwise
|
|
226
|
-
// there is nothing to half-write, so a missing blob is moot; the snapshot is honestly skipped below). Fetch each
|
|
227
|
-
// DISTINCT hash once (content-addressed) so a large manifest doesn't refetch. This is a lightweight PRESENCE
|
|
228
|
-
// pre-check (fail-FAST before we write any entries) — the AUTHORITATIVE sha256 content-address integrity check is
|
|
229
|
-
// done ONCE inside importManifest (core 1.141.0); we deliberately don't re-hash here to avoid double-hashing
|
|
230
|
-
// a possibly-large blob set. The importManifest {ok:false} below is the backstop for anything the presence check
|
|
231
|
-
// can't see (a blob present-but-corrupted-in-transit, a getBlob that rejects only on a later call).
|
|
232
|
-
if (canImportSnapshots) {
|
|
233
|
-
const distinct = new Set();
|
|
234
|
-
for (const snap of bundle.snapshots)
|
|
235
|
-
for (const [, hash] of snap.manifest)
|
|
236
|
-
distinct.add(hash);
|
|
237
|
-
for (const hash of distinct) {
|
|
238
|
-
const bytes = await getBlob(hash);
|
|
239
|
-
if (!bytes) {
|
|
240
|
-
throw new Error(`session-sync import: missing blob ${hash} for session ${sessionId} (refusing a partial-tree import)`);
|
|
241
|
-
}
|
|
242
|
-
}
|
|
243
|
-
}
|
|
244
|
-
// ── ⓪ overwrite-dst subsystem WIPE (fork/stale with explicit consent ONLY) ─────────────────────────────────
|
|
245
|
-
// "overwrite-dst" means the user chose KEEP-SOURCE — the result must be the SOURCE's state, not "the source's
|
|
246
|
-
// conversation + the destination's leftover policy/anchors/snapshots" (a MIXED state is worse than lost history:
|
|
247
|
-
// dangling anchors resolve onto purged entries, an abandoned branch's policy rows keep gating, and a shared-key
|
|
248
|
-
// snapshot keeps the dst's bytes because importManifest is create-once). So before replaying ①/③/④ we wipe the
|
|
249
|
-
// dst's per-session subsystem state. Crash-safety: the user consented to discarding the dst's state, and every
|
|
250
|
-
// wipe+replay leg is idempotent → a crash mid-wipe is recovered by the same retry-to-completion as §8's ②–④.
|
|
251
|
-
// · snapshots: prefer the purge seam `deleteBySession` (SQL twins: manifests drop now, blobs go to the async
|
|
252
|
-
// orphan GC); fall back to the core-REQUIRED `reap(scope, [])` (in-memory/file stores GC unreferenced blobs
|
|
253
|
-
// inline — content-addressed, so blobs still referenced by other scopes survive).
|
|
254
|
-
// · anchors / policy: `deleteBySession` where the store carries it — since core 1.423 that is EVERY in-tree
|
|
255
|
-
// store (the seam is optional on core's SessionPolicyStore interface and implemented by its File/InMemory
|
|
256
|
-
// stores too — [1796]§三 → [1801]; the feature-detect stays for third-party stores that predate the seam).
|
|
257
|
-
const overwriting = (rel.relation === "fork" || rel.relation === "stale") && opts?.resolution === "overwrite-dst";
|
|
258
|
-
if (overwriting) {
|
|
259
|
-
const fsWipe = fs;
|
|
260
|
-
if (typeof fsWipe.deleteBySession === "function")
|
|
261
|
-
await fsWipe.deleteBySession(sessionId);
|
|
262
|
-
else
|
|
263
|
-
await fsWipe.reap(sessionId, []);
|
|
264
|
-
// 🔴 [2195] 同族(2026-08-01):这两条此前是「有 seam 就删,没有就**静默跳过**」——而本段开头
|
|
265
|
-
// 逐字论证了为什么必须删(「a MIXED state is worse than lost history:dangling anchors resolve onto
|
|
266
|
-
// purged entries, an abandoned branch's policy rows keep gating」)。论证了坏状态不可接受,却在能力
|
|
267
|
-
// 缺席时放行了它自己点名的那个坏状态,且**一声不响**。与 session owner 门那条同族:
|
|
268
|
-
// **能力探测的失败方向决定整条保证的失败方向**,保护型能力缺席必须 fail-closed。
|
|
269
|
-
//
|
|
270
|
-
// 改成 fail-loud:wipe 不了就不继续(此时还**没有**任何写入落地 —— 本段在 §8 的 ①③④ 之前,
|
|
271
|
-
// 所以抛在这里是干净的中止,不是半写状态)。in-tree 每个 store 自 core 1.423 起都带这个 seam
|
|
272
|
-
// ⇒ 我们自己的部署零影响;受影响的只有 seam 之前的第三方 store,而它们此前拿到的是坏数据。
|
|
273
|
-
// 🔴 [2195] 同族(2026-08-01):这两条此前都是「有 seam 就删,没有就**静默跳过**」——而本段开头
|
|
274
|
-
// 逐字论证了为什么必须删(「a MIXED state is worse than lost history:dangling anchors resolve onto
|
|
275
|
-
// purged entries, an abandoned branch's policy rows keep gating」)。论证了坏状态不可接受,却在能力
|
|
276
|
-
// 缺席时放行了它自己点名的那个坏状态,且一声不响。
|
|
277
|
-
//
|
|
278
|
-
// 但两条的**类型事实不同**(旧码用同一个 `as { deleteBySession?: … }` 把两者抹平了,那个 `as` 正是
|
|
279
|
-
// 遮蔽物):
|
|
280
|
-
// · `ResumeAnchorStore.deleteBySession` —— **必填**。类型系统已经排除了「没有」的可能,那个
|
|
281
|
-
// feature-detect 是**为一个不可达场景**写的分支;注释里「for third-party stores that predate the
|
|
282
|
-
// seam」的说法与类型不符(这样的 store 根本进不来)。⇒ 删掉探测,直接调用。真有 JS 层绕过来的
|
|
283
|
-
// 残缺实现,直接调用抛 TypeError 也是 fail-loud,不必自己写。
|
|
284
|
-
// · `SessionPolicyStore.deleteBySession` —— **可选**(且返回 `Promise<void>`,与 anchor 的
|
|
285
|
-
// `Promise<number>` 不同;这个差异也是被那个 `as` 抹掉的)。⇒ 探测保留,但缺席时 fail-loud。
|
|
286
|
-
//
|
|
287
|
-
// 抛在这里是干净的中止:本段在 §8 的 ①③④ 之前,还没有任何写入落地。
|
|
288
|
-
await dstBackend.resumeAnchor().deleteBySession(sessionId);
|
|
289
|
-
const policyWipe = dstBackend.sessionPolicy();
|
|
290
|
-
if (typeof policyWipe.deleteBySession !== "function") {
|
|
291
|
-
throw new Error(`overwrite-dst cannot proceed: this deployment's session-policy store has no deleteBySession purge seam, so the destination's policy rows for this session cannot be wiped. Completing the import would leave a MIXED state (the source's conversation plus the destination's stale policy rows, which keep gating), which is worse than the history loss you consented to. Upgrade to a policy store carrying deleteBySession (every in-tree store has it since core 1.423), or re-run without resolution:"overwrite-dst".`);
|
|
292
|
-
}
|
|
293
|
-
await policyWipe.deleteBySession(sessionId);
|
|
294
|
-
}
|
|
295
|
-
// ── §8 atomic order ─────────────────────────────────────────────────────────────────────────────────────────
|
|
296
|
-
// ① snapshots — content-addressed blobs + manifest, BEFORE session_meta. Feature-detect importManifest: ALL backends
|
|
297
|
-
// now carry it (durable TiDB/PG + core's File/InMemory since 1.141.0) → a local dst imports snapshots for
|
|
298
|
-
// real. The only remaining honest-degrade is a backend whose FileSnapshotStore predates the seam (none in-tree).
|
|
299
|
-
// importManifest NEVER throws — it returns a FileSnapshotResult; a {ok:false} (rejecting/missing/hash-mismatched
|
|
300
|
-
// blob — e.g. corrupted in transit) is re-raised here as a fail-closed THROW so we abort BEFORE the entries commit
|
|
301
|
-
// (no entry↔file split). The whole import is the caller's transaction: a throw here means no session_meta is ever
|
|
302
|
-
// written (the commit point is inside importEntries, step ②), so the dst stays clean (only collectable orphans).
|
|
303
|
-
let snapshotsImported = 0;
|
|
304
|
-
let snapshotsSkipped = 0;
|
|
305
|
-
if (importFace) {
|
|
306
|
-
const importManifest = importFace.importManifest;
|
|
307
|
-
for (const snap of bundle.snapshots) {
|
|
308
|
-
const r = await importManifest(sessionId, snap.key, new Map(snap.manifest), getBlob);
|
|
309
|
-
if (!r.ok) {
|
|
310
|
-
throw new Error(`session-sync import: snapshot import failed for session ${sessionId} key ${snap.key} (${r.error.code}: ${r.error.message}) — fail-closed, refusing a partial-tree import`);
|
|
311
|
-
}
|
|
312
|
-
snapshotsImported++;
|
|
313
|
-
}
|
|
314
|
-
}
|
|
315
|
-
else {
|
|
316
|
-
snapshotsSkipped = bundle.snapshots.length; // pre-1.141.0 dst — files pend the import seam; honest degrade (NOT a silent drop)
|
|
317
|
-
}
|
|
318
|
-
// ② entries — core gate (again, authoritative) + session_meta LAST = the commit point. Owner = importingPrincipal
|
|
319
|
-
// (NEVER the bundle's, §9). Written via the IDEMPOTENT replaceEntries (purge-then-import) — NOT importEntries,
|
|
320
|
-
// whose plain INSERT crashes on a duplicate PK when the session already exists (fast-forward / overwrite-resolved
|
|
321
|
-
// / a §8 retry). When the relation is `identical` the log is already present & equal → SKIP the entries write
|
|
322
|
-
// entirely (the snapshot/policy/anchor replay below is idempotent and still runs to heal anything missing). A
|
|
323
|
-
// destination without the replace seam can't accept a session at all → throw.
|
|
324
|
-
// 🔴 同上(`session-sync-content.ts` 顶注):`identical` 只说明 id 集合相等,不说明内容相同。这条路上
|
|
325
|
-
// `bundle.entries` 与目的端日志都在手上 ⇒ 直接比内容摘要;不符就照常改写(不抛错、不 409)。复用 §7 已经
|
|
326
|
-
// 读到的 `dstEntries`(同一个 dstBackend/sessionId)——这两次读之间 dst 未被任何写触碰(overwrite-dst 擦除
|
|
327
|
-
// 只在 relation 为 fork/stale 时才跑,identical 分支不可能落进那条腿),不必也不该对同一行重新 exportEntries
|
|
328
|
-
// 一次(旧实现还用 `.catch(() => null)` 把这次读的故障裸吞成"目的端为空",见下方 identicalIdsAlsoIdenticalContent
|
|
329
|
-
// 对 null 的处置——一次真实的店读故障会被悄悄当成"内容不等"而不是 fail-loud)。
|
|
330
|
-
const contentEqual = rel.relation === "identical" && identicalIdsAlsoIdenticalContent(bundle.entries, dstEntries);
|
|
331
|
-
if (rel.relation !== "identical" || !contentEqual) {
|
|
332
|
-
const session = ownerAware(dstBackend);
|
|
333
|
-
const replaceEntries = session.replaceEntries?.bind(session);
|
|
334
|
-
if (!replaceEntries) {
|
|
335
|
-
throw new Error(`session-sync import: destination backend (${dstBackend.kind}) has no replaceEntries seam`);
|
|
336
|
-
}
|
|
337
|
-
await replaceEntries(sessionId, importingPrincipal, bundle.entries);
|
|
338
|
-
}
|
|
339
|
-
// ③ policy — replay each (principal, rules) record VERBATIM. The E6 tighten-only gate is the security boundary here:
|
|
340
|
-
// we do NOT pass operator:true unconditionally (a non-operator pushing a client-controlled bundle could otherwise
|
|
341
|
-
// LOOSEN an operator-tightened dst session policy — the loosen the E6 route refuses). `opts.operatorOk` defaults
|
|
342
|
-
// FALSE → the tighten gate applies, so a TIGHTEN-ONLY import succeeds (design §4 "merge tighten-only") and a
|
|
343
|
-
// loosening replay throws SessionPolicyError("loosen_forbidden") — which we do NOT swallow; it propagates to the
|
|
344
|
-
// route so the malicious loosen is refused (entries may already have committed: the conversation syncs, only the
|
|
345
|
-
// loosen is refused = the correct fail-closed outcome). operator:true ONLY when the AUTHENTICATED importer is a
|
|
346
|
-
// verified operator. principal is carried VERBATIM (NOT re-keyed to importingPrincipal): the policy `principal` is
|
|
347
|
-
// the per-TASK rule-owner key (`spec.principal`), not the session owner, so re-keying would collapse distinct
|
|
348
|
-
// per-principal rows and could clobber. The session-wide row (principal=undefined) stays session-wide and still
|
|
349
|
-
// applies to the new owner.
|
|
350
|
-
// IDEMPOTENT REPLAY (the A6 fix): a put whose content (rev stripped, normalized) already equals the dst row is
|
|
351
|
-
// SKIPPED — putRules bumps `rev` on every write, and `rev` is the optimistic-lock token operators hold for
|
|
352
|
-
// read-modify-write. Without the skip, every §8 crash-retry (and every harmless duplicate sync) advances every
|
|
353
|
-
// row's rev while changing nothing ⇒ the operator's next putRules({expectedRev}) throws `conflict` on rules whose
|
|
354
|
-
// content never moved. With it, retry-after-crash converges to the same BYTES as a single clean run, which is
|
|
355
|
-
// what "steps ③/④ are idempotent" (§8 above) actually has to mean. normalizeRules gives both sides the same
|
|
356
|
-
// canonical field order/shape, so JSON.stringify is a sound equality here (array ORDER is significant on purpose:
|
|
357
|
-
// a reordered list is a different stored byte-state and must be written).
|
|
358
|
-
const policyStore = dstBackend.sessionPolicy();
|
|
359
|
-
const canonRules = (r) => JSON.stringify(normalizeRules(stripRev(r)));
|
|
360
|
-
for (const rec of bundle.policy) {
|
|
361
|
-
const cur = await policyStore.getRules(sessionId, rec.principal);
|
|
362
|
-
if (cur !== null && canonRules(cur) === canonRules(rec.rules))
|
|
363
|
-
continue; // content already identical — don't touch rev
|
|
364
|
-
await policyStore.putRules(sessionId, rec.principal, rec.rules, { operator: opts?.operatorOk === true });
|
|
365
|
-
}
|
|
366
|
-
// ④ anchors — owner RE-KEYED to importingPrincipal (§9): the E18 owner-guard resolves under the NEW owner so
|
|
367
|
-
// rewind-to-message keeps working post-move (the source owner, e.g. local null, would never resolve in the cloud).
|
|
368
|
-
const anchorStore = dstBackend.resumeAnchor();
|
|
369
|
-
for (const a of bundle.anchors) {
|
|
370
|
-
await anchorStore.put(sessionId, a.eventId, a.entryId, importingPrincipal);
|
|
371
|
-
}
|
|
372
|
-
return { snapshotsImported, snapshotsSkipped, relation: rel.relation };
|
|
373
|
-
}
|
|
374
73
|
/**
|
|
375
74
|
* §7 DRY-RUN — classify what importing `sessionId` from `srcBackend` into `dstBackend` WOULD do, WITHOUT writing
|
|
376
|
-
* anything
|
|
377
|
-
* {@link importSession} (so the shell can present a keep-local / keep-cloud / fork-new choice up front).
|
|
75
|
+
* anything (the shell can present a keep-local / keep-cloud / fork-new choice up front).
|
|
378
76
|
*
|
|
379
77
|
* Returns `null` when the SOURCE session does not exist (no bundle to plan); otherwise the {@link SyncRelation}
|
|
380
78
|
* between the source's full durable log and the destination's current log (a `null` dst log ⇒ `fresh`). No writes.
|
|
@@ -386,4 +84,64 @@ export async function planSync(sessionId, srcBackend, dstBackend) {
|
|
|
386
84
|
const dstEntries = (await ownerAware(dstBackend).exportEntries?.(sessionId)) ?? null;
|
|
387
85
|
return classifySyncRelationship(srcEntries, dstEntries);
|
|
388
86
|
}
|
|
87
|
+
// ═══ #137 —— 死码 importSession 独有保护的活路由移植(910c524 顶注 TOMBSTONE 两缺口的修复面)═══
|
|
88
|
+
// 本段是「测红复现(fanout-characterization #137 L1/L2)→ 修绿」的绿半场;顶注 TOMBSTONE 的
|
|
89
|
+
// 「NO live-route equivalent」声明随本批作废(保留原文供考古,状态以本段+路由接线为准)。
|
|
90
|
+
/** #137 ①(死码 A6 修的移植):同内容判据 —— 比较「归一化、去 rev」后的字节形。putRules 每次
|
|
91
|
+
* 无条件写都 rev+1,而 rev 是 operator 乐观锁的 CAS 令牌(`PutRulesOptions.expectedRev`),
|
|
92
|
+
* 幂等重试/无害重复同步不该把在途 CAS 打失败。归一化用 core 的 `normalizeRules`(store 落盘
|
|
93
|
+
* 前走同一函数 ⇒ 两侧同坐标);`stripRev` 去掉记录内嵌的 rev(`listBySession` 的 rules 含 rev,
|
|
94
|
+
* 它不是内容)。 */
|
|
95
|
+
export function sameRulesContent(stored, incoming) {
|
|
96
|
+
if (stored === null)
|
|
97
|
+
return false;
|
|
98
|
+
const norm = (r) => JSON.stringify(normalizeRules(stripRev(r)));
|
|
99
|
+
return norm(stored) === norm(incoming);
|
|
100
|
+
}
|
|
101
|
+
/** #137 ① 的重放腿共享实现:PUSH 路由的两条 policy 重放腿(Phase A identical 短路支 / Phase B
|
|
102
|
+
* commit 后支)都从这里走 —— 等内容跳过(rev 零漂移),真变更照常落地(E6 tighten-only 门在
|
|
103
|
+
* putRules 内部,此处不重述)。 */
|
|
104
|
+
export async function replayPolicyRecords(store, sessionId, records, operatorOk) {
|
|
105
|
+
for (const rec of records) {
|
|
106
|
+
const cur = await store.getRules(sessionId, rec.principal);
|
|
107
|
+
if (sameRulesContent(cur, rec.rules))
|
|
108
|
+
continue;
|
|
109
|
+
await store.putRules(sessionId, rec.principal, rec.rules, { operator: operatorOk });
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
/** #137 ② 能力预检(fail-loud 方向,[2195] 判据:保护型能力缺席=fail-closed):consented
|
|
113
|
+
* overwrite-dst 需要 policy 店的 `deleteBySession` purge seam 才能不留混合态;缺席=true,路由在
|
|
114
|
+
* commit **之前**用它干净中止(此时零写入落地),wire 拒因字面量归路由所有(api-error-text-freeze
|
|
115
|
+
* 锚字面站点)。anchor 店的 deleteBySession 是类型必填、快照店有 core 必填的 `reap` 兜底,都不构成
|
|
116
|
+
* 缺席面;唯一可缺的就是 policy 这条(core 接口上是可选方法)。 */
|
|
117
|
+
export function overwriteWipeIncapable(policyStore) {
|
|
118
|
+
return policyStore !== undefined && typeof policyStore.deleteBySession !== "function";
|
|
119
|
+
}
|
|
120
|
+
/** #137 ②(死码 ⓪ 擦除步的移植):consented overwrite-dst = keep-source,结果必须是**源端那
|
|
121
|
+
* 一份**,不得混成「源端对话 + 目的端遗留 policy/anchor/快照」(混合态比丢历史更危险:悬空
|
|
122
|
+
* anchor 指向已 purge 的 entry、被放弃分支的 policy 行继续 gating、共享 key 快照因
|
|
123
|
+
* importManifest create-once 永远留着目的端字节)。调用时点=commit(原子 swap)成功**之后**、
|
|
124
|
+
* 附随物重放**之前**:死码在任何写入前擦(其 entries 写本身非原子);活路由有真 commit point,
|
|
125
|
+
* 擦在 commit 前会在「commit 失败」时留下反向混合态(目的端对话完好、附随物已毁)——方向更坏。
|
|
126
|
+
* 诚实残余:进程在 commit 与本函数之间崩溃时,重试落 Phase A identical 支(无 wipe)⇒ 遗留行
|
|
127
|
+
* 可存活;该窗口窄于修前世界(修前=遗留行**恒**存活),与 §8「快照重放失败 ⇒ 422 重试自愈」
|
|
128
|
+
* 同款接受,记录于此不装没有。
|
|
129
|
+
* 各店姿势(死码 verbatim 语义):快照=deleteBySession(SQL 孪生:manifest 即删、blob 走异步
|
|
130
|
+
* 孤儿 GC)缺席则 core 必填 `reap(scope, [])`(内容寻址,他 scope 仍引用的 blob 存活);
|
|
131
|
+
* anchor=直调(类型必填);policy=deleteBySession(路由已用 {@link overwriteWipeIncapable}
|
|
132
|
+
* 预检过,此处缺席=类型外的 JS 层残缺实现,直调抛 TypeError 即 fail-loud)。 */
|
|
133
|
+
export async function wipeSessionAttendants(stores, sessionId) {
|
|
134
|
+
const fs = stores.fileSnapshotStore;
|
|
135
|
+
if (fs !== undefined) {
|
|
136
|
+
if (typeof fs.deleteBySession === "function")
|
|
137
|
+
await fs.deleteBySession(sessionId);
|
|
138
|
+
else
|
|
139
|
+
await fs.reap(sessionId, []);
|
|
140
|
+
}
|
|
141
|
+
if (stores.resumeAnchorStore !== undefined)
|
|
142
|
+
await stores.resumeAnchorStore.deleteBySession(sessionId);
|
|
143
|
+
const policy = stores.sessionPolicyStore;
|
|
144
|
+
if (policy !== undefined)
|
|
145
|
+
await policy.deleteBySession(sessionId);
|
|
146
|
+
}
|
|
389
147
|
//# sourceMappingURL=session-sync.js.map
|
package/dist/tool-approval.js
CHANGED
|
@@ -382,18 +382,19 @@ export class ToolApprovalCoordinator {
|
|
|
382
382
|
// [1559]三:emit 完成 vs TTL/abort/respond 三选一落定——显式 race,不让挂死的 emit 独占执行权。
|
|
383
383
|
await Promise.race([emitAllP, allowedP]);
|
|
384
384
|
const settled = await allowedP;
|
|
385
|
-
// Completion breadcrumb (card dismiss) — fire-and-forget to every target that actually got the OPEN frame
|
|
386
|
-
//
|
|
387
|
-
//
|
|
388
|
-
//
|
|
389
|
-
//
|
|
390
|
-
//
|
|
391
|
-
//
|
|
392
|
-
//
|
|
385
|
+
// Completion breadcrumb (card dismiss) — fire-and-forget to every target that actually got the OPEN frame,
|
|
386
|
+
// so a slow/hung emit never delays returning to core AND every connection that saw the card learns it
|
|
387
|
+
// resolved elsewhere([1559]四「其余连接收 resolved/dismissed 通知」的直接落点——单连接场景退化为
|
|
388
|
+
// 原来的一次 emit)。**per-ctx 链在该 ctx 自己的 openP 之后且看它的布尔结果**([2557] 批3 F2 D1 修,
|
|
389
|
+
// 2026-08-06):emitOne 失败是 resolve(false) 不是 reject,旧形 `.then(() => emit(complete))` 对
|
|
390
|
+
// resolve 值全放行 ⇒ open 没送达的连接(瞬断型 emit 目标)也收 `tool_approval_complete` = 孤儿
|
|
391
|
+
// close,注释与代码曾不符(红先=wire-pairing-approval D1,修后翻正)。`delivered` 门后:
|
|
392
|
+
// `.then((delivered) => delivered ? c.emit(...) : undefined)` —— complete emit 若同步 throw,
|
|
393
|
+
// `.then` 回调内抛出转成 rejection 由尾部 `.catch` 吸收(链形不变)。
|
|
393
394
|
for (const c of aliveCtxs) {
|
|
394
395
|
void openByCtx
|
|
395
396
|
.get(c)
|
|
396
|
-
.then(() => c.emit({ type: "tool_approval_complete", approvalId: id, outcome: lastOutcome }))
|
|
397
|
+
.then((delivered) => (delivered ? c.emit({ type: "tool_approval_complete", approvalId: id, outcome: lastOutcome }) : undefined))
|
|
397
398
|
.catch(() => undefined);
|
|
398
399
|
}
|
|
399
400
|
// emit 全灭 = 卡从未送达任何人(≠人拒绝/TTL 走人)——同属「无活人可达」类 ⇒ "unavailable" 交 durable
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sema-agent/server",
|
|
3
|
-
"version": "6.
|
|
3
|
+
"version": "6.7.0",
|
|
4
4
|
"description": "Sema Server — the server/API implementation layer for Sema, wiring core, registry, model providers, and cloud agent execution. Built on @sema-agent/core.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "BUSL-1.1",
|
|
@@ -54,7 +54,7 @@
|
|
|
54
54
|
"build:binary:run-local:darwin-arm64": "bun build --compile --target=bun-darwin-arm64 src/run-local.ts --outfile dist/run-local-darwin-arm64"
|
|
55
55
|
},
|
|
56
56
|
"dependencies": {
|
|
57
|
-
"@sema-agent/core": "^5.
|
|
57
|
+
"@sema-agent/core": "^5.11.0",
|
|
58
58
|
"@sema-agent/registry-core": "^0.14.0",
|
|
59
59
|
"e2b": "^2.28.0",
|
|
60
60
|
"libsodium-wrappers": "^0.8.4",
|
|
@@ -68,7 +68,7 @@
|
|
|
68
68
|
"sharp": "^0.35.3"
|
|
69
69
|
},
|
|
70
70
|
"devDependencies": {
|
|
71
|
-
"@sema-agent/sdk": "^6.
|
|
71
|
+
"@sema-agent/sdk": "^6.5.0",
|
|
72
72
|
"@types/libsodium-wrappers": "^0.7.14",
|
|
73
73
|
"@types/node": "22.10.2",
|
|
74
74
|
"@types/pg": "^8.20.0",
|