@sema-agent/server 7.43.0 → 7.44.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.
Files changed (345) hide show
  1. package/USAGE.md +18 -0
  2. package/dist/adoption/plan.js +31 -192
  3. package/dist/adoption/quiesce.js +3 -86
  4. package/dist/adoption/runner.js +3 -140
  5. package/dist/adoption/sql.js +0 -74
  6. package/dist/adoption/wire.js +0 -80
  7. package/dist/approval-ask-machine.js +0 -75
  8. package/dist/approval-card.js +0 -323
  9. package/dist/approval-deny-reasons.js +0 -42
  10. package/dist/approval-hmac.js +0 -35
  11. package/dist/approval-reconciler.js +8 -166
  12. package/dist/approval.js +4 -67
  13. package/dist/audit.js +1 -44
  14. package/dist/auth-bridge.js +4 -47
  15. package/dist/auth-keys.js +0 -23
  16. package/dist/bake-runner/main.js +4 -65
  17. package/dist/bake-runner/protocol.js +3 -58
  18. package/dist/bake-runner/runner.js +5 -91
  19. package/dist/bench/l8/artifact.js +2 -61
  20. package/dist/bench/l8/escape.js +0 -25
  21. package/dist/bench/l8/index.js +0 -14
  22. package/dist/bench/l8/probes.js +0 -60
  23. package/dist/bench/l8/run-probes.js +2 -45
  24. package/dist/bench/s1/arms.js +14 -189
  25. package/dist/bench/s1/live-deps.js +11 -253
  26. package/dist/bench/s1/oracle.js +1 -36
  27. package/dist/bench/s1/repair-oracle-adapter.js +0 -30
  28. package/dist/bench/s1/reviewer.js +0 -34
  29. package/dist/bench/s1/row.js +0 -62
  30. package/dist/bench/s1/run-firm.js +4 -70
  31. package/dist/bench/s1/runner-ctx.js +0 -40
  32. package/dist/bench/s1/tasks.js +0 -115
  33. package/dist/boot/adoption.js +0 -20
  34. package/dist/boot/budget-tracing.js +3 -55
  35. package/dist/boot/config-center.js +43 -551
  36. package/dist/boot/coordinators.js +1 -69
  37. package/dist/boot/crash-last.js +0 -18
  38. package/dist/boot/deferred-sandbox-path-env.js +3 -119
  39. package/dist/boot/execution-env.js +5 -171
  40. package/dist/boot/governance-seams.js +0 -92
  41. package/dist/boot/leader.js +0 -69
  42. package/dist/boot/limit-sync.js +0 -2
  43. package/dist/boot/memory-boundary.js +3 -91
  44. package/dist/boot/org-memory.js +1 -25
  45. package/dist/boot/parked-revive-gate.js +0 -150
  46. package/dist/boot/permission-rules-audit.js +1 -56
  47. package/dist/boot/reapers.js +15 -273
  48. package/dist/boot/resolve-spec.js +9 -768
  49. package/dist/boot/retention-lane.js +4 -87
  50. package/dist/boot/runner-deps.js +3 -297
  51. package/dist/boot/runtime-caps.js +0 -40
  52. package/dist/boot/session-faces.js +2 -133
  53. package/dist/boot/shutdown.js +9 -83
  54. package/dist/boot/side-query-lane.js +2 -137
  55. package/dist/boot/stores.js +15 -215
  56. package/dist/boot/task-list-lane.js +0 -18
  57. package/dist/boot/webfetch-summarize-lane.js +0 -47
  58. package/dist/boot/workflow-orchestration.js +3 -99
  59. package/dist/boot-reclaim.js +0 -20
  60. package/dist/bounded-session-map.js +0 -19
  61. package/dist/brain.js +2 -139
  62. package/dist/budget.js +9 -244
  63. package/dist/capabilities/builtin-tools.js +0 -3
  64. package/dist/capabilities/center-plugins.js +6 -51
  65. package/dist/capabilities/center-prompts.js +6 -71
  66. package/dist/capabilities/code-review-council.js +7 -40
  67. package/dist/capabilities/collab-workflows.js +1 -44
  68. package/dist/capabilities/hands-lane.js +0 -65
  69. package/dist/capabilities/memory-notice.js +0 -70
  70. package/dist/capabilities/prompt.js +0 -14
  71. package/dist/capabilities/prompts/code-review.js +0 -14
  72. package/dist/capabilities/prompts/identity.js +0 -6
  73. package/dist/capabilities/prompts/team.js +0 -4
  74. package/dist/capabilities/repo-tools.js +0 -34
  75. package/dist/capabilities/sandbox-file-send.js +3 -92
  76. package/dist/capabilities/scenarios.d.ts +0 -1
  77. package/dist/capabilities/scenarios.js +18 -279
  78. package/dist/capabilities/select-environment-tool.js +0 -32
  79. package/dist/capabilities/send-user-file-tool.js +2 -66
  80. package/dist/capabilities/skills.d.ts +1 -2
  81. package/dist/capabilities/skills.js +3 -21
  82. package/dist/capabilities/team.d.ts +3 -11
  83. package/dist/capabilities/team.js +3 -51
  84. package/dist/capabilities/tool-defer.js +0 -3
  85. package/dist/config-center/apply-effective.js +15 -330
  86. package/dist/config-center/apply-ledger.js +5 -38
  87. package/dist/config-center/facade.js +0 -41
  88. package/dist/config-center/hot-keys-registry.js +0 -20
  89. package/dist/config-center/http-client.js +2 -125
  90. package/dist/config-center/mcp-revocation.js +2 -34
  91. package/dist/config-center/read-face.js +0 -59
  92. package/dist/config-center/restart-signal.js +1 -85
  93. package/dist/config-center/skills-mcp.d.ts +1 -1
  94. package/dist/config-center/skills-mcp.js +9 -76
  95. package/dist/config-center/stage-limits.js +8 -35
  96. package/dist/config-invariants.js +0 -16
  97. package/dist/config-lkg.js +0 -42
  98. package/dist/config-provider.js +3 -186
  99. package/dist/config-types.js +0 -5
  100. package/dist/config.js +107 -1145
  101. package/dist/degenerate-instrument.js +3 -67
  102. package/dist/deployment-governance.js +0 -124
  103. package/dist/digest-form.js +0 -12
  104. package/dist/elicitation.js +3 -86
  105. package/dist/env-facts.js +7 -75
  106. package/dist/fleet/fleet-bus.js +34 -507
  107. package/dist/fleet/fleet-reconciler.js +9 -149
  108. package/dist/fleet/fleet-terminal-window.js +10 -178
  109. package/dist/fleet/subagent-tail-bus.js +3 -72
  110. package/dist/fleet-client.js +10 -70
  111. package/dist/fleet-lease.js +5 -79
  112. package/dist/git-api-kind.js +0 -3
  113. package/dist/governance-ask-marks.js +2 -78
  114. package/dist/hooks/branch-transcript.js +0 -74
  115. package/dist/hooks/cc-agent-hook-prompt.js +0 -29
  116. package/dist/hooks/cc-stop-prompt.js +1 -46
  117. package/dist/hooks/hook-llm.js +1 -53
  118. package/dist/hooks/hook-runner.js +20 -414
  119. package/dist/http/active-run-conflict.js +4 -129
  120. package/dist/http/cursor-fingerprint.d.ts +5 -0
  121. package/dist/http/cursor-fingerprint.js +5 -0
  122. package/dist/http/idempotency.js +0 -37
  123. package/dist/http/principal-gate.js +3 -40
  124. package/dist/http/route-ctx.js +0 -9
  125. package/dist/http/routes/a2a-serve.js +7 -319
  126. package/dist/http/routes/admin-config-refresh.js +0 -4
  127. package/dist/http/routes/admin-drain.js +0 -10
  128. package/dist/http/routes/adoption.js +1 -29
  129. package/dist/http/routes/agents-roster.js +1 -47
  130. package/dist/http/routes/approvals-assistant.js +32 -395
  131. package/dist/http/routes/attachments.js +4 -22
  132. package/dist/http/routes/capabilities.js +3 -420
  133. package/dist/http/routes/diagnostics.js +1 -81
  134. package/dist/http/routes/fleet.js +9 -185
  135. package/dist/http/routes/images.js +14 -239
  136. package/dist/http/routes/leader.js +0 -13
  137. package/dist/http/routes/memory-bundle.js +1 -61
  138. package/dist/http/routes/memory-policy.js +9 -99
  139. package/dist/http/routes/notify-wake.js +3 -37
  140. package/dist/http/routes/observability.js +2 -19
  141. package/dist/http/routes/retention-ops.js +2 -34
  142. package/dist/http/routes/rules.js +1 -93
  143. package/dist/http/routes/runs.js +64 -873
  144. package/dist/http/routes/session-sync.js +19 -258
  145. package/dist/http/routes/sessions-list.js +17 -43
  146. package/dist/http/routes/sessions.js +34 -226
  147. package/dist/http/routes/shared-memory.js +5 -36
  148. package/dist/http/routes/side-query.js +1 -87
  149. package/dist/http/routes/tasks.js +52 -722
  150. package/dist/http/routes/trace-usage.js +32 -195
  151. package/dist/http/routes/workflows.js +19 -195
  152. package/dist/http/run-meta.js +0 -6
  153. package/dist/http/send.js +0 -32
  154. package/dist/http/server.js +92 -1597
  155. package/dist/http/sse-lifecycle.js +2 -13
  156. package/dist/http/sse-log.js +3 -48
  157. package/dist/http/tar.js +5 -21
  158. package/dist/http/verify-rounds.js +0 -5
  159. package/dist/http/wire-gate.js +0 -9
  160. package/dist/http/workspace-content.js +0 -10
  161. package/dist/images/bake-validate.js +1 -70
  162. package/dist/images/manifest.js +1 -6
  163. package/dist/index.js +0 -21
  164. package/dist/key-resolver.js +2 -17
  165. package/dist/leader/diffout.js +1 -20
  166. package/dist/leader/diffup.js +0 -47
  167. package/dist/leader/endpoint.js +2 -57
  168. package/dist/leader/fanout.js +3 -45
  169. package/dist/leader/grader-env-factory.js +3 -72
  170. package/dist/leader/leader.js +5 -156
  171. package/dist/leader/merge.js +8 -115
  172. package/dist/leader/planner.js +3 -54
  173. package/dist/leader/repair-oracle.js +1 -60
  174. package/dist/leader/repair-wire.js +2 -79
  175. package/dist/leader/wire.js +8 -307
  176. package/dist/lsp/e2b-bridge.js +4 -64
  177. package/dist/lsp/e2b-manager.js +6 -94
  178. package/dist/lsp/lsp-frames.js +0 -12
  179. package/dist/lsp/manager.js +4 -96
  180. package/dist/lsp/ws-transport.js +5 -55
  181. package/dist/lsp-evict.js +1 -15
  182. package/dist/main.js +57 -765
  183. package/dist/memory-bundle-engine.js +0 -55
  184. package/dist/memory-export.js +0 -4
  185. package/dist/memory-posture.js +1 -15
  186. package/dist/memory-scope.js +11 -148
  187. package/dist/memory-sync-client.js +2 -44
  188. package/dist/memory-sync.js +1 -80
  189. package/dist/model-select.js +3 -80
  190. package/dist/observability/cost-quota.js +1 -17
  191. package/dist/observability/cost-taxonomy.js +0 -34
  192. package/dist/observability/fail-open.js +7 -86
  193. package/dist/observability/logger.js +0 -6
  194. package/dist/observability/metrics.js +0 -94
  195. package/dist/observability/otel-exporter.js +3 -13
  196. package/dist/observability/principal-context.js +0 -9
  197. package/dist/observability/prompt-manifest.js +1 -37
  198. package/dist/observability/rate-limit.js +0 -4
  199. package/dist/observability/secret-env-scrub.js +2 -56
  200. package/dist/observability/tool-trace.js +1 -70
  201. package/dist/orchestration/hardened-vm-runner.js +4 -118
  202. package/dist/orchestration/hardened-vm-worker-runner.js +1 -26
  203. package/dist/orchestration/hardened-vm-worker.js +0 -27
  204. package/dist/orchestration/subagent-steer.js +1 -45
  205. package/dist/orchestration/workflow-agent-steer.js +1 -80
  206. package/dist/orchestration/workflow-completion-inbox.js +32 -285
  207. package/dist/orchestration/workflow-notify-journal.js +16 -259
  208. package/dist/org-memory-admission.js +3 -47
  209. package/dist/parent-watch.js +2 -48
  210. package/dist/parked-decide.js +1 -109
  211. package/dist/per-task-image.js +0 -57
  212. package/dist/plan-cache-probe.js +3 -27
  213. package/dist/plugins/adoption-log-sql.js +2 -119
  214. package/dist/plugins/approval-ask-store-memory.js +3 -38
  215. package/dist/plugins/approval-ask-store-sql.js +6 -188
  216. package/dist/plugins/approval-exemption-store.js +2 -28
  217. package/dist/plugins/background-agent-store-sql.js +4 -105
  218. package/dist/plugins/background-shell-support.js +14 -122
  219. package/dist/plugins/blob-backend.js +6 -169
  220. package/dist/plugins/breaker-state-sql.js +8 -46
  221. package/dist/plugins/caching-session-store.js +4 -106
  222. package/dist/plugins/checkpoint-store-sql.js +18 -505
  223. package/dist/plugins/e2b-orphan-reclaim.js +0 -45
  224. package/dist/plugins/file-outcome-sink.js +0 -9
  225. package/dist/plugins/file-resume-anchor-store.js +4 -43
  226. package/dist/plugins/file-run-store.js +26 -364
  227. package/dist/plugins/file-snapshot-store-sql.js +10 -181
  228. package/dist/plugins/fork-routing-session-store.js +8 -111
  229. package/dist/plugins/host-platform.js +2 -91
  230. package/dist/plugins/image-bake-store-sql.js +7 -250
  231. package/dist/plugins/image-index-sql.js +4 -123
  232. package/dist/plugins/k8s-bg-scripts.js +4 -88
  233. package/dist/plugins/k8s-exec-protocol.js +0 -41
  234. package/dist/plugins/leader-run-store-sql.js +0 -101
  235. package/dist/plugins/local-checkpoint-store.js +8 -128
  236. package/dist/plugins/local-session-store.js +34 -296
  237. package/dist/plugins/local-task-attachment-store.js +2 -16
  238. package/dist/plugins/mailbox-store-sql.js +8 -65
  239. package/dist/plugins/memory-embedder-fingerprint.js +5 -166
  240. package/dist/plugins/memory-embedder.js +4 -70
  241. package/dist/plugins/memory-engine-pg.js +6 -170
  242. package/dist/plugins/memory-engine-tidb.js +7 -155
  243. package/dist/plugins/memory-engine-vector-util.js +0 -10
  244. package/dist/plugins/memory-key-guards.js +0 -34
  245. package/dist/plugins/memory-origin-law.js +0 -187
  246. package/dist/plugins/memory-resume-anchor-store.js +0 -17
  247. package/dist/plugins/memory-run-store.js +12 -89
  248. package/dist/plugins/memory-session-policy-store.js +0 -17
  249. package/dist/plugins/memory-sync-store-pg.js +4 -49
  250. package/dist/plugins/memory-sync-store-tidb.js +3 -35
  251. package/dist/plugins/outcome-ledger-sql.js +3 -97
  252. package/dist/plugins/permission-rule-store-file.js +5 -133
  253. package/dist/plugins/permission-rule-store-sql.d.ts +3 -0
  254. package/dist/plugins/permission-rule-store-sql.js +50 -307
  255. package/dist/plugins/pg-cost-quota.js +0 -7
  256. package/dist/plugins/pg-pool.js +0 -92
  257. package/dist/plugins/pg-rate-limiter.js +2 -13
  258. package/dist/plugins/pg-safe-json.js +4 -40
  259. package/dist/plugins/pg-session-storage.js +25 -189
  260. package/dist/plugins/posix-shell-fs.js +1 -31
  261. package/dist/plugins/remote-env-adb.js +12 -101
  262. package/dist/plugins/remote-env-e2b.js +40 -371
  263. package/dist/plugins/remote-env-file-error.js +0 -37
  264. package/dist/plugins/remote-env-host.js +63 -483
  265. package/dist/plugins/remote-env-k8s.js +32 -326
  266. package/dist/plugins/remote-env-local-docker.js +24 -157
  267. package/dist/plugins/remote-env-ssh.js +20 -128
  268. package/dist/plugins/remote-scratchpad.js +2 -32
  269. package/dist/plugins/remote-shell.js +1 -32
  270. package/dist/plugins/resume-anchor-store-sql.js +0 -11
  271. package/dist/plugins/retention-lane-store-sql.js +0 -108
  272. package/dist/plugins/retention-store-sql.js +4 -383
  273. package/dist/plugins/roster-store-sql.js +0 -55
  274. package/dist/plugins/run-store-sql.js +14 -251
  275. package/dist/plugins/s3-presign.js +2 -49
  276. package/dist/plugins/scheduler-support.js +3 -80
  277. package/dist/plugins/send-file-ledger.js +4 -53
  278. package/dist/plugins/send-user-file.js +4 -94
  279. package/dist/plugins/session-placement.js +1 -89
  280. package/dist/plugins/session-policy-store-sql.js +3 -81
  281. package/dist/plugins/session-store.js +0 -57
  282. package/dist/plugins/shared-memory-store-sql.js +4 -167
  283. package/dist/plugins/sql-driver.js +0 -17
  284. package/dist/plugins/sql-errors.js +0 -7
  285. package/dist/plugins/sql-escape.js +0 -8
  286. package/dist/plugins/sql-row-helpers.js +0 -25
  287. package/dist/plugins/store-backend.js +42 -222
  288. package/dist/plugins/store-contracts.js +2 -46
  289. package/dist/plugins/task-attachment-store.js +3 -49
  290. package/dist/plugins/task-list-store-sql.js +0 -79
  291. package/dist/plugins/tidb-cost-quota.js +1 -4
  292. package/dist/plugins/tidb-pool.js +1 -207
  293. package/dist/plugins/tidb-rate-limiter.js +3 -9
  294. package/dist/plugins/tidb-session-storage.js +4 -70
  295. package/dist/plugins/tidb-session-store.js +19 -341
  296. package/dist/plugins/tool-result-store-sql.js +4 -179
  297. package/dist/plugins/usage-window-store-sql.js +0 -8
  298. package/dist/plugins/web-search.js +10 -132
  299. package/dist/plugins/workflow-journal-store-sql.js +2 -58
  300. package/dist/plugins/workflow-run-store-sql.js +6 -91
  301. package/dist/plugins/worktree-isolation.js +6 -126
  302. package/dist/plugins/write-behind-counter.js +16 -75
  303. package/dist/principal-jwt.js +5 -60
  304. package/dist/project-memory.js +15 -146
  305. package/dist/prompts-domain-validate.js +1 -51
  306. package/dist/question.js +2 -111
  307. package/dist/resource-suspend.js +0 -18
  308. package/dist/router/route-orchestration.js +0 -77
  309. package/dist/rules-consent.d.ts +44 -5
  310. package/dist/rules-consent.js +75 -215
  311. package/dist/run-local.js +14 -381
  312. package/dist/runs.js +29 -617
  313. package/dist/runtime-caps-resolver.js +6 -132
  314. package/dist/runtime-governance.js +1 -232
  315. package/dist/sandbox-pkg-source.js +0 -37
  316. package/dist/sealed-key.js +3 -68
  317. package/dist/security.js +6 -291
  318. package/dist/session-leaf-bus.js +0 -32
  319. package/dist/session-sync-content.js +1 -69
  320. package/dist/session-sync-kernel.js +3 -60
  321. package/dist/session-sync.js +3 -66
  322. package/dist/session-titler.js +7 -41
  323. package/dist/session-watch.js +9 -73
  324. package/dist/shared-memory-scope-authorizer.js +0 -18
  325. package/dist/sighup-idle.js +1 -12
  326. package/dist/spec-fields.js +4 -143
  327. package/dist/store-live-probe.js +3 -49
  328. package/dist/task-a2a.js +1 -126
  329. package/dist/task-cwd.js +1 -103
  330. package/dist/task-mcp.js +1 -89
  331. package/dist/task-settings.js +10 -300
  332. package/dist/task-workflow.js +8 -75
  333. package/dist/tool-approval.d.ts +28 -1
  334. package/dist/tool-approval.js +70 -1430
  335. package/dist/trace/artifacts.js +5 -20
  336. package/dist/trace/engine-notice-wire.js +6 -138
  337. package/dist/trace/ledger-sink.js +7 -96
  338. package/dist/trace/project.js +4 -448
  339. package/dist/trace/redact.js +9 -77
  340. package/dist/turn-activity.js +1 -27
  341. package/dist/usage-analytics.js +4 -34
  342. package/dist/wall-clock-jump-guard.js +1 -68
  343. package/package.json +3 -3
  344. package/dist/capabilities/scenario-alias.d.ts +0 -27
  345. package/dist/capabilities/scenario-alias.js +0 -61
