@sema-agent/server 7.138.0 → 7.139.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/MIGRATION.md +276 -7
- package/README.md +7 -4
- package/README.zh-CN.md +6 -4
- package/USAGE.md +73 -29
- package/deploy/sema-up/README.md +1 -1
- package/deploy/sema-up/lib/lanes.json +3 -2
- package/dist/adoption/wire.d.ts +3 -3
- package/dist/approval-card.d.ts +20 -20
- package/dist/bench/s1/arms.d.ts +1 -1
- package/dist/bench/s1/arms.js +1 -3
- package/dist/bench/s1/run-firm.js +2 -2
- package/dist/boot/device-lane.d.ts +8 -3
- package/dist/boot/device-lane.js +7 -2
- package/dist/boot/execution-env.d.ts +4 -2
- package/dist/boot/execution-env.js +16 -24
- package/dist/boot/execution-lane.d.ts +1 -1
- package/dist/boot/governance-seams.d.ts +3 -2
- package/dist/boot/governance-seams.js +9 -11
- package/dist/boot/memory-consolidation.js +2 -2
- package/dist/boot/resolve-spec.d.ts +1 -1
- package/dist/boot/resolve-spec.js +8 -9
- package/dist/boot/retention-lane.d.ts +5 -5
- package/dist/boot/retention-lane.js +0 -6
- package/dist/boot/runner-deps.d.ts +3 -1
- package/dist/boot/runner-deps.js +2 -1
- package/dist/boot/session-faces.d.ts +0 -3
- package/dist/boot/session-faces.js +15 -8
- package/dist/boot/stage-02-web-admin-pull.js +2 -2
- package/dist/boot/stage-05-execution-env.js +2 -2
- package/dist/boot/stage-06-runners.js +0 -1
- package/dist/boot/stage-09-leader.js +1 -2
- package/dist/boot/stage-10-http-server.js +2 -2
- package/dist/boot/stores.js +4 -4
- package/dist/boot/web-admin.js +2 -2
- package/dist/capabilities/mcp-probe.js +1 -1
- package/dist/capabilities/scenarios.d.ts +1 -1
- package/dist/capability-facts.d.ts +15 -7
- package/dist/capability-facts.js +8 -3
- package/dist/config-catalog.js +7 -7
- package/dist/config-invariants.d.ts +23 -3
- package/dist/config-invariants.js +42 -0
- package/dist/config-provider.d.ts +19 -24
- package/dist/config-provider.js +27 -12
- package/dist/config-types.d.ts +10 -5
- package/dist/config.d.ts +33 -0
- package/dist/config.js +44 -15
- package/dist/delegated-session-owner.d.ts +2 -2
- package/dist/deployment-shape.d.ts +1 -1
- package/dist/device-enrollment.d.ts +41 -2
- package/dist/device-enrollment.js +27 -12
- package/dist/device-store.d.ts +58 -85
- package/dist/device-store.js +19 -49
- package/dist/device-ws-hub.js +2 -1
- package/dist/env-name-allowlist-knobs.d.ts +5 -4
- package/dist/exec-cut.d.ts +60 -0
- package/dist/exec-cut.js +36 -0
- package/dist/execution-lane-caps.d.ts +19 -0
- package/dist/execution-lane-caps.js +10 -0
- package/dist/execution-placement.d.ts +259 -0
- package/dist/execution-placement.js +199 -0
- package/dist/field-conflict.d.ts +3 -2
- package/dist/field-conflict.js +1 -0
- package/dist/hosted-posture.d.ts +2 -2
- package/dist/http/admission.js +8 -19
- package/dist/http/checkpoint-ctx.d.ts +21 -12
- package/dist/http/checkpoint-ctx.js +4 -2
- package/dist/http/claimed-run.d.ts +1 -1
- package/dist/http/durable-run-leg.js +9 -5
- package/dist/http/execution-context.d.ts +218 -0
- package/dist/http/execution-context.js +330 -0
- package/dist/http/resume-legs.js +50 -20
- package/dist/http/route-ctx.d.ts +14 -8
- package/dist/http/routes/background.d.ts +29 -1
- package/dist/http/routes/background.js +29 -2
- package/dist/http/routes/devices.d.ts +13 -19
- package/dist/http/routes/devices.js +21 -26
- package/dist/http/routes/session-sync.js +110 -15
- package/dist/http/routes/sessions.d.ts +6 -0
- package/dist/http/routes/sessions.js +115 -11
- package/dist/http/routes/side-query.d.ts +12 -4
- package/dist/http/routes/side-query.js +4 -5
- package/dist/http/routes/tasks.js +37 -31
- package/dist/http/server.d.ts +35 -19
- package/dist/http/server.js +11 -0
- package/dist/http/session-inherit-env.d.ts +3 -4
- package/dist/http/session-inherit-env.js +1 -1
- package/dist/http/wire-types.d.ts +8 -12
- package/dist/index.d.ts +1 -0
- package/dist/memory-sync-client.d.ts +53 -14
- package/dist/memory-sync-client.js +196 -116
- package/dist/memory-sync.d.ts +1 -1
- package/dist/memory-sync.js +3 -2
- package/dist/observability/cost-taxonomy.d.ts +2 -2
- package/dist/observability/fail-open.d.ts +12 -0
- package/dist/observability/fail-open.js +12 -0
- package/dist/observability/run-terminal.js +0 -1
- package/dist/orchestration/workflow-notify-journal.js +4 -4
- package/dist/org-memory-admission.d.ts +4 -3
- package/dist/org-memory-admission.js +6 -6
- package/dist/parked-decide.d.ts +34 -9
- package/dist/parked-decide.js +12 -19
- package/dist/parked-revive-surface.d.ts +12 -15
- package/dist/parked-revive-surface.js +3 -6
- package/dist/peer-seat.d.ts +13 -7
- package/dist/peer-seat.js +37 -3
- package/dist/plugins/approval-ask-store-file.d.ts +7 -0
- package/dist/plugins/approval-ask-store-file.js +11 -0
- package/dist/plugins/approval-ask-store-memory.d.ts +6 -0
- package/dist/plugins/approval-ask-store-memory.js +12 -0
- package/dist/plugins/approval-ask-store-sql.d.ts +22 -5
- package/dist/plugins/approval-ask-store-sql.js +30 -12
- package/dist/plugins/caching-session-store.d.ts +6 -2
- package/dist/plugins/caching-session-store.js +9 -3
- package/dist/plugins/checkpoint-store-sql.d.ts +6 -5
- package/dist/plugins/checkpoint-store-sql.js +5 -7
- package/dist/plugins/device-store-sql.d.ts +25 -51
- package/dist/plugins/device-store-sql.js +49 -103
- package/dist/plugins/execution-placement-sql.d.ts +151 -0
- package/dist/plugins/execution-placement-sql.js +223 -0
- package/dist/plugins/file-history-store-sql.d.ts +7 -6
- package/dist/plugins/file-history-store-sql.js +5 -5
- package/dist/plugins/file-run-store.d.ts +6 -1
- package/dist/plugins/file-run-store.js +4 -0
- package/dist/plugins/fork-routing-session-store.d.ts +6 -0
- package/dist/plugins/fork-routing-session-store.js +16 -0
- package/dist/plugins/local-session-store.d.ts +149 -50
- package/dist/plugins/local-session-store.js +382 -107
- package/dist/plugins/memory-key-guards.d.ts +4 -0
- package/dist/plugins/memory-key-guards.js +1 -0
- package/dist/plugins/memory-run-store.d.ts +1 -1
- package/dist/plugins/one-time-migrations.d.ts +111 -3
- package/dist/plugins/one-time-migrations.js +380 -10
- package/dist/plugins/outcome-ledger-sql.d.ts +2 -2
- package/dist/plugins/permission-rule-store-sql.d.ts +13 -15
- package/dist/plugins/permission-rule-store-sql.js +13 -16
- package/dist/plugins/pg-pool.d.ts +7 -3
- package/dist/plugins/pg-session-storage.d.ts +31 -20
- package/dist/plugins/pg-session-storage.js +120 -52
- package/dist/plugins/remote-env-adb.d.ts +20 -4
- package/dist/plugins/remote-env-adb.js +70 -62
- package/dist/plugins/remote-env-device.d.ts +30 -12
- package/dist/plugins/remote-env-device.js +18 -17
- package/dist/plugins/remote-env-e2b.d.ts +18 -4
- package/dist/plugins/remote-env-e2b.js +41 -42
- package/dist/plugins/remote-env-file-error.d.ts +2 -0
- package/dist/plugins/remote-env-file-error.js +1 -1
- package/dist/plugins/remote-env-host.d.ts +21 -8
- package/dist/plugins/remote-env-host.js +66 -70
- package/dist/plugins/remote-env-k8s.d.ts +11 -16
- package/dist/plugins/remote-env-k8s.js +61 -54
- package/dist/plugins/remote-env-local-docker.d.ts +29 -6
- package/dist/plugins/remote-env-local-docker.js +46 -59
- package/dist/plugins/remote-env-ssh.d.ts +16 -4
- package/dist/plugins/remote-env-ssh.js +72 -56
- package/dist/plugins/remote-shell.d.ts +100 -3
- package/dist/plugins/remote-shell.js +166 -2
- package/dist/plugins/retention-store-sql.d.ts +9 -6
- package/dist/plugins/retention-store-sql.js +7 -1
- package/dist/plugins/run-store-sql.d.ts +13 -3
- package/dist/plugins/run-store-sql.js +5 -0
- package/dist/plugins/schema-consistency.d.ts +15 -5
- package/dist/plugins/schema-consistency.js +14 -8
- package/dist/plugins/schema-drift-sentence.d.ts +27 -0
- package/dist/plugins/schema-drift-sentence.js +38 -0
- package/dist/plugins/session-fork.d.ts +54 -0
- package/dist/plugins/session-fork.js +38 -1
- package/dist/plugins/session-row-sql.d.ts +22 -0
- package/dist/plugins/session-row-sql.js +81 -0
- package/dist/plugins/session-store.d.ts +1 -1
- package/dist/plugins/store-backend.d.ts +1 -1
- package/dist/plugins/store-backend.js +19 -7
- package/dist/plugins/store-contracts.d.ts +25 -5
- package/dist/plugins/tidb-pool.d.ts +12 -7
- package/dist/plugins/tidb-pool.js +9 -3
- package/dist/plugins/tidb-session-store.d.ts +57 -26
- package/dist/plugins/tidb-session-store.js +118 -50
- package/dist/plugins/tool-result-store-sql.d.ts +3 -2
- package/dist/plugins/tool-result-store-sql.js +18 -10
- package/dist/project-memory.d.ts +9 -15
- package/dist/project-memory.js +5 -4
- package/dist/request-key-closure.js +1 -1
- package/dist/request-value-closure.d.ts +6 -7
- package/dist/request-value-closure.js +1 -3
- package/dist/run-local.d.ts +6 -0
- package/dist/run-local.js +24 -20
- package/dist/runs.d.ts +2 -1
- package/dist/runs.js +3 -1
- package/dist/security.d.ts +38 -12
- package/dist/security.js +8 -2
- package/dist/server-secret-env.d.ts +9 -5
- package/dist/session-children-retire.d.ts +27 -9
- package/dist/session-children-retire.js +9 -5
- package/dist/session-incarnation.d.ts +19 -9
- package/dist/session-incarnation.js +4 -2
- package/dist/session-row.d.ts +114 -5
- package/dist/session-row.js +55 -5
- package/dist/session-titler.d.ts +2 -2
- package/dist/task-a2a.d.ts +1 -1
- package/dist/task-cwd.d.ts +6 -1
- package/dist/task-mcp.d.ts +3 -3
- package/dist/tool-approval.d.ts +6 -0
- package/dist/tool-approval.js +8 -1
- package/dist/trace/project.d.ts +1 -0
- package/dist/trace/project.js +1 -0
- package/dist/trace/self-minted-wire.d.ts +111 -2
- package/dist/trace/self-minted-wire.js +38 -1
- package/dist/url-refusal.d.ts +1 -1
- package/dist/web-admin-client/apply-effective.d.ts +17 -3
- package/dist/web-admin-client/apply-effective.js +20 -10
- package/dist/web-admin-client/skills-mcp.js +3 -2
- package/dist/web-admin-client/types.d.ts +6 -0
- package/package.json +3 -3
- package/wire-changes/server@7.139.0.json +348 -0
- package/dist/http/device-placement.d.ts +0 -175
- package/dist/http/device-placement.js +0 -145
|
@@ -54,7 +54,7 @@ export declare class CachingSessionStore implements SessionStore {
|
|
|
54
54
|
private readonly onStaleHit?;
|
|
55
55
|
/** core `SessionStore.headOf` —— 按 id 读一行(属主 / 住客 / 叶子)的唯一读口,**纯转发、不经暖缓存**(答的是行,绝不从暖缓存里那只会话的元数据答 ——
|
|
56
56
|
* 缓存的会话活得比「另一只店删掉又重建了这一行」长)。内层店必有(本类的暖命中按它核新鲜)。🔴 必须转发:生产默认把真店包进本类,授权口 / 属主门 /
|
|
57
|
-
*
|
|
57
|
+
* k8s 工作区空间 / SSE 探针 / resume-at 读的都是本类。 */
|
|
58
58
|
readonly headOf: SessionHeadReader["headOf"];
|
|
59
59
|
readonly register?: (sessionId: string, owner: string | null) => Promise<void>;
|
|
60
60
|
/** [ref] 委派继承律的认领口,纯转发(写的是行上的属主那一格 —— 只 CAS 子行自己,不碰暖缓存里的会话;停驻挂点在默认部署上读的就是本类 —— 与 `placementOf` 同转发同缺:内店记录委派树 ⇒ 两只都在)。 */
|
|
@@ -72,12 +72,16 @@ export declare class CachingSessionStore implements SessionStore {
|
|
|
72
72
|
q?: string;
|
|
73
73
|
}) => Promise<SessionListItem[]>;
|
|
74
74
|
readonly fork?: (sourceId: string, owner: string | null) => Promise<string | null>;
|
|
75
|
+
/** fork 续写的店契约(原样转发;与 fork 同理绕过暖缓存 —— 读写的是持久行,新 id 本来就不在缓存里)。 */
|
|
76
|
+
readonly forkTo?: OwnerAwareSessionStore["forkTo"];
|
|
77
|
+
/** 执行席(原样转发:放置是会话**行**上的一格,缓存只缓存会话日志的暖句柄,不缓存行格 —— 读与写都直达内层店)。 */
|
|
78
|
+
readonly executionPlacement?: OwnerAwareSessionStore["executionPlacement"];
|
|
75
79
|
readonly deleteSession?: (sessionId: string, owner: string | null) => Promise<boolean>;
|
|
76
80
|
/** The session row's list projection for one id + its writable bits (`title` / `pinned`) — DURABLE, pure pass-through
|
|
77
81
|
* (they read / write `session_meta` / the local sidecars, never the warm cache). 🔴 Must forward (the retention / placements
|
|
78
82
|
* lesson): the production default wraps the real store in this class, and the route's fact `sessionPatch` reads THIS object. */
|
|
79
83
|
readonly sessionSummaryOf?: (sessionId: string) => Promise<SessionListItem | undefined>;
|
|
80
|
-
readonly
|
|
84
|
+
readonly writeSessionRowCells?: OwnerAwareSessionStore["writeSessionRowCells"];
|
|
81
85
|
/** The auto-titler's NULL-only title write (SQL twins + local sidecar carry it; pure pass-through). */
|
|
82
86
|
readonly setTitleIfNull?: (sessionId: string, title: string) => Promise<boolean>;
|
|
83
87
|
readonly probeTitle?: (sessionId: string) => Promise<"none" | "untitled" | "titled">;
|
|
@@ -12,9 +12,11 @@ export class CachingSessionStore {
|
|
|
12
12
|
claimOwnerless;
|
|
13
13
|
listSessions;
|
|
14
14
|
fork;
|
|
15
|
+
forkTo;
|
|
16
|
+
executionPlacement;
|
|
15
17
|
deleteSession;
|
|
16
18
|
sessionSummaryOf;
|
|
17
|
-
|
|
19
|
+
writeSessionRowCells;
|
|
18
20
|
setTitleIfNull;
|
|
19
21
|
probeTitle;
|
|
20
22
|
readInstructionSource;
|
|
@@ -57,8 +59,8 @@ export class CachingSessionStore {
|
|
|
57
59
|
this.listSessions = ownerAware.listSessions.bind(inner);
|
|
58
60
|
if (ownerAware.sessionSummaryOf)
|
|
59
61
|
this.sessionSummaryOf = ownerAware.sessionSummaryOf.bind(inner);
|
|
60
|
-
if (ownerAware.
|
|
61
|
-
this.
|
|
62
|
+
if (ownerAware.writeSessionRowCells)
|
|
63
|
+
this.writeSessionRowCells = ownerAware.writeSessionRowCells.bind(inner);
|
|
62
64
|
if (ownerAware.setTitleIfNull)
|
|
63
65
|
this.setTitleIfNull = ownerAware.setTitleIfNull.bind(inner);
|
|
64
66
|
if (ownerAware.probeTitle)
|
|
@@ -73,6 +75,10 @@ export class CachingSessionStore {
|
|
|
73
75
|
this.recordSessionWorkspace = ownerAware.recordSessionWorkspace.bind(inner);
|
|
74
76
|
if (ownerAware.fork)
|
|
75
77
|
this.fork = ownerAware.fork.bind(inner);
|
|
78
|
+
if (ownerAware.forkTo)
|
|
79
|
+
this.forkTo = ownerAware.forkTo.bind(inner);
|
|
80
|
+
if (ownerAware.executionPlacement)
|
|
81
|
+
this.executionPlacement = ownerAware.executionPlacement;
|
|
76
82
|
if (ownerAware.exportEntries)
|
|
77
83
|
this.exportEntries = ownerAware.exportEntries.bind(inner);
|
|
78
84
|
if (ownerAware.listEntryIds)
|
|
@@ -240,8 +240,8 @@ export declare function pendingRowPrecedes(a: {
|
|
|
240
240
|
*
|
|
241
241
|
* 🔴 为什么是一张表而不是每列一个 `assert*ColumnSchema` 函数([ref] 归一):[ref]① 那次是一列一函数,
|
|
242
242
|
* 本批 [ref] 又要加两列 —— 照抄就是第二、第三份三十行的同形代码,而它们**唯一的差别只有列名与那句话**。
|
|
243
|
-
* 探测法(恒空 `WHERE 1=0` 读 + 方言缺列码)、失败方向(
|
|
244
|
-
* (
|
|
243
|
+
* 探测法(恒空 `WHERE 1=0` 读 + 方言缺列码)、失败方向(只在**可证**缺列时才拒)、恢复动作
|
|
244
|
+
* (部署者按拒因打印的 ALTER 在原表上加列;表里是在飞的停驻,不动它)三者对整张表是同一条,所以规则只留一条,列变成数据:下一次加列
|
|
245
245
|
* 只加一行,不再多一条规则。
|
|
246
246
|
*/
|
|
247
247
|
export declare const CHECKPOINT_ADDED_COLUMNS: ReadonlyArray<{
|
|
@@ -254,14 +254,15 @@ export declare const CHECKPOINT_ADDED_COLUMNS: ReadonlyArray<{
|
|
|
254
254
|
* 依据是本仓 assert*Schema 探针族的真先例与失败方向本身)。[ref] 起判据对 {@link CHECKPOINT_ADDED_COLUMNS}
|
|
255
255
|
* **整张列册**跑(见那张表的头注:归一的理由)。
|
|
256
256
|
*
|
|
257
|
-
* 病:本仓不发 `ALTER TABLE`(SCHEMA POLICY
|
|
257
|
+
* 病:本仓不发 `ALTER TABLE`(SCHEMA POLICY:代码是唯一真源,存量库缺列由部署者按拒启句打印的 ALTER 自己执行)。一台**没跟上**就升上来的
|
|
258
258
|
* 部署,`CREATE TABLE IF NOT EXISTS` 对存量 `checkpoint` 是空操作 —— 后加的列不在,而写口与读口
|
|
259
259
|
* **无条件**引用它们。没有这道断言,服务照常过启动与健康检查,直到运行期才响,且首撞点在用户的
|
|
260
260
|
* 审批 / resume 路径上。
|
|
261
261
|
*
|
|
262
262
|
* 判据 = **能力探测**(恒空的 `WHERE 1=0` 读)+ **方言的缺列错误码**({@link isMissingColumnError},
|
|
263
|
-
* 判据属主 `sql-errors.ts`);其余错误**原样 rethrow**,一个字都不加工 ——
|
|
264
|
-
*
|
|
263
|
+
* 判据属主 `sql-errors.ts`);其余错误**原样 rethrow**,一个字都不加工 —— 把一次瞬时故障诊断成「缺列」的拒因
|
|
264
|
+
* 比它要挡的缺陷更贵([ref] 两轮收窄的结论逐字继承)。拒因的出路是**闭集三条**(与整库对账同一份,{@link describeProbedTableDrift}),
|
|
265
|
+
* 没有销毁性的一条:这张表里是在飞的停驻,出路是原表上加列。
|
|
265
266
|
*
|
|
266
267
|
* 位置:与三条先例同款,不在 `ensureSchema` 里(那条通道的契约是「只发 CREATE」),放在 boot 的
|
|
267
268
|
* store 装配段、路由装配之前;**无条件**跑(与 [ref] tool_result 探针同档)——checkpoint 表在两方言的
|
|
@@ -9,6 +9,7 @@ import { recordFailOpen } from "../observability/fail-open.js";
|
|
|
9
9
|
import { durableRowIdentity, withoutCheckpointToken } from "../checkpoint-identity.js";
|
|
10
10
|
import { mysqlDriver, pgDriver, dialectProtocolJsonEncoder } from "./sql-driver.js";
|
|
11
11
|
import { isDupKeyError, isMissingColumnError, rowTooLargeOrSelf, StoreRowTooLargeError } from "./sql-errors.js";
|
|
12
|
+
import { describeProbedTableDrift } from "./schema-drift-sentence.js";
|
|
12
13
|
import { MANAGED_RETENTION, UNMANAGED_RETENTION } from "./retention-store-sql.js";
|
|
13
14
|
import { approvalPayloadFingerprint } from "./approval-payload-fingerprint.js";
|
|
14
15
|
const CTX_REWRITE_PAGE = 500;
|
|
@@ -61,13 +62,10 @@ export async function assertCheckpointColumnSchema(query, dialect) {
|
|
|
61
62
|
throw err;
|
|
62
63
|
throw new Error(`checkpoint is missing the column ${column} — refusing to start. ` +
|
|
63
64
|
`On this table ${breaksAt} — at runtime, after a healthy-looking boot. ` +
|
|
64
|
-
"
|
|
65
|
-
"`
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
"durable parks (suspended approvals/reviews awaiting a decision) — those cards are lost and their runs must be " +
|
|
69
|
-
"re-triggered; `checkpoint_ctx`, run rows and every other persistent face are untouched. " +
|
|
70
|
-
`Underlying probe error: ${err instanceof Error ? err.message : String(err)}`);
|
|
65
|
+
"The table holds IN-FLIGHT durable parks (suspended approvals/reviews awaiting a decision), so the way forward is an in-place " +
|
|
66
|
+
"ALTER that keeps them; `checkpoint_ctx`, run rows and every other persistent face are untouched.\n" +
|
|
67
|
+
`Underlying probe error: ${err instanceof Error ? err.message : String(err)}\n\n` +
|
|
68
|
+
(await describeProbedTableDrift(query, dialect, ["checkpoint"])));
|
|
71
69
|
}
|
|
72
70
|
}
|
|
73
71
|
}
|
|
@@ -1,14 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* device lane
|
|
2
|
+
* device lane 三表持久层(design/device-executor-lane-v2 §6)—— `DeviceStore` 双方言实现
|
|
3
3
|
* (`TiDBDeviceStore` / `PgDeviceStore`,SINGLE-FILE DUAL-DIALECT,[ref] A12 形:一个
|
|
4
4
|
* `SqlDeviceStore` 接 `SqlDriver`,两个薄 ctor 子类落方言绑定)。语义真源 = `../device-store.js`
|
|
5
5
|
* (类型 + 闭集 + InMemory 参照实现),准入链 = `../device-enrollment.js`。
|
|
6
6
|
*
|
|
7
|
-
*
|
|
7
|
+
* 三张表:
|
|
8
8
|
* · `devices` —— 设备注册簿 + presence 投影(单设备单活连 ⇒ 1:1,省一次 join,§6 注①)
|
|
9
9
|
* · `device_enroll_tokens` —— 一次性 enrollment 凭据(明文一现,落库只有 sha256)
|
|
10
|
-
* · `device_sessions` —— **根会话**↔设备绑定(resume 路由真源,§4.5)
|
|
11
10
|
* · `device_audit` —— 安全轴事件审计(§4.8 标「审计行」的 8 个 event)
|
|
11
|
+
* **根会话 ↔ 设备绑定不在本店的表里**:它是根会话行(`session_meta`)上这一位住客的执行格 writer(device, D)(列与语句的唯一属主 =
|
|
12
|
+
* `execution-placement-sql.ts`;规则在 `../execution-placement.ts` 头注)。读只经会话店的执行席 `seat.read(root)`;本店只有两只写 ——
|
|
13
|
+
* 首绑 {@link SqlDeviceStore.placeDeviceExecution} 与换绑 {@link SqlDeviceStore.rebindDeviceExecution},各是一只事务,锁序全局一条:设备行 → 根 `session_meta` → `task_*`。
|
|
12
14
|
* 刻意**没有** `device_instructions` 表:指令 pending 表是内存态(§6 注③——指令比 run 短命,落库只会
|
|
13
15
|
* 造「表里 pending、run 已死」的僵尸行;at-most-once 靠 §5.3 投递边界,不靠指令落库)。
|
|
14
16
|
*
|
|
@@ -26,7 +28,6 @@
|
|
|
26
28
|
*
|
|
27
29
|
* ── 方言差异(A12 doctrine:每处显式写在调用点)────────────────────────────────────────────────────
|
|
28
30
|
* - `?`(位置序) vs `$n`(显式编号);动态 WHERE(`listAudit`)靠 `ph(dialect, n)` 生成。
|
|
29
|
-
* - `INSERT IGNORE` vs `ON CONFLICT (pk) DO NOTHING`(`bindSession` 的首绑 CAS)。
|
|
30
31
|
* - `AUTO_INCREMENT` vs `BIGSERIAL`;`JSON` vs `JSONB`(+ `$n::jsonb` 显式 cast)。
|
|
31
32
|
* - `KEY …` 内联 vs 独立 `CREATE INDEX IF NOT EXISTS`。
|
|
32
33
|
* - 回读名单:两方言**同走**同事务 `UPDATE` 后 `SELECT`(PG 本可用 RETURNING,为保单一读路径不用——
|
|
@@ -46,19 +47,18 @@
|
|
|
46
47
|
import type { Pool as MySqlPool } from "mysql2/promise";
|
|
47
48
|
import type { Pool as PgPool } from "pg";
|
|
48
49
|
import { type SqlDriver } from "./sql-driver.js";
|
|
49
|
-
import { type AcquireConnectionInput, type AcquireConnectionResult, type AppendAuditInput, type
|
|
50
|
+
import { type AcquireConnectionInput, type AcquireConnectionResult, type AppendAuditInput, type ConsumeEnrollTokenInput, type ConsumeEnrollTokenResult, type DeviceAdmissionView, type DeviceAuditRow, type DeviceEnrollTokenRow, type DeviceOwner, type DeviceRow, type DeviceStore, type HeartbeatConnectionInput, type IssueEnrollTokenInput, type ListAuditFilter, type PlaceDeviceExecutionInput, type PlaceDeviceExecutionResult, type RebindDeviceExecutionInput, type RebindDeviceExecutionResult, type RevokeDeviceInput, type RevokeDeviceResult } from "../device-store.js";
|
|
50
51
|
import { type IndexSpec } from "./ensure-index.js";
|
|
51
52
|
/**
|
|
52
53
|
* ── 命名(**落库一律单数**,与设计稿的复数形不同)──────────────────────────────────────────────────
|
|
53
|
-
* design v2 §6 的 DDL 写的是 `devices` / `
|
|
54
|
+
* design v2 §6 的 DDL 写的是 `devices` / `device_enrollment_tokens` / `device_audit`(复数),但本仓有一条
|
|
54
55
|
* 常驻机器门:`test/schema-naming-invariants.test.ts` 判据① —— 建表的表名必须在 `TABLE_VOCABULARY` 闭集里
|
|
55
56
|
* **且词表全是单数形**。[ref] 车1 的 `retention_hold` / `retention_tombstone` 是同一处先例(那份设计稿同样
|
|
56
|
-
* 写的复数,落库单数)
|
|
57
|
+
* 写的复数,落库单数)。所以三张表落库名为 `device` / `device_enroll_token` /
|
|
57
58
|
* `device_audit`,**表结构与设计稿逐列一致**,只有名字随仓规。新表 = 先进词表再建。
|
|
58
59
|
*/
|
|
59
60
|
export declare const DEVICES_TABLE = "device";
|
|
60
61
|
export declare const DEVICE_ENROLL_TOKENS_TABLE = "device_enroll_token";
|
|
61
|
-
export declare const DEVICE_SESSIONS_TABLE = "device_session";
|
|
62
62
|
export declare const DEVICE_AUDIT_TABLE = "device_audit";
|
|
63
63
|
export declare const TIDB_DEVICE_STATEMENTS: readonly string[];
|
|
64
64
|
/** {@link TIDB_DEVICE_STATEMENTS} 的遍历壳(集成测试用;生产装配由中央 ensureSchema 展开同一份数组)。 */
|
|
@@ -134,55 +134,29 @@ export declare class SqlDeviceStore implements DeviceStore {
|
|
|
134
134
|
heartbeatConnection(input: HeartbeatConnectionInput): Promise<boolean>;
|
|
135
135
|
releaseConnection(deviceId: string, generation: number): Promise<boolean>;
|
|
136
136
|
/**
|
|
137
|
-
*
|
|
138
|
-
*
|
|
139
|
-
*
|
|
140
|
-
* 的旧版本,于是把 session 绑到一台**刚被吊销**的设备上(double-admit 形)。锁读在两引擎都是当前读 ——
|
|
141
|
-
* 前提写在 {@link SqlTxConn.begin} 的 @contract txn.read-semantics。这把锁在 `devices` 表上,与下面
|
|
142
|
-
* 被插入的 `device_session` 行不是同一行。
|
|
143
|
-
*
|
|
144
|
-
* 🔴 形 B · cas-insert([ref] / [ref])—— 本站点是全仓 `INSERT IGNORE` 的**正当用法**:
|
|
145
|
-
* `INSERT IGNORE` / `ON CONFLICT DO NOTHING` 的 affected(1/0)是引擎对「谁赢」的**权威**回答,而
|
|
146
|
-
* `ON DUPLICATE KEY UPDATE pk = pk` 的 affected 新行/既有行都是 1、分不出赢家 ⇒ 这里换不得动词。
|
|
147
|
-
* 代价必须付清:**同一事务此后不得对被插入的那一行做加锁读**。InnoDB 下撞重复键的 IGNORE 在既有行
|
|
148
|
-
* 取 **S 锁**,随后的 `SELECT … FOR UPDATE` 要升 X;两个并发首绑各持一把 S 互等 ⇒ ER_LOCK_DEADLOCK
|
|
149
|
-
* (TiDB 悲观事务无共享锁,这一形在它上面恒绿 —— 双库门看不见)。于是两条路径分家:
|
|
150
|
-
* · **赢家**(affected === 1):行是自己刚插的,回执用**入参 + 插入时刻**直接构造,零回读。插入时刻
|
|
151
|
-
* 仍由 **DB 时钟**给出(插入前一句 `SELECT NOW(3)` / `SELECT now()`,再把它当参数写进 INSERT)——
|
|
152
|
-
* 时间权威留在 DB,回执里的 `boundAtMs` 与落库值**逐字节同一个**,不是应用侧的近似。
|
|
153
|
-
* · **输家**(affected === 0):**结束当前事务**,在**新事务**里 `SELECT … FOR UPDATE` 观察。那一刻
|
|
154
|
-
* 赢家已提交,读到的是一把干净的 X 锁(无升级);判 同 deviceId = 幂等成功,异 deviceId =
|
|
155
|
-
* `conflict`(无未定义态)。设备状态门在输家路径上已经过完,不需要重来。
|
|
137
|
+
* 设备行的**当前读**(`FOR UPDATE`):首绑 / 换绑两只事务的第一句(全局锁序的第一把:设备行 → 根 `session_meta` → `task_*`)。
|
|
138
|
+
* 普通读下,一次与吊销并发的绑定会读到「还 active」的旧版本,于是把会话绑到一台**刚被吊销**的设备上(double-admit 形);锁读在两引擎都是当前读 ——
|
|
139
|
+
* 前提写在 {@link SqlTxConn.begin} 的 @contract txn.read-semantics。行不在 / 不是该属主的 ⇒ `undefined`(反枚举同形)。
|
|
156
140
|
*/
|
|
157
|
-
|
|
141
|
+
private lockDeviceStatus;
|
|
158
142
|
/**
|
|
159
|
-
* 首绑
|
|
143
|
+
* §4.3.2 首绑(§3.3 转换表 place / bind 两行)—— **一只事务**:锁设备行(active ∧ 属主)→ 锁定读根会话行的执行格 → 判官
|
|
144
|
+
* {@link settledDeviceBindOutcome}(与内存参照实现同一只)→ absent 用 place 语句、writer(device, ∅) 用 bind 语句,写只按住客谓词。
|
|
160
145
|
*
|
|
161
|
-
*
|
|
162
|
-
*
|
|
163
|
-
*
|
|
164
|
-
*
|
|
165
|
-
* 并发输家不会互等([ref] 的死锁形正是那次升级)。
|
|
166
|
-
*
|
|
167
|
-
* 行不在 = 赢家的绑定在这一瞬被 `unbindSession` 删了(真实可能,不是不变量破)。这仍然是**响亮**的:
|
|
168
|
-
* 首绑既没成、也说不出冲突对象,折成任何一种业务回执都是撒谎([ref]:未知失败不伪装成正常拒绝)。
|
|
146
|
+
* 不读认领:这是认领持有者自己的初始放置写(读写序律唯一的例外,`execution-placement-sql.ts` 头注)。锁序:设备行 → 根 `session_meta`。
|
|
147
|
+
* 持着根行的锁判过「可写」之后那句 UPDATE 不可能 0 行 —— 0 行即驱动 / 引擎异常,响亮抛,不折成业务分支([ref])。
|
|
148
|
+
* 并发两只首绑在根行锁上串行:输家的锁定读看到赢家的格 ⇒ 同设备 `same`(幂等成功)/ 异设备 `device_conflict`(单赢家,无未定义态);
|
|
149
|
+
* 修前的 `INSERT IGNORE` 撞键 S 锁升级死锁形([ref])在这里不存在 —— 两边取的都是同一行的 X 锁,没有升级。
|
|
169
150
|
*/
|
|
170
|
-
|
|
151
|
+
placeDeviceExecution(input: PlaceDeviceExecutionInput): Promise<PlaceDeviceExecutionResult>;
|
|
171
152
|
/**
|
|
172
|
-
* O4 显式换绑(2026-08-30 v2 稿 §2)——
|
|
173
|
-
* `
|
|
174
|
-
*
|
|
175
|
-
*
|
|
176
|
-
*
|
|
177
|
-
* · 设备行必须当前读 —— 快照读会把「与吊销并发的换绑」绑到一台刚被吊销的设备上(double-admit 形);
|
|
178
|
-
* · 绑定行必须当前读且**持锁到提交** —— 两个并发换绑(同 expectedRev)在锁上串行:赢家 rev+1 提交,
|
|
179
|
-
* 输家的锁读看到新 rev ⇒ 走 `rev_conflict` 携当前行(单赢家,无未定义态)。锁读判等之后,CAS
|
|
180
|
-
* UPDATE(`rev = ?` 谓词 + `rev+1` 恒变列)在持锁下不可能 0 行 —— 0 行即驱动/引擎异常,响亮抛,
|
|
181
|
-
* 不折成业务分支([ref]:未知失败不伪装成正常拒绝)。
|
|
153
|
+
* O4 显式换绑(2026-08-30 v2 稿 §2;§3.3 转换表 rebind 行)—— **一只事务**:锁目标设备行(在 / 属 owner / 未吊销)→ 读写序律那一读
|
|
154
|
+
* {@link lockRootAndReadClaim}(用本事务自己的连接先 `FOR UPDATE` 锁根 `session_meta` 行,再新语句锁定读 `task_active`;有认领 ⇒ `active_run`,
|
|
155
|
+
* 什么都不写 —— 这一读是「换绑不与在飞 run 交错」的**保证**,路由的池级 `getActiveTaskId` 只作早拒)→ 锁定读执行格 → 判官
|
|
156
|
+
* {@link settledDeviceRebindOutcome}(住客 ∧ 两属主列 ∧ rev;幂等短路先于写)→ CAS(`execution_rev + 1` 恒变列)→ `session_rebound` 审计行,同一事务提交。
|
|
157
|
+
* 审计写不进去 = 整次换绑失败(fail-closed,[ref])。持锁判过之后 CAS 不可能 0 行 —— 0 行即引擎异常,响亮抛。三形语义 oracle = `InMemoryDeviceStore.rebindDeviceExecution`。
|
|
182
158
|
*/
|
|
183
|
-
|
|
184
|
-
getSessionBinding(rootSessionId: string): Promise<DeviceSessionRow | null>;
|
|
185
|
-
deleteByRootSession(rootSessionId: string): Promise<number>;
|
|
159
|
+
rebindDeviceExecution(input: RebindDeviceExecutionInput): Promise<RebindDeviceExecutionResult>;
|
|
186
160
|
appendAudit(input: AppendAuditInput): Promise<void>;
|
|
187
161
|
/** 审计写的**唯一**语句(池级与事务内共用;`SqlExec` 是两者的公共面)。 */
|
|
188
162
|
private insertAuditOn;
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { mysqlDriver, pgDriver, dialectJsonEncoder } from "./sql-driver.js";
|
|
2
2
|
import { isDupKeyError, isCheckViolationError } from "./sql-errors.js";
|
|
3
|
-
import { DEVICE_AUDIT_EVENTS, assertAcquireShape, assertDeviceIdShape, assertDeviceOwnerShape, assertEnrollTokenHashShape, assertIssueTokenShape, assertNewDeviceShape, assertRebindShape, assertRevokeShape,
|
|
3
|
+
import { DEVICE_AUDIT_EVENTS, assertAcquireShape, assertDeviceIdShape, assertDeviceOwnerShape, assertEnrollTokenHashShape, assertIssueTokenShape, assertNewDeviceShape, assertPlaceDeviceShape, assertRebindShape, assertRevokeShape, assertWholeSeconds, parseDeviceAuditEvent, parseDevicePlatformOs, parseDeviceStatus, } from "../device-store.js";
|
|
4
|
+
import { WRITABLE, boundDeviceOf, settledDeviceBindOutcome, settledDeviceRebindOutcome } from "../execution-placement.js";
|
|
5
|
+
import { bindDeviceStatement, lockRootAndReadClaim, placeWriterStatement, readPlacementOn, rebindDeviceStatement, rowsOnSqlExec } from "./execution-placement-sql.js";
|
|
4
6
|
import { ensureIndexes } from "./ensure-index.js";
|
|
5
7
|
import { decodeJsonColumn, foldJsonColumn, jsonColumnText, readJsonObjectColumn } from "./sql-json-column.js";
|
|
6
8
|
export const DEVICES_TABLE = "device";
|
|
7
9
|
export const DEVICE_ENROLL_TOKENS_TABLE = "device_enroll_token";
|
|
8
|
-
export const DEVICE_SESSIONS_TABLE = "device_session";
|
|
9
10
|
export const DEVICE_AUDIT_TABLE = "device_audit";
|
|
10
11
|
const UQ_DEVICES_PUBKEY = "uq_device_pubkey";
|
|
11
12
|
const AUDIT_EVENT_CHECK = DEVICE_AUDIT_EVENTS.map((e) => `'${e}'`).join(", ");
|
|
@@ -56,17 +57,6 @@ export const TIDB_DEVICE_STATEMENTS = [
|
|
|
56
57
|
consumed_pubkey VARBINARY(128) NULL,
|
|
57
58
|
rev BIGINT NOT NULL DEFAULT 0,
|
|
58
59
|
PRIMARY KEY (token_hash)
|
|
59
|
-
) COLLATE utf8mb4_bin`,
|
|
60
|
-
`CREATE TABLE IF NOT EXISTS ${DEVICE_SESSIONS_TABLE} (
|
|
61
|
-
-- 键 = 根会话(task-R1-3:后代继承不另绑);一根会话恒一设备(改绑 = 显式动作)。
|
|
62
|
-
root_session_id VARBINARY(128) NOT NULL,
|
|
63
|
-
device_id VARBINARY(64) NOT NULL,
|
|
64
|
-
owner_tenant VARBINARY(190) NOT NULL,
|
|
65
|
-
owner_subject VARBINARY(190) NOT NULL,
|
|
66
|
-
bound_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
|
67
|
-
rev BIGINT NOT NULL DEFAULT 0,
|
|
68
|
-
PRIMARY KEY (root_session_id),
|
|
69
|
-
KEY idx_device_session_device (device_id)
|
|
70
60
|
) COLLATE utf8mb4_bin`,
|
|
71
61
|
`CREATE TABLE IF NOT EXISTS ${DEVICE_AUDIT_TABLE} (
|
|
72
62
|
id BIGINT AUTO_INCREMENT,
|
|
@@ -90,7 +80,6 @@ export async function ensureTiDBDeviceSchema(pool) {
|
|
|
90
80
|
}
|
|
91
81
|
export const DEVICE_INDEXES = [
|
|
92
82
|
{ table: `${DEVICES_TABLE}`, name: "idx_device_owner", columns: ["owner_tenant", "owner_subject"] },
|
|
93
|
-
{ table: `${DEVICE_SESSIONS_TABLE}`, name: "idx_device_session_device", columns: ["device_id"] },
|
|
94
83
|
{ table: `${DEVICE_AUDIT_TABLE}`, name: "idx_device_audit_device", columns: ["device_id", "at"] },
|
|
95
84
|
{ table: `${DEVICE_AUDIT_TABLE}`, name: "idx_device_audit_event", columns: ["event", "at"] },
|
|
96
85
|
];
|
|
@@ -133,15 +122,6 @@ export async function ensurePgDeviceSchema(q) {
|
|
|
133
122
|
consumed_pubkey VARCHAR(128) COLLATE "C",
|
|
134
123
|
rev BIGINT NOT NULL DEFAULT 0,
|
|
135
124
|
PRIMARY KEY (token_hash)
|
|
136
|
-
)`);
|
|
137
|
-
await q(`CREATE TABLE IF NOT EXISTS ${DEVICE_SESSIONS_TABLE} (
|
|
138
|
-
root_session_id VARCHAR(128) COLLATE "C" NOT NULL,
|
|
139
|
-
device_id VARCHAR(64) COLLATE "C" NOT NULL,
|
|
140
|
-
owner_tenant VARCHAR(190) COLLATE "C" NOT NULL,
|
|
141
|
-
owner_subject VARCHAR(190) COLLATE "C" NOT NULL,
|
|
142
|
-
bound_at TIMESTAMPTZ(3) NOT NULL DEFAULT now(),
|
|
143
|
-
rev BIGINT NOT NULL DEFAULT 0,
|
|
144
|
-
PRIMARY KEY (root_session_id)
|
|
145
125
|
)`);
|
|
146
126
|
await q(`CREATE TABLE IF NOT EXISTS ${DEVICE_AUDIT_TABLE} (
|
|
147
127
|
id BIGSERIAL PRIMARY KEY,
|
|
@@ -172,8 +152,8 @@ export async function assertDeviceAuditRebindEventSchema(db) {
|
|
|
172
152
|
: `ALTER TABLE ${DEVICE_AUDIT_TABLE} DROP CHECK ck_device_audit_event; ALTER TABLE ${DEVICE_AUDIT_TABLE} ADD CONSTRAINT ck_device_audit_event CHECK (event IN (${AUDIT_EVENT_CHECK}));`;
|
|
173
153
|
throw new Error(`${DEVICE_AUDIT_TABLE} still carries a pre-O4 event CHECK that rejects 'session_rebound' — refusing to start. ` +
|
|
174
154
|
"The rebind verb (POST /v1/devices/sessions/:rootSessionId/rebind) writes this audit event in the same transaction " +
|
|
175
|
-
"as its CAS, so on this table every rebind would fail at runtime after a healthy-looking boot.
|
|
176
|
-
"
|
|
155
|
+
"as its CAS, so on this table every rebind would fail at runtime after a healthy-looking boot. Nothing in this build " +
|
|
156
|
+
"rewrites this table's constraint on its own, and dropping an AUDIT table would destroy security-axis history — run the manual " +
|
|
177
157
|
`constraint rebuild instead (history preserved, defense-in-depth restored to the current closed set): \`${remedy}\` ` +
|
|
178
158
|
"The application layer remains the enforcing authority for this closed set (parseDeviceAuditEvent). " +
|
|
179
159
|
`Underlying probe error: ${err instanceof Error ? err.message : String(err)}`);
|
|
@@ -234,9 +214,6 @@ function tokenCols(dialect) {
|
|
|
234
214
|
"rev",
|
|
235
215
|
].join(", ");
|
|
236
216
|
}
|
|
237
|
-
function sessionCols(dialect) {
|
|
238
|
-
return ["root_session_id", "device_id", "owner_tenant", "owner_subject", ms(dialect, "bound_at", "bound_at_ms"), "rev"].join(", ");
|
|
239
|
-
}
|
|
240
217
|
function auditCols(dialect) {
|
|
241
218
|
return ["id", ms(dialect, "at", "at_ms"), "event", "device_id", "owner_tenant", "owner_subject", jsonColumnText(dialect, "detail")].join(", ");
|
|
242
219
|
}
|
|
@@ -287,15 +264,6 @@ function mapTokenRow(r) {
|
|
|
287
264
|
rev: Number(r.rev),
|
|
288
265
|
};
|
|
289
266
|
}
|
|
290
|
-
function mapSessionRow(r) {
|
|
291
|
-
return {
|
|
292
|
-
rootSessionId: bufToStr(r.root_session_id),
|
|
293
|
-
deviceId: bufToStr(r.device_id),
|
|
294
|
-
owner: { tenant: bufToStr(r.owner_tenant), subject: bufToStr(r.owner_subject) },
|
|
295
|
-
boundAtMs: Number(r.bound_at_ms),
|
|
296
|
-
rev: Number(r.rev),
|
|
297
|
-
};
|
|
298
|
-
}
|
|
299
267
|
function mapAuditRow(r) {
|
|
300
268
|
const tenant = strOrNull(r.owner_tenant);
|
|
301
269
|
const subject = strOrNull(r.owner_subject);
|
|
@@ -528,51 +496,38 @@ export class SqlDeviceStore {
|
|
|
528
496
|
const res = await this.db.query(this.q(`UPDATE ${DEVICES_TABLE} SET lease_expires_at = NULL, rev = rev + 1 WHERE device_id = ? AND conn_generation = ?`, `UPDATE ${DEVICES_TABLE} SET lease_expires_at = NULL, rev = rev + 1 WHERE device_id = $1 AND conn_generation = $2`), [deviceId, generation]);
|
|
529
497
|
return res.affected === 1;
|
|
530
498
|
}
|
|
531
|
-
async
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
499
|
+
async lockDeviceStatus(conn, deviceId, owner) {
|
|
500
|
+
const dev = await conn.query(this.q(`SELECT status FROM ${DEVICES_TABLE} WHERE device_id = ? AND owner_tenant = ? AND owner_subject = ? FOR UPDATE`, `SELECT status FROM ${DEVICES_TABLE} WHERE device_id = $1 AND owner_tenant = $2 AND owner_subject = $3 FOR UPDATE`), [deviceId, owner.tenant, owner.subject]);
|
|
501
|
+
return dev.rows[0] ? parseDeviceStatus(bufToStr(dev.rows[0].status)) : undefined;
|
|
502
|
+
}
|
|
503
|
+
async placeDeviceExecution(input) {
|
|
504
|
+
assertPlaceDeviceShape(input);
|
|
535
505
|
const conn = await this.db.connect();
|
|
536
506
|
try {
|
|
537
507
|
await conn.begin();
|
|
538
|
-
const
|
|
539
|
-
if (
|
|
508
|
+
const status = await this.lockDeviceStatus(conn, input.deviceId, input.owner);
|
|
509
|
+
if (status !== "active") {
|
|
540
510
|
await conn.rollback();
|
|
541
|
-
return { outcome: "device_not_found" };
|
|
511
|
+
return { outcome: status === undefined ? "device_not_found" : "device_revoked" };
|
|
542
512
|
}
|
|
543
|
-
|
|
513
|
+
const rowsOn = rowsOnSqlExec(conn);
|
|
514
|
+
const reading = await readPlacementOn(rowsOn, this.db.dialect, input.root);
|
|
515
|
+
const settled = settledDeviceBindOutcome(input.root, reading, input.occupant, input.deviceId);
|
|
516
|
+
if (settled !== WRITABLE) {
|
|
544
517
|
await conn.rollback();
|
|
545
|
-
return
|
|
546
|
-
}
|
|
547
|
-
const clock = await conn.query(this.q(`SELECT ${ms("tidb", NOW("tidb"), "bound_at_ms")}`, `SELECT ${ms("pg", NOW("pg"), "bound_at_ms")}`));
|
|
548
|
-
const boundAtMs = Number(clock.rows[0].bound_at_ms);
|
|
549
|
-
const ins = await conn.query(this.q(`INSERT IGNORE INTO ${DEVICE_SESSIONS_TABLE} (root_session_id, device_id, owner_tenant, owner_subject, bound_at, rev) VALUES (?, ?, ?, ?, FROM_UNIXTIME(? / 1000), 0)`, `INSERT INTO ${DEVICE_SESSIONS_TABLE} (root_session_id, device_id, owner_tenant, owner_subject, bound_at, rev) VALUES ($1, $2, $3, $4, to_timestamp($5::double precision / 1000.0), 0) ON CONFLICT (root_session_id) DO NOTHING`), [input.rootSessionId, input.deviceId, input.owner.tenant, input.owner.subject, boundAtMs]);
|
|
550
|
-
if (ins.affected === 1) {
|
|
551
|
-
await conn.commit();
|
|
552
|
-
return { outcome: "bound", row: { rootSessionId: input.rootSessionId, deviceId: input.deviceId, owner: input.owner, boundAtMs, rev: 0 } };
|
|
518
|
+
return settled;
|
|
553
519
|
}
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
}
|
|
563
|
-
return await this.observeLostBind(input);
|
|
564
|
-
}
|
|
565
|
-
async observeLostBind(input) {
|
|
566
|
-
const conn = await this.db.connect();
|
|
567
|
-
try {
|
|
568
|
-
await conn.begin();
|
|
569
|
-
const { rows } = await conn.query(this.q(`SELECT ${sessionCols("tidb")} FROM ${DEVICE_SESSIONS_TABLE} WHERE root_session_id = ? FOR UPDATE`, `SELECT ${sessionCols("pg")} FROM ${DEVICE_SESSIONS_TABLE} WHERE root_session_id = $1 FOR UPDATE`), [input.rootSessionId]);
|
|
520
|
+
const now = new Date();
|
|
521
|
+
const device = { id: input.deviceId, owner: input.owner };
|
|
522
|
+
const stmt = reading.cell.role === "absent"
|
|
523
|
+
? placeWriterStatement(this.db.dialect, input.root, input.occupant, { lane: "device", device }, now)
|
|
524
|
+
: bindDeviceStatement(this.db.dialect, input.root, input.occupant, device, now);
|
|
525
|
+
const res = await conn.query(stmt.text, [...stmt.values]);
|
|
526
|
+
if (res.affected !== 1)
|
|
527
|
+
throw new Error(`device-store: first bind of session ${input.root} affected ${res.affected} rows under a held row lock — engine anomaly, refusing to guess`);
|
|
570
528
|
await conn.commit();
|
|
571
|
-
const
|
|
572
|
-
|
|
573
|
-
throw new Error("device-store: the winning session binding vanished before the losing binder could observe it");
|
|
574
|
-
const same = row.deviceId === input.deviceId && row.owner.tenant === input.owner.tenant && row.owner.subject === input.owner.subject;
|
|
575
|
-
return { outcome: same ? "idempotent" : "conflict", row };
|
|
529
|
+
const placed = { id: input.deviceId, owner: { tenant: input.owner.tenant, subject: input.owner.subject }, rev: 0, placedAt: now.toISOString() };
|
|
530
|
+
return { outcome: "bound", device: placed };
|
|
576
531
|
}
|
|
577
532
|
catch (err) {
|
|
578
533
|
await conn.rollback().catch(() => undefined);
|
|
@@ -582,40 +537,41 @@ export class SqlDeviceStore {
|
|
|
582
537
|
conn.release();
|
|
583
538
|
}
|
|
584
539
|
}
|
|
585
|
-
async
|
|
540
|
+
async rebindDeviceExecution(input) {
|
|
586
541
|
assertRebindShape(input);
|
|
587
542
|
const conn = await this.db.connect();
|
|
588
543
|
try {
|
|
589
544
|
await conn.begin();
|
|
590
|
-
|
|
591
|
-
if (!dev.rows[0] || parseDeviceStatus(bufToStr(dev.rows[0].status)) !== "active") {
|
|
545
|
+
if ((await this.lockDeviceStatus(conn, input.targetDeviceId, input.owner)) !== "active") {
|
|
592
546
|
await conn.rollback();
|
|
593
547
|
return { outcome: "device_not_found" };
|
|
594
548
|
}
|
|
595
|
-
const
|
|
596
|
-
|
|
549
|
+
const rowsOn = rowsOnSqlExec(conn);
|
|
550
|
+
const claim = await lockRootAndReadClaim(rowsOn, this.db.dialect, input.root);
|
|
551
|
+
if (!claim.rowPresent) {
|
|
597
552
|
await conn.rollback();
|
|
598
|
-
return { outcome: "
|
|
553
|
+
return { outcome: "gone" };
|
|
599
554
|
}
|
|
600
|
-
|
|
601
|
-
if (row.deviceId === input.toDeviceId && row.rev === input.expectedRev) {
|
|
555
|
+
if (claim.activeTaskId !== null) {
|
|
602
556
|
await conn.rollback();
|
|
603
|
-
return { outcome: "
|
|
557
|
+
return { outcome: "active_run", activeTaskId: claim.activeTaskId };
|
|
604
558
|
}
|
|
605
|
-
|
|
559
|
+
const reading = await readPlacementOn(rowsOn, this.db.dialect, input.root);
|
|
560
|
+
const settled = settledDeviceRebindOutcome(reading, input.occupant, input.owner, input.expectedRev, input.targetDeviceId);
|
|
561
|
+
if (settled !== WRITABLE) {
|
|
606
562
|
await conn.rollback();
|
|
607
|
-
return
|
|
563
|
+
return settled;
|
|
608
564
|
}
|
|
609
|
-
const
|
|
610
|
-
|
|
565
|
+
const from = boundDeviceOf(reading.cell).id;
|
|
566
|
+
const now = new Date();
|
|
567
|
+
const stmt = rebindDeviceStatement(this.db.dialect, input.root, input.occupant, input.owner, input.expectedRev, input.targetDeviceId, now);
|
|
568
|
+
const cas = await conn.query(stmt.text, [...stmt.values]);
|
|
569
|
+
if (cas.affected !== 1)
|
|
611
570
|
throw new Error(`device-store: rebind CAS affected ${cas.affected} rows under a held row lock — engine anomaly, refusing to guess`);
|
|
612
|
-
|
|
613
|
-
await this.insertAuditOn(conn, { event: "session_rebound", deviceId: input.
|
|
614
|
-
const { rows } = await conn.query(this.q(`SELECT ${sessionCols("tidb")} FROM ${DEVICE_SESSIONS_TABLE} WHERE root_session_id = ?`, `SELECT ${sessionCols("pg")} FROM ${DEVICE_SESSIONS_TABLE} WHERE root_session_id = $1`), [input.rootSessionId]);
|
|
571
|
+
const rev = input.expectedRev + 1;
|
|
572
|
+
await this.insertAuditOn(conn, { event: "session_rebound", deviceId: input.targetDeviceId, owner: input.owner, detail: { from, to: input.targetDeviceId, rev } });
|
|
615
573
|
await conn.commit();
|
|
616
|
-
|
|
617
|
-
throw new Error("device-store: rebound session binding vanished inside its own transaction");
|
|
618
|
-
return { outcome: "rebound", row: mapSessionRow(rows[0]) };
|
|
574
|
+
return { outcome: "rebound", from, device: { id: input.targetDeviceId, owner: { tenant: input.owner.tenant, subject: input.owner.subject }, rev, placedAt: now.toISOString() } };
|
|
619
575
|
}
|
|
620
576
|
catch (err) {
|
|
621
577
|
await conn.rollback().catch(() => undefined);
|
|
@@ -625,16 +581,6 @@ export class SqlDeviceStore {
|
|
|
625
581
|
conn.release();
|
|
626
582
|
}
|
|
627
583
|
}
|
|
628
|
-
async getSessionBinding(rootSessionId) {
|
|
629
|
-
assertRootSessionIdShape(rootSessionId);
|
|
630
|
-
const { rows } = await this.db.query(this.q(`SELECT ${sessionCols("tidb")} FROM ${DEVICE_SESSIONS_TABLE} WHERE root_session_id = ?`, `SELECT ${sessionCols("pg")} FROM ${DEVICE_SESSIONS_TABLE} WHERE root_session_id = $1`), [rootSessionId]);
|
|
631
|
-
return rows[0] ? mapSessionRow(rows[0]) : null;
|
|
632
|
-
}
|
|
633
|
-
async deleteByRootSession(rootSessionId) {
|
|
634
|
-
assertRootSessionIdShape(rootSessionId);
|
|
635
|
-
const res = await this.db.query(this.q(`DELETE FROM ${DEVICE_SESSIONS_TABLE} WHERE root_session_id = ?`, `DELETE FROM ${DEVICE_SESSIONS_TABLE} WHERE root_session_id = $1`), [rootSessionId]);
|
|
636
|
-
return res.affected;
|
|
637
|
-
}
|
|
638
584
|
async appendAudit(input) {
|
|
639
585
|
await this.insertAuditOn(this.db, input);
|
|
640
586
|
}
|