@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.
- package/USAGE.md +18 -0
- package/dist/adoption/plan.js +31 -192
- package/dist/adoption/quiesce.js +3 -86
- package/dist/adoption/runner.js +3 -140
- package/dist/adoption/sql.js +0 -74
- package/dist/adoption/wire.js +0 -80
- package/dist/approval-ask-machine.js +0 -75
- package/dist/approval-card.js +0 -323
- package/dist/approval-deny-reasons.js +0 -42
- package/dist/approval-hmac.js +0 -35
- package/dist/approval-reconciler.js +8 -166
- package/dist/approval.js +4 -67
- package/dist/audit.js +1 -44
- package/dist/auth-bridge.js +4 -47
- package/dist/auth-keys.js +0 -23
- package/dist/bake-runner/main.js +4 -65
- package/dist/bake-runner/protocol.js +3 -58
- package/dist/bake-runner/runner.js +5 -91
- package/dist/bench/l8/artifact.js +2 -61
- package/dist/bench/l8/escape.js +0 -25
- package/dist/bench/l8/index.js +0 -14
- package/dist/bench/l8/probes.js +0 -60
- package/dist/bench/l8/run-probes.js +2 -45
- package/dist/bench/s1/arms.js +14 -189
- package/dist/bench/s1/live-deps.js +11 -253
- package/dist/bench/s1/oracle.js +1 -36
- package/dist/bench/s1/repair-oracle-adapter.js +0 -30
- package/dist/bench/s1/reviewer.js +0 -34
- package/dist/bench/s1/row.js +0 -62
- package/dist/bench/s1/run-firm.js +4 -70
- package/dist/bench/s1/runner-ctx.js +0 -40
- package/dist/bench/s1/tasks.js +0 -115
- package/dist/boot/adoption.js +0 -20
- package/dist/boot/budget-tracing.js +3 -55
- package/dist/boot/config-center.js +43 -551
- package/dist/boot/coordinators.js +1 -69
- package/dist/boot/crash-last.js +0 -18
- package/dist/boot/deferred-sandbox-path-env.js +3 -119
- package/dist/boot/execution-env.js +5 -171
- package/dist/boot/governance-seams.js +0 -92
- package/dist/boot/leader.js +0 -69
- package/dist/boot/limit-sync.js +0 -2
- package/dist/boot/memory-boundary.js +3 -91
- package/dist/boot/org-memory.js +1 -25
- package/dist/boot/parked-revive-gate.js +0 -150
- package/dist/boot/permission-rules-audit.js +1 -56
- package/dist/boot/reapers.js +15 -273
- package/dist/boot/resolve-spec.js +9 -768
- package/dist/boot/retention-lane.js +4 -87
- package/dist/boot/runner-deps.js +3 -297
- package/dist/boot/runtime-caps.js +0 -40
- package/dist/boot/session-faces.js +2 -133
- package/dist/boot/shutdown.js +9 -83
- package/dist/boot/side-query-lane.js +2 -137
- package/dist/boot/stores.js +15 -215
- package/dist/boot/task-list-lane.js +0 -18
- package/dist/boot/webfetch-summarize-lane.js +0 -47
- package/dist/boot/workflow-orchestration.js +3 -99
- package/dist/boot-reclaim.js +0 -20
- package/dist/bounded-session-map.js +0 -19
- package/dist/brain.js +2 -139
- package/dist/budget.js +9 -244
- package/dist/capabilities/builtin-tools.js +0 -3
- package/dist/capabilities/center-plugins.js +6 -51
- package/dist/capabilities/center-prompts.js +6 -71
- package/dist/capabilities/code-review-council.js +7 -40
- package/dist/capabilities/collab-workflows.js +1 -44
- package/dist/capabilities/hands-lane.js +0 -65
- package/dist/capabilities/memory-notice.js +0 -70
- package/dist/capabilities/prompt.js +0 -14
- package/dist/capabilities/prompts/code-review.js +0 -14
- package/dist/capabilities/prompts/identity.js +0 -6
- package/dist/capabilities/prompts/team.js +0 -4
- package/dist/capabilities/repo-tools.js +0 -34
- package/dist/capabilities/sandbox-file-send.js +3 -92
- package/dist/capabilities/scenarios.d.ts +0 -1
- package/dist/capabilities/scenarios.js +18 -279
- package/dist/capabilities/select-environment-tool.js +0 -32
- package/dist/capabilities/send-user-file-tool.js +2 -66
- package/dist/capabilities/skills.d.ts +1 -2
- package/dist/capabilities/skills.js +3 -21
- package/dist/capabilities/team.d.ts +3 -11
- package/dist/capabilities/team.js +3 -51
- package/dist/capabilities/tool-defer.js +0 -3
- package/dist/config-center/apply-effective.js +15 -330
- package/dist/config-center/apply-ledger.js +5 -38
- package/dist/config-center/facade.js +0 -41
- package/dist/config-center/hot-keys-registry.js +0 -20
- package/dist/config-center/http-client.js +2 -125
- package/dist/config-center/mcp-revocation.js +2 -34
- package/dist/config-center/read-face.js +0 -59
- package/dist/config-center/restart-signal.js +1 -85
- package/dist/config-center/skills-mcp.d.ts +1 -1
- package/dist/config-center/skills-mcp.js +9 -76
- package/dist/config-center/stage-limits.js +8 -35
- package/dist/config-invariants.js +0 -16
- package/dist/config-lkg.js +0 -42
- package/dist/config-provider.js +3 -186
- package/dist/config-types.js +0 -5
- package/dist/config.js +107 -1145
- package/dist/degenerate-instrument.js +3 -67
- package/dist/deployment-governance.js +0 -124
- package/dist/digest-form.js +0 -12
- package/dist/elicitation.js +3 -86
- package/dist/env-facts.js +7 -75
- package/dist/fleet/fleet-bus.js +34 -507
- package/dist/fleet/fleet-reconciler.js +9 -149
- package/dist/fleet/fleet-terminal-window.js +10 -178
- package/dist/fleet/subagent-tail-bus.js +3 -72
- package/dist/fleet-client.js +10 -70
- package/dist/fleet-lease.js +5 -79
- package/dist/git-api-kind.js +0 -3
- package/dist/governance-ask-marks.js +2 -78
- package/dist/hooks/branch-transcript.js +0 -74
- package/dist/hooks/cc-agent-hook-prompt.js +0 -29
- package/dist/hooks/cc-stop-prompt.js +1 -46
- package/dist/hooks/hook-llm.js +1 -53
- package/dist/hooks/hook-runner.js +20 -414
- package/dist/http/active-run-conflict.js +4 -129
- package/dist/http/cursor-fingerprint.d.ts +5 -0
- package/dist/http/cursor-fingerprint.js +5 -0
- package/dist/http/idempotency.js +0 -37
- package/dist/http/principal-gate.js +3 -40
- package/dist/http/route-ctx.js +0 -9
- package/dist/http/routes/a2a-serve.js +7 -319
- package/dist/http/routes/admin-config-refresh.js +0 -4
- package/dist/http/routes/admin-drain.js +0 -10
- package/dist/http/routes/adoption.js +1 -29
- package/dist/http/routes/agents-roster.js +1 -47
- package/dist/http/routes/approvals-assistant.js +32 -395
- package/dist/http/routes/attachments.js +4 -22
- package/dist/http/routes/capabilities.js +3 -420
- package/dist/http/routes/diagnostics.js +1 -81
- package/dist/http/routes/fleet.js +9 -185
- package/dist/http/routes/images.js +14 -239
- package/dist/http/routes/leader.js +0 -13
- package/dist/http/routes/memory-bundle.js +1 -61
- package/dist/http/routes/memory-policy.js +9 -99
- package/dist/http/routes/notify-wake.js +3 -37
- package/dist/http/routes/observability.js +2 -19
- package/dist/http/routes/retention-ops.js +2 -34
- package/dist/http/routes/rules.js +1 -93
- package/dist/http/routes/runs.js +64 -873
- package/dist/http/routes/session-sync.js +19 -258
- package/dist/http/routes/sessions-list.js +17 -43
- package/dist/http/routes/sessions.js +34 -226
- package/dist/http/routes/shared-memory.js +5 -36
- package/dist/http/routes/side-query.js +1 -87
- package/dist/http/routes/tasks.js +52 -722
- package/dist/http/routes/trace-usage.js +32 -195
- package/dist/http/routes/workflows.js +19 -195
- package/dist/http/run-meta.js +0 -6
- package/dist/http/send.js +0 -32
- package/dist/http/server.js +92 -1597
- package/dist/http/sse-lifecycle.js +2 -13
- package/dist/http/sse-log.js +3 -48
- package/dist/http/tar.js +5 -21
- package/dist/http/verify-rounds.js +0 -5
- package/dist/http/wire-gate.js +0 -9
- package/dist/http/workspace-content.js +0 -10
- package/dist/images/bake-validate.js +1 -70
- package/dist/images/manifest.js +1 -6
- package/dist/index.js +0 -21
- package/dist/key-resolver.js +2 -17
- package/dist/leader/diffout.js +1 -20
- package/dist/leader/diffup.js +0 -47
- package/dist/leader/endpoint.js +2 -57
- package/dist/leader/fanout.js +3 -45
- package/dist/leader/grader-env-factory.js +3 -72
- package/dist/leader/leader.js +5 -156
- package/dist/leader/merge.js +8 -115
- package/dist/leader/planner.js +3 -54
- package/dist/leader/repair-oracle.js +1 -60
- package/dist/leader/repair-wire.js +2 -79
- package/dist/leader/wire.js +8 -307
- package/dist/lsp/e2b-bridge.js +4 -64
- package/dist/lsp/e2b-manager.js +6 -94
- package/dist/lsp/lsp-frames.js +0 -12
- package/dist/lsp/manager.js +4 -96
- package/dist/lsp/ws-transport.js +5 -55
- package/dist/lsp-evict.js +1 -15
- package/dist/main.js +57 -765
- package/dist/memory-bundle-engine.js +0 -55
- package/dist/memory-export.js +0 -4
- package/dist/memory-posture.js +1 -15
- package/dist/memory-scope.js +11 -148
- package/dist/memory-sync-client.js +2 -44
- package/dist/memory-sync.js +1 -80
- package/dist/model-select.js +3 -80
- package/dist/observability/cost-quota.js +1 -17
- package/dist/observability/cost-taxonomy.js +0 -34
- package/dist/observability/fail-open.js +7 -86
- package/dist/observability/logger.js +0 -6
- package/dist/observability/metrics.js +0 -94
- package/dist/observability/otel-exporter.js +3 -13
- package/dist/observability/principal-context.js +0 -9
- package/dist/observability/prompt-manifest.js +1 -37
- package/dist/observability/rate-limit.js +0 -4
- package/dist/observability/secret-env-scrub.js +2 -56
- package/dist/observability/tool-trace.js +1 -70
- package/dist/orchestration/hardened-vm-runner.js +4 -118
- package/dist/orchestration/hardened-vm-worker-runner.js +1 -26
- package/dist/orchestration/hardened-vm-worker.js +0 -27
- package/dist/orchestration/subagent-steer.js +1 -45
- package/dist/orchestration/workflow-agent-steer.js +1 -80
- package/dist/orchestration/workflow-completion-inbox.js +32 -285
- package/dist/orchestration/workflow-notify-journal.js +16 -259
- package/dist/org-memory-admission.js +3 -47
- package/dist/parent-watch.js +2 -48
- package/dist/parked-decide.js +1 -109
- package/dist/per-task-image.js +0 -57
- package/dist/plan-cache-probe.js +3 -27
- package/dist/plugins/adoption-log-sql.js +2 -119
- package/dist/plugins/approval-ask-store-memory.js +3 -38
- package/dist/plugins/approval-ask-store-sql.js +6 -188
- package/dist/plugins/approval-exemption-store.js +2 -28
- package/dist/plugins/background-agent-store-sql.js +4 -105
- package/dist/plugins/background-shell-support.js +14 -122
- package/dist/plugins/blob-backend.js +6 -169
- package/dist/plugins/breaker-state-sql.js +8 -46
- package/dist/plugins/caching-session-store.js +4 -106
- package/dist/plugins/checkpoint-store-sql.js +18 -505
- package/dist/plugins/e2b-orphan-reclaim.js +0 -45
- package/dist/plugins/file-outcome-sink.js +0 -9
- package/dist/plugins/file-resume-anchor-store.js +4 -43
- package/dist/plugins/file-run-store.js +26 -364
- package/dist/plugins/file-snapshot-store-sql.js +10 -181
- package/dist/plugins/fork-routing-session-store.js +8 -111
- package/dist/plugins/host-platform.js +2 -91
- package/dist/plugins/image-bake-store-sql.js +7 -250
- package/dist/plugins/image-index-sql.js +4 -123
- package/dist/plugins/k8s-bg-scripts.js +4 -88
- package/dist/plugins/k8s-exec-protocol.js +0 -41
- package/dist/plugins/leader-run-store-sql.js +0 -101
- package/dist/plugins/local-checkpoint-store.js +8 -128
- package/dist/plugins/local-session-store.js +34 -296
- package/dist/plugins/local-task-attachment-store.js +2 -16
- package/dist/plugins/mailbox-store-sql.js +8 -65
- package/dist/plugins/memory-embedder-fingerprint.js +5 -166
- package/dist/plugins/memory-embedder.js +4 -70
- package/dist/plugins/memory-engine-pg.js +6 -170
- package/dist/plugins/memory-engine-tidb.js +7 -155
- package/dist/plugins/memory-engine-vector-util.js +0 -10
- package/dist/plugins/memory-key-guards.js +0 -34
- package/dist/plugins/memory-origin-law.js +0 -187
- package/dist/plugins/memory-resume-anchor-store.js +0 -17
- package/dist/plugins/memory-run-store.js +12 -89
- package/dist/plugins/memory-session-policy-store.js +0 -17
- package/dist/plugins/memory-sync-store-pg.js +4 -49
- package/dist/plugins/memory-sync-store-tidb.js +3 -35
- package/dist/plugins/outcome-ledger-sql.js +3 -97
- package/dist/plugins/permission-rule-store-file.js +5 -133
- package/dist/plugins/permission-rule-store-sql.d.ts +3 -0
- package/dist/plugins/permission-rule-store-sql.js +50 -307
- package/dist/plugins/pg-cost-quota.js +0 -7
- package/dist/plugins/pg-pool.js +0 -92
- package/dist/plugins/pg-rate-limiter.js +2 -13
- package/dist/plugins/pg-safe-json.js +4 -40
- package/dist/plugins/pg-session-storage.js +25 -189
- package/dist/plugins/posix-shell-fs.js +1 -31
- package/dist/plugins/remote-env-adb.js +12 -101
- package/dist/plugins/remote-env-e2b.js +40 -371
- package/dist/plugins/remote-env-file-error.js +0 -37
- package/dist/plugins/remote-env-host.js +63 -483
- package/dist/plugins/remote-env-k8s.js +32 -326
- package/dist/plugins/remote-env-local-docker.js +24 -157
- package/dist/plugins/remote-env-ssh.js +20 -128
- package/dist/plugins/remote-scratchpad.js +2 -32
- package/dist/plugins/remote-shell.js +1 -32
- package/dist/plugins/resume-anchor-store-sql.js +0 -11
- package/dist/plugins/retention-lane-store-sql.js +0 -108
- package/dist/plugins/retention-store-sql.js +4 -383
- package/dist/plugins/roster-store-sql.js +0 -55
- package/dist/plugins/run-store-sql.js +14 -251
- package/dist/plugins/s3-presign.js +2 -49
- package/dist/plugins/scheduler-support.js +3 -80
- package/dist/plugins/send-file-ledger.js +4 -53
- package/dist/plugins/send-user-file.js +4 -94
- package/dist/plugins/session-placement.js +1 -89
- package/dist/plugins/session-policy-store-sql.js +3 -81
- package/dist/plugins/session-store.js +0 -57
- package/dist/plugins/shared-memory-store-sql.js +4 -167
- package/dist/plugins/sql-driver.js +0 -17
- package/dist/plugins/sql-errors.js +0 -7
- package/dist/plugins/sql-escape.js +0 -8
- package/dist/plugins/sql-row-helpers.js +0 -25
- package/dist/plugins/store-backend.js +42 -222
- package/dist/plugins/store-contracts.js +2 -46
- package/dist/plugins/task-attachment-store.js +3 -49
- package/dist/plugins/task-list-store-sql.js +0 -79
- package/dist/plugins/tidb-cost-quota.js +1 -4
- package/dist/plugins/tidb-pool.js +1 -207
- package/dist/plugins/tidb-rate-limiter.js +3 -9
- package/dist/plugins/tidb-session-storage.js +4 -70
- package/dist/plugins/tidb-session-store.js +19 -341
- package/dist/plugins/tool-result-store-sql.js +4 -179
- package/dist/plugins/usage-window-store-sql.js +0 -8
- package/dist/plugins/web-search.js +10 -132
- package/dist/plugins/workflow-journal-store-sql.js +2 -58
- package/dist/plugins/workflow-run-store-sql.js +6 -91
- package/dist/plugins/worktree-isolation.js +6 -126
- package/dist/plugins/write-behind-counter.js +16 -75
- package/dist/principal-jwt.js +5 -60
- package/dist/project-memory.js +15 -146
- package/dist/prompts-domain-validate.js +1 -51
- package/dist/question.js +2 -111
- package/dist/resource-suspend.js +0 -18
- package/dist/router/route-orchestration.js +0 -77
- package/dist/rules-consent.d.ts +44 -5
- package/dist/rules-consent.js +75 -215
- package/dist/run-local.js +14 -381
- package/dist/runs.js +29 -617
- package/dist/runtime-caps-resolver.js +6 -132
- package/dist/runtime-governance.js +1 -232
- package/dist/sandbox-pkg-source.js +0 -37
- package/dist/sealed-key.js +3 -68
- package/dist/security.js +6 -291
- package/dist/session-leaf-bus.js +0 -32
- package/dist/session-sync-content.js +1 -69
- package/dist/session-sync-kernel.js +3 -60
- package/dist/session-sync.js +3 -66
- package/dist/session-titler.js +7 -41
- package/dist/session-watch.js +9 -73
- package/dist/shared-memory-scope-authorizer.js +0 -18
- package/dist/sighup-idle.js +1 -12
- package/dist/spec-fields.js +4 -143
- package/dist/store-live-probe.js +3 -49
- package/dist/task-a2a.js +1 -126
- package/dist/task-cwd.js +1 -103
- package/dist/task-mcp.js +1 -89
- package/dist/task-settings.js +10 -300
- package/dist/task-workflow.js +8 -75
- package/dist/tool-approval.d.ts +28 -1
- package/dist/tool-approval.js +70 -1430
- package/dist/trace/artifacts.js +5 -20
- package/dist/trace/engine-notice-wire.js +6 -138
- package/dist/trace/ledger-sink.js +7 -96
- package/dist/trace/project.js +4 -448
- package/dist/trace/redact.js +9 -77
- package/dist/turn-activity.js +1 -27
- package/dist/usage-analytics.js +4 -34
- package/dist/wall-clock-jump-guard.js +1 -68
- package/package.json +3 -3
- package/dist/capabilities/scenario-alias.d.ts +0 -27
- package/dist/capabilities/scenario-alias.js +0 -61
|
@@ -1,34 +1,14 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* TiDB-backed Session event-log center (L1) — the durable "persistence center".
|
|
3
|
-
*
|
|
4
|
-
* Implements core's `SessionStore`. `acquire` rebuilds a session from its append-only event log
|
|
5
|
-
* (= wake/resume), so any stateless runner instance can continue it. Cross-instance write safety is
|
|
6
|
-
* enforced by the storage layer's compare-and-set (core's F2 seam); crash consistency (orphan
|
|
7
|
-
* tool-call after a mid-turn crash) is handled by the Runner calling `reconcileInterruptedSession`
|
|
8
|
-
* on acquire (core's F1).
|
|
9
|
-
*
|
|
10
|
-
* Persistence only. **Tenant authorization (who may wake which session) lives in the service layer**
|
|
11
|
-
* (`src/security.ts`), which uses `ownerOf` / `register` below. The store does not infer identity.
|
|
12
|
-
*/
|
|
13
|
-
// 🔴 core 1.403 迁移(2026-07-25):runtime 构造用法,`Session` → `StoredSession`(见 audit.ts 同款注释)。
|
|
14
1
|
import { StoredSession, SessionError, uuidv7, validateEntriesForImport, leafIdAfterEntry } from "@sema-agent/core";
|
|
15
2
|
import { TiDBSessionStorage } from "./tidb-session-storage.js";
|
|
16
3
|
import { escapeLike } from "./sql-escape.js";
|
|
17
4
|
import { MANAGED_RETENTION } from "./retention-store-sql.js";
|
|
18
5
|
import { contentForkRelation, fastForwardSharedContentDiverged } from "../session-sync-content.js";
|
|
19
6
|
import { assertPlacementAdmission, LIST_PLACED_BATCH, PLACEMENT_SELECT_COLUMNS, placedRowFromRow, placementFromRow, placementInsertValues, SQL_SESSION_PLACEMENTS, } from "./session-placement.js";
|
|
20
|
-
import { classifySyncRelationshipByIds, SyncConflictError, stagingIdFor, STAGING_ID_MARKER, STAGING_GC_GRACE_MS,
|
|
21
|
-
} from "../session-sync-kernel.js";
|
|
7
|
+
import { classifySyncRelationshipByIds, SyncConflictError, stagingIdFor, STAGING_ID_MARKER, STAGING_GC_GRACE_MS, } from "../session-sync-kernel.js";
|
|
22
8
|
export class TiDBSessionStore {
|
|
23
9
|
pool;
|
|
24
|
-
/** #270 车1:托管留存声明。读法与「为什么 SQL 店答 managed 而实现体在别处」逐字见
|
|
25
|
-
* `retention-store-sql.ts` 的 {@link MANAGED_RETENTION}。 */
|
|
26
10
|
retention = MANAGED_RETENTION;
|
|
27
|
-
/** #292 C18(core 5.48.0 design/323):placement **声明**。六条义务的实装点逐条见
|
|
28
|
-
* {@link SQL_SESSION_PLACEMENTS} 顶注与本类的 acquire / listSessions / release / listPlaced /
|
|
29
|
-
* placementOf 各处的 `#292` 注。 */
|
|
30
11
|
placements = SQL_SESSION_PLACEMENTS;
|
|
31
|
-
/** In-flight acquisitions keyed by id, so concurrent acquire(sameId) in one process share one. */
|
|
32
12
|
pending = new Map();
|
|
33
13
|
constructor(pool) {
|
|
34
14
|
this.pool = pool;
|
|
@@ -36,18 +16,11 @@ export class TiDBSessionStore {
|
|
|
36
16
|
async acquire(sessionId, opts) {
|
|
37
17
|
if (!sessionId) {
|
|
38
18
|
const id = uuidv7();
|
|
39
|
-
// #292 义务:placement 与行**同一条 INSERT** 落库(创建时一次落行,first-write 不可变)。
|
|
40
19
|
await this.register(id, null, opts?.placement);
|
|
41
20
|
return this.load(id, false, opts);
|
|
42
21
|
}
|
|
43
|
-
// design/114 Phase3 (F1): a `requireExisting` acquire NEVER creates and can REJECT (not_found) — it MUST bypass
|
|
44
|
-
// the shared create-dedup `pending`, else a concurrent create-capable waiter awaiting that same promise would
|
|
45
|
-
// inherit the not_found rejection instead of creating-on-miss. Two concurrent requireExisting opens each just
|
|
46
|
-
// wake independently (both resolve or both fail loud) — harmless (it only opens, never double-creates).
|
|
47
22
|
if (opts?.requireExisting)
|
|
48
23
|
return this.load(sessionId, true, opts);
|
|
49
|
-
// #292:准入判据**按调用方逐一判**(core `TtlSessionStore` 的同款纪律)——共享的 in-flight promise
|
|
50
|
-
// 只共享「会话被唤醒」这件事,绝不让一个 claim 形等待者继承可信创建方的准入,或反过来。
|
|
51
24
|
const inflight = this.pending.get(sessionId);
|
|
52
25
|
if (inflight) {
|
|
53
26
|
const acquired = await inflight;
|
|
@@ -61,55 +34,29 @@ export class TiDBSessionStore {
|
|
|
61
34
|
async load(sessionId, requireExisting = false, opts) {
|
|
62
35
|
let storage = await TiDBSessionStorage.wake(this.pool, sessionId);
|
|
63
36
|
if (storage) {
|
|
64
|
-
// #292 义务 6(claim 关门)+ 镜像方向:判据吃的是**已持久化**的元组(wake 顺路带回,零额外往返)。
|
|
65
37
|
assertPlacementAdmission(sessionId, (await storage.getMetadata()).placement, opts);
|
|
66
38
|
return { session: new StoredSession(storage), sessionId };
|
|
67
39
|
}
|
|
68
|
-
// design/114 Phase3: `requireExisting` ⇒ FAIL LOUD on a genuinely missing session (no session_meta row),
|
|
69
|
-
// never the silent create-on-miss that turns a reuse-style warm-resume into a fresh empty run. core's
|
|
70
|
-
// prepare-task maps this store `not_found` to the caller-facing `resume.session_not_found`.
|
|
71
40
|
if (requireExisting)
|
|
72
41
|
throw new SessionError("not_found", `session ${sessionId} not found`);
|
|
73
|
-
// No meta row yet (a caller-chosen brand-new id the service didn't pre-register). Create it
|
|
74
|
-
// ownerless; only the caller who chose this id can reach it, and it holds no prior data.
|
|
75
42
|
await this.register(sessionId, null, opts?.placement);
|
|
76
43
|
storage = await TiDBSessionStorage.wake(this.pool, sessionId);
|
|
77
|
-
// 刚 register 完却唤不醒 = 有人在这两步之间把行删了(带外 purge / 留存腿)。旧形在这里是一个
|
|
78
|
-
// `storage!` 非空断言,真撞上时炸成一条无信息的 `TypeError: reading 'getMetadata' of null`
|
|
79
|
-
// (本批实测撞到过一次:邻车在同一测试库上整表 DELETE)。改成响亮且**说得出发生了什么**的错。
|
|
80
44
|
if (!storage) {
|
|
81
45
|
throw new SessionError("storage", `session ${sessionId} vanished between register and wake (a concurrent purge won the race) — retry`);
|
|
82
46
|
}
|
|
83
|
-
// #292 镜像方向(pre-claim squat):带 placement 的创建必须**回读**到持久化的同组元组——并发的
|
|
84
|
-
// claim 形若抢先建出普通行,我们的 INSERT 是 no-op,回读即缺席 ⇒ 响亮拒(不是静默降级成普通会话)。
|
|
85
47
|
assertPlacementAdmission(sessionId, (await storage.getMetadata()).placement, opts);
|
|
86
48
|
return { session: new StoredSession(storage), sessionId };
|
|
87
49
|
}
|
|
88
|
-
/** Idempotently create a session_meta row with an owner. Existing owner is never overwritten.
|
|
89
|
-
* #292:`placement` 在场时与行同一条 INSERT 落六列;ON DUPLICATE 臂是 no-op ⇒ 已存在的行的元组
|
|
90
|
-
* **永不被重写**(first-write 不可变,义务 5 的写侧半场)。 */
|
|
91
50
|
async register(sessionId, owner, placement) {
|
|
92
51
|
const now = new Date();
|
|
93
52
|
await this.pool.query("INSERT INTO session_meta (session_id, owner, created_at, updated_at, leaf_id, leaf_seq, " +
|
|
94
53
|
"placement_kind, placement_scope, placement_parent_session_id, placement_root_session_id, placement_handle, placed_at) " +
|
|
95
54
|
"VALUES (?,?,?,?,NULL,0,?,?,?,?,?,?) ON DUPLICATE KEY UPDATE session_id = session_id", [sessionId, owner, now, now, ...placementInsertValues(placement, now)]);
|
|
96
55
|
}
|
|
97
|
-
/**
|
|
98
|
-
* #292 义务 6 的廉价探针 —— 一行 PK SELECT:`undefined` = 普通会话或无此行。
|
|
99
|
-
* (release 的冷缓存腿、包装店的暖命中准入判、诊断面共用这一只。)
|
|
100
|
-
*/
|
|
101
56
|
async placementOf(sessionId) {
|
|
102
57
|
const [rows] = await this.pool.query(`SELECT ${PLACEMENT_SELECT_COLUMNS} FROM session_meta WHERE session_id = ?`, [sessionId]);
|
|
103
58
|
return placementFromRow(rows[0]);
|
|
104
59
|
}
|
|
105
|
-
/**
|
|
106
|
-
* #292 义务 5 —— placed 分区的**自有枚举**(联合 reap 的分区腿输入)。
|
|
107
|
-
*
|
|
108
|
-
* 年龄锚 = `COALESCE(updated_at, placed_at)`:`updated_at` 是本店的「最后活动」标记(register 时置、
|
|
109
|
-
* 每次 append/touch 推进),取不到才回落 `placed_at` —— 与 core File 店「mtime 优先、placedAt 回落」
|
|
110
|
-
* 的读法同义。行按年龄升序、上界 {@link LIST_PLACED_BATCH}(理由见那里)。
|
|
111
|
-
* join 半缺(scope/handle 任一为 NULL)⇒ `tupleIncomplete` 诚实变体,消费方对它 fail-closed。
|
|
112
|
-
*/
|
|
113
60
|
async listPlaced(kind, opts) {
|
|
114
61
|
const params = [kind];
|
|
115
62
|
let sql = `SELECT session_id, updated_at, ${PLACEMENT_SELECT_COLUMNS} FROM session_meta WHERE placement_kind = ?`;
|
|
@@ -126,7 +73,6 @@ export class TiDBSessionStore {
|
|
|
126
73
|
const [rows] = await this.pool.query(sql, params);
|
|
127
74
|
return rows.map((r) => placedRowFromRow(String(r.session_id), r, r.updated_at instanceof Date ? r.updated_at.getTime() : undefined));
|
|
128
75
|
}
|
|
129
|
-
/** Owner principal of a session, `null` if registered ownerless, `undefined` if no such session. */
|
|
130
76
|
async ownerOf(sessionId) {
|
|
131
77
|
const [rows] = await this.pool.query("SELECT owner FROM session_meta WHERE session_id = ?", [sessionId]);
|
|
132
78
|
if (rows.length === 0)
|
|
@@ -139,51 +85,26 @@ export class TiDBSessionStore {
|
|
|
139
85
|
sessionId,
|
|
140
86
|
]);
|
|
141
87
|
}
|
|
142
|
-
/** E18 — the session's current leaf `SessionTreeEntry.id` (`session_meta.leaf_id`, set transactionally on every
|
|
143
|
-
* persist). A single SELECT, NO acquire/lock/CAS — safe to call mid-run (write-safety is the append-time CAS, not
|
|
144
|
-
* an acquire lease) and cache-bypassing (the resume-at turn-capture reads it without touching the warm session).
|
|
145
|
-
* Returns the leaf id, or null when the session has none / no such session. */
|
|
146
88
|
async getLeafId(sessionId) {
|
|
147
89
|
const [rows] = await this.pool.query("SELECT leaf_id FROM session_meta WHERE session_id = ?", [sessionId]);
|
|
148
90
|
return rows[0] ? (rows[0].leaf_id ?? null) : null;
|
|
149
91
|
}
|
|
150
|
-
/** [1196] 六轮复审:owner+leafId **单行原子快照**(SSE watch 探针的租户围栏读)——两独立 SELECT 在
|
|
151
|
-
* delete/reclaim 缝里可拼出「旧 owner+新 leaf」混世代组合;单 SELECT 单行=天然一致。undefined=无此会话。 */
|
|
152
92
|
async getHead(sessionId) {
|
|
153
93
|
const [rows] = await this.pool.query("SELECT owner, leaf_id FROM session_meta WHERE session_id = ?", [sessionId]);
|
|
154
94
|
if (rows.length === 0)
|
|
155
95
|
return undefined;
|
|
156
96
|
return { owner: rows[0].owner ?? null, leafId: rows[0].leaf_id ?? null };
|
|
157
97
|
}
|
|
158
|
-
/**
|
|
159
|
-
* Durable history is retained; just drop any in-flight cache entry.
|
|
160
|
-
*
|
|
161
|
-
* 🔴 #292 义务 4 —— **placed 会话例外:release 是真删除**(durable 史一并)。联合 reap 先按 rev 条件删
|
|
162
|
-
* 掉 agent 行、赢了才调本方法终结转录;若这里还按「归还租约」走,一条 30 天的转录就会失去属主行地
|
|
163
|
-
* 悬着(正是这条义务要关死的 strand)。判据取**持久化元组**(冷缓存/重启后照样成立,不依赖任何进程
|
|
164
|
-
* 内状态);删除本身是 ABA 安全的**条件删**(`placement_kind IS NOT NULL`)——探针与删除之间若真有
|
|
165
|
-
* 「行被删掉又以同 id 重建成普通会话」的世代翻转,条件删当场落空,不会误杀那只无辜的新会话。
|
|
166
|
-
* 删除失败**响亮上浮**(`SessionError("storage")`):吞掉失败等于让 reap 数一条「已终结」而字节还在
|
|
167
|
-
* (core File 店同批的 rmSync 失败上浮是同一条法)。
|
|
168
|
-
*/
|
|
169
98
|
async release(sessionId) {
|
|
170
99
|
this.pending.delete(sessionId);
|
|
171
100
|
const placed = await this.placementOf(sessionId);
|
|
172
101
|
if (placed === undefined)
|
|
173
|
-
return;
|
|
102
|
+
return;
|
|
174
103
|
await this.deletePlacedSession(sessionId);
|
|
175
104
|
}
|
|
176
|
-
/**
|
|
177
|
-
* core B-17 的 `forget` 面 —— **丢引用,不动 durable 史**(与 {@link release} 的分工:release 是
|
|
178
|
-
* 生命周期终结口,forget 是「我用完了这只句柄」)。#292 之后这条分工是承重的:release 对 placed
|
|
179
|
-
* 会话已是真删除,于是每一处「只为验存在 / 写完收工」的收尾都必须落在 forget 上(preflight 纪律,
|
|
180
|
-
* [4743] 义务①)。本店的进程内状态只有 in-flight 去重表,故实现就是丢那一格。
|
|
181
|
-
*/
|
|
182
105
|
forget(sessionId) {
|
|
183
106
|
this.pending.delete(sessionId);
|
|
184
107
|
}
|
|
185
|
-
/** 真删除一只 placed 会话的两张表(条件删门在 `placement_kind IS NOT NULL`;事务内 meta 先删=提交点,
|
|
186
|
-
* 与 {@link deleteSession} 同序)。任何 I/O 失败上浮成 `SessionError("storage")`,绝不吞。 */
|
|
187
108
|
async deletePlacedSession(sessionId) {
|
|
188
109
|
const conn = await this.pool.getConnection();
|
|
189
110
|
try {
|
|
@@ -198,7 +119,6 @@ export class TiDBSessionStore {
|
|
|
198
119
|
await conn.rollback();
|
|
199
120
|
}
|
|
200
121
|
catch {
|
|
201
|
-
/* ignore */
|
|
202
122
|
}
|
|
203
123
|
throw err instanceof SessionError
|
|
204
124
|
? err
|
|
@@ -208,58 +128,26 @@ export class TiDBSessionStore {
|
|
|
208
128
|
conn.release();
|
|
209
129
|
}
|
|
210
130
|
}
|
|
211
|
-
// ───────────────────────── SessionRepo seam (§0.5 session-ownership) ─────────────────────────
|
|
212
|
-
// E16/E17/E21: the service EXTENDS core's `SessionRepo` (list/fork/delete) over its OWN session_meta /
|
|
213
|
-
// session_event tables — a `TiDBSessionRepo`-style implementation (core's `PgSessionRepo` targets core's
|
|
214
|
-
// own `agent_*` tables, so it can't be consumed directly; the dossier §0.5 prescribes a backend-mirrored
|
|
215
|
-
// impl here). Enumeration + lifecycle now come from the SESSION abstraction (session identity), not the
|
|
216
|
-
// `task_run` runs ledger; `task_run` retreats to a pure runs ledger that only SUPPLIES run-level preview.
|
|
217
|
-
/**
|
|
218
|
-
* E16 — list the caller's sessions (CC /resume picker), keyset-paginated newest-first by last activity.
|
|
219
|
-
* Session IDENTITY/enumeration comes from `session_meta` (owner-filtered on the service's OWN owner column);
|
|
220
|
-
* a LEFT JOIN onto the `task_run` runs ledger supplies run-level PREVIEW (latest run's objective/status,
|
|
221
|
-
* run count) but never gates which sessions exist — a session with zero runs still lists (preview null/0).
|
|
222
|
-
* Keyset on `(updated_at, session_id)`; `updated_at` is the session's last-activity marker (touch on every
|
|
223
|
-
* run/append). The shipped {@link SessionSummary} shape is preserved (additive): `lastActivityAt` =
|
|
224
|
-
* `session_meta.updated_at`, `firstActivityAt` = `session_meta.created_at`.
|
|
225
|
-
*/
|
|
226
131
|
async listSessions(opts) {
|
|
227
132
|
const params = [];
|
|
228
|
-
// 🔴 #292 义务 2:placed 会话**永不上宿主枚举面**——壳的 /resume 选择器不许把一条子代转录当成可
|
|
229
|
-
// 续聊的会话摆出来(而且它一旦被选中,claim 形 acquire 还会当场拒)。谓词打在**持久化列**上 ⇒
|
|
230
|
-
// 重启后照样成立(义务的「restart-stable」一半)。
|
|
231
133
|
const where = ["sm.placement_kind IS NULL"];
|
|
232
134
|
if (opts.owner) {
|
|
233
|
-
|
|
234
|
-
// 仍然**不是** `owner IS NOT DISTINCT FROM ?`——具名的别人一行都不多给。
|
|
235
|
-
where.push(opts.includeUnowned ? "(sm.owner = ? OR sm.owner IS NULL)" : "sm.owner = ?"); // idx_owner
|
|
135
|
+
where.push(opts.includeUnowned ? "(sm.owner = ? OR sm.owner IS NULL)" : "sm.owner = ?");
|
|
236
136
|
params.push(opts.owner);
|
|
237
137
|
}
|
|
238
138
|
if (opts.q) {
|
|
239
|
-
// `?q=` search: match sessions where ANY run's objective preview contains q (EXISTS on the
|
|
240
|
-
// runs ledger — better recall than latest-run-only, still session-granular). LOWER-LIKE = collation-proof
|
|
241
|
-
// case-insensitivity (TiDB table collation may be _bin); escapeLike + ESCAPE pins the wildcard semantics
|
|
242
|
-
// (deployment baseline: default sql_mode — under NO_BACKSLASH_ESCAPES the '\\' literal would break, a
|
|
243
|
-
// known escapeLike-family boundary shared with the tool-result store).
|
|
244
|
-
// No tr.owner predicate BY DESIGN: the outer WHERE already proves the session is the caller's;
|
|
245
|
-
// a same-session_id foreign-owner run requires DB write access to fabricate (session_meta PK + uuidv7),
|
|
246
|
-
// and an owner predicate would drop the LEGITIMATE mixed-owner form (anonymous run → principal attach,
|
|
247
|
-
// the E21 diverged-owner case) — recall loss for a 1-bit defense against an attacker who can already write.
|
|
248
139
|
where.push("EXISTS (SELECT 1 FROM task_run tr WHERE tr.session_id = sm.session_id AND LOWER(tr.objective_preview) LIKE LOWER(?) ESCAPE '\\\\')");
|
|
249
140
|
params.push(`%${escapeLike(opts.q)}%`);
|
|
250
141
|
}
|
|
251
142
|
if (opts.cursor) {
|
|
252
|
-
where.push("(sm.updated_at < ? OR (sm.updated_at = ? AND sm.session_id < ?))");
|
|
143
|
+
where.push("(sm.updated_at < ? OR (sm.updated_at = ? AND sm.session_id < ?))");
|
|
253
144
|
params.push(new Date(opts.cursor.lastActivityAt), new Date(opts.cursor.lastActivityAt), opts.cursor.sessionId);
|
|
254
145
|
}
|
|
255
|
-
// The run-preview JOIN: the LATEST run per session (rn=1, ties broken by task_id so EXACTLY one row joins —
|
|
256
|
-
// a bare MAX(created_at) JOIN would fan out the outer row when two runs share a created_at) + a count rollup.
|
|
257
|
-
// A LEFT JOIN keeps sessions with NO runs (preview NULL, run_count 0). Bounded by per-tenant data today.
|
|
258
146
|
const sql = "SELECT sm.session_id, sm.owner, sm.updated_at AS last_activity, sm.created_at AS first_activity, sm.title, " +
|
|
259
147
|
" COALESCE(r.run_count, 0) AS run_count, latest.objective_preview, latest.status, latest.task_id AS last_run_id " +
|
|
260
148
|
"FROM session_meta sm " +
|
|
261
149
|
"LEFT JOIN (SELECT session_id, COUNT(*) AS run_count FROM task_run GROUP BY session_id) r ON r.session_id = sm.session_id " +
|
|
262
|
-
"LEFT JOIN (SELECT session_id, objective_preview, status, task_id FROM (" +
|
|
150
|
+
"LEFT JOIN (SELECT session_id, objective_preview, status, task_id FROM (" +
|
|
263
151
|
" SELECT session_id, objective_preview, status, task_id, " +
|
|
264
152
|
" ROW_NUMBER() OVER (PARTITION BY session_id ORDER BY created_at DESC, task_id DESC) AS rn FROM task_run" +
|
|
265
153
|
" ) lr WHERE rn = 1) latest ON latest.session_id = sm.session_id " +
|
|
@@ -275,55 +163,33 @@ export class TiDBSessionStore {
|
|
|
275
163
|
runCount: Number(r.run_count),
|
|
276
164
|
objectivePreview: r.objective_preview ?? null,
|
|
277
165
|
lastStatus: r.status == null ? "" : String(r.status),
|
|
278
|
-
lastRunId: r.last_run_id ?? null,
|
|
279
|
-
title: r.title ?? null,
|
|
166
|
+
lastRunId: r.last_run_id ?? null,
|
|
167
|
+
title: r.title ?? null,
|
|
280
168
|
}));
|
|
281
169
|
}
|
|
282
|
-
/** Cheap pre-LLM probe: "none" = session_meta row not yet REGISTERED (createRun fires
|
|
283
|
-
* before the Runner's session acquire — the titler retries), "untitled" / "titled" as read. */
|
|
284
170
|
async probeTitle(sessionId) {
|
|
285
171
|
const [rows] = await this.pool.query("SELECT title FROM session_meta WHERE session_id = ?", [sessionId]);
|
|
286
172
|
if (!rows[0])
|
|
287
173
|
return "none";
|
|
288
174
|
return rows[0].title == null ? "untitled" : "titled";
|
|
289
175
|
}
|
|
290
|
-
/** Write-once auto-title: lands ONLY when title IS NULL (a concurrent/prior title wins;
|
|
291
|
-
* the user-facing rename layer is BFF-side). Returns whether this call set it. */
|
|
292
176
|
async setTitleIfNull(sessionId, title) {
|
|
293
177
|
const [r] = await this.pool.query("UPDATE session_meta SET title = ? WHERE session_id = ? AND title IS NULL", [title, sessionId]);
|
|
294
178
|
return r.affectedRows > 0;
|
|
295
179
|
}
|
|
296
|
-
/**
|
|
297
|
-
* E17 — fork a session: copy its WHOLE durable history to a NEW uuidv7 session owned by `owner`, mirroring
|
|
298
|
-
* core's `PgSessionRepo.fork`. Entry ids / parent links are per-session and copy VERBATIM; only `seq` is
|
|
299
|
-
* re-densified from 0 in the new session. The new leaf = the source leaf (session_meta.leaf_id).
|
|
300
|
-
* Returns the new sessionId, or null if the source does not exist (the route maps that to 404).
|
|
301
|
-
*
|
|
302
|
-
* 🔴 FULL history, NOT `wake().getEntries()` (adversarial-review MEDIUM): `wake` loads only the bounded tail
|
|
303
|
-
* `[floor..leaf]` from the latest compaction floor (F3), so forking via it would copy only the active tail of a
|
|
304
|
-
* COMPACTED session and SILENTLY LOSE all pre-compaction history (contradicting the whole-history-copy intent).
|
|
305
|
-
* We instead SELECT the entire `session_event` log ordered by seq — every durable entry, compaction floor
|
|
306
|
-
* bypassed — and re-seq it dense from 0 into the new session, ids/parents/payload byte-verbatim.
|
|
307
|
-
*/
|
|
308
180
|
async fork(sourceId, owner) {
|
|
309
|
-
// The source's authoritative leaf (and existence check) come from session_meta — null meta ⇒ unknown source.
|
|
310
181
|
const [metaRows] = await this.pool.query("SELECT leaf_id FROM session_meta WHERE session_id = ?", [sourceId]);
|
|
311
182
|
if (!metaRows[0])
|
|
312
183
|
return null;
|
|
313
184
|
const leafId = metaRows[0].leaf_id ?? null;
|
|
314
|
-
// FULL durable history (compaction floor bypassed) — every entry, oldest first. payload is the stored entry blob.
|
|
315
185
|
const [eventRows] = await this.pool.query("SELECT entry_id, parent_id, type, payload, ts FROM session_event WHERE session_id = ? ORDER BY seq ASC", [sourceId]);
|
|
316
186
|
const newId = uuidv7();
|
|
317
187
|
const now = new Date();
|
|
318
188
|
const conn = await this.pool.getConnection();
|
|
319
189
|
try {
|
|
320
190
|
await conn.beginTransaction();
|
|
321
|
-
// Entries land BEFORE the session_meta row (mirrors core BUG-1): the meta row is the commit point —
|
|
322
|
-
// a mid-fork failure leaves orphan events with NO meta row (invisible to wake), never a half-session.
|
|
323
191
|
for (let seq = 0; seq < eventRows.length; seq++) {
|
|
324
192
|
const r = eventRows[seq];
|
|
325
|
-
// Copy the payload blob VERBATIM (string or already-parsed object); re-stringify only an object so the
|
|
326
|
-
// INSERT binds a JSON string. entry_id/parent_id/type/ts are carried over unchanged; only seq is re-densified.
|
|
327
193
|
const payload = typeof r.payload === "string" ? r.payload : JSON.stringify(r.payload);
|
|
328
194
|
await conn.query("INSERT INTO session_event (session_id, seq, entry_id, parent_id, type, payload, ts) VALUES (?,?,?,?,?,?,?)", [newId, seq, r.entry_id, r.parent_id ?? null, r.type, payload, r.ts]);
|
|
329
195
|
}
|
|
@@ -336,7 +202,6 @@ export class TiDBSessionStore {
|
|
|
336
202
|
await conn.rollback();
|
|
337
203
|
}
|
|
338
204
|
catch {
|
|
339
|
-
/* ignore */
|
|
340
205
|
}
|
|
341
206
|
throw err instanceof SessionError ? err : new SessionError("storage", `fork of session ${sourceId} failed`, err instanceof Error ? err : undefined);
|
|
342
207
|
}
|
|
@@ -344,100 +209,46 @@ export class TiDBSessionStore {
|
|
|
344
209
|
conn.release();
|
|
345
210
|
}
|
|
346
211
|
}
|
|
347
|
-
/**
|
|
348
|
-
* 2c session-sync — EXPORT a session's full durable entry log for cross-backend migration. This is the
|
|
349
|
-
* READ half of {@link fork}, surfaced as the core `SessionRepo.exportEntries` seam: the WHOLE `session_event` log,
|
|
350
|
-
* oldest-first, ids/parents/payload/ts VERBATIM, with the compaction floor BYPASSED (NOT `wake().getEntries()`,
|
|
351
|
-
* which returns only the bounded `[floor..leaf]` tail and would silently truncate pre-floor history — see fork's
|
|
352
|
-
* note at :149-152). Returns `null` if the source does not exist (no `session_meta` row), mirroring fork's null
|
|
353
|
-
* semantics; an existing-but-empty session returns `[]`. The owner is NOT exported — import re-stamps it (§9).
|
|
354
|
-
*/
|
|
355
212
|
async exportEntries(sessionId) {
|
|
356
|
-
// Existence check = session_meta (a session with zero events but a meta row is a real, empty session ⇒ []).
|
|
357
213
|
const [metaRows] = await this.pool.query("SELECT 1 FROM session_meta WHERE session_id = ?", [sessionId]);
|
|
358
214
|
if (!metaRows[0])
|
|
359
215
|
return null;
|
|
360
|
-
// FULL durable log (compaction floor bypassed) — every entry, oldest first. payload is the stored entry blob.
|
|
361
216
|
const [eventRows] = await this.pool.query("SELECT entry_id, parent_id, type, payload, ts FROM session_event WHERE session_id = ? ORDER BY seq ASC", [sessionId]);
|
|
362
|
-
// The export contract is `SessionTreeEntry[]` (the full entry blob), not the column projection. The stored
|
|
363
|
-
// `payload` IS the verbatim entry (id/parentId/timestamp + variant fields) — parse a string blob, pass an
|
|
364
|
-
// already-parsed object (mysql2 returns JSON columns parsed) through.
|
|
365
217
|
return eventRows.map((r) => (typeof r.payload === "string" ? JSON.parse(r.payload) : r.payload));
|
|
366
218
|
}
|
|
367
|
-
/**
|
|
368
|
-
* 2c session-sync P1d-α (PULL streaming) — the IDS-ONLY projection of {@link exportEntries}: the entry ids of the
|
|
369
|
-
* WHOLE durable log, oldest-first (`SELECT entry_id … ORDER BY seq ASC`), with no payloads. Small even for a huge
|
|
370
|
-
* session, so the PULL `GET /sync/manifest` can carry it (and feed it to `classifySyncRelationshipByIds`) instead of
|
|
371
|
-
* the entries. Returns `null` if the source does not exist (no `session_meta` row), mirroring {@link exportEntries}.
|
|
372
|
-
*/
|
|
373
219
|
async listEntryIds(sessionId) {
|
|
374
220
|
const [metaRows] = await this.pool.query("SELECT 1 FROM session_meta WHERE session_id = ?", [sessionId]);
|
|
375
221
|
if (!metaRows[0])
|
|
376
|
-
return null;
|
|
222
|
+
return null;
|
|
377
223
|
const [rows] = await this.pool.query("SELECT entry_id FROM session_event WHERE session_id = ? ORDER BY seq ASC", [sessionId]);
|
|
378
224
|
return rows.map((r) => String(r.entry_id));
|
|
379
225
|
}
|
|
380
|
-
/**
|
|
381
|
-
* 2c session-sync P1d-α (PULL streaming) — STREAM the WHOLE durable log (compaction floor bypassed), oldest-first,
|
|
382
|
-
* as an async generator that KEYSET-PAGES the `session_event` table: mysql2 buffers a plain SELECT, so a 1M-row
|
|
383
|
-
* session would OOM if read in one shot. Instead the generator loops `… WHERE seq > cursor ORDER BY seq ASC LIMIT K`
|
|
384
|
-
* (K = `opts.batchSize ?? 500`), advancing the cursor to the last `seq` of each batch, until a SHORT page (fewer than
|
|
385
|
-
* K rows) ends the stream. Each batch is its OWN `pool.query` (a pooled connection acquired + released per batch), so
|
|
386
|
-
* a slow NDJSON consumer can NEVER pin one connection across the whole stream (the clay-mandated concurrency
|
|
387
|
-
* discipline). Start cursor = `opts.afterSeq ?? -1` (seq is 0-based dense, so `> -1` = from seq 0).
|
|
388
|
-
*
|
|
389
|
-
* Resolves to `null` (NOT an empty async-iterable) when the session does not exist — checked via `session_meta`
|
|
390
|
-
* EAGERLY here (before returning the iterable), exactly like {@link exportEntries}, so a missing session is distinct
|
|
391
|
-
* from a real-but-empty one (an empty stream). payload is parsed like {@link exportEntries} (string → JSON.parse,
|
|
392
|
-
* object passthrough). The check is eager (the method is async) because a generator body is lazy — it can't decide
|
|
393
|
-
* `null` vs. iterable on its own first pull.
|
|
394
|
-
*/
|
|
395
226
|
async exportEntriesStream(sessionId, opts) {
|
|
396
227
|
const [metaRows] = await this.pool.query("SELECT 1 FROM session_meta WHERE session_id = ?", [sessionId]);
|
|
397
228
|
if (!metaRows[0])
|
|
398
|
-
return null;
|
|
229
|
+
return null;
|
|
399
230
|
const pool = this.pool;
|
|
400
231
|
const batchSize = opts?.batchSize ?? 500;
|
|
401
232
|
async function* gen() {
|
|
402
|
-
let cursor = opts?.afterSeq ?? -1;
|
|
233
|
+
let cursor = opts?.afterSeq ?? -1;
|
|
403
234
|
for (;;) {
|
|
404
|
-
// ONE pooled query per batch (NOT a held connection across the whole stream) — keyset on (session_id, seq).
|
|
405
235
|
const [rows] = await pool.query("SELECT seq, payload FROM session_event WHERE session_id = ? AND seq > ? ORDER BY seq ASC LIMIT ?", [sessionId, cursor, batchSize]);
|
|
406
236
|
for (const r of rows) {
|
|
407
237
|
const p = r.payload;
|
|
408
238
|
yield (typeof p === "string" ? JSON.parse(p) : p);
|
|
409
239
|
}
|
|
410
240
|
if (rows.length < batchSize)
|
|
411
|
-
return;
|
|
412
|
-
cursor = Number(rows[rows.length - 1].seq);
|
|
241
|
+
return;
|
|
242
|
+
cursor = Number(rows[rows.length - 1].seq);
|
|
413
243
|
}
|
|
414
244
|
}
|
|
415
245
|
return gen();
|
|
416
246
|
}
|
|
417
|
-
/**
|
|
418
|
-
* 2c session-sync — IMPORT a verbatim entry log (from another backend's {@link exportEntries}) into a
|
|
419
|
-
* (typically NEW) `sessionId`. This is the WRITE half of {@link fork}, surfaced as `SessionRepo.importEntries`,
|
|
420
|
-
* with two 2c-mandated differences from fork:
|
|
421
|
-
* (a) OWNER RE-STAMP (§9): `owner` is the AUTHENTICATED importing principal passed by the caller; the bundle's
|
|
422
|
-
* own owner is NEVER trusted (a local push is always owner=null → would become an ownerless cloud session
|
|
423
|
-
* any anonymous caller could attach to). Mirrors the fork route's server-side `scope.gateOwner`.
|
|
424
|
-
* (b) the single INVARIANT GATE is core's {@link validateEntriesForImport} (unique ids / parent-before-child /
|
|
425
|
-
* exactly-one-root / leaf-resolvable / structural refs present) — we persist its VERBATIM output and do NOT
|
|
426
|
-
* re-implement any of those checks in SQL ([[core-service-boundary]]; the same discipline fork's hand-SQL
|
|
427
|
-
* was warned about). A violation throws `SessionError("invalid_session")` and NOTHING is written.
|
|
428
|
-
* The write itself mirrors fork: re-seq dense from 0, entry_id/parent_id/payload/ts verbatim, in ONE txn with
|
|
429
|
-
* `session_meta` LAST as the commit point (a mid-import crash leaves orphan events with no meta = invisible to
|
|
430
|
-
* wake, never a half-session). The leaf is the replayed leaf (core's `leafIdAfterEntry`), matching core's import.
|
|
431
|
-
*/
|
|
432
247
|
async importEntries(sessionId, owner, entries) {
|
|
433
|
-
// Fail-CLOSED invariant gate FIRST (before any write) — returns the entries verbatim or throws invalid_session.
|
|
434
248
|
const validated = validateEntriesForImport(entries);
|
|
435
249
|
const conn = await this.pool.getConnection();
|
|
436
250
|
try {
|
|
437
251
|
await conn.beginTransaction();
|
|
438
|
-
// The shared insert body (entries re-seq dense from 0, then session_meta LAST as the commit point). A plain
|
|
439
|
-
// INSERT — assumes a FRESH sessionId; a duplicate (session already present) crashes on the PK. replaceEntries
|
|
440
|
-
// is the idempotent-over-existing path (it DELETEs first inside the same txn).
|
|
441
252
|
await this.insertValidatedLog(conn, sessionId, owner, validated);
|
|
442
253
|
await conn.commit();
|
|
443
254
|
}
|
|
@@ -446,50 +257,28 @@ export class TiDBSessionStore {
|
|
|
446
257
|
await conn.rollback();
|
|
447
258
|
}
|
|
448
259
|
catch {
|
|
449
|
-
/* ignore */
|
|
450
260
|
}
|
|
451
261
|
if (err instanceof SessionError)
|
|
452
|
-
throw err;
|
|
262
|
+
throw err;
|
|
453
263
|
throw new SessionError("storage", `import of session ${sessionId} failed`, err instanceof Error ? err : undefined);
|
|
454
264
|
}
|
|
455
265
|
finally {
|
|
456
266
|
conn.release();
|
|
457
267
|
}
|
|
458
268
|
}
|
|
459
|
-
/**
|
|
460
|
-
* 2c session-sync (§7/§8) — IDEMPOTENT REPLACE: purge any existing conversation log for `sessionId` then re-import
|
|
461
|
-
* `entries` verbatim under `owner`, ALL in ONE txn. Unlike {@link importEntries} (a plain INSERT that crashes on a
|
|
462
|
-
* duplicate PK when the session already exists at the destination), replace is safe to run OVER an existing session
|
|
463
|
-
* — the §7 fast-forward path and a §8 retry-to-completion both land on it. The validate gate runs FIRST (fail-closed,
|
|
464
|
-
* before any write), then DELETE events + the OWNER-GUARDED meta delete (defense-in-depth, mirrors {@link deleteSession}
|
|
465
|
-
* — a wrong-owner caller can never replace another tenant's session), then the SAME shared insert body importEntries
|
|
466
|
-
* uses (so there is ONE source of the insert logic, no copy-paste drift). session_meta is still written LAST = commit
|
|
467
|
-
* point, so a mid-replace crash (post-delete, pre-insert) leaves orphan-free state (no meta = invisible to wake).
|
|
468
|
-
*/
|
|
469
269
|
async replaceEntries(sessionId, owner, entries) {
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
// 子代转录的内容被外部 push 覆盖,等于 agent 行还指着它、里面已经是别人的历史。⇒ 响亮拒。
|
|
473
|
-
await this.assertNotPlaced(sessionId, "replace"); // 快路(零工:命中即拒,不进事务)
|
|
474
|
-
const validated = validateEntriesForImport(entries); // fail-closed gate BEFORE any write (mirrors importEntries)
|
|
270
|
+
await this.assertNotPlaced(sessionId, "replace");
|
|
271
|
+
const validated = validateEntriesForImport(entries);
|
|
475
272
|
const conn = await this.pool.getConnection();
|
|
476
273
|
try {
|
|
477
274
|
await conn.beginTransaction();
|
|
478
|
-
// 🔴 #292(codex 对抗复审 R1-[high] 的同族窗,验真后修):上面那道快路是**事务外**的 ⇒ 与本事务
|
|
479
|
-
// 之间有 TOCTOU 窗。真门在事务内:①锁读一次元组(存在的行被行锁住;MySQL 的 gap lock 还会挡住
|
|
480
|
-
// 同键的并发 INSERT);②meta 的 DELETE 加 `placement_kind IS NULL` 硬条件 —— 即便判据被绕过,
|
|
481
|
-
// 一条 placed 行也**不可能**被这条路径删掉(placed 行的 owner 恒 NULL,而 replace(owner=null)
|
|
482
|
-
// 的属主门恰好匹配它 —— 没有这个条件,一次匿名 push 就能把子代转录整只换掉)。
|
|
483
275
|
const [placedRows] = await conn.query("SELECT placement_kind FROM session_meta WHERE session_id = ? FOR UPDATE", [sessionId]);
|
|
484
276
|
if (placedRows[0]?.placement_kind != null) {
|
|
485
277
|
await conn.rollback();
|
|
486
278
|
throw new SessionError("placement_refused", `session ${sessionId} belongs to the ${String(placedRows[0].placement_kind)} placement partition — a session-sync replace cannot overwrite a delegation transcript`);
|
|
487
279
|
}
|
|
488
|
-
// Purge the existing log first (idempotent: a missing session deletes 0 rows). Events first, then the
|
|
489
|
-
// owner-guarded meta delete (`owner <=> ?` — a legacy null-owner session matches owner=null), defense-in-depth.
|
|
490
280
|
await conn.query("DELETE FROM session_event WHERE session_id = ?", [sessionId]);
|
|
491
281
|
await conn.query("DELETE FROM session_meta WHERE session_id = ? AND owner <=> ? AND placement_kind IS NULL", [sessionId, owner]);
|
|
492
|
-
// …then re-insert the validated log (entries + meta-as-commit-point) via the SHARED body.
|
|
493
282
|
await this.insertValidatedLog(conn, sessionId, owner, validated);
|
|
494
283
|
await conn.commit();
|
|
495
284
|
}
|
|
@@ -498,77 +287,41 @@ export class TiDBSessionStore {
|
|
|
498
287
|
await conn.rollback();
|
|
499
288
|
}
|
|
500
289
|
catch {
|
|
501
|
-
/* ignore */
|
|
502
290
|
}
|
|
503
291
|
if (err instanceof SessionError)
|
|
504
|
-
throw err;
|
|
292
|
+
throw err;
|
|
505
293
|
throw new SessionError("storage", `replace of session ${sessionId} failed`, err instanceof Error ? err : undefined);
|
|
506
294
|
}
|
|
507
295
|
finally {
|
|
508
296
|
conn.release();
|
|
509
297
|
}
|
|
510
298
|
}
|
|
511
|
-
/**
|
|
512
|
-
* Shared insert half of {@link importEntries} / {@link replaceEntries} (ONE source of the insert logic, no drift):
|
|
513
|
-
* write the validated entries re-seq dense from 0 (entry_id/parent_id/type/ts verbatim), THEN session_meta LAST as
|
|
514
|
-
* the commit point. Runs inside the caller's open transaction (`conn`) so the caller owns begin/commit/rollback.
|
|
515
|
-
* `validated` is the OUTPUT of {@link validateEntriesForImport} (the caller runs the gate before opening the txn).
|
|
516
|
-
*/
|
|
517
299
|
async insertValidatedLog(conn, sessionId, owner, validated) {
|
|
518
|
-
// The replayed leaf (last entry's leaf target, mirroring core resolveLeafId / validateEntriesForImport).
|
|
519
300
|
let leafId = null;
|
|
520
301
|
for (const e of validated)
|
|
521
|
-
leafId = leafIdAfterEntry(e);
|
|
302
|
+
leafId = leafIdAfterEntry(e);
|
|
522
303
|
const now = new Date();
|
|
523
|
-
// Entries land BEFORE session_meta (mirrors fork / core BUG-1): the meta row is the commit point.
|
|
524
304
|
for (let seq = 0; seq < validated.length; seq++) {
|
|
525
305
|
const e = validated[seq];
|
|
526
|
-
// payload = the verbatim entry blob (stringified for the JSON column bind); entry_id/parent_id/type/ts carried
|
|
527
|
-
// over unchanged from the entry; only seq is re-densified. ts comes from the entry's own ISO timestamp.
|
|
528
306
|
await conn.query("INSERT INTO session_event (session_id, seq, entry_id, parent_id, type, payload, ts) VALUES (?,?,?,?,?,?,?)", [sessionId, seq, e.id, e.parentId ?? null, e.type, JSON.stringify(e), new Date(e.timestamp)]);
|
|
529
307
|
}
|
|
530
|
-
// session_meta LAST = commit point. owner = the RE-STAMPED authenticated principal (never the bundle's).
|
|
531
308
|
await conn.query("INSERT INTO session_meta (session_id, owner, created_at, updated_at, leaf_id, leaf_seq) VALUES (?,?,?,?,?,?)", [sessionId, owner, now, now, leafId, validated.length]);
|
|
532
309
|
}
|
|
533
|
-
/**
|
|
534
|
-
* 2c session-sync P1d-β (PUSH streaming, §15) — begin a STAGED import into `realSessionId` under the shadow id
|
|
535
|
-
* `${realSessionId}#stg-${token}`. The route mints the uuidv7 `token` (the store never calls uuidv7/Date.now itself
|
|
536
|
-
* → the staging id is deterministic + the route owns the lease key). Returns a {@link TiDBStagingHandle} the route
|
|
537
|
-
* streams entry batches into ({@link StagingHandle.appendBatch}) then commits ({@link StagingHandle.commit}, the
|
|
538
|
-
* atomic swap) or aborts. No row is touched here — the handle is a stateless cursor over this pool.
|
|
539
|
-
*/
|
|
540
310
|
beginImportStaging(realSessionId, token) {
|
|
541
311
|
return new TiDBStagingHandle(this.pool, realSessionId, stagingIdFor(realSessionId, token));
|
|
542
312
|
}
|
|
543
|
-
/** #292:「这个 id 是 placed ⇒ 该写路径不适用」的共用拒绝(session-sync 的两条换装腿共用)。 */
|
|
544
313
|
async assertNotPlaced(sessionId, what) {
|
|
545
314
|
const placed = await this.placementOf(sessionId);
|
|
546
315
|
if (placed !== undefined) {
|
|
547
316
|
throw new SessionError("placement_refused", `session ${sessionId} belongs to the ${placed.kind} placement partition — a session-sync ${what} cannot overwrite a delegation transcript`);
|
|
548
317
|
}
|
|
549
318
|
}
|
|
550
|
-
/**
|
|
551
|
-
* 2c session-sync P1d-β (staged-row inspection) — read the rows STAGED under `stagingId` (NO session_meta probe — a
|
|
552
|
-
* staging id has no meta row by construction, so {@link exportEntries} would return null), oldest-first. The Phase-B
|
|
553
|
-
* import gate is now the per-line {@link StreamingImportValidator} (core 1.143.0) run during the stream, so this
|
|
554
|
-
* is no longer the validation path; it remains the staged-row inspection seam (tests/diagnostics). Returns `[]` for an
|
|
555
|
-
* empty/unknown staging id.
|
|
556
|
-
*/
|
|
557
319
|
async readStagedEntries(stagingId) {
|
|
558
320
|
const [rows] = await this.pool.query("SELECT payload FROM session_event WHERE session_id = ? ORDER BY seq ASC", [stagingId]);
|
|
559
321
|
return rows.map((r) => (typeof r.payload === "string" ? JSON.parse(r.payload) : r.payload));
|
|
560
322
|
}
|
|
561
|
-
/**
|
|
562
|
-
* 2c session-sync P1d-β (§4) — reaper for ABANDONED staging sessions: DELETE session_event rows under a staging id
|
|
563
|
-
* (`session_id LIKE '%#stg-%'`) that has NO session_meta (never committed → invisible orphans) AND whose OLDEST
|
|
564
|
-
* staged row is older than the grace window (an IN-FLIGHT stream keeps appending fresh `ts` rows → its MIN(ts) is
|
|
565
|
-
* recent → it is NOT reaped, exactly like sweepOrphanBlobs' created_at grace). Per-staging-id, so a slow but live
|
|
566
|
-
* stream is never reaped while another abandoned one is. Returns the affected row count. Driven by main.ts's reaper.
|
|
567
|
-
*/
|
|
568
323
|
async sweepStagingSessions() {
|
|
569
324
|
const cutoff = new Date(Date.now() - STAGING_GC_GRACE_MS);
|
|
570
|
-
// Candidate staging ids = those with rows older than the cutoff AND no meta row. GROUP BY so an in-flight id (its
|
|
571
|
-
// MIN(ts) recent) is excluded; LEFT JOIN session_meta so a (defensively) committed staging id is never touched.
|
|
572
325
|
const [cands] = await this.pool.query("SELECT se.session_id FROM session_event se LEFT JOIN session_meta sm ON sm.session_id = se.session_id " +
|
|
573
326
|
"WHERE se.session_id LIKE ? AND sm.session_id IS NULL " +
|
|
574
327
|
"GROUP BY se.session_id HAVING MIN(se.ts) < ?", [`%${STAGING_ID_MARKER}%`, cutoff]);
|
|
@@ -579,28 +332,11 @@ export class TiDBSessionStore {
|
|
|
579
332
|
}
|
|
580
333
|
return removed;
|
|
581
334
|
}
|
|
582
|
-
/**
|
|
583
|
-
* E21 — purge a session's CONVERSATION HISTORY (session_meta + session_event). Idempotent: a missing
|
|
584
|
-
* session is a no-op (returns false). The service's OWN run-ledger / checkpoint / tool-result rows are
|
|
585
|
-
* purged separately by the route's `purgeSession` coordinator — this method owns only the session
|
|
586
|
-
* abstraction's tables (the `SessionRepo.delete` half).
|
|
587
|
-
*
|
|
588
|
-
* `owner` is the single-DB-fleet null-safe SQL owner guard on `session_meta.owner` (defense-in-depth — the
|
|
589
|
-
* route already owner-gates; `<=>` so a legacy null-owner session matches owner=null). session_event carries
|
|
590
|
-
* no owner column, so its purge is GATED on the owner-matched meta delete: if the meta DELETE matched 0 rows
|
|
591
|
-
* (wrong owner / already gone) we skip the event delete — so a wrong-owner caller can never strand-delete
|
|
592
|
-
* another tenant's events. Meta is deleted FIRST (mirrors core BUG-2: once it's gone, wake is `null`
|
|
593
|
-
* regardless of leftover events; if a crash interleaves, orphan events with NO meta are invisible, never a
|
|
594
|
-
* half-session), and the event purge only runs after that owner-matched commit point.
|
|
595
|
-
*/
|
|
596
335
|
async deleteSession(sessionId, owner) {
|
|
597
336
|
const conn = await this.pool.getConnection();
|
|
598
337
|
try {
|
|
599
338
|
await conn.beginTransaction();
|
|
600
|
-
// Meta row FIRST + owner-guarded: once it's gone, wake is `null` regardless of leftover events.
|
|
601
339
|
const [metaRes] = await conn.query("DELETE FROM session_meta WHERE session_id = ? AND owner <=> ?", [sessionId, owner]);
|
|
602
|
-
// Only purge events when the OWNER-matched meta row was removed — gates the un-owner-columned event table
|
|
603
|
-
// through the meta owner check (a wrong-owner / already-gone session removes 0 meta rows → skip the events).
|
|
604
340
|
if (metaRes.affectedRows > 0)
|
|
605
341
|
await conn.query("DELETE FROM session_event WHERE session_id = ?", [sessionId]);
|
|
606
342
|
await conn.commit();
|
|
@@ -611,7 +347,6 @@ export class TiDBSessionStore {
|
|
|
611
347
|
await conn.rollback();
|
|
612
348
|
}
|
|
613
349
|
catch {
|
|
614
|
-
/* ignore */
|
|
615
350
|
}
|
|
616
351
|
throw err;
|
|
617
352
|
}
|
|
@@ -620,53 +355,30 @@ export class TiDBSessionStore {
|
|
|
620
355
|
}
|
|
621
356
|
}
|
|
622
357
|
get size() {
|
|
623
|
-
// Best-effort: durable store reloads per acquire (no long-lived cache), so report in-flight only.
|
|
624
358
|
return this.pending.size;
|
|
625
359
|
}
|
|
626
360
|
dispose() {
|
|
627
|
-
// The pool is owned by main.ts and closed there; nothing process-local to release.
|
|
628
361
|
this.pending.clear();
|
|
629
362
|
}
|
|
630
363
|
}
|
|
631
|
-
/**
|
|
632
|
-
* 2c session-sync P1d-β (PUSH streaming, §15 + §4) — a TiDB {@link StagingHandle}: stream entry batches into a SHADOW
|
|
633
|
-
* `session_event` partition (the staging id, NO meta row → invisible to wake), then atomically SWAP them into the real
|
|
634
|
-
* session id. Server memory is bounded by the in-flight batch (one txn per batch), not the whole log. The swap is a
|
|
635
|
-
* row-key RENAME (`UPDATE session_event SET session_id`) — metadata-only, NO payload re-copy — so the commit cost is
|
|
636
|
-
* the rename + a DELETE of the (purged) dst rows, in ONE txn, with session_meta written LAST = the sole commit point.
|
|
637
|
-
*/
|
|
638
364
|
class TiDBStagingHandle {
|
|
639
365
|
pool;
|
|
640
366
|
realSessionId;
|
|
641
367
|
stagingId;
|
|
642
|
-
/** Running DENSE seq counter for the staging rows. Advanced ONLY for a logically-NEW entry id (tracked in `seen`), so
|
|
643
|
-
* a re-streamed entry (Phase-B resume) is skipped and the staged seqs stay dense 0..N-1 (NO holes that would collide
|
|
644
|
-
* with a future F2 leaf_seq append on the committed session). */
|
|
645
368
|
nextSeq = 0;
|
|
646
|
-
/** Entry ids already staged by THIS handle — a resume re-streams from the start, so dedup keeps seqs dense + idempotent. */
|
|
647
369
|
seen = new Set();
|
|
648
370
|
constructor(pool, realSessionId, stagingId) {
|
|
649
371
|
this.pool = pool;
|
|
650
372
|
this.realSessionId = realSessionId;
|
|
651
373
|
this.stagingId = stagingId;
|
|
652
374
|
}
|
|
653
|
-
/** Append a batch to the staging id, re-seq'd DENSE from the running counter (skipping ids already staged), IDEMPOTENTLY
|
|
654
|
-
* (ON DUPLICATE KEY UPDATE on the `(session_id, entry_id)` uk is the durable backstop too). One txn per batch
|
|
655
|
-
* (bounded). NO meta row is written (the staging id stays invisible). ts is the entry's own ISO timestamp (verbatim). */
|
|
656
375
|
async appendBatch(entries) {
|
|
657
|
-
// Build the NEW rows (skip already-staged ids — resume dedup keeps seqs dense), then ONE multi-row INSERT for the
|
|
658
|
-
// whole batch (bounded → bounded packet/txn) instead of N round-trips. ON DUPLICATE KEY UPDATE is the durable
|
|
659
|
-
// backstop for the rare case a dup slips past `seen` (e.g. a concurrent re-stream).
|
|
660
376
|
const rows = [];
|
|
661
377
|
const tuples = [];
|
|
662
378
|
for (const e of entries) {
|
|
663
379
|
if (this.seen.has(e.id))
|
|
664
380
|
continue;
|
|
665
381
|
tuples.push("(?,?,?,?,?,?,?)");
|
|
666
|
-
// 对抗复查 A-1(CRITICAL):staging 行的 ts 必须是「行插入时间」——写源 entry 的历史 timestamp 会让
|
|
667
|
-
// reaper 的 MIN(ts)<cutoff 判据把「正在导入的历史会话」当成弃置 staging 删掉(导入 1 小时前的会话
|
|
668
|
-
// =导入中被 reap)。真时间源恒在 payload(e.timestamp);ts 列的唯一读消费=fork 的行拷贝(carry-over
|
|
669
|
-
// 一致)与 reaper 活性判据,写 now 零语义破坏。
|
|
670
382
|
rows.push(this.stagingId, this.nextSeq, e.id, e.parentId ?? null, e.type, JSON.stringify(e), new Date());
|
|
671
383
|
this.seen.add(e.id);
|
|
672
384
|
this.nextSeq++;
|
|
@@ -681,46 +393,24 @@ class TiDBStagingHandle {
|
|
|
681
393
|
throw err instanceof SessionError ? err : new SessionError("storage", `staged append for ${this.realSessionId} failed`, err instanceof Error ? err : undefined);
|
|
682
394
|
}
|
|
683
395
|
}
|
|
684
|
-
/**
|
|
685
|
-
* The ATOMIC swap (ONE txn), §15 commit:
|
|
686
|
-
* 1. IN-COMMIT RE-CLASSIFY (closes the Phase-A plan→commit TOCTOU): read the dst's CURRENT entry ids + the staged
|
|
687
|
-
* ids under `FOR UPDATE` (row-locks serialize a concurrent commit/run) → classifySyncRelationshipByIds. A flip
|
|
688
|
-
* to fork/stale WITHOUT `resolution:"overwrite-dst"` → ROLLBACK → throw SyncConflictError (route → 409).
|
|
689
|
-
* 2. DELETE the dst's existing session_event rows (the replaceEntries purge).
|
|
690
|
-
* 3. UPDATE session_event SET session_id = real WHERE session_id = staging (the row-key RENAME — metadata only).
|
|
691
|
-
* 4. INSERT/UPSERT session_meta(real, owner, leaf_id=replayedLeaf, leaf_seq=N) LAST = the sole commit point.
|
|
692
|
-
* A crash before COMMIT: the DELETE+rename roll back (dst untouched) and the staging rows keep no meta = invisible
|
|
693
|
-
* orphans (reaped by sweepStagingSessions). `owner` is the re-stamped importing principal (§9).
|
|
694
|
-
*/
|
|
695
396
|
async commit(owner, opts) {
|
|
696
397
|
const conn = await this.pool.getConnection();
|
|
697
398
|
try {
|
|
698
399
|
await conn.beginTransaction();
|
|
699
|
-
// (1) in-commit re-classify — read BOTH logs' ids oldest-first under FOR UPDATE (serialize against a racing
|
|
700
|
-
// commit/append to the dst). The staged ids ARE the source (already dense 0..N-1 under the staging id).
|
|
701
400
|
const [stagedRows] = await conn.query("SELECT entry_id FROM session_event WHERE session_id = ? ORDER BY seq ASC FOR UPDATE", [this.stagingId]);
|
|
702
401
|
const [dstRows] = await conn.query("SELECT entry_id FROM session_event WHERE session_id = ? ORDER BY seq ASC FOR UPDATE", [this.realSessionId]);
|
|
703
|
-
// `placement_kind` 捎带在既有的存在性探针上(#292:placed 目的地拒绝整段换装,理由与
|
|
704
|
-
// `replaceEntries` 的同款注逐字同——两条腿是同一件事的两种写法)。这里**刻意不加 `FOR UPDATE`**:
|
|
705
|
-
// 它对**缺席行**在 PG 上什么都锁不到(窗关不上),在 MySQL 上又会 gap-lock 住整段键区间(把一次
|
|
706
|
-
// 本可以合法先到的 placement 创建堵成锁等待)——两边都不划算。真门在提交前的那次权威复读(见下)。
|
|
707
402
|
const [dstMeta] = await conn.query("SELECT placement_kind FROM session_meta WHERE session_id = ?", [this.realSessionId]);
|
|
708
403
|
if (dstMeta[0]?.placement_kind != null) {
|
|
709
404
|
await conn.rollback();
|
|
710
405
|
throw new SessionError("placement_refused", `session ${this.realSessionId} belongs to the ${String(dstMeta[0].placement_kind)} placement partition — a session-sync staged commit cannot overwrite a delegation transcript`);
|
|
711
406
|
}
|
|
712
407
|
const srcIds = stagedRows.map((r) => String(r.entry_id));
|
|
713
|
-
const dstIds = dstMeta[0] ? dstRows.map((r) => String(r.entry_id)) : null;
|
|
408
|
+
const dstIds = dstMeta[0] ? dstRows.map((r) => String(r.entry_id)) : null;
|
|
714
409
|
const rel = classifySyncRelationshipByIds(srcIds, dstIds);
|
|
715
410
|
if ((rel.relation === "fork" || rel.relation === "stale") && opts?.resolution !== "overwrite-dst") {
|
|
716
411
|
await conn.rollback();
|
|
717
412
|
throw new SyncConflictError(this.realSessionId, rel);
|
|
718
413
|
}
|
|
719
|
-
// (1b) 🔴 [2557] A5/F2(2026-08-05):fast_forward 的**载荷**判据 —— dst ⊆ src 按 id,但共享 id 的
|
|
720
|
-
// 内容可能已分叉(目的端纠正过某条、源端拿旧版+追加);整段换装会静默销毁目的端版本,§7 在载荷层
|
|
721
|
-
// 同样要求 409(除非显式 overwrite-dst)。共享段载荷只在这条臂上读(commit 本就是 O(total) 行操作
|
|
722
|
-
// ——DELETE dst + rename staged——所以这次成比例的载荷读不改变量级);行已被上面的 FOR UPDATE 锁住,
|
|
723
|
-
// 同 txn 重读载荷是一致读。判据与 fork 铸形见 session-sync-content.ts。
|
|
724
414
|
if (rel.relation === "fast_forward" && opts?.resolution !== "overwrite-dst" && dstIds !== null && dstIds.length > 0) {
|
|
725
415
|
const [dstFull] = await conn.query("SELECT payload FROM session_event WHERE session_id = ? ORDER BY seq ASC", [this.realSessionId]);
|
|
726
416
|
const [stagedFull] = await conn.query("SELECT entry_id, payload FROM session_event WHERE session_id = ? ORDER BY seq ASC", [this.stagingId]);
|
|
@@ -734,13 +424,8 @@ class TiDBStagingHandle {
|
|
|
734
424
|
throw new SyncConflictError(this.realSessionId, contentForkRelation(diverged, rel.newEntryIds));
|
|
735
425
|
}
|
|
736
426
|
}
|
|
737
|
-
// (2) purge the dst's existing log (the replaceEntries purge; idempotent — fresh deletes 0).
|
|
738
427
|
await conn.query("DELETE FROM session_event WHERE session_id = ?", [this.realSessionId]);
|
|
739
|
-
// (3) RENAME the staged rows onto the real session id (row-key UPDATE — metadata only, NO payload re-copy). seq is
|
|
740
|
-
// already dense 0..N-1 under the staging id (appendBatch re-seq'd it), so no re-densify is needed on rename.
|
|
741
428
|
await conn.query("UPDATE session_event SET session_id = ? WHERE session_id = ?", [this.realSessionId, this.stagingId]);
|
|
742
|
-
// (4) session_meta LAST = the sole commit point. The replayed leaf = the last staged entry's leaf target — read
|
|
743
|
-
// the last staged payload (now renamed) to compute leafIdAfterEntry (core's helper; no service re-statement).
|
|
744
429
|
const [lastRow] = await conn.query("SELECT payload FROM session_event WHERE session_id = ? ORDER BY seq DESC LIMIT 1", [this.realSessionId]);
|
|
745
430
|
let leafId = null;
|
|
746
431
|
if (lastRow[0]) {
|
|
@@ -751,12 +436,6 @@ class TiDBStagingHandle {
|
|
|
751
436
|
const now = new Date();
|
|
752
437
|
await conn.query("INSERT INTO session_meta (session_id, owner, created_at, updated_at, leaf_id, leaf_seq) VALUES (?,?,?,?,?,?) " +
|
|
753
438
|
"ON DUPLICATE KEY UPDATE owner = VALUES(owner), updated_at = VALUES(updated_at), leaf_id = VALUES(leaf_id), leaf_seq = VALUES(leaf_seq)", [this.realSessionId, owner, now, now, leafId, srcIds.length]);
|
|
754
|
-
// 🔴 #292(codex 对抗复审 R1-[high],验真后修)—— **提交前的最后一道门**:上面那次 upsert 之后,
|
|
755
|
-
// 这一行**一定**被本事务锁住(不管它是我们刚插的、还是并发方先插我们随后 UPDATE 的),于是这次
|
|
756
|
-
// 复读是**权威**的。病:目标行在(1)的探针时**还不存在** ⇒ 探针无行可锁(PG 的 FOR UPDATE 对
|
|
757
|
-
// 缺席行不锁任何东西),另一事务可以在此期间把同 id 铸成 placed 转录;没有这道复读,本事务会把
|
|
758
|
-
// 它的事件整段换掉、并把 owner/leaf 改写成 push 方的 —— agent 行还指着那条会话,里面已经是别人
|
|
759
|
-
// 的历史。命中即整事务回滚(事件的 DELETE/rename 一并撤销),响亮 `placement_refused`。
|
|
760
439
|
const [afterMeta] = await conn.query("SELECT placement_kind FROM session_meta WHERE session_id = ?", [this.realSessionId]);
|
|
761
440
|
if (afterMeta[0]?.placement_kind != null) {
|
|
762
441
|
await conn.rollback();
|
|
@@ -769,7 +448,7 @@ class TiDBStagingHandle {
|
|
|
769
448
|
try {
|
|
770
449
|
await conn.rollback();
|
|
771
450
|
}
|
|
772
|
-
catch {
|
|
451
|
+
catch { }
|
|
773
452
|
if (err instanceof SyncConflictError || err instanceof SessionError)
|
|
774
453
|
throw err;
|
|
775
454
|
throw new SessionError("storage", `staged commit for ${this.realSessionId} failed`, err instanceof Error ? err : undefined);
|
|
@@ -778,7 +457,6 @@ class TiDBStagingHandle {
|
|
|
778
457
|
conn.release();
|
|
779
458
|
}
|
|
780
459
|
}
|
|
781
|
-
/** Drop the staging rows (Phase B aborted before commit). Idempotent (a missing staging id deletes 0). */
|
|
782
460
|
async abort() {
|
|
783
461
|
await this.pool.query("DELETE FROM session_event WHERE session_id = ?", [this.stagingId]);
|
|
784
462
|
}
|