@@ -7,31 +7,18 @@ import { classifySyncRelationshipByIds, SyncConflictError } from "../../session-
7
7
  import { isUuidShape } from "../../security.js";
8
8
  import { sendJson, sendError, msg } from "../send.js";
9
9
  import { gatedPrincipal, explicitOperatorOk } from "../principal-gate.js";
10
- /** 2c session-sync PUT /sync/blobs/:hash per-blob upload cap. A snapshot blob is ONE file's bytes, bounded by core's
11
- * DEFAULT_SNAPSHOT_BOUNDS (the whole tree ≤ 64 MiB), so a single blob can never exceed the per-snapshot total — this
12
- * cap (= 64 MiB, ≥ that bound) is the reject-not-OOM ceiling: the blob body is read with a running size guard that
13
- * 413s the instant it is exceeded (NEVER buffered unboundedly), the §5 "reject, not OOM" requirement. Larger than
14
- * MAX_BODY (8 MiB, the JSON cap) on purpose — a blob is binary file content, not a JSON request. */
15
10
  const SYNC_BLOB_MAX_BYTES = 64 * 1024 * 1024;
16
- /** 2c session-sync P1d-β: the per-LINE cap on the Phase-B NDJSON entry stream — ONE entry must fit in this (the §1.5
17
- * ruling: a session_event row is ≤~20KB because core offloads >20K tool results, so a few MiB is a generous ceiling).
18
- * A line exceeding it → 413 (reject, not OOM): a single oversized entry would otherwise force an unbounded line buffer. */
19
11
  const SYNC_NDJSON_LINE_MAX_BYTES = 8 * 1024 * 1024;
20
- /** 2c session-sync P1d-β: the Phase-B entry batch size — entries are fed to `appendBatch` in chunks of this many, so
21
- * in-flight memory is O(batch), not O(session). 500 mirrors the PULL stream's keyset page size (one bounded txn). */
22
12
  const SYNC_IMPORT_BATCH = 500;
23
- // SHA256_HEX_RE = 64 lowercase-hex chars, a sha256 content-address (the blob hash + the GET :hash param shape).
24
13
  import { SHA256_HEX_RE } from "../../digest-form.js";
25
14
  export function createSessionSyncLocal() {
26
15
  const pendingStagings = new Map();
27
16
  const importLeases = new Map();
28
- /** 租约续期 —— entries 上传腿每收一批就调,所以大会话的长上传不会被误判成陈旧。 */
29
17
  const touchImportLease = (sessionId) => {
30
18
  const cur = importLeases.get(sessionId);
31
19
  if (cur)
32
20
  importLeases.set(sessionId, { ...cur, at: Date.now() });
33
21
  };
34
- /** Drop a finished/aborted staging from the in-process maps + release its per-session import lease. Idempotent. */
35
22
  const cleanupStaging = (stagingId) => {
36
23
  const p = pendingStagings.get(stagingId);
37
24
  if (p)
@@ -45,27 +32,13 @@ export async function handleSessionSync(req, res, url, ctx) {
45
32
  await handleSessionSyncBody(req, res, url, ctx, miss);
46
33
  return !miss.fell;
47
34
  }
48
- /** 路由体 = 从 `server.ts` 的 `handle()` 里**整段剪切**过来的原文(唯一改动:统一去缩进两格)。 */
49
35
  async function handleSessionSyncBody(req, res, url, ctx, miss) {
50
36
  const { deps } = ctx;
51
37
  const { readJson, readRawBody, safeDecode, sessionOwnerScopeForWrite, rateLimited } = ctx.helpers;
52
38
  const { pendingStagings, importLeases, touchImportLease, cleanupStaging } = ctx.local.sessionSync;
53
- // ── 2c session-sync (P1d) — the cloud as a SYNC PEER (sema-internal server/docs/DESIGN-session-sync.md §15 + §5–§10) ──────────────
54
- // The real 2c topology is TWO PROCESSES (the local shell's local backend + this cloud service); these routes let
55
- // the local peer PULL a session's whole state out of the cloud or PUSH its own in. Both halves are STREAMING
56
- // implementations that fan out across ALL four session subsystems (entries + fileSnapshot + sessionPolicy +
57
- // resumeAnchor) IN-ROUTE (PULL = exportSessionManifest + keyset-paged NDJSON; PUSH = two-phase staging re-running
58
- // the §8 fanout below) — they need the WHOLE backend (deps.backend), not the individual stores. The whole-bundle
59
- // exportSession/importSession orchestrators are NOT called here (#51 死码判定,见 src/session-sync.ts 顶注). Every route is
60
- // owner-gated like fork/delete (§9): a PULL 404s a non-owner (no exfiltration oracle); a PUSH re-stamps the
61
- // imported owner to the AUTHENTICATED principal (the bundle's owner is ignored). 501 when no durable session
62
- // store is wired (parity with fork: capability says yes ⟺ the route resolves).
63
39
  {
64
40
  const sm = /^\/v1\/sessions\/([^/]+)\/sync\/(manifest|entries|import|plan|blobs(?:\/([^/]+))?|snapshots\/([^/]+)\/blobs\/([^/]+)|import\/([^/]+)\/entries)$/.exec(url);
65
41
  if (sm) {
66
- // Durable-store gate (501) FIRST is wrong for an auth oracle, so AUTH-FIRST below mirrors fork/delete; but the
67
- // backend is needed even to know the owner, so we gate on it up front and rely on AUTH being checked before
68
- // any store touch. ownerOf is the existence/authz primitive every branch uses.
69
42
  const ownerOf = deps.sessionStorage?.ownerOf?.bind(deps.sessionStorage);
70
43
  if (!deps.backend || !ownerOf) {
71
44
  sendError(res, 501, "capability.session_store_required", "session sync requires a durable session store");
@@ -75,44 +48,32 @@ async function handleSessionSyncBody(req, res, url, ctx, miss) {
75
48
  if (!scope.fleetWide && scope.gateOwner === null) {
76
49
  sendError(res, 401, "auth.unauthorized", "unauthorized");
77
50
  return;
78
- } // auth-first
51
+ }
79
52
  const sessionId = safeDecode(sm[1]);
80
- // uuid ANY version — a v4-keyed legacy shell session must be pushable (it was
81
- // creatable via the lenient submit face); crafted-id classes (LIKE metachars, over-long) still shut out.
82
53
  if (sessionId === null || !isUuidShape(sessionId)) {
83
54
  sendError(res, 400, "request.id_invalid", "invalid session id (must be a canonical uuid)");
84
55
  return;
85
56
  }
86
- // Two owner gates, BOTH 404 a foreign session with no existence oracle:
87
- // • PULL (read the cloud's session): the session MUST exist (undefined⇒404) AND the caller must own it.
88
- // • PUSH/PLAN (write into / plan against the cloud): own-or-FRESH — `undefined` (no such session yet, the
89
- // caller is about to import it) OR the caller's own ⇒ allowed; a DIFFERENT owner ⇒ 404.
90
57
  const owner = await ownerOf(sessionId);
91
58
  const ownsOrFleet = (o) => scope.fleetWide || o === scope.gateOwner;
92
59
  const pullGateOk = () => {
93
60
  if (owner === undefined) {
94
61
  sendError(res, 404, "not_found.session", "session not found");
95
62
  return false;
96
- } // must exist to pull
63
+ }
97
64
  if (!ownsOrFleet(owner)) {
98
65
  sendError(res, 404, "not_found.session", "session not found");
99
66
  return false;
100
- } // no oracle
67
+ }
101
68
  return true;
102
69
  };
103
70
  const pushGateOk = () => {
104
71
  if (owner !== undefined && !ownsOrFleet(owner)) {
105
72
  sendError(res, 404, "not_found.session", "session not found");
106
73
  return false;
107
- } // foreign → no oracle
108
- return true; // undefined (fresh, caller will import) OR own/fleet
74
+ }
75
+ return true;
109
76
  };
110
- // GET …/sync/manifest — PULL: export the MANIFEST (entry IDS + per-snapshot relPath→blobHash + policy +
111
- // anchors + leafId), NO entry payloads and NO blob bytes (P1d-α §1.5/§5/§15: bounded memory — the entries
112
- // stream separately via …/sync/entries). The local peer diffs entryIds → classifySyncRelationshipByIds, then
113
- // pulls the entries (NDJSON) + only the blobHashes it lacks. exportSessionManifest returns null ONLY for "no
114
- // such session" (the owner gate above already proved existence+ownership, so a null here is a raced delete →
115
- // 404).
116
77
  if (sm[2] === "manifest" && req.method === "GET") {
117
78
  if (!pullGateOk())
118
79
  return;
@@ -120,22 +81,13 @@ async function handleSessionSyncBody(req, res, url, ctx, miss) {
120
81
  if (out === null) {
121
82
  sendError(res, 404, "not_found.session", "session not found");
122
83
  return;
123
- } // raced delete after the gate
84
+ }
124
85
  sendJson(res, 200, { manifest: out.manifest });
125
86
  return;
126
87
  }
127
- // GET …/sync/entries[?afterSeq=N] — PULL the entry PAYLOADS as an NDJSON stream (P1d-α §1.5/§5/§15). The store
128
- // seam keyset-PAGES the durable log (one pooled query per batch → bounded server memory; a slow client can't
129
- // pin a connection), and we apply HTTP backpressure (await drain on a full socket buffer) so a slow consumer
130
- // can't OOM the server either. The frame: a {"__sync":"begin",…} header line, then one JSON entry per line,
131
- // then a {"__sync":"end","count":N} TRAILER line — a MISSING trailer signals a truncated stream to the client.
132
88
  if (sm[2] === "entries" && req.method === "GET") {
133
89
  if (!pullGateOk())
134
90
  return;
135
- // afterSeq (optional resume cursor): a non-negative integer; reject anything else as a 400 (never a NaN
136
- // cursor). It is the BACKEND-NATIVE durable `seq` cursor (yield entries whose seq > afterSeq) — a resume is
137
- // always against the SAME backend, so the seq base (durable `seq` column vs. local 0-based index) needn't be
138
- // portable across backends.
139
91
  const afterSeqRaw = new URL(req.url ?? "", "http://x").searchParams.get("afterSeq");
140
92
  let afterSeq;
141
93
  if (afterSeqRaw !== null) {
@@ -150,16 +102,13 @@ async function handleSessionSyncBody(req, res, url, ctx, miss) {
150
102
  if (!stream) {
151
103
  sendError(res, 501, "capability.session_store_required", "session entry streaming requires a durable session store");
152
104
  return;
153
- } // method absent
105
+ }
154
106
  const it = await stream(sessionId, afterSeq === undefined ? undefined : { afterSeq });
155
107
  if (it === null) {
156
108
  sendError(res, 404, "not_found.session", "session not found");
157
109
  return;
158
- } // raced delete after the gate
159
- // Headers committed — past this point we cannot change the status; a mid-stream error just ends the body
160
- // WITHOUT the trailer, which the client reads as truncation.
110
+ }
161
111
  res.writeHead(200, { "content-type": "application/x-ndjson", "transfer-encoding": "chunked" });
162
- // backpressure-aware write: if the socket buffer is full (write returns false), await 'drain' before the next.
163
112
  const write = async (line) => { if (!res.write(line))
164
113
  await once(res, "drain"); };
165
114
  let count = 0;
@@ -169,21 +118,15 @@ async function handleSessionSyncBody(req, res, url, ctx, miss) {
169
118
  await write(`${JSON.stringify(entry)}\n`);
170
119
  count++;
171
120
  }
172
- await write(`${JSON.stringify({ __sync: "end", count })}\n`); // trailer — its ABSENCE = truncated stream
121
+ await write(`${JSON.stringify({ __sync: "end", count })}\n`);
173
122
  res.end();
174
123
  }
175
124
  catch (e) {
176
- // The iterable (or a write) threw mid-stream. Headers are already sent, so we can't 4xx/5xx — just END the
177
- // body. The MISSING trailer tells the client the stream was truncated. Log it for the operator.
178
125
  deps.logger?.error?.("sync entries stream failed", { sessionId, count, error: msg(e) });
179
126
  res.end();
180
127
  }
181
128
  return;
182
129
  }
183
- // GET …/sync/snapshots/:key/blobs/:hash — PULL one content-addressed blob's RAW bytes. The SCOPE CHECK is the
184
- // security boundary: a content-addressed blob is shared across sessions, so we must NOT let an owner fetch an
185
- // arbitrary hash (another tenant's file content). We require the requested :hash to be a value in THIS
186
- // session's snapshot `:key` manifest — a session owner may only fetch blobs ITS OWN snapshots reference.
187
130
  if (sm[4] !== undefined && req.method === "GET") {
188
131
  if (!pullGateOk())
189
132
  return;
@@ -199,8 +142,6 @@ async function handleSessionSyncBody(req, res, url, ctx, miss) {
199
142
  }
200
143
  const fs = deps.fileSnapshotStore;
201
144
  const manifest = await fs?.exportManifest?.(sessionId, key);
202
- // null manifest (no such snapshot) OR the hash is not referenced by it ⇒ 404 (the scope boundary — never a
203
- // blind content-addressed read of an unscoped hash).
204
145
  if (!manifest || ![...manifest.values()].includes(hash)) {
205
146
  sendError(res, 404, "not_found.blob", "blob not found");
206
147
  return;
@@ -214,16 +155,7 @@ async function handleSessionSyncBody(req, res, url, ctx, miss) {
214
155
  res.end(Buffer.from(bytes));
215
156
  return;
216
157
  }
217
- // PUT …/sync/blobs/:hash — PUSH one content-addressed blob INTO the cloud's snapshot store, ahead of the bundle
218
- // import (§5 streamed negotiation: the local peer GETs the manifest, then PUTs only the blobs the cloud lacks).
219
- // own-or-FRESH gate. Verify sha256(body)===:hash (the content-address is the integrity guarantee; a transfer
220
- // crosses the net) BEFORE storing. The body is read with a PER-BLOB cap (reject-not-OOM).
221
158
  if (sm[3] !== undefined && req.method === "PUT") {
222
- // 缝合审 S7:限流对**字节写入**要对称。同族的 `POST /v1/attachments`(也是「客户端把任意字节
223
- // 写进持久存储」)走 rateLimited;这条腿此前只有 own-or-fresh 门 —— 单个 blob 有 cap,但**次数
224
- // 不封**,持合法 principal 的 caller 可全速灌存储/带宽。attachments 那段注释自己写着
225
- // 「上限=server 权威…sync-blob 同姿」:尺寸口径同姿了,限流口径没跟上。
226
- // 只加 rateLimited,**不加** quotaExceeded —— blob 上传不跑模型,与 runs.ts 的 detach/stop 同姿。
227
159
  if (rateLimited(req, res))
228
160
  return;
229
161
  if (!pushGateOk())
@@ -238,12 +170,8 @@ async function handleSessionSyncBody(req, res, url, ctx, miss) {
238
170
  sendError(res, 501, "capability.snapshot_store_required", "blob upload requires a durable snapshot store");
239
171
  return;
240
172
  }
241
- const body = await readRawBody(req, SYNC_BLOB_MAX_BYTES); // 413s mid-stream if it exceeds the cap (no unbounded buffer)
242
- // (a): SQL-default blob store cap — 413 typed, NOT the 502 the store's never-throw result would become
243
- // (502 = "retry"; an over-cap blob can never land, so retrying it forever is the wrong contract).
173
+ const body = await readRawBody(req, SYNC_BLOB_MAX_BYTES);
244
174
  if (deps.snapshotBlobSqlCapBytes !== undefined && body.byteLength > deps.snapshotBlobSqlCapBytes) {
245
- // 复审 D-F1/B-F2:归一漏网(多行体,旧门正则扫不到)——A6 迁移到 sendError 后此站点由
246
- // errorCode 必填位置参数保结构(不再靠源级反射门兜底)。
247
175
  sendError(res, 413, "blob_too_large_for_sql", `blob of ${body.byteLength} bytes exceeds this deployment's SQL snapshot-blob cap (${deps.snapshotBlobSqlCapBytes} bytes — TiDB's txn-entry-size-limit is the low wall at 6MiB by default; the mysql-protocol packet limit sits above it) — configure MinIO object storage (MINIO_ENDPOINT/MINIO_ACCESS_KEY/MINIO_SECRET_KEY) for large snapshot blobs, or raise SNAPSHOT_BLOB_SQL_MAX_BYTES if your deployment lifted those limits`);
248
176
  return;
249
177
  }
@@ -251,29 +179,17 @@ async function handleSessionSyncBody(req, res, url, ctx, miss) {
251
179
  sendError(res, 400, "request.hash_mismatch", "blob hash mismatch");
252
180
  return;
253
181
  }
254
- // core 1.142.0: putBlob is a fail-closed never-throw seam — a backend write error (e.g. a MinIO non-2xx PUT)
255
- // is {ok:false}, NOT a thrown 500. Surface it as 502 so the local peer retries rather than treating the bundle
256
- // blob as durably stored when it isn't.
257
182
  const put = await fs.putBlob(hash, new Uint8Array(body));
258
183
  if (!put.ok) {
259
184
  sendError(res, 502, put.error.code, "blob store write failed");
260
185
  return;
261
186
  }
262
- res.writeHead(204).end(); // no body
187
+ res.writeHead(204).end();
263
188
  return;
264
189
  }
265
- // POST …/sync/import — PHASE A (PUSH, P1d-β §15.2): a SMALL JSON body — `{entryIds, snapshots, policy, anchors,
266
- // resolution?}` — NO entries (they stream in Phase B). Classify (§7), pre-check blob presence (§10), guard the
267
- // §4 import-vs-import lease + the active-run window, then mint a stagingId, open a StagingHandle, and park the
268
- // Phase-A metadata. Returns `200 {stagingId, relation}`. The entries-inline whole-bundle import is GONE (the
269
- // §1.5 OOM offender) — Phase B streams the log into the shadow id so server memory is bounded by batch.
270
190
  if (sm[2] === "import" && req.method === "POST") {
271
191
  if (!pushGateOk())
272
192
  return;
273
- // sessionMirror 观测面(裁定 (c),论证在 ServiceDeps.sessionMirrorRuling 注释):Phase A
274
- // 到达 = 一次镜像 push 落到本 server 的时点信号。该 principal 若处 org 治理态(required=true),记
275
- // audit 日志 + metric,把「治理生效」与「到达流」在运营面对上——这是 server 侧唯一诚实可观测的面
276
- // (没到达的会话 server 看不见,真执法在壳)。fire-and-forget + 双层吞错:观测绝不拖慢/拖垮 sync。
277
193
  if (deps.sessionMirrorRuling && !scope.fleetWide && scope.gateOwner !== null) {
278
194
  const auditPrincipal = scope.gateOwner;
279
195
  void deps.sessionMirrorRuling(auditPrincipal).then((mirror) => {
@@ -295,8 +211,6 @@ async function handleSessionSyncBody(req, res, url, ctx, miss) {
295
211
  sendError(res, 400, "request.invalid_json", "invalid JSON body");
296
212
  return;
297
213
  }
298
- // Shape validation (§10): entryIds must be a string[]; snapshots an array of {key:string, manifest:array};
299
- // policy/anchors arrays. A malformed shape → 400 (never a 500 from a downstream TypeError).
300
214
  if (!Array.isArray(body.entryIds) || !body.entryIds.every((x) => typeof x === "string")) {
301
215
  sendError(res, 400, "request.field_invalid", "entryIds must be an array of strings");
302
216
  return;
@@ -311,56 +225,19 @@ async function handleSessionSyncBody(req, res, url, ctx, miss) {
311
225
  return;
312
226
  }
313
227
  const resolution = body.resolution;
314
- // 🔴 方案 B(黑板 [1701]→[1702]→[1707] 三端对齐):**可选** `logDigest` = 源端整条日志的内容摘要
315
- // (core 1.414 `sessionLogDigest`)。老客户端不送 ⇒ 行为与此前逐字相同。坏形**不拒**,当作没送
316
- // (core 口径:摘要不符/不可比永远不得变成 error / 409 / 拒绝同步 —— 它只意味着"多干一趟活")。
317
228
  const srcLogDigest = typeof body.logDigest === "string" && body.logDigest.length > 0 ? body.logDigest : undefined;
318
229
  const entryIds = body.entryIds;
319
230
  const snapshots = body.snapshots;
320
231
  const policy = body.policy;
321
232
  const anchors = body.anchors;
322
- // §7 classify (BEFORE issuing a stagingId): dst current ids → classifySyncRelationshipByIds. A fork/stale
323
- // without overwrite-dst → 409 (no stream needed). `identical` → 200 {relation:"identical"} (no Phase B; the
324
- // snapshot/policy/anchor replay is idempotent and not strictly needed — the dst already holds this log; the
325
- // shell may re-push with no consequence). NB the AUTHORITATIVE re-classify is in commit() (the TOCTOU close).
326
233
  const dstIds = (await deps.sessionStorage.listEntryIds?.(sessionId)) ?? null;
327
234
  const relA = classifySyncRelationshipByIds(entryIds, dstIds);
328
235
  if ((relA.relation === "fork" || relA.relation === "stale") && resolution !== "overwrite-dst") {
329
236
  sendError(res, 409, "conflict", "sync conflict", { relation: relA });
330
237
  return;
331
238
  }
332
- // 🔴 内容判据(方案 B 的 Phase A 半场):`identical` 只说明 **id 集合**相等。源端内容在 Phase A 还没上传,
333
- // 所以这里只能靠调用方送来的 `logDigest`;拿目的端自己的日志算一次摘要跟它比。
334
- // ⚠️ 目的端全量读**只在这条路上做**(要短路、且调用方送了摘要)—— 成本只花在它能改变结论的地方。
335
- // 三态:相等 ⇒ 可以短路;不等 ⇒ **不短路**(照常开 staging 走完整同步);判不可比(scheme 不同/
336
- // 对端没送)⇒ 同样**不短路**,退回完整同步 = 安全方向。
337
239
  let payloadVerified = false;
338
240
  let digestSaysDiffer = false;
339
- // ── 这条路依赖的那条**性质**,以及它为什么由依赖下限 + 一条行为测试守着(不是由版本名判断守着)──
340
- // 本路要求:**同一份日志,内存形与 JSON 往返形必须同摘要**。因为它比的正是「客户端从**内存**算的
341
- // `logDigest`」对「server 从**已持久化**(JSON 往返过)的日志算的摘要」。
342
- // `sessionLogDigest` 的 **v1** 不满足它:v1 走引擎那个规范化序列化器,**刻意**区分 `{a: undefined}`
343
- // 与 `{}`,而 core 自己成规模产 own-key=undefined 的 entry(清除标签、无 data 的 custom entry)
344
- // ⇒ 两形必然不等 ⇒ **永不短路**,却看起来像在工作(症状只是「Phase B 总在跑」,查不出原因):
345
- // 纯成本、零收益,还多一次目的端全量读。core 黑板 [1709] 明确「别接 v1」,1.415.0 改算法并升 scheme v1→v2。
346
- //
347
- // 🔴 我一开始写的是 `SESSION_LOG_DIGEST_SCHEME !== "sema-log-v1"` 这道**名字门**。升上 1.415 后
348
- // `tsc` 直接判它恒真(core 把 scheme 收成了字面量类型)—— 而这提醒了一件更要紧的事:名字门本来就
349
- // **守不住这条性质**。它只认识"v1 这个名字",对将来任何一个同样破坏往返稳定性的 v3 一样放行。
350
- // ⇒ 改成两道**真守得住**的:
351
- // ① **依赖下限** `@sema-agent/core: ^1.416.0`。⚠️ package.json 是 JSON、写不了旁注,所以"这条下限是
352
- // 承重的、不是随手升的"记在两处:**本注释** + `CHANGELOG.md` 1.277.0/1.277.1 两条。
353
- // 降回更低的下限,tsc 不会有任何抱怨 —— 拦它的是 ② 那两道行为门。
354
- // ② **行为门**(都在 `test/sync-content-digest.test.ts`),两条**方向相反**、缺一不可:
355
- // · **往返稳定**:同一份含 own-key=undefined 的日志,内存形与 JSON 往返形必须同摘要。
356
- // 它坏 ⇒ **永不短路**(纯成本,安全方向)。core 摘要 v1 不满足它(v1 刻意区分 `{a:undefined}`
357
- // 与 `{}`,而 core 成规模产这种 entry),黑板 [1709] 明确「别接 v1」。
358
- // · 🔴 **防撞**:内容不同 ⇒ 摘要必须不同,**按深度枚举**(坏值与真实内容同级)。
359
- // 它坏 ⇒ **错误短路** = 静默丢数据,方向与上面相反、后果严重得多。core 摘要 **v2** 不满足它
360
- // (逐键降级只做在 entry 顶层,而真实内容在深度 ≥2 ⇒ 一个 bigint 吃掉同级全部内容),
361
- // 1.416.0 / v3 修复(黑板 [1714])。
362
- // 两条钉的都是**性质**、不是版本名 —— 任何 scheme 再破坏其一都会红。
363
- // (教训同形于 core 的 RB-149:源码/名字级判据挡的是写法,不是行为。)
364
241
  if (relA.relation === "identical" && srcLogDigest !== undefined) {
365
242
  const dstEntries = await deps.sessionStorage.exportEntries?.(sessionId).catch(() => null) ?? null;
366
243
  const dstDigest = dstEntries === null ? undefined : sessionLogDigest(dstEntries);
@@ -370,16 +247,10 @@ async function handleSessionSyncBody(req, res, url, ctx, miss) {
370
247
  else
371
248
  digestSaysDiffer = true;
372
249
  }
373
- // 不可比 ⇒ 两个标志都保持 false ⇒ 走下面「不短路」那一支(退回完整同步)
374
250
  if (!payloadVerified)
375
251
  digestSaysDiffer = true;
376
252
  }
377
253
  if (relA.relation === "identical" && !digestSaysDiffer) {
378
- // 对抗复查 A-2(CRITICAL):identical 不能裸短路——若上一轮 commit 后 snapshot/policy/anchor 重放
379
- // 失败(422),entries 已落库,重试的 Phase A 就落在这里;裸 200 会把「对话已同步但文件树缺失」
380
- // 焊死成永久分裂(Phase B 的重放永远不再运行)。identical 时把附随物幂等重放(snapshot=
381
- // content-addressed importManifest,已存在=no-op;policy/anchor put=幂等),失败=同款 422,
382
- // 客户端 re-PUT blob 再重试——自愈闭环。
383
254
  const importFaceIdentical = fileSnapshotImportFace(deps.fileSnapshotStore);
384
255
  if (importFaceIdentical && deps.fileSnapshotStore?.getBlob && snapshots.length > 0) {
385
256
  const getBlobA = (h) => deps.fileSnapshotStore.getBlob(h);
@@ -392,38 +263,19 @@ async function handleSessionSyncBody(req, res, url, ctx, miss) {
392
263
  }
393
264
  }
394
265
  const opOk = explicitOperatorOk(gatedPrincipal(req, deps.config), deps.config.operatorPrincipals);
395
- // #137 ①:等内容跳过(replayPolicyRecords)——这条腿在**每次** identical 重推上都跑,
396
- // 无条件 putRules 会把 rev(operator 乐观锁 CAS 令牌)按重推次数推着走。
397
266
  if (deps.sessionPolicyStore)
398
267
  await replayPolicyRecords(deps.sessionPolicyStore, sessionId, policy, opOk);
399
268
  const importingP = scope.fleetWide ? (owner ?? null) : scope.gateOwner;
400
269
  if (deps.resumeAnchorStore)
401
270
  for (const a of anchors)
402
271
  await deps.resumeAnchorStore.put(sessionId, a.eventId, a.entryId, importingP);
403
- // 🔴 措辞诚实化(2026-07-25,深挖第一轮 B2/B3/B4/G1):`identical` 的判据是 **entry id 集合相等**,
404
- // 分类器(`classifySyncRelationshipByIds`)**从不看载荷/父子结构** —— 而 entry id 是 uuidv7、
405
- // **不是内容寻址**,所以「同 id 异载荷」结构上完全可能。此时 entries 被**整段跳过**,客户端拿到 200,
406
- // 目的端却仍是它自己那份内容(探针用真 HTTP 复现过:本地改过内容再 push,云端保持原样、200)。
407
- // 现在把判据写进响应(`basis`)并显式声明**未验证载荷**,这样客户端不会把它读成"内容已同步"。
408
- // ✅ 状态更新(2026-08-05,[2557] 2-i 核账):上面说的「真修」——Phase A 拿内容摘要——**已经落了**
409
- // (`logDigest` 就在本函数上方,三态钉=fanout-characterization §H2/F3);commit 半场的载荷判据也齐
410
- // (identical=照常改写、fast_forward 分叉=409,见 session-sync-content.ts)。仍到不了这里的只剩
411
- // **不送 digest 的老客户端**(按设计永久保留的老路,现状钉=G1)——对它们 `basis:"entry-ids"` 的
412
- // 未验声明就是全部真相。
413
272
  sendJson(res, 200, {
414
273
  relation: "identical",
415
- // 判据如实:调用方送了可比摘要且相等 ⇒ 内容**真的**验过;否则仍只是 id 集合相等。
416
274
  basis: payloadVerified ? "entry-ids+digest" : "entry-ids",
417
275
  payloadVerified,
418
276
  });
419
277
  return;
420
278
  }
421
- // §4 active-run guard (don't drop a live append) + per-session import lease (don't let two staged imports race
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 仍可达」的组合按构造不存在(能力面与路由解析同真值)。
427
279
  const activeTaskId = await deps.runStore?.getActiveTaskId(sessionId);
428
280
  if (activeTaskId) {
429
281
  sendError(res, 409, "session_active", "session has an active run; sync after it settles", { activeTaskId });
@@ -431,34 +283,22 @@ async function handleSessionSyncBody(req, res, url, ctx, miss) {
431
283
  }
432
284
  const heldLease = importLeases.get(sessionId);
433
285
  if (heldLease) {
434
- // 🔴 数值守卫而不是直接乘:任何**早于这个字段**的 config 对象(旧部署配置、精简的测试 harness)
435
- // 会让它是 `undefined` ⇒ `undefined * 1000 = NaN` ⇒ `NaN > 0` 为假 ⇒ **接管静默不发生**,
436
- // 自愈通道在不报错的情况下消失。实测撞到过(一份最小 config 的探针 harness)。缺省取与 config
437
- // 同一个默认值(600s),而不是 0 —— 0 的语义是"明确关掉",不该由"漏配"来选中它。
438
286
  const staleSecRaw = deps.config.syncImportLeaseStaleSec;
439
287
  const staleSec = typeof staleSecRaw === "number" && Number.isFinite(staleSecRaw) ? staleSecRaw : 600;
440
288
  const staleMs = staleSec * 1000;
441
289
  const ageMs = Date.now() - heldLease.at;
442
290
  if (staleMs > 0 && ageMs >= staleMs) {
443
- // 接管:上一个持有者已经 staleMs 没有任何活动(既没上传 entries 也没 commit)⇒ 判它死了。
444
- // 先清掉它的 staging(否则那份 staged 行会留成孤儿),再让本次 Phase A 正常继续。
445
291
  deps.logger?.warn?.("sync_import_lease_taken_over", { sessionId, ageMs, staleMs, stagingId: heldLease.stagingId });
446
292
  cleanupStaging(heldLease.stagingId);
447
- importLeases.delete(sessionId); // cleanupStaging 只在 pendingStagings 里还有它时才删租约,这里兜底
293
+ importLeases.delete(sessionId);
448
294
  }
449
295
  else {
450
- // 错误文案点明**会自愈**:此前的措辞让客户端以为是永久状态,而它其实等一会儿就能重试。
451
296
  const retryAfterSec = staleMs > 0 ? Math.max(1, Math.ceil((staleMs - ageMs) / 1000)) : undefined;
452
297
  sendError(res, 409, "import_in_flight", "an import is already in flight for this session"
453
298
  + (retryAfterSec !== undefined ? ` (a stalled holder is taken over after ${staleSec}s of inactivity; retry in ~${retryAfterSec}s)` : ""), retryAfterSec !== undefined ? { retryAfterSec } : undefined);
454
299
  return;
455
300
  }
456
301
  }
457
- // §10 blob-presence pre-check (BEFORE staging): every referenced blobHash must be present in the cloud's OWN
458
- // snapshot store (blobs were pre-PUT). A missing one → 422 (PUT it, then retry) — fail FAST so we never open a
459
- // staging that can only fail at commit. Uses the store's hasBlobs (a single SQL INDEX probe — finding 12) NOT
460
- // a full getBlob GET-per-hash (under MinIO that would download + re-hash every ≤64 MiB object just to test
461
- // presence). Feature-detect hasBlobs; if a store lacks it, fall back to getBlob (parity for older stores).
462
302
  const fss = deps.fileSnapshotStore;
463
303
  const canImportSnapshots = fileSnapshotImportFace(fss) !== undefined;
464
304
  if (canImportSnapshots) {
@@ -484,15 +324,8 @@ async function handleSessionSyncBody(req, res, url, ctx, miss) {
484
324
  }
485
325
  }
486
326
  }
487
- // importingPrincipal (§9) + operatorOk (E6 tighten-only) — mirror the v1 fixes; applied at commit (policy replay).
488
327
  const importingPrincipal = scope.fleetWide ? (owner ?? null) : scope.gateOwner;
489
328
  const operatorOk = explicitOperatorOk(gatedPrincipal(req, deps.config), deps.config.operatorPrincipals);
490
- // Mint the stagingId TOKEN (the route supplies it, NOT the store) + open the handle + take the lease + park
491
- // the metadata. Phase B drives it; abandoned ones are reaped (durable rows) / dropped on restart. The token is
492
- // a SHORT random hex (8 bytes = 16 chars), NOT a full uuidv7: the staging id is `${sessionId}#stg-${token}` and
493
- // session_event.session_id is VARCHAR(64) — a 36-char uuidv7 session id + "#stg-" (5) leaves 23 chars, so a
494
- // 16-char token fits (and is collision-resistant for the few in-flight stagings of ONE session — the lease
495
- // already serializes them per session). randomBytes is unguessable (it rides the URL the client gets back).
496
329
  const token = randomBytes(8).toString("hex");
497
330
  const handle = deps.sessionStorage.beginImportStaging(sessionId, token);
498
331
  const stagingId = handle.stagingId;
@@ -504,11 +337,6 @@ async function handleSessionSyncBody(req, res, url, ctx, miss) {
504
337
  sendJson(res, 200, { stagingId, relation: relA.relation });
505
338
  return;
506
339
  }
507
- // POST …/sync/import/:stagingId/entries — PHASE B (PUSH, P1d-β §15.3): stream the entry log as an NDJSON request
508
- // body into the staged shadow id (bounded memory — line buffer + batch), driving core's STREAMING import gate
509
- // ({@link StreamingImportValidator}) ONE entry per line, then `finish()` at stream end (exactly-one-root +
510
- // leaf-resolvable) → commit (the atomic swap) → replay snapshots/policy/anchors. Re-POSTing the same stagingId is
511
- // idempotent (appendBatch dedups; the commit re-runs). sm[6] = the stagingId.
512
340
  if (sm[6] !== undefined && req.method === "POST") {
513
341
  const stagingId = safeDecode(sm[6]);
514
342
  if (stagingId === null) {
@@ -516,8 +344,6 @@ async function handleSessionSyncBody(req, res, url, ctx, miss) {
516
344
  return;
517
345
  }
518
346
  const pending = pendingStagings.get(stagingId);
519
- // A wrong/foreign/already-finished stagingId → 404 (no oracle). It must also belong to THIS url's session and
520
- // to the caller (the principal that opened it), unless the caller is fleet-wide (ops on-behalf).
521
347
  if (!pending || pending.realSessionId !== sessionId) {
522
348
  sendError(res, 404, "not_found.staging", "staging not found");
523
349
  return;
@@ -526,24 +352,15 @@ async function handleSessionSyncBody(req, res, url, ctx, miss) {
526
352
  sendError(res, 404, "not_found.staging", "staging not found");
527
353
  return;
528
354
  }
529
- const getBlob = (h) => deps.fileSnapshotStore.getBlob(h); // cloud's OWN store
355
+ const getBlob = (h) => deps.fileSnapshotStore.getBlob(h);
530
356
  const importFaceB = fileSnapshotImportFace(deps.fileSnapshotStore);
531
- // ── Stream the NDJSON request body → core's StreamingImportValidator per line → batched appendBatch (bounded
532
- // memory: line buffer + batch). The streaming validator IS the single invariant gate (unique ids /
533
- // parent-before-child / structural refs / one-root / leaf-resolvable) — core 1.143.0 — so the service
534
- // no longer re-states ANY of those rules (no ad-hoc Tier-1 pre-check, no Tier-2 read-back-and-re-validate;
535
- // the invariant gate belongs to core and the service does not duplicate it). `step()` throws SessionError("invalid_session") on a per-entry violation;
536
- // `finish()` throws on the end-of-stream invariants. The route keeps ONLY the TRANSPORT-level guards (the
537
- // per-line byte cap → 413, JSON parse → 400, and the object/string-id shape so a `null`/non-object line maps
538
- // to a typed 422 instead of an unmapped TypeError); these are NOT session-tree invariants. ──
539
357
  const validator = new StreamingImportValidator();
540
- let lineBuf = ""; // the partial-line accumulator (bytes counted against the per-line cap)
358
+ let lineBuf = "";
541
359
  let pendingBatch = [];
542
360
  const flush = async () => { if (pendingBatch.length) {
543
361
  await pending.handle.appendBatch(pendingBatch);
544
362
  pendingBatch = [];
545
363
  } };
546
- // Map a transport-level violation to a typed 4xx WITHOUT committing (the handle's staged rows are abandoned → reaped).
547
364
  class PrecheckError extends Error {
548
365
  status;
549
366
  constructor(status, msg) {
@@ -551,21 +368,16 @@ async function handleSessionSyncBody(req, res, url, ctx, miss) {
551
368
  this.status = status;
552
369
  }
553
370
  }
554
- const streamedIds = new Set(); // A-3: bind the actual stream to Phase A's declaration
371
+ const streamedIds = new Set();
555
372
  const onEntry = (e) => {
556
373
  if (!e || typeof e !== "object" || typeof e.id !== "string")
557
374
  throw new PrecheckError(422, "malformed entry (missing id)");
558
375
  streamedIds.add(e.id);
559
- validator.step(e); // core's invariant gate, one entry at a time (oldest-first) — throws SessionError on a tree violation
376
+ validator.step(e);
560
377
  };
561
- // 对抗复查 A-7(HIGH):per-chunk Buffer.toString("utf8") mangles a MULTI-BYTE character that
562
- // straddles a network chunk boundary (each half decodes to U+FFFD; the JSON often stays valid →
563
- // the payload is SILENTLY rewritten — CJK content is the common casualty). StringDecoder holds the
564
- // partial sequence across chunks — the standard Node fix.
565
378
  const utf8 = new StringDecoder("utf8");
566
379
  try {
567
380
  for await (const chunk of req) {
568
- // 租约续期(见 importLeases 旁注):上传本身就是活性证据 —— 大会话的长上传不该被判成"持有者死了"。
569
381
  touchImportLease(pending.realSessionId);
570
382
  lineBuf += utf8.write(chunk);
571
383
  if (Buffer.byteLength(lineBuf, "utf8") > SYNC_NDJSON_LINE_MAX_BYTES)
@@ -575,7 +387,7 @@ async function handleSessionSyncBody(req, res, url, ctx, miss) {
575
387
  const line = lineBuf.slice(0, nl).trim();
576
388
  lineBuf = lineBuf.slice(nl + 1);
577
389
  if (line.length === 0)
578
- continue; // tolerate blank lines / a trailing newline
390
+ continue;
579
391
  let e;
580
392
  try {
581
393
  e = JSON.parse(line);
@@ -589,8 +401,7 @@ async function handleSessionSyncBody(req, res, url, ctx, miss) {
589
401
  await flush();
590
402
  }
591
403
  }
592
- // any trailing partial line (no final newline) is still a complete entry to honor.
593
- lineBuf += utf8.end(); // flush a dangling partial sequence (A-7)
404
+ lineBuf += utf8.end();
594
405
  const tail = lineBuf.trim();
595
406
  if (tail.length > 0) {
596
407
  let e;
@@ -604,21 +415,12 @@ async function handleSessionSyncBody(req, res, url, ctx, miss) {
604
415
  pendingBatch.push(e);
605
416
  }
606
417
  await flush();
607
- // End-of-stream invariants (exactly-one-root + leaf-resolvable) — finish() throws SessionError on violation.
608
418
  validator.finish();
609
- // 对抗复查 A-3(HIGH):the streamed tree must BE the Phase-A-declared one — same count, same id set.
610
- // Without this, a caller could open a staging with ids=A (snapshots/anchors parked against A) and
611
- // stream a different-but-valid tree B to commit; the classification and parked artifacts would
612
- // silently reference the wrong log. Set equality (order is core-validated separately).
613
419
  if (streamedIds.size !== pending.declaredEntryIds.length || !pending.declaredEntryIds.every((id) => streamedIds.has(id))) {
614
420
  throw new PrecheckError(422, "streamed entries do not match the Phase-A declared entryIds");
615
421
  }
616
422
  }
617
423
  catch (e) {
618
- // A transport-level PrecheckError carries its own status; a core SessionError("invalid_session") (a step()
619
- // or finish() invariant violation) maps to 422. Either way: drop the staged rows + the lease/map entry up
620
- // front (a malformed stream won't be retried under THIS stagingId; the client re-opens Phase A). The reaper
621
- // is the backstop if the abort itself fails. Anything else re-throws to the outer handler.
622
424
  if (e instanceof PrecheckError) {
623
425
  await pending.handle.abort().catch(() => undefined);
624
426
  cleanupStaging(stagingId);
@@ -631,20 +433,10 @@ async function handleSessionSyncBody(req, res, url, ctx, miss) {
631
433
  sendError(res, 422, "invalid_entries", "staged session log failed validation");
632
434
  return;
633
435
  }
634
- // 对抗复查 A-4(HIGH):a NON-validation exception (storage blip, dropped connection) used to re-throw
635
- // with the lease + pendingStagings entry INTACT — the outer handler 500s, and every later Phase A for
636
- // this session 409s `import_in_flight` until the process restarts (the DB reaper can't clear the
637
- // in-memory Set). Clean up here too: abort the staged rows (best-effort; reaper backstops) + release
638
- // the lease, so the client's retry re-opens Phase A instead of wedging. Then re-throw for the 500.
639
436
  await pending.handle.abort().catch(() => undefined);
640
437
  cleanupStaging(stagingId);
641
438
  throw e;
642
439
  }
643
- // 对抗复查 A-6(HIGH,route 半件):re-check the ACTIVE-RUN guard right before commit — Phase A's check
644
- // can be minutes stale by the time the stream finishes, and an overwrite-dst commit would purge a log a
645
- // LIVE run is appending to. (The owner half lives in the store commit's in-txn re-classify + FOR UPDATE;
646
- // a residual sub-second TOCTOU between this check and the txn remains — documented, not load-bearing for
647
- // correctness since the commit txn serializes against appends via FOR UPDATE.)
648
440
  const activeAtCommit = await deps.runStore?.getActiveTaskId(sessionId);
649
441
  if (activeAtCommit) {
650
442
  await pending.handle.abort().catch(() => undefined);
@@ -652,33 +444,17 @@ async function handleSessionSyncBody(req, res, url, ctx, miss) {
652
444
  sendError(res, 409, "session_active", "session started an active run while the import streamed; sync after it settles", { activeTaskId: activeAtCommit });
653
445
  return;
654
446
  }
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
447
  if (pending.resolution !== undefined && overwriteWipeIncapable(deps.sessionPolicyStore)) {
660
448
  await pending.handle.abort().catch(() => undefined);
661
449
  cleanupStaging(stagingId);
662
450
  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
451
  return;
664
452
  }
665
- // ── commit (the atomic swap) → wipe (overwrite-dst) → replay snapshots/policy/anchors → cache evict → release lease ──
666
453
  try {
667
454
  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
455
  if (pending.resolution === "overwrite-dst" && (committed.relation === "fork" || committed.relation === "stale")) {
676
456
  await wipeSessionAttendants({ fileSnapshotStore: deps.fileSnapshotStore, sessionPolicyStore: deps.sessionPolicyStore, resumeAnchorStore: deps.resumeAnchorStore }, sessionId);
677
457
  }
678
- // ① snapshots — content-addressed import (idempotent). importManifest never throws → a {ok:false} is a
679
- // fail-closed 422 (a blob the Phase-A presence check couldn't catch — corrupted in transit). Note: the
680
- // ENTRIES are already committed (the swap), so a snapshot failure here means the conversation synced but a
681
- // file tree did not — we surface 422 so the client re-PUTs the blob and re-POSTs (idempotent).
682
458
  if (importFaceB) {
683
459
  const importManifest = importFaceB.importManifest;
684
460
  for (const snap of pending.snapshots) {
@@ -690,33 +466,24 @@ async function handleSessionSyncBody(req, res, url, ctx, miss) {
690
466
  }
691
467
  }
692
468
  }
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 令牌)。
696
469
  const policyStore = deps.sessionPolicyStore;
697
470
  if (policyStore)
698
471
  await replayPolicyRecords(policyStore, sessionId, pending.policy, pending.operatorOk);
699
- // ③ anchors — owner RE-KEYED to the importing principal (§9).
700
472
  const anchorStore = deps.resumeAnchorStore;
701
473
  if (anchorStore)
702
474
  for (const a of pending.anchors)
703
475
  await anchorStore.put(sessionId, a.eventId, a.entryId, pending.owner);
704
- // The commit wrote durable state via the RAW store, bypassing the CachingSessionStore wrapper → evict the warm handle.
705
476
  deps.sessionStorage.evict?.(sessionId);
706
477
  cleanupStaging(stagingId);
707
478
  sendJson(res, 200, { relation: committed.relation });
708
479
  }
709
480
  catch (e) {
710
- // A commit-time conflict (the in-txn re-classify flipped to fork/stale, TOCTOU close) rolled the swap back →
711
- // the staging rows still exist → abort them; the client must restart from Phase A with a fresh resolution.
712
481
  if (e instanceof SyncConflictError) {
713
482
  await pending.handle.abort().catch(() => undefined);
714
483
  cleanupStaging(stagingId);
715
484
  sendError(res, 409, "conflict", "sync conflict", { relation: e.relation });
716
485
  return;
717
486
  }
718
- // A SessionPolicyError happens AFTER the swap committed (the policy replay is post-commit) → the conversation
719
- // synced, only the loosen is refused (fail-closed). The staging rows were already renamed away (abort no-op).
720
487
  if (e instanceof SessionPolicyError) {
721
488
  cleanupStaging(stagingId);
722
489
  sendError(res, e.code === "loosen_forbidden" ? 403 : 409, e.code, e.message);
@@ -727,11 +494,6 @@ async function handleSessionSyncBody(req, res, url, ctx, miss) {
727
494
  }
728
495
  return;
729
496
  }
730
- // POST …/sync/plan — DRY-RUN: classify what a PUSH of the LOCAL peer's log (body.entryIds, the OTHER process's
731
- // entry-id list) WOULD do against the cloud's current log, WITHOUT writing. The same-process planSync(both
732
- // backends) doesn't map the HTTP topology (one peer is remote) → the route classifies by ids: the client ids
733
- // are the SOURCE (oldest-first), the cloud's current entries are the DST. own-or-FRESH gate (a fresh cloud
734
- // session ⇒ the relation is `fresh`).
735
497
  if (sm[2] === "plan" && req.method === "POST") {
736
498
  if (!pushGateOk())
737
499
  return;
@@ -747,12 +509,11 @@ async function handleSessionSyncBody(req, res, url, ctx, miss) {
747
509
  sendError(res, 400, "request.field_invalid", "entryIds must be an array of strings");
748
510
  return;
749
511
  }
750
- const dst = await deps.sessionStorage?.exportEntries?.(sessionId); // the cloud's current log (null if fresh)
512
+ const dst = await deps.sessionStorage?.exportEntries?.(sessionId);
751
513
  const relation = classifySyncRelationshipByIds(body.entryIds, dst ? dst.map((e) => e.id) : null);
752
514
  sendJson(res, 200, relation);
753
515
  return;
754
516
  }
755
- // A matched sync path but a wrong METHOD (e.g. POST /manifest) → 404 (no method-specific 405 elsewhere).
756
517
  sendError(res, 404, "not_found.route", "not found");
757
518
  return;
758
519
  }