@sema-agent/server 6.6.0 → 6.8.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/bake-runner/main.js +11 -0
- package/dist/boot/leader.d.ts +11 -0
- package/dist/boot/leader.js +22 -0
- package/dist/config.js +13 -5
- package/dist/http/routes/session-sync.js +32 -6
- package/dist/leader/wire.d.ts +11 -1
- package/dist/leader/wire.js +63 -47
- package/dist/runs.js +3 -1
- package/dist/session-sync.d.ts +82 -120
- package/dist/session-sync.js +67 -309
- package/dist/spec-fields.js +4 -3
- package/package.json +3 -3
package/dist/bake-runner/main.js
CHANGED
|
@@ -15,6 +15,14 @@ import { parseNumOrFailNonNegative } from "../config.js";
|
|
|
15
15
|
import { shellQuote as shellSafe } from "../plugins/remote-shell.js";
|
|
16
16
|
import { BakeRunner, } from "./runner.js";
|
|
17
17
|
const exec = promisify(execCb);
|
|
18
|
+
// 鲁棒性批5 A3(2026-08-05):claim/ingest/heartbeat 三个 fetch 此前零超时——bake-runner 的整条 loop()
|
|
19
|
+
// 是这台构建宿主机唯一的主循环,一次悬挂的 image-api 连接(网络分区/黑洞 TCP)会让 claim/heartbeat 永久
|
|
20
|
+
// 挂起,直到底层 socket 超时(可能几十分钟),期间既不上报心跳也不能认领新 bake——整机静默停摆。
|
|
21
|
+
// 上界两档:claim/ingest 不在心跳热路径,给 30s 吃满慢网 RTT + 大 body;heartbeat 必须显著小于
|
|
22
|
+
// BAKE_HEARTBEAT_MS 的默认量级(30s)——否则一次超时本身就吃掉一整个心跳周期,取 10s(同
|
|
23
|
+
// fleet-client.ts `FLEET_CLIENT_FETCH_TIMEOUT_MS` 判据的普适形)。
|
|
24
|
+
const BAKE_CLAIM_INGEST_FETCH_TIMEOUT_MS = 30_000;
|
|
25
|
+
const BAKE_HEARTBEAT_FETCH_TIMEOUT_MS = 10_000;
|
|
18
26
|
function loadEnv() {
|
|
19
27
|
const need = (k) => {
|
|
20
28
|
const v = process.env[k];
|
|
@@ -55,6 +63,7 @@ function makeApiClient(env, log) {
|
|
|
55
63
|
method: "POST",
|
|
56
64
|
headers: auth,
|
|
57
65
|
body: JSON.stringify({ runnerId: env.runnerId }),
|
|
66
|
+
signal: AbortSignal.timeout(BAKE_CLAIM_INGEST_FETCH_TIMEOUT_MS),
|
|
58
67
|
});
|
|
59
68
|
if (res.status === 204)
|
|
60
69
|
return null; // empty queue — the routine "nothing to do" case, no log line
|
|
@@ -93,6 +102,7 @@ function makeApiClient(env, log) {
|
|
|
93
102
|
method: "POST",
|
|
94
103
|
headers: { ...auth, "x-bake-ingest-secret": ingestSecret },
|
|
95
104
|
body: JSON.stringify(frame),
|
|
105
|
+
signal: AbortSignal.timeout(BAKE_CLAIM_INGEST_FETCH_TIMEOUT_MS),
|
|
96
106
|
});
|
|
97
107
|
const body = res.ok ? (await res.json().catch(() => ({}))) : {};
|
|
98
108
|
return {
|
|
@@ -107,6 +117,7 @@ function makeApiClient(env, log) {
|
|
|
107
117
|
method: "POST",
|
|
108
118
|
headers: { ...auth, "x-bake-ingest-secret": ingestSecret },
|
|
109
119
|
body: JSON.stringify({ event: "heartbeat" }),
|
|
120
|
+
signal: AbortSignal.timeout(BAKE_HEARTBEAT_FETCH_TIMEOUT_MS),
|
|
110
121
|
});
|
|
111
122
|
const body = res.ok ? (await res.json().catch(() => ({}))) : {};
|
|
112
123
|
return {
|
package/dist/boot/leader.d.ts
CHANGED
|
@@ -23,5 +23,16 @@ export interface LeaderCtx {
|
|
|
23
23
|
sessionStore: ReturnType<StoreBackend["session"]>;
|
|
24
24
|
checkpointStore: CheckpointStoreFull | undefined;
|
|
25
25
|
}
|
|
26
|
+
/**
|
|
27
|
+
* 鲁棒性批5 A6(2026-08-05):k8s 腿要求 MinIO 三件套(ENDPOINT/ACCESS_KEY/SECRET_KEY)全在——半开(一件缺)
|
|
28
|
+
* 就让 `leaderEndpoint` 的三元式落到 undefined,此前**零披露**:运维显式开了 LEADER_ENABLED +
|
|
29
|
+
* REMOTE_EXEC=k8s,却在日志里既看不到 `leader_endpoint_enabled` 也看不到任何"为什么没启用"的信号——只能
|
|
30
|
+
* 靠读源码才知道要去检查 MinIO 三件套。
|
|
31
|
+
*
|
|
32
|
+
* 纯谓词,抽出独立函数:只吃 leaderEnabled/leaderProvider/hasS3 三个原语 + 直接读 process.env 取缺失的
|
|
33
|
+
* 具体变量名,不依赖完整 `LeaderCtx`(构造一整套 brain/pricing/sessionStore 只为测一个 warn 分支不值当)。
|
|
34
|
+
* 返回 null = 不适用该警告(未开启/非 k8s 腿/三件套齐全);非 null = 该报警,携带具体缺的变量名列表。
|
|
35
|
+
*/
|
|
36
|
+
export declare function leaderK8sMinioGap(leaderEnabled: boolean, leaderProvider: string | undefined, hasS3: boolean): string[] | null;
|
|
26
37
|
export declare function createLeaderFace(ctx: LeaderCtx): ReturnType<typeof createLeaderEndpoint> | undefined;
|
|
27
38
|
//# sourceMappingURL=leader.d.ts.map
|
package/dist/boot/leader.js
CHANGED
|
@@ -1,5 +1,24 @@
|
|
|
1
1
|
import { createLeaderEndpoint } from "../leader/endpoint.js";
|
|
2
2
|
import { createLeaderRunner } from "../leader/wire.js";
|
|
3
|
+
/**
|
|
4
|
+
* 鲁棒性批5 A6(2026-08-05):k8s 腿要求 MinIO 三件套(ENDPOINT/ACCESS_KEY/SECRET_KEY)全在——半开(一件缺)
|
|
5
|
+
* 就让 `leaderEndpoint` 的三元式落到 undefined,此前**零披露**:运维显式开了 LEADER_ENABLED +
|
|
6
|
+
* REMOTE_EXEC=k8s,却在日志里既看不到 `leader_endpoint_enabled` 也看不到任何"为什么没启用"的信号——只能
|
|
7
|
+
* 靠读源码才知道要去检查 MinIO 三件套。
|
|
8
|
+
*
|
|
9
|
+
* 纯谓词,抽出独立函数:只吃 leaderEnabled/leaderProvider/hasS3 三个原语 + 直接读 process.env 取缺失的
|
|
10
|
+
* 具体变量名,不依赖完整 `LeaderCtx`(构造一整套 brain/pricing/sessionStore 只为测一个 warn 分支不值当)。
|
|
11
|
+
* 返回 null = 不适用该警告(未开启/非 k8s 腿/三件套齐全);非 null = 该报警,携带具体缺的变量名列表。
|
|
12
|
+
*/
|
|
13
|
+
export function leaderK8sMinioGap(leaderEnabled, leaderProvider, hasS3) {
|
|
14
|
+
if (!(leaderEnabled && leaderProvider === "k8s" && !hasS3))
|
|
15
|
+
return null;
|
|
16
|
+
return [
|
|
17
|
+
!process.env.MINIO_ENDPOINT && "MINIO_ENDPOINT",
|
|
18
|
+
!process.env.MINIO_ACCESS_KEY && "MINIO_ACCESS_KEY",
|
|
19
|
+
!process.env.MINIO_SECRET_KEY && "MINIO_SECRET_KEY",
|
|
20
|
+
].filter((v) => typeof v === "string");
|
|
21
|
+
}
|
|
3
22
|
export function createLeaderFace(ctx) {
|
|
4
23
|
const { config, logger, brain, pricing, executionEnvFactory, toolResultStore, sessionStore, checkpointStore } = ctx;
|
|
5
24
|
// v2 leader endpoint (design/50 + design/68): wire when LEADER_ENABLED + an isolated remote-exec backend
|
|
@@ -19,6 +38,9 @@ export function createLeaderFace(ctx) {
|
|
|
19
38
|
}
|
|
20
39
|
: {};
|
|
21
40
|
const leaderProvider = config.remoteExec?.provider;
|
|
41
|
+
const minioGap = leaderK8sMinioGap(config.leaderEnabled, leaderProvider, "s3" in leaderMinio);
|
|
42
|
+
if (minioGap)
|
|
43
|
+
logger.warn("leader_k8s_minio_incomplete", { missing: minioGap });
|
|
22
44
|
// DUAL-MODE §5: orchestration is an ENGINE capability, not fleet-only — the TOC `host` lane runs leader fan-out
|
|
23
45
|
// bounded by ONE box (isolation=none, NON-durable: no snapshot, so the durable sub-worker suspend block below is
|
|
24
46
|
// skipped — host workers run to completion in-process-adjacent). e2b/k8s keep their isolated/suspendable posture.
|
package/dist/config.js
CHANGED
|
@@ -491,9 +491,11 @@ function parseStoreDomain(ctx) {
|
|
|
491
491
|
user: env("MYSQL_USER"),
|
|
492
492
|
password: env("MYSQL_PASSWORD", ""),
|
|
493
493
|
database: env("MYSQL_DATABASE"),
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
494
|
+
// 鲁棒性批5 A2(2026-08-05):此前裸 `Number(process.env.MYSQL_POOL_SIZE)` ——一个手滑值(如
|
|
495
|
+
// "20;drop") → NaN,driver 的 `connectionLimit` 直接拿到 NaN 而不是走池大小默认,行为因驱动
|
|
496
|
+
// 而异(未必 fail-loud)。optFinitePositiveEnv 与 dbQueryTimeoutMs 同款:非法值回默认(undefined
|
|
497
|
+
// = driver 默认池大小)+ S20 boot 警告,合法值照常生效。
|
|
498
|
+
connectionLimit: optFinitePositiveEnv("MYSQL_POOL_SIZE"),
|
|
497
499
|
}
|
|
498
500
|
: undefined,
|
|
499
501
|
pg: needsDb && dbBackend === "pg"
|
|
@@ -503,7 +505,8 @@ function parseStoreDomain(ctx) {
|
|
|
503
505
|
user: env("PG_USER"),
|
|
504
506
|
password: env("PG_PASSWORD", ""),
|
|
505
507
|
database: env("PG_DATABASE"),
|
|
506
|
-
|
|
508
|
+
// 同 MYSQL_POOL_SIZE 族(鲁棒性批5 A2)。
|
|
509
|
+
connectionLimit: optFinitePositiveEnv("PG_POOL_SIZE"),
|
|
507
510
|
}
|
|
508
511
|
: undefined,
|
|
509
512
|
// S9 deeper fix: per-query DB timeout (see the interface doc). Soft knob — a typo degrades to the default
|
|
@@ -1177,7 +1180,12 @@ function parseOrchestrationDomain(ctx) {
|
|
|
1177
1180
|
...(process.env.DOCKER_HOST ? { dockerHost: process.env.DOCKER_HOST } : {}),
|
|
1178
1181
|
...(process.env.DOCKER_MEMORY ? { memory: process.env.DOCKER_MEMORY } : {}),
|
|
1179
1182
|
...(process.env.DOCKER_CPUS ? { cpus: Number(process.env.DOCKER_CPUS) } : {}),
|
|
1180
|
-
|
|
1183
|
+
// 鲁棒性批5 A1(2026-08-05):此前裸 `Number(process.env.DOCKER_PIDS_LIMIT)`——非数字值(如
|
|
1184
|
+
// 手滑的 "512;") → NaN,而消费端(local-docker 执行环境)对 `pidsLimit` 的守卫是
|
|
1185
|
+
// `?? 512`(只挡 null/undefined),NaN 穿透守卫;随后 `NaN > 0` 恒假,`--pids-limit` 整个
|
|
1186
|
+
// 从 docker run 参数里省略——运维以为设了 fork-bomb 背栓,实际背栓被静默卸掉。
|
|
1187
|
+
// optFinitePositiveEnv:非法值回 undefined(消费端默认 512 生效)+ S20 boot 警告。
|
|
1188
|
+
...((n) => (n !== undefined ? { pidsLimit: n } : {}))(optFinitePositiveEnv("DOCKER_PIDS_LIMIT")),
|
|
1181
1189
|
...(boolEnv("DOCKER_DROP_CAPS", false) ? { dropAllCaps: true } : {}),
|
|
1182
1190
|
...(process.env.DOCKER_NETWORK
|
|
1183
1191
|
? { network: enumEnv("DOCKER_NETWORK", "bridge", ["none", "bridge", "host"]) }
|
|
@@ -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)
|
|
@@ -419,6 +420,10 @@ async function handleSessionSyncBody(req, res, url, ctx, miss) {
|
|
|
419
420
|
}
|
|
420
421
|
// §4 active-run guard (don't drop a live append) + per-session import lease (don't let two staged imports race
|
|
421
422
|
// to commit the same session). Both 409.
|
|
423
|
+
// `runStore?.` 的可选链不是守卫豁免面(鲁棒性批5 #7 亲验定性,2026-08-06):runStore 仅在
|
|
424
|
+
// **backend 整体缺席**时才 undefined(tidb/pg/local 三形态的 backend.run() 全都在,main.ts:378),
|
|
425
|
+
// 而 backend 缺席时本路由的 Phase A 已在 `beginImportStaging` 探测处硬 501(capability.session_store_
|
|
426
|
+
// required)——「守卫被跳过而 sync 仍可达」的组合按构造不存在(能力面与路由解析同真值)。
|
|
422
427
|
const activeTaskId = await deps.runStore?.getActiveTaskId(sessionId);
|
|
423
428
|
if (activeTaskId) {
|
|
424
429
|
sendError(res, 409, "session_active", "session has an active run; sync after it settles", { activeTaskId });
|
|
@@ -647,9 +652,29 @@ async function handleSessionSyncBody(req, res, url, ctx, miss) {
|
|
|
647
652
|
sendError(res, 409, "session_active", "session started an active run while the import streamed; sync after it settles", { activeTaskId: activeAtCommit });
|
|
648
653
|
return;
|
|
649
654
|
}
|
|
650
|
-
//
|
|
655
|
+
// #137 ② 能力预检(fail-loud,[2195] 判据:保护型能力缺席=fail-closed):consent 了 overwrite
|
|
656
|
+
// 而 policy 店无 purge seam ⇒ 在 commit **之前**干净中止(此刻零写入落地),绝不静默留混合态。
|
|
657
|
+
// 只看 resolution 不看 relation(commit 的 in-txn re-classify 还没跑):保守方向——你要求了
|
|
658
|
+
// overwrite 而这套部署擦不动,哪怕最终 re-classify 判 fresh 也先拒,拒因里给了无损重跑姿势。
|
|
659
|
+
if (pending.resolution !== undefined && overwriteWipeIncapable(deps.sessionPolicyStore)) {
|
|
660
|
+
await pending.handle.abort().catch(() => undefined);
|
|
661
|
+
cleanupStaging(stagingId);
|
|
662
|
+
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");
|
|
663
|
+
return;
|
|
664
|
+
}
|
|
665
|
+
// ── commit (the atomic swap) → wipe (overwrite-dst) → replay snapshots/policy/anchors → cache evict → release lease ──
|
|
651
666
|
try {
|
|
652
667
|
const committed = await pending.handle.commit(pending.owner, pending.resolution ? { resolution: pending.resolution } : undefined);
|
|
668
|
+
// #137 ②(死码 ⓪ 擦除步的活路由移植,判据钉=fanout-characterization L2):consented
|
|
669
|
+
// overwrite-dst = keep-source ⇒ 在重放**之前**擦掉目的端遗留的 policy/anchor/快照行,否则
|
|
670
|
+
// 悬空 anchor 指向已 purge 的 entry、被放弃分支的 policy 行继续 gating、共享 key 快照因
|
|
671
|
+
// importManifest create-once 永远留着目的端字节。条件按 commit 权威 relation 收窄:fresh 不擦
|
|
672
|
+
// (B5 钉:注册过但零 entry 的目的端,原有附随物原地留存)、fast_forward 不擦(同支延长,无
|
|
673
|
+
// 被放弃分支)。崩溃残余(commit 与 wipe 之间死进程 ⇒ 重试落 identical 支无 wipe)在
|
|
674
|
+
// wipeSessionAttendants 的 doc 注如实记录——窗口窄于修前(修前=遗留行恒存活)。
|
|
675
|
+
if (pending.resolution === "overwrite-dst" && (committed.relation === "fork" || committed.relation === "stale")) {
|
|
676
|
+
await wipeSessionAttendants({ fileSnapshotStore: deps.fileSnapshotStore, sessionPolicyStore: deps.sessionPolicyStore, resumeAnchorStore: deps.resumeAnchorStore }, sessionId);
|
|
677
|
+
}
|
|
653
678
|
// ① snapshots — content-addressed import (idempotent). importManifest never throws → a {ok:false} is a
|
|
654
679
|
// fail-closed 422 (a blob the Phase-A presence check couldn't catch — corrupted in transit). Note: the
|
|
655
680
|
// ENTRIES are already committed (the swap), so a snapshot failure here means the conversation synced but a
|
|
@@ -666,10 +691,11 @@ async function handleSessionSyncBody(req, res, url, ctx, miss) {
|
|
|
666
691
|
}
|
|
667
692
|
}
|
|
668
693
|
// ② policy — replay each record; the E6 tighten-only gate applies unless the importer is a verified operator.
|
|
694
|
+
// #137 ①:等内容跳过(replayPolicyRecords)——crash-retry 会整段重跑本重放,无条件 put 让
|
|
695
|
+
// 每次重试都推 rev(operator 乐观锁 CAS 令牌)。
|
|
669
696
|
const policyStore = deps.sessionPolicyStore;
|
|
670
697
|
if (policyStore)
|
|
671
|
-
|
|
672
|
-
await policyStore.putRules(sessionId, rec.principal, rec.rules, { operator: pending.operatorOk });
|
|
698
|
+
await replayPolicyRecords(policyStore, sessionId, pending.policy, pending.operatorOk);
|
|
673
699
|
// ③ anchors — owner RE-KEYED to the importing principal (§9).
|
|
674
700
|
const anchorStore = deps.resumeAnchorStore;
|
|
675
701
|
if (anchorStore)
|
package/dist/leader/wire.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { type Brain, type Model, type ModelRoles, type ModelPricing, type TaskSpec, type ExecutionEnvFactory, type RemoteExecutionEnv, type ToolResultStore, type SessionStore } from "@sema-agent/core";
|
|
2
2
|
import type { CheckpointStoreFull } from "../plugins/store-backend.js";
|
|
3
|
-
import { type LeaderResult } from "./leader.js";
|
|
3
|
+
import { type LeaderDeps, type LeaderResult } from "./leader.js";
|
|
4
4
|
import type { LeaderRequestBody } from "./endpoint.js";
|
|
5
5
|
export interface LeaderWireConfig {
|
|
6
6
|
/** Static-env compat mode (E2B only): the leader provisions/owns each env. Required when `envFactory` unset. */
|
|
@@ -162,6 +162,16 @@ export declare function leaderLoopConfig(cfg: {
|
|
|
162
162
|
repairRounds?: number;
|
|
163
163
|
conflictRounds?: number;
|
|
164
164
|
}, env?: NodeJS.ProcessEnv): LeaderLoopConfig;
|
|
165
|
+
export declare const LEADER_PUSH_NETWORK_TIMEOUT_MS = 120000;
|
|
166
|
+
export declare const LEADER_PUSH_LOCAL_TIMEOUT_MS = 30000;
|
|
167
|
+
/** Coordinator(control plane,持有推送凭据)的 git push 闭包——从 `createLeaderRunner` 抽出为独立工厂,
|
|
168
|
+
* 仅依赖 durableRemote/targetRef/git 身份(不依赖 wire 内其余状态),因此可以脱离完整的
|
|
169
|
+
* plan→provision→fan-out→merge 管线单测(那条管线需要真 brain + E2B/k8s env)。行为与抽出前逐字相同,
|
|
170
|
+
* 唯一新增是六条 execFileSync 各自的 `timeout`。 */
|
|
171
|
+
export declare function createCoordinatorPush(durableRemote: string, targetRef: string | undefined, ident: {
|
|
172
|
+
name: string;
|
|
173
|
+
email: string;
|
|
174
|
+
}): LeaderDeps["push"];
|
|
165
175
|
export declare function createLeaderRunner(cfg: LeaderWireConfig): (body: LeaderRequestBody) => Promise<LeaderResult>;
|
|
166
176
|
export {};
|
|
167
177
|
//# sourceMappingURL=wire.d.ts.map
|
package/dist/leader/wire.js
CHANGED
|
@@ -177,6 +177,68 @@ export function leaderLoopConfig(cfg, env = process.env) {
|
|
|
177
177
|
...(env.LEADER_BUDGET_USD ? { replanBudgetUsd: parseNumOrFailNonNegative("LEADER_BUDGET_USD", env.LEADER_BUDGET_USD) } : {}),
|
|
178
178
|
};
|
|
179
179
|
}
|
|
180
|
+
// 鲁棒性批5 A4(2026-08-05):Coordinator 的六条 execFileSync(clone/config×2/am/ls-remote/push×2)此前零超时。
|
|
181
|
+
// `execFileSync` 是**同步**调用——它挂起的是整个 Node 事件循环,不只是这一次 leader 跑;`durableRemote`
|
|
182
|
+
// 是运维配的、可能半开/不可达的远端(DNS 黑洞、TCP SYN 丢、对端 git-upload-pack 卡死),一旦挂住,这一个
|
|
183
|
+
// 副本上**所有并发请求**一起冻结,直到操作系统层 TCP 超时(可能几十分钟)才会松绑。
|
|
184
|
+
// 上界两档:clone/push/ls-remote 是网络往返(仓可能较大,给 120s);config/am 是纯本地操作(不该合法挂起,
|
|
185
|
+
// 但损坏的 object store / 文件锁可能卡住 `git am`),给 30s——同 fleet-client.ts `FLEET_CLIENT_FETCH_TIMEOUT_MS`
|
|
186
|
+
// 判据的普适形(操作分类决定上界,不是一刀切)。
|
|
187
|
+
export const LEADER_PUSH_NETWORK_TIMEOUT_MS = 120_000;
|
|
188
|
+
export const LEADER_PUSH_LOCAL_TIMEOUT_MS = 30_000;
|
|
189
|
+
/** Coordinator(control plane,持有推送凭据)的 git push 闭包——从 `createLeaderRunner` 抽出为独立工厂,
|
|
190
|
+
* 仅依赖 durableRemote/targetRef/git 身份(不依赖 wire 内其余状态),因此可以脱离完整的
|
|
191
|
+
* plan→provision→fan-out→merge 管线单测(那条管线需要真 brain + E2B/k8s env)。行为与抽出前逐字相同,
|
|
192
|
+
* 唯一新增是六条 execFileSync 各自的 `timeout`。 */
|
|
193
|
+
export function createCoordinatorPush(durableRemote, targetRef, ident) {
|
|
194
|
+
return async (integratedPatch, baseSha) => {
|
|
195
|
+
// Coordinator (control plane, creds): clone durable remote, apply integrated series, force-with-lease push.
|
|
196
|
+
// 🔴 requires `git` on the service host PATH (deploy req).
|
|
197
|
+
const dir = mkdtempSync(join(tmpdir(), "leader-coord-"));
|
|
198
|
+
// Force C locale so the `raced` regex below matches git's (English) push-rejection messages regardless of
|
|
199
|
+
// the host locale — otherwise a translated message → regex miss → a real race mislabeled non-retryable (council #5).
|
|
200
|
+
const gitEnv = { ...process.env, LC_ALL: "C" };
|
|
201
|
+
try {
|
|
202
|
+
execFileSync("git", ["clone", "-q", durableRemote, dir], { env: gitEnv, timeout: LEADER_PUSH_NETWORK_TIMEOUT_MS });
|
|
203
|
+
execFileSync("git", ["-C", dir, "config", "user.name", ident.name], { timeout: LEADER_PUSH_LOCAL_TIMEOUT_MS });
|
|
204
|
+
execFileSync("git", ["-C", dir, "config", "user.email", ident.email], { timeout: LEADER_PUSH_LOCAL_TIMEOUT_MS });
|
|
205
|
+
writeFileSync(join(dir, ".leader.patch"), integratedPatch);
|
|
206
|
+
execFileSync("git", ["-C", dir, "am", "--3way", ".leader.patch"], { env: gitEnv, timeout: LEADER_PUSH_LOCAL_TIMEOUT_MS });
|
|
207
|
+
rmSync(join(dir, ".leader.patch"));
|
|
208
|
+
const ref = targetRef ?? "refs/heads/main";
|
|
209
|
+
// CAS against the TARGET ref's ACTUAL current tip (not baseSha). The old `=ref:baseSha` lease assumed
|
|
210
|
+
// the target ref already pointed at baseSha — but a FRESH ref (or one at any other commit) is never at
|
|
211
|
+
// baseSha, so the lease rejected EVERY such push and mislabeled it "raced" (found live: auto1 reached
|
|
212
|
+
// push after a clean 6-worker fan-out + green gradle, and failed only here). ls-remote gives the ref's
|
|
213
|
+
// current oid (empty = it doesn't exist yet) → lease against THAT, which still detects a real concurrent
|
|
214
|
+
// merge race (the ref moved since we read it) but lets a normal create/update through.
|
|
215
|
+
// Does the target ref already exist? (exact full-ref match; --refs drops peeled annotated-tag lines.)
|
|
216
|
+
const ls = execFileSync("git", ["-C", dir, "ls-remote", "--refs", "origin", ref], { encoding: "utf8", env: gitEnv, timeout: LEADER_PUSH_NETWORK_TIMEOUT_MS });
|
|
217
|
+
const exists = ls.split("\n").some((l) => l.split("\t")[1] === ref);
|
|
218
|
+
if (exists) {
|
|
219
|
+
// Existing ref → keep the original CAS against baseSha (detects a concurrent merge that moved the ref
|
|
220
|
+
// off the base this run started from — council#2). NOT against a just-read end-of-run tip: that would
|
|
221
|
+
// let leader B force-push over leader A's concurrent merge (Codex review #1). A ref that exists but
|
|
222
|
+
// isn't at baseSha → refuse (use a fresh per-run targetRef to avoid that — the proving-ground does).
|
|
223
|
+
execFileSync("git", ["-C", dir, "push", `--force-with-lease=${ref}:${baseSha}`, "origin", `HEAD:${ref}`], { env: gitEnv, timeout: LEADER_PUSH_NETWORK_TIMEOUT_MS });
|
|
224
|
+
}
|
|
225
|
+
else {
|
|
226
|
+
// Absent → create with a "must not exist" lease (empty expected oid): a concurrent create races safely
|
|
227
|
+
// (rejected as stale info) instead of silently fast-forwarding. The original `:baseSha` lease wrongly
|
|
228
|
+
// rejected a fresh ref (it's never at baseSha) — that was auto1's only failure after a green build.
|
|
229
|
+
execFileSync("git", ["-C", dir, "push", `--force-with-lease=${ref}:`, "origin", `HEAD:${ref}`], { env: gitEnv, timeout: LEADER_PUSH_NETWORK_TIMEOUT_MS });
|
|
230
|
+
}
|
|
231
|
+
return { ok: true, ref };
|
|
232
|
+
}
|
|
233
|
+
catch (e) {
|
|
234
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
235
|
+
return { ok: false, raced: /stale info|force-with-lease|\[rejected\]|non-fast-forward/.test(msg), error: msg };
|
|
236
|
+
}
|
|
237
|
+
finally {
|
|
238
|
+
rmSync(dir, { recursive: true, force: true });
|
|
239
|
+
}
|
|
240
|
+
};
|
|
241
|
+
}
|
|
180
242
|
export function createLeaderRunner(cfg) {
|
|
181
243
|
const W = cfg.workspace ?? "/home/user";
|
|
182
244
|
const repo = `${W}/repo`;
|
|
@@ -563,53 +625,7 @@ export function createLeaderRunner(cfg) {
|
|
|
563
625
|
}
|
|
564
626
|
return { env: asIntegrationEnv(env), destroy: () => env.destroy().then(() => { }), ...(mkRepair(env, repo, body.oracleFiles ?? []) ? { repair: mkRepair(env, repo, body.oracleFiles ?? []) } : {}), ...(mkConflictResolver(env, repo, body.oracleFiles ?? []) ? { conflictResolver: mkConflictResolver(env, repo, body.oracleFiles ?? []) } : {}) };
|
|
565
627
|
};
|
|
566
|
-
const push =
|
|
567
|
-
// Coordinator (control plane, creds): clone durable remote, apply integrated series, force-with-lease push.
|
|
568
|
-
// 🔴 requires `git` on the service host PATH (deploy req).
|
|
569
|
-
const dir = mkdtempSync(join(tmpdir(), "leader-coord-"));
|
|
570
|
-
// Force C locale so the `raced` regex below matches git's (English) push-rejection messages regardless of
|
|
571
|
-
// the host locale — otherwise a translated message → regex miss → a real race mislabeled non-retryable (council #5).
|
|
572
|
-
const gitEnv = { ...process.env, LC_ALL: "C" };
|
|
573
|
-
try {
|
|
574
|
-
execFileSync("git", ["clone", "-q", body.durableRemote, dir], { env: gitEnv });
|
|
575
|
-
execFileSync("git", ["-C", dir, "config", "user.name", ident.name]);
|
|
576
|
-
execFileSync("git", ["-C", dir, "config", "user.email", ident.email]);
|
|
577
|
-
writeFileSync(join(dir, ".leader.patch"), integratedPatch);
|
|
578
|
-
execFileSync("git", ["-C", dir, "am", "--3way", ".leader.patch"], { env: gitEnv });
|
|
579
|
-
rmSync(join(dir, ".leader.patch"));
|
|
580
|
-
const ref = body.targetRef ?? "refs/heads/main";
|
|
581
|
-
// CAS against the TARGET ref's ACTUAL current tip (not baseSha). The old `=ref:baseSha` lease assumed
|
|
582
|
-
// the target ref already pointed at baseSha — but a FRESH ref (or one at any other commit) is never at
|
|
583
|
-
// baseSha, so the lease rejected EVERY such push and mislabeled it "raced" (found live: auto1 reached
|
|
584
|
-
// push after a clean 6-worker fan-out + green gradle, and failed only here). ls-remote gives the ref's
|
|
585
|
-
// current oid (empty = it doesn't exist yet) → lease against THAT, which still detects a real concurrent
|
|
586
|
-
// merge race (the ref moved since we read it) but lets a normal create/update through.
|
|
587
|
-
// Does the target ref already exist? (exact full-ref match; --refs drops peeled annotated-tag lines.)
|
|
588
|
-
const ls = execFileSync("git", ["-C", dir, "ls-remote", "--refs", "origin", ref], { encoding: "utf8", env: gitEnv });
|
|
589
|
-
const exists = ls.split("\n").some((l) => l.split("\t")[1] === ref);
|
|
590
|
-
if (exists) {
|
|
591
|
-
// Existing ref → keep the original CAS against baseSha (detects a concurrent merge that moved the ref
|
|
592
|
-
// off the base this run started from — council#2). NOT against a just-read end-of-run tip: that would
|
|
593
|
-
// let leader B force-push over leader A's concurrent merge (Codex review #1). A ref that exists but
|
|
594
|
-
// isn't at baseSha → refuse (use a fresh per-run targetRef to avoid that — the proving-ground does).
|
|
595
|
-
execFileSync("git", ["-C", dir, "push", `--force-with-lease=${ref}:${baseSha}`, "origin", `HEAD:${ref}`], { env: gitEnv });
|
|
596
|
-
}
|
|
597
|
-
else {
|
|
598
|
-
// Absent → create with a "must not exist" lease (empty expected oid): a concurrent create races safely
|
|
599
|
-
// (rejected as stale info) instead of silently fast-forwarding. The original `:baseSha` lease wrongly
|
|
600
|
-
// rejected a fresh ref (it's never at baseSha) — that was auto1's only failure after a green build.
|
|
601
|
-
execFileSync("git", ["-C", dir, "push", `--force-with-lease=${ref}:`, "origin", `HEAD:${ref}`], { env: gitEnv });
|
|
602
|
-
}
|
|
603
|
-
return { ok: true, ref };
|
|
604
|
-
}
|
|
605
|
-
catch (e) {
|
|
606
|
-
const msg = e instanceof Error ? e.message : String(e);
|
|
607
|
-
return { ok: false, raced: /stale info|force-with-lease|\[rejected\]|non-fast-forward/.test(msg), error: msg };
|
|
608
|
-
}
|
|
609
|
-
finally {
|
|
610
|
-
rmSync(dir, { recursive: true, force: true });
|
|
611
|
-
}
|
|
612
|
-
};
|
|
628
|
+
const push = createCoordinatorPush(body.durableRemote, body.targetRef, ident);
|
|
613
629
|
const deps = {
|
|
614
630
|
plan,
|
|
615
631
|
provisionWorker,
|
package/dist/runs.js
CHANGED
|
@@ -362,7 +362,9 @@ promptManifests) {
|
|
|
362
362
|
// run-store mutation/poll below (the run row was created with owner = principal ?? null).
|
|
363
363
|
const owner = principal ?? null;
|
|
364
364
|
const heartbeat = setInterval(() => {
|
|
365
|
-
|
|
365
|
+
// 鲁棒性批5 A5(2026-08-05):与下面 cancel/preempt 两条兄弟同族——此前裸吞错,store 持续故障期间本 run
|
|
366
|
+
// 的心跳每拍静默落空、运维零信号,直到某个副本的 reapStale 把它误判死亡(心跳失败正是那个误判的前兆)。
|
|
367
|
+
void runStore.heartbeat(taskId, owner).catch(() => { metrics?.inc("run_signal_poll_errors_total", { kind: "heartbeat" }); });
|
|
366
368
|
// Cross-replica cancel: the cancel may have landed on another instance, which only set the durable flag.
|
|
367
369
|
// Poll it here so the OWNING instance aborts its in-flight run (bounded by HEARTBEAT_MS).
|
|
368
370
|
// 鲁棒性批3 A5(2026-08-04,§M 感知链路):poll 失败本身有界(下一拍重试),但此前**零披露**——
|
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/spec-fields.js
CHANGED
|
@@ -69,9 +69,10 @@ export function normalizeAttachments(v) {
|
|
|
69
69
|
...(o.todoReminder === true ? { todoReminder: true } : {}),
|
|
70
70
|
...(changedFiles !== undefined ? { changedFiles } : {}),
|
|
71
71
|
...(o.planModeReminder === true ? { planModeReminder: true } : {}),
|
|
72
|
-
// core 1.253 G1
|
|
73
|
-
//
|
|
74
|
-
|
|
72
|
+
// core 1.253 G1 → 5.12.0 BREAKING-3:压缩后 bg 任务重述从 opt-in 翻 **DEFAULT-ON**(显式 false
|
|
73
|
+
// 恒关/缺席走默认)。literal-true 形在此翻转后就是轴B #1 的原案复刻(agentListing/skillsListing
|
|
74
|
+
// 当年同病):丢 false = 客户端显式关断被静默压成默认 ON,wire 上无法表达关闭 ⇒ 迁 boolean 透传。
|
|
75
|
+
...(typeof o.backgroundTasks === "boolean" ? { backgroundTasks: o.backgroundTasks } : {}),
|
|
75
76
|
...(o.toolsDelta === true ? { toolsDelta: true } : {}),
|
|
76
77
|
...(o.todoReminderMode === "baseline" || o.todoReminderMode === "off" ? { todoReminderMode: o.todoReminderMode } : {}),
|
|
77
78
|
...(o.budgetUsd === true ? { budgetUsd: true } : {}),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sema-agent/server",
|
|
3
|
-
"version": "6.
|
|
3
|
+
"version": "6.8.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.12.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",
|