@sema-agent/server 7.51.0 → 7.52.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/README.md +1 -1
- package/README.zh-CN.md +1 -1
- package/USAGE.md +83 -20
- package/dist/adoption/plan.d.ts +4 -4
- package/dist/adoption/plan.js +11 -0
- package/dist/approval-card.d.ts +26 -2
- package/dist/approval-card.js +9 -0
- package/dist/approval-hmac.d.ts +1 -1
- package/dist/approval-reconciler.d.ts +2 -2
- package/dist/approval-reconciler.js +1 -1
- package/dist/bench/l8/artifact.d.ts +1 -1
- package/dist/bench/s1/arms.d.ts +1 -1
- package/dist/bench/s1/live-deps.d.ts +1 -1
- package/dist/bench/s1/oracle.d.ts +1 -1
- package/dist/bench/s1/row.d.ts +1 -1
- package/dist/boot/budget-tracing.d.ts +1 -1
- package/dist/boot/budget-tracing.js +2 -5
- package/dist/boot/coordinators.js +3 -1
- package/dist/boot/deferred-sandbox-path-env.d.ts +1 -1
- package/dist/boot/engine-lease.d.ts +1 -1
- package/dist/boot/execution-env.d.ts +1 -1
- package/dist/boot/governance-seams.d.ts +2 -2
- package/dist/boot/leader.d.ts +1 -1
- package/dist/boot/memory-boundary.d.ts +2 -2
- package/dist/boot/memory-consolidation.d.ts +6 -6
- package/dist/boot/org-memory.d.ts +1 -1
- package/dist/boot/parked-revive-gate.d.ts +3 -3
- package/dist/boot/reapers.d.ts +2 -2
- package/dist/boot/reapers.js +17 -4
- package/dist/boot/resolve-spec.d.ts +12 -0
- package/dist/boot/resolve-spec.js +29 -7
- package/dist/boot/retention-lane.d.ts +2 -2
- package/dist/boot/runner-deps.d.ts +17 -7
- package/dist/boot/runner-deps.js +4 -2
- package/dist/boot/runtime-caps.d.ts +23 -0
- package/dist/boot/runtime-caps.js +24 -2
- package/dist/boot/session-faces.d.ts +1 -1
- package/dist/boot/session-faces.js +14 -3
- package/dist/boot/stores.js +31 -1
- package/dist/boot/task-list-lane.d.ts +2 -2
- package/dist/brain.js +51 -31
- package/dist/budget.d.ts +2 -2
- package/dist/capabilities/center-plugins.d.ts +1 -1
- package/dist/capabilities/collab-workflows.d.ts +1 -1
- package/dist/capabilities/hands-lane.d.ts +3 -3
- package/dist/capabilities/scenarios.d.ts +3 -3
- package/dist/capabilities/skills.d.ts +1 -1
- package/dist/config-center/hot-keys-registry.js +5 -0
- package/dist/config-center/http-client.d.ts +2 -2
- package/dist/config-center/read-face.d.ts +1 -1
- package/dist/config-center/restart-signal.d.ts +1 -1
- package/dist/config-types.d.ts +78 -18
- package/dist/config.js +45 -10
- package/dist/deployment-governance.d.ts +1 -1
- package/dist/device-enrollment.d.ts +2 -2
- package/dist/device-store.d.ts +3 -3
- package/dist/device-ws-protocol.d.ts +2 -2
- package/dist/env-facts.d.ts +1 -1
- package/dist/execution-lane-caps.d.ts +1 -1
- package/dist/fleet/fleet-bus.d.ts +1 -1
- package/dist/fleet/fleet-reconciler.d.ts +1 -1
- package/dist/fleet/fleet-terminal-window.d.ts +1 -1
- package/dist/http/active-run-conflict.d.ts +2 -2
- package/dist/http/principal-gate.d.ts +1 -1
- package/dist/http/route-ctx.d.ts +24 -4
- package/dist/http/routes/admin-memory-optout.d.ts +48 -0
- package/dist/http/routes/admin-memory-optout.js +75 -0
- package/dist/http/routes/agents-roster.d.ts +1 -1
- package/dist/http/routes/approvals-assistant.js +21 -21
- package/dist/http/routes/capabilities.js +20 -9
- package/dist/http/routes/diagnostics.d.ts +1 -1
- package/dist/http/routes/leader.d.ts +1 -1
- package/dist/http/routes/memory-bundle.d.ts +1 -1
- package/dist/http/routes/memory-bundle.js +3 -1
- package/dist/http/routes/memory-compliance.d.ts +4 -4
- package/dist/http/routes/memory-origin.d.ts +4 -4
- package/dist/http/routes/memory-origin.js +3 -1
- package/dist/http/routes/memory-policy.js +6 -2
- package/dist/http/routes/retention-ops.d.ts +1 -1
- package/dist/http/routes/rules.d.ts +5 -2
- package/dist/http/routes/rules.js +84 -5
- package/dist/http/routes/run-memory-capture.d.ts +34 -0
- package/dist/http/routes/run-memory-capture.js +97 -0
- package/dist/http/routes/runs.d.ts +1 -0
- package/dist/http/routes/runs.js +212 -6
- package/dist/http/routes/session-sync.d.ts +4 -6
- package/dist/http/routes/session-sync.js +60 -47
- package/dist/http/routes/sessions.js +61 -188
- package/dist/http/routes/shared-memory.d.ts +1 -1
- package/dist/http/routes/tasks.js +1 -1
- package/dist/http/routes/workflows.js +19 -0
- package/dist/http/server.d.ts +35 -13
- package/dist/http/server.js +105 -12
- package/dist/http/verify-rounds.d.ts +1 -1
- package/dist/http/wire-types.d.ts +41 -8
- package/dist/leader/endpoint.d.ts +1 -1
- package/dist/leader/leader.d.ts +1 -1
- package/dist/leader/repair-oracle.d.ts +2 -2
- package/dist/leader/repair-oracle.js +1 -1
- package/dist/main.js +13 -6
- package/dist/memory-bundle-engine.d.ts +1 -1
- package/dist/memory-operator-faces.d.ts +5 -5
- package/dist/memory-origin-fence.d.ts +50 -0
- package/dist/memory-origin-fence.js +15 -0
- package/dist/memory-posture.d.ts +13 -1
- package/dist/memory-posture.js +1 -0
- package/dist/memory-scope.d.ts +6 -1
- package/dist/memory-scope.js +3 -2
- package/dist/model-select.d.ts +2 -2
- package/dist/observability/fail-open.d.ts +17 -5
- package/dist/observability/fail-open.js +17 -5
- package/dist/orchestration/hardened-vm-runner.d.ts +2 -2
- package/dist/orchestration/workflow-completion-inbox.d.ts +2 -2
- package/dist/plugins/background-shell-support.d.ts +2 -2
- package/dist/plugins/blob-backend.d.ts +7 -2
- package/dist/plugins/blob-backend.js +22 -18
- package/dist/plugins/caching-session-store.d.ts +7 -3
- package/dist/plugins/checkpoint-store-sql.d.ts +2 -2
- package/dist/plugins/file-history-store-sql.d.ts +170 -0
- package/dist/plugins/file-history-store-sql.js +827 -0
- package/dist/plugins/file-run-store.d.ts +44 -5
- package/dist/plugins/file-run-store.js +85 -13
- package/dist/plugins/fork-routing-session-store.d.ts +1 -1
- package/dist/plugins/fork-routing-session-store.js +2 -2
- package/dist/plugins/leader-run-store-sql.d.ts +1 -1
- package/dist/plugins/local-session-store.d.ts +9 -1
- package/dist/plugins/memory-embedder-fingerprint.d.ts +1 -1
- package/dist/plugins/memory-optout-grant-store-sql.d.ts +139 -0
- package/dist/plugins/memory-optout-grant-store-sql.js +157 -0
- package/dist/plugins/memory-origin-law.d.ts +2 -2
- package/dist/plugins/memory-run-store.d.ts +4 -3
- package/dist/plugins/memory-run-store.js +2 -1
- package/dist/plugins/permission-rule-store-file.d.ts +16 -6
- package/dist/plugins/permission-rule-store-file.js +77 -13
- package/dist/plugins/permission-rule-store-sql.d.ts +156 -8
- package/dist/plugins/permission-rule-store-sql.js +123 -21
- package/dist/plugins/pg-pool.js +59 -0
- package/dist/plugins/pg-safe-json.d.ts +1 -1
- package/dist/plugins/remote-env-device.d.ts +1 -1
- package/dist/plugins/remote-env-k8s.d.ts +1 -1
- package/dist/plugins/retention-lane-store-sql.d.ts +3 -3
- package/dist/plugins/retention-store-sql.d.ts +7 -7
- package/dist/plugins/retention-store-sql.js +6 -0
- package/dist/plugins/run-store-sql.d.ts +9 -3
- package/dist/plugins/run-store-sql.js +2 -2
- package/dist/plugins/send-user-file.d.ts +1 -1
- package/dist/plugins/session-placement.d.ts +1 -1
- package/dist/plugins/sql-errors.d.ts +1 -1
- package/dist/plugins/store-backend.d.ts +59 -29
- package/dist/plugins/store-backend.js +75 -15
- package/dist/plugins/store-contracts.d.ts +5 -3
- package/dist/plugins/task-list-store-sql.d.ts +1 -1
- package/dist/plugins/tidb-pool.js +59 -0
- package/dist/plugins/web-search.d.ts +1 -1
- package/dist/prompts-domain-validate.js +1 -1
- package/dist/resource-suspend.d.ts +1 -1
- package/dist/resource-window.d.ts +3 -3
- package/dist/rules-consent.d.ts +231 -9
- package/dist/rules-consent.js +411 -13
- package/dist/run-local.js +1 -0
- package/dist/runs.d.ts +15 -1
- package/dist/runs.js +19 -0
- package/dist/runtime-caps-resolver.d.ts +36 -1
- package/dist/runtime-caps-resolver.js +25 -0
- package/dist/runtime-governance.d.ts +1 -1
- package/dist/security.d.ts +5 -5
- package/dist/security.js +1 -1
- package/dist/session-sync.d.ts +52 -37
- package/dist/session-sync.js +19 -16
- package/dist/shared-memory-scope-authorizer.d.ts +1 -1
- package/dist/sighup-idle.d.ts +1 -1
- package/dist/spec-fields.d.ts +22 -1
- package/dist/spec-fields.js +11 -0
- package/dist/store-live-probe.d.ts +2 -2
- package/dist/task-a2a.d.ts +1 -1
- package/dist/task-cwd.d.ts +1 -1
- package/dist/task-mcp.d.ts +3 -3
- package/dist/task-settings.d.ts +3 -3
- package/dist/task-workflow.d.ts +1 -1
- package/dist/text-bidi.d.ts +2 -2
- package/dist/tool-approval.d.ts +191 -9
- package/dist/tool-approval.js +248 -29
- package/dist/trace/core-keyset-guard.d.ts +1 -1
- package/dist/trace/engine-notice-wire.d.ts +6 -4
- package/dist/trace/engine-notice-wire.js +2 -0
- package/dist/trace/ledger-events.d.ts +1 -1
- package/dist/trace/ledger-sink.d.ts +2 -2
- package/dist/trace/project.d.ts +3 -2
- package/dist/trace/project.js +1 -0
- package/dist/turn-activity.d.ts +1 -1
- package/dist/wall-clock-jump-guard.d.ts +3 -3
- package/package.json +2 -2
- package/dist/http/tar.d.ts +0 -17
- package/dist/http/tar.js +0 -53
- package/dist/http/workspace-content.d.ts +0 -11
- package/dist/http/workspace-content.js +0 -20
- package/dist/plugins/file-snapshot-store-sql.d.ts +0 -170
- package/dist/plugins/file-snapshot-store-sql.js +0 -213
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { createHash, randomBytes } from "node:crypto";
|
|
1
|
+
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
|
2
2
|
import { z } from "zod";
|
|
3
|
-
import { applyTombstones, assertDeleteDeltaCarriesNoAdd, assertRedemptionNotQuarantined, foldDelta, parseAllowRuleText, screenRuleSyncState, PERMISSION_RULE_WRITER, RULE_SYNC_DROP_CODES, } from "@sema-agent/core";
|
|
3
|
+
import { applyTombstones, assertDeleteDeltaCarriesNoAdd, assertRedemptionNotQuarantined, assertWriteDeltaScopeDurable, foldDelta, parseAllowRuleText, screenRuleSyncState, PERMISSION_RULE_WRITER, RULE_SYNC_DROP_CODES, } from "@sema-agent/core";
|
|
4
4
|
import { dialectProtocolJsonEncoder } from "./sql-driver.js";
|
|
5
5
|
import { isMissingColumnError } from "./sql-errors.js";
|
|
6
6
|
export function writerOfSqlRuleStore(store) {
|
|
@@ -14,7 +14,7 @@ export function writerOfSqlRuleStore(store) {
|
|
|
14
14
|
return undefined;
|
|
15
15
|
return { nextDot: nextDot, apply: apply, readRaw: readRaw };
|
|
16
16
|
}
|
|
17
|
-
const
|
|
17
|
+
export const DurableRuleScopeSchema = z.union([
|
|
18
18
|
z.object({ kind: z.literal("global") }).strict(),
|
|
19
19
|
z.object({ kind: z.literal("project"), root: z.string().min(1) }).strict(),
|
|
20
20
|
]);
|
|
@@ -28,12 +28,12 @@ const PersistedAllowRuleSchema = z
|
|
|
28
28
|
tool: z.literal("Bash"),
|
|
29
29
|
match: z.enum(["exact", "prefix"]),
|
|
30
30
|
command: z.string(),
|
|
31
|
-
scope:
|
|
31
|
+
scope: DurableRuleScopeSchema,
|
|
32
32
|
adds: z.array(RuleAddSchema),
|
|
33
33
|
})
|
|
34
34
|
.strict();
|
|
35
35
|
const RuleTombstoneSchema = z
|
|
36
|
-
.object({ rule: z.string(), scope:
|
|
36
|
+
.object({ rule: z.string(), scope: DurableRuleScopeSchema, removedDots: z.array(RuleDotSchema).min(1), deletedBy: RuleDotSchema })
|
|
37
37
|
.strict();
|
|
38
38
|
const QUARANTINE_REASONS = new Set(Object.entries(RULE_SYNC_DROP_CODES)
|
|
39
39
|
.filter(([, side]) => side.includes("local-quarantined"))
|
|
@@ -42,14 +42,14 @@ const isQuarantineReason = (v) => typeof v === "string" && QUARANTINE_REASONS.ha
|
|
|
42
42
|
const QuarantinedRuleAddSchema = z
|
|
43
43
|
.object({
|
|
44
44
|
rule: z.string(),
|
|
45
|
-
scope:
|
|
45
|
+
scope: DurableRuleScopeSchema,
|
|
46
46
|
add: RuleAddSchema,
|
|
47
47
|
reason: z.custom(isQuarantineReason, { message: "not a registered permission-rule quarantine reason" }),
|
|
48
48
|
atMs: z.number().int().nonnegative().safe(),
|
|
49
49
|
})
|
|
50
50
|
.strict();
|
|
51
51
|
const FrontierSchema = z.record(z.string(), z.number().int().nonnegative().safe());
|
|
52
|
-
const RuleCandidateSchema = z.object({ rule: z.string(), scope:
|
|
52
|
+
const RuleCandidateSchema = z.object({ rule: z.string(), scope: DurableRuleScopeSchema }).strict();
|
|
53
53
|
const RedeemedDotsSchema = z.record(z.string(), RuleDotSchema);
|
|
54
54
|
const RuleEditedSchema = z.object({ index: z.number().int().nonnegative().safe(), text: z.string(), at: z.string() }).strict();
|
|
55
55
|
const RuleOffer2Schema = z.union([
|
|
@@ -143,16 +143,41 @@ export const TIDB_PERMISSION_RULE_STATEMENTS = [
|
|
|
143
143
|
principal VARCHAR(512) NOT NULL,
|
|
144
144
|
approval_id VARCHAR(190) NOT NULL,
|
|
145
145
|
-- payload_hash:载荷绑定(F1 ④)。= prepare 时刻候选集的规范摘要;redeem 时与记录上的候选集
|
|
146
|
-
-- 重算比对,不符 ⇒ 拒(
|
|
146
|
+
-- 重算比对,不符 ⇒ 拒(改动/重放不可扩权)。
|
|
147
147
|
payload_hash VARCHAR(190) NOT NULL,
|
|
148
148
|
payload_json LONGTEXT NOT NULL,
|
|
149
149
|
expires_at_ms BIGINT NOT NULL,
|
|
150
150
|
-- consumed_at_ms:一次性消费位(F1 ③)。消费 = 单条条件 UPDATE(WHERE consumed_at_ms IS NULL),
|
|
151
151
|
-- **禁 read-then-write** —— 并发双 redeem 必须恰一成功,那只能由引擎的行锁回答。
|
|
152
152
|
consumed_at_ms BIGINT NULL,
|
|
153
|
+
-- claim_id:#265 A7 的**认领代次**(codex 对抗轮 R1-F1,验真后修)。每一次 consume / reclaim
|
|
154
|
+
-- 铸一枚新的;release / settle 的 WHERE 里**必须**带着它。没有这一列时两条语句问的是"有没有
|
|
155
|
+
-- 认领",不是"这是**谁的**认领"—— 于是一个跑过头被顶替的旧持有者能清掉现持有者的认领(第三条腿
|
|
156
|
+
-- 随即又认领一次),或把自己的陈旧终局写成这张票的答案。围栏把"恰一赢者"从一句话变成一条谓词。
|
|
157
|
+
claim_id VARCHAR(190) NULL,
|
|
158
|
+
-- purpose:#265 A7 的**车道绑定**(codex 对抗轮 R1-F2,验真后修)。两条导入车道共用这张表,而它们
|
|
159
|
+
-- 的票语义**刻意不同**(cc-import 一次性 / local-import 可恢复三态)。不刻这一位,一张 cc 票拿到
|
|
160
|
+
-- local 口就白得三态语义(cc-import 的一次性拒重放在**不改它一行代码**的情况下被绕过),反向则更坏:
|
|
161
|
+
-- 一张 local 票被 cc 口执行完却不落定,local 口随后按崩溃窗把它再执行一遍。不可变:铸票时定,此后
|
|
162
|
+
-- 每一次读写都拿它当匹配项(不符 ⇒ 与"票不存在"同形,零存在性 oracle)。
|
|
163
|
+
purpose VARCHAR(32) NOT NULL,
|
|
164
|
+
-- redeemed_outcome:#265 A7 的**第三态**(SETTLED)。NULL = 还没落定;非 NULL = 这张票的兑付**已经
|
|
165
|
+
-- 结束**,里面是那一次的逐字终局(JSON)。它把票的两态(未认领/已认领)扩成三态
|
|
166
|
+
-- MINTED → CLAIMED → SETTLED,而三态正是"认领之后死进程"这件事唯一能被区分出来的形:
|
|
167
|
+
-- CLAIMED-未结 = 上一次执行中途没了(可重驱),SETTLED = 已经答过了(逐字回放,零二次执行)。
|
|
168
|
+
-- 🔴 只服务 local-import 车道;cc-import 的票**语义不变**(一次性,同票二次 redeem 恒拒)——
|
|
169
|
+
-- 那条车道一个字节都不写这一列,两条车道的语义因此不共享一个开关(purpose 列是那条边界的执法者)。
|
|
170
|
+
redeemed_outcome LONGTEXT NULL,
|
|
171
|
+
-- settled_at_ms:落定**发生的时刻**(codex 对抗轮 R3-F2,验真后修)。清扫的「已落定」那一支必须按
|
|
172
|
+
-- 它判,不能按 expires_at_ms —— 一次**临期恢复**写下终局的那一刻,票早就过期很久了,按过期判会让
|
|
173
|
+
-- 这份刚生出来的权威回执**下一拍**就被删:规则已经写进店里,而属主的丢响应重放拿到 404。
|
|
174
|
+
-- 有了这一列,「落定之后必有一个完整的回放窗」对每一条路都成立,而不只是对准时兑付的那条。
|
|
175
|
+
settled_at_ms BIGINT NULL,
|
|
153
176
|
created_at_ms BIGINT NOT NULL,
|
|
154
177
|
PRIMARY KEY (ticket_id),
|
|
155
178
|
KEY idx_permission_rule_ticket_owner (owner_key),
|
|
179
|
+
-- 保留期腿 R6-F1 的支撑索引:孤儿 pending 的清扫谓词里有一条按 approval_id 的 NOT EXISTS 内查。
|
|
180
|
+
KEY idx_permission_rule_ticket_approval (approval_id),
|
|
156
181
|
-- 保留期腿的支撑索引(同上):WHERE expires_at_ms <= ? 是每 tick 一次的区间扫。
|
|
157
182
|
KEY idx_permission_rule_ticket_expires (expires_at_ms)
|
|
158
183
|
) COLLATE utf8mb4_bin`,
|
|
@@ -181,6 +206,25 @@ export async function assertPermissionRuleApprovalSchema(query, dialect) {
|
|
|
181
206
|
`Underlying probe error: ${err instanceof Error ? err.message : String(err)}`);
|
|
182
207
|
}
|
|
183
208
|
}
|
|
209
|
+
export async function assertPermissionRuleTicketSchema(query, dialect) {
|
|
210
|
+
try {
|
|
211
|
+
await query(`SELECT redeemed_outcome, claim_id, purpose, settled_at_ms FROM ${PERMISSION_RULE_TICKET_TABLE} WHERE 1=0`);
|
|
212
|
+
}
|
|
213
|
+
catch (err) {
|
|
214
|
+
if (!isMissingColumnError(err, dialect))
|
|
215
|
+
throw err;
|
|
216
|
+
throw new Error(`${PERMISSION_RULE_TICKET_TABLE} is missing required columns (redeemed_outcome / claim_id / purpose / settled_at_ms) — refusing to start. This build needs ` +
|
|
217
|
+
`the #265 A7 three-state redemption columns (MINTED → CLAIMED → SETTLED, the claim generation that fences a superseded ` +
|
|
218
|
+
`claimant, and the lane binding that keeps cc-import and local-import tickets from being interchangeable): without them a ` +
|
|
219
|
+
`local-import ticket can be claimed but never settled, so a redemption that already wrote rules to the store answers its ` +
|
|
220
|
+
`owner with a retry-later it can never satisfy. This repository ships no ALTER TABLE migrations, so the table must be recreated: run ` +
|
|
221
|
+
`\`DROP TABLE ${PERMISSION_RULE_TICKET_TABLE};\` on the ${dialect === "pg" ? "PostgreSQL" : "MySQL-protocol"} backend and ` +
|
|
222
|
+
`restart — the schema is recreated at boot. The cost is bounded and small: this table holds minute-TTL single-use import ` +
|
|
223
|
+
`tickets (a governance transient that dies on expiry), so dropping it only makes anyone mid-preview re-run prepare; ` +
|
|
224
|
+
`persisted rules live in ${PERMISSION_RULE_TABLE} and are untouched. ` +
|
|
225
|
+
`Underlying probe error: ${err instanceof Error ? err.message : String(err)}`);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
184
228
|
export async function ensurePgPermissionRuleSchema(q) {
|
|
185
229
|
await q(`CREATE TABLE IF NOT EXISTS ${PERMISSION_RULE_TABLE} (
|
|
186
230
|
owner_key VARCHAR(190) COLLATE "C" NOT NULL,
|
|
@@ -237,10 +281,19 @@ export async function ensurePgPermissionRuleSchema(q) {
|
|
|
237
281
|
payload_json TEXT COLLATE "C" NOT NULL,
|
|
238
282
|
expires_at_ms BIGINT NOT NULL,
|
|
239
283
|
consumed_at_ms BIGINT,
|
|
284
|
+
-- claim_id / purpose / redeemed_outcome:#265 A7 三列(MySQL 孪生的行内注写了各自的理由 ——
|
|
285
|
+
-- 认领代次是 release/settle 的围栏、purpose 是两条导入车道的绑定位、redeemed_outcome 是第三态)。
|
|
286
|
+
claim_id VARCHAR(190) COLLATE "C",
|
|
287
|
+
purpose VARCHAR(32) COLLATE "C" NOT NULL,
|
|
288
|
+
redeemed_outcome TEXT COLLATE "C",
|
|
289
|
+
-- settled_at_ms:落定时刻(MySQL 孪生的行内注写了理由:清扫的已落定支按它判,否则临期恢复出来的
|
|
290
|
+
-- 权威回执下一拍就没)。
|
|
291
|
+
settled_at_ms BIGINT,
|
|
240
292
|
created_at_ms BIGINT NOT NULL,
|
|
241
293
|
PRIMARY KEY (ticket_id)
|
|
242
294
|
)`);
|
|
243
295
|
await q(`CREATE INDEX IF NOT EXISTS idx_permission_rule_ticket_owner ON ${PERMISSION_RULE_TICKET_TABLE} (owner_key)`);
|
|
296
|
+
await q(`CREATE INDEX IF NOT EXISTS idx_permission_rule_ticket_approval ON ${PERMISSION_RULE_TICKET_TABLE} (approval_id)`);
|
|
244
297
|
await q(`CREATE INDEX IF NOT EXISTS idx_permission_rule_ticket_expires ON ${PERMISSION_RULE_TICKET_TABLE} (expires_at_ms)`);
|
|
245
298
|
}
|
|
246
299
|
export function buildRuleOwnerKey(owner) {
|
|
@@ -398,6 +451,7 @@ class SqlPermissionRuleStore {
|
|
|
398
451
|
throw new Error("the SQL permission-rule backend does not implement the sync-join landing pipeline (core's applySyncJoin is not on the package's public export face); " +
|
|
399
452
|
"no sync client is wired in this deployment, so this arm has no caller — see permission-rule-store-sql.ts header");
|
|
400
453
|
}
|
|
454
|
+
assertWriteDeltaScopeDurable(delta);
|
|
401
455
|
await this.ensureBucket();
|
|
402
456
|
const cur = await this.read();
|
|
403
457
|
if (cur.rev !== opts.expectedRev)
|
|
@@ -558,6 +612,10 @@ export class SqlRuleApprovalRecordStore {
|
|
|
558
612
|
}
|
|
559
613
|
}
|
|
560
614
|
export const RULE_IMPORT_TICKET_GC_GRACE_MS = 10 * 60_000;
|
|
615
|
+
export const SQL_DB_NOW_MS = {
|
|
616
|
+
tidb: "CAST(UNIX_TIMESTAMP(NOW(3)) * 1000 AS SIGNED)",
|
|
617
|
+
pg: "(EXTRACT(EPOCH FROM clock_timestamp()) * 1000)::bigint",
|
|
618
|
+
};
|
|
561
619
|
export function buildRulePayloadHash(candidates) {
|
|
562
620
|
return createHash("sha256").update(canonicalJson(candidates), "utf8").digest("hex");
|
|
563
621
|
}
|
|
@@ -575,9 +633,9 @@ export class SqlRuleImportTicketStore {
|
|
|
575
633
|
const nowMs = this.now();
|
|
576
634
|
const expiresAtMs = nowMs + input.ttlMs;
|
|
577
635
|
const payloadHash = buildRulePayloadHash(input.candidates);
|
|
578
|
-
await this.db.query(this.q(`INSERT INTO ${PERMISSION_RULE_TICKET_TABLE} (ticket_id, owner_key, principal, approval_id, payload_hash, payload_json, expires_at_ms, consumed_at_ms, created_at_ms)
|
|
579
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, NULL, ?)`, `INSERT INTO ${PERMISSION_RULE_TICKET_TABLE} (ticket_id, owner_key, principal, approval_id, payload_hash, payload_json, expires_at_ms, consumed_at_ms, created_at_ms)
|
|
580
|
-
VALUES ($1, $2, $3, $4, $5, $6, $7, NULL, $8)`), [
|
|
636
|
+
await this.db.query(this.q(`INSERT INTO ${PERMISSION_RULE_TICKET_TABLE} (ticket_id, owner_key, principal, approval_id, payload_hash, payload_json, expires_at_ms, consumed_at_ms, claim_id, purpose, created_at_ms)
|
|
637
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, NULL, NULL, ?, ?)`, `INSERT INTO ${PERMISSION_RULE_TICKET_TABLE} (ticket_id, owner_key, principal, approval_id, payload_hash, payload_json, expires_at_ms, consumed_at_ms, claim_id, purpose, created_at_ms)
|
|
638
|
+
VALUES ($1, $2, $3, $4, $5, $6, $7, NULL, NULL, $8, $9)`), [
|
|
581
639
|
input.ticketId,
|
|
582
640
|
buildRuleOwnerKey({ kind: "principal", principal: input.principal }),
|
|
583
641
|
input.principal,
|
|
@@ -585,44 +643,88 @@ export class SqlRuleImportTicketStore {
|
|
|
585
643
|
payloadHash,
|
|
586
644
|
dialectProtocolJsonEncoder(this.db.dialect)(input.candidates, "permission_rule_ticket.payload_json"),
|
|
587
645
|
expiresAtMs,
|
|
646
|
+
input.purpose,
|
|
588
647
|
nowMs,
|
|
589
648
|
]);
|
|
590
649
|
return { ticketId: input.ticketId, approvalId: input.approvalId, payloadHash, expiresAtMs };
|
|
591
650
|
}
|
|
592
|
-
async consume(ticketId, principal, opts) {
|
|
593
|
-
const
|
|
594
|
-
const decisionNowMs = opts?.decisionNowMs ?? stampMs;
|
|
651
|
+
async consume(ticketId, principal, purpose, opts) {
|
|
652
|
+
const decisionNowMs = opts?.decisionNowMs ?? this.now();
|
|
595
653
|
const ownerKey = buildRuleOwnerKey({ kind: "principal", principal });
|
|
596
654
|
const row = await this.readRow(ticketId);
|
|
597
655
|
if (row === undefined)
|
|
598
656
|
return { ok: false, reason: "unknown" };
|
|
599
657
|
if (row.ownerKey !== ownerKey)
|
|
600
658
|
return { ok: false, reason: "wrong-principal" };
|
|
659
|
+
if (row.purpose !== purpose)
|
|
660
|
+
return { ok: false, reason: "wrong-purpose" };
|
|
601
661
|
if (row.consumedAtMs !== undefined)
|
|
602
662
|
return { ok: false, reason: "consumed" };
|
|
603
663
|
if (row.expiresAtMs <= decisionNowMs)
|
|
604
664
|
return { ok: false, reason: "expired" };
|
|
605
|
-
const
|
|
665
|
+
const claimId = randomUUID();
|
|
666
|
+
const { affected } = await this.db.query(this.q(`UPDATE ${PERMISSION_RULE_TICKET_TABLE} SET consumed_at_ms = ${SQL_DB_NOW_MS.tidb}, claim_id = ? WHERE ticket_id = ? AND owner_key = ? AND purpose = ? AND consumed_at_ms IS NULL AND expires_at_ms > ?`, `UPDATE ${PERMISSION_RULE_TICKET_TABLE} SET consumed_at_ms = ${SQL_DB_NOW_MS.pg}, claim_id = $1 WHERE ticket_id = $2 AND owner_key = $3 AND purpose = $4 AND consumed_at_ms IS NULL AND expires_at_ms > $5`), [claimId, ticketId, ownerKey, purpose, decisionNowMs]);
|
|
606
667
|
if (affected === 1)
|
|
607
|
-
return { ok: true, approvalId: row.approvalId, payloadHash: row.payloadHash };
|
|
668
|
+
return { ok: true, approvalId: row.approvalId, payloadHash: row.payloadHash, claimId };
|
|
608
669
|
return { ok: false, reason: "consumed" };
|
|
609
670
|
}
|
|
610
|
-
async release(ticketId, principal, mustRemainValidMs = 0, opts) {
|
|
671
|
+
async release(ticketId, principal, claimId, mustRemainValidMs = 0, opts) {
|
|
611
672
|
const nowMs = opts?.decisionNowMs ?? this.now();
|
|
612
|
-
const { affected } = await this.db.query(this.q(`UPDATE ${PERMISSION_RULE_TICKET_TABLE} SET consumed_at_ms = NULL WHERE ticket_id = ? AND owner_key = ? AND consumed_at_ms IS NOT NULL AND expires_at_ms > ?`, `UPDATE ${PERMISSION_RULE_TICKET_TABLE} SET consumed_at_ms = NULL WHERE ticket_id = $1 AND owner_key = $2 AND consumed_at_ms IS NOT NULL AND expires_at_ms > $
|
|
673
|
+
const { affected } = await this.db.query(this.q(`UPDATE ${PERMISSION_RULE_TICKET_TABLE} SET consumed_at_ms = NULL, claim_id = NULL WHERE ticket_id = ? AND owner_key = ? AND claim_id = ? AND consumed_at_ms IS NOT NULL AND redeemed_outcome IS NULL AND expires_at_ms > ?`, `UPDATE ${PERMISSION_RULE_TICKET_TABLE} SET consumed_at_ms = NULL, claim_id = NULL WHERE ticket_id = $1 AND owner_key = $2 AND claim_id = $3 AND consumed_at_ms IS NOT NULL AND redeemed_outcome IS NULL AND expires_at_ms > $4`), [ticketId, buildRuleOwnerKey({ kind: "principal", principal }), claimId, nowMs + mustRemainValidMs]);
|
|
674
|
+
return affected === 1;
|
|
675
|
+
}
|
|
676
|
+
async settle(ticketId, principal, claimId, outcome) {
|
|
677
|
+
const { affected } = await this.db.query(this.q(`UPDATE ${PERMISSION_RULE_TICKET_TABLE} SET redeemed_outcome = ?, settled_at_ms = ${SQL_DB_NOW_MS.tidb} WHERE ticket_id = ? AND owner_key = ? AND claim_id = ? AND consumed_at_ms IS NOT NULL AND redeemed_outcome IS NULL`, `UPDATE ${PERMISSION_RULE_TICKET_TABLE} SET redeemed_outcome = $1, settled_at_ms = ${SQL_DB_NOW_MS.pg} WHERE ticket_id = $2 AND owner_key = $3 AND claim_id = $4 AND consumed_at_ms IS NOT NULL AND redeemed_outcome IS NULL`), [
|
|
678
|
+
dialectProtocolJsonEncoder(this.db.dialect)(outcome, "permission_rule_ticket.redeemed_outcome"),
|
|
679
|
+
ticketId,
|
|
680
|
+
buildRuleOwnerKey({ kind: "principal", principal }),
|
|
681
|
+
claimId,
|
|
682
|
+
]);
|
|
613
683
|
return affected === 1;
|
|
614
684
|
}
|
|
685
|
+
async peek(ticketId, principal, purpose) {
|
|
686
|
+
const row = await this.readRow(ticketId);
|
|
687
|
+
if (row === undefined || row.ownerKey !== buildRuleOwnerKey({ kind: "principal", principal }) || row.purpose !== purpose)
|
|
688
|
+
return undefined;
|
|
689
|
+
if (row.redeemedOutcome !== undefined) {
|
|
690
|
+
let outcome;
|
|
691
|
+
try {
|
|
692
|
+
outcome = JSON.parse(row.redeemedOutcome);
|
|
693
|
+
}
|
|
694
|
+
catch (err) {
|
|
695
|
+
throw new Error(`${PERMISSION_RULE_TICKET_TABLE}.redeemed_outcome for ticket ${ticketId} is not valid JSON (${err instanceof Error ? err.message : String(err)})`);
|
|
696
|
+
}
|
|
697
|
+
return { state: "settled", outcome };
|
|
698
|
+
}
|
|
699
|
+
if (row.consumedAtMs === undefined)
|
|
700
|
+
return { state: "minted" };
|
|
701
|
+
return { state: "claimed", claimAgeMs: Math.max(0, row.dbNowMs - row.consumedAtMs) };
|
|
702
|
+
}
|
|
703
|
+
async reclaim(ticketId, principal, purpose, minClaimAgeMs) {
|
|
704
|
+
const row = await this.readRow(ticketId);
|
|
705
|
+
if (row === undefined || row.ownerKey !== buildRuleOwnerKey({ kind: "principal", principal }) || row.purpose !== purpose)
|
|
706
|
+
return { ok: false };
|
|
707
|
+
if (row.claimId === undefined)
|
|
708
|
+
return { ok: false };
|
|
709
|
+
const claimId = randomUUID();
|
|
710
|
+
const { affected } = await this.db.query(this.q(`UPDATE ${PERMISSION_RULE_TICKET_TABLE} SET consumed_at_ms = ${SQL_DB_NOW_MS.tidb}, claim_id = ? WHERE ticket_id = ? AND owner_key = ? AND purpose = ? AND claim_id = ? AND consumed_at_ms IS NOT NULL AND consumed_at_ms <= ${SQL_DB_NOW_MS.tidb} - ? AND redeemed_outcome IS NULL`, `UPDATE ${PERMISSION_RULE_TICKET_TABLE} SET consumed_at_ms = ${SQL_DB_NOW_MS.pg}, claim_id = $1 WHERE ticket_id = $2 AND owner_key = $3 AND purpose = $4 AND claim_id = $5 AND consumed_at_ms IS NOT NULL AND consumed_at_ms <= ${SQL_DB_NOW_MS.pg} - $6 AND redeemed_outcome IS NULL`), [claimId, ticketId, buildRuleOwnerKey({ kind: "principal", principal }), purpose, row.claimId, minClaimAgeMs]);
|
|
711
|
+
return affected === 1 ? { ok: true, approvalId: row.approvalId, payloadHash: row.payloadHash, claimId } : { ok: false };
|
|
712
|
+
}
|
|
615
713
|
async readRow(ticketId) {
|
|
616
|
-
const { rows } = await this.db.query(this.q(`SELECT owner_key, approval_id, payload_hash, expires_at_ms, consumed_at_ms FROM ${PERMISSION_RULE_TICKET_TABLE} WHERE ticket_id = ?`, `SELECT owner_key, approval_id, payload_hash, expires_at_ms, consumed_at_ms FROM ${PERMISSION_RULE_TICKET_TABLE} WHERE ticket_id = $1`), [ticketId]);
|
|
714
|
+
const { rows } = await this.db.query(this.q(`SELECT owner_key, approval_id, payload_hash, purpose, expires_at_ms, consumed_at_ms, claim_id, redeemed_outcome, ${SQL_DB_NOW_MS.tidb} AS db_now_ms FROM ${PERMISSION_RULE_TICKET_TABLE} WHERE ticket_id = ?`, `SELECT owner_key, approval_id, payload_hash, purpose, expires_at_ms, consumed_at_ms, claim_id, redeemed_outcome, ${SQL_DB_NOW_MS.pg} AS db_now_ms FROM ${PERMISSION_RULE_TICKET_TABLE} WHERE ticket_id = $1`), [ticketId]);
|
|
617
715
|
const row = rows[0];
|
|
618
716
|
if (row === undefined)
|
|
619
717
|
return undefined;
|
|
620
718
|
return {
|
|
719
|
+
dbNowMs: Number(row.db_now_ms),
|
|
621
720
|
ownerKey: String(row.owner_key),
|
|
622
721
|
approvalId: String(row.approval_id),
|
|
623
722
|
payloadHash: String(row.payload_hash),
|
|
723
|
+
purpose: String(row.purpose),
|
|
624
724
|
expiresAtMs: Number(row.expires_at_ms),
|
|
625
725
|
...(row.consumed_at_ms === null || row.consumed_at_ms === undefined ? {} : { consumedAtMs: Number(row.consumed_at_ms) }),
|
|
726
|
+
...(row.claim_id === null || row.claim_id === undefined ? {} : { claimId: String(row.claim_id) }),
|
|
727
|
+
...(row.redeemed_outcome === null || row.redeemed_outcome === undefined ? {} : { redeemedOutcome: String(row.redeemed_outcome) }),
|
|
626
728
|
};
|
|
627
729
|
}
|
|
628
730
|
}
|
|
@@ -635,8 +737,8 @@ export function createSqlPermissionRuleStores(db, now) {
|
|
|
635
737
|
approvals: new SqlRuleApprovalRecordStore(db, now),
|
|
636
738
|
tickets: new SqlRuleImportTicketStore(db, now),
|
|
637
739
|
reapExpired: async (nowMs) => {
|
|
638
|
-
const deadTickets = await db.query(q(`DELETE FROM ${PERMISSION_RULE_TICKET_TABLE} WHERE expires_at_ms <= ? LIMIT ${RULE_REAP_BATCH}`, `DELETE FROM ${PERMISSION_RULE_TICKET_TABLE} WHERE ctid IN (SELECT ctid FROM ${PERMISSION_RULE_TICKET_TABLE} WHERE expires_at_ms <= $1 LIMIT ${RULE_REAP_BATCH})`), [nowMs - RULE_IMPORT_TICKET_GC_GRACE_MS]);
|
|
639
|
-
const orphanPending = await db.query(q(`DELETE FROM ${PERMISSION_RULE_APPROVAL_TABLE} WHERE state = 'pending' AND created_at_ms < ? LIMIT ${RULE_REAP_BATCH}`, `DELETE FROM ${PERMISSION_RULE_APPROVAL_TABLE} WHERE ctid IN (SELECT ctid FROM ${PERMISSION_RULE_APPROVAL_TABLE} WHERE state = 'pending' AND created_at_ms < $1 LIMIT ${RULE_REAP_BATCH})`), [nowMs - RULE_PENDING_APPROVAL_RETENTION_MS]);
|
|
740
|
+
const deadTickets = await db.query(q(`DELETE FROM ${PERMISSION_RULE_TICKET_TABLE} WHERE (consumed_at_ms IS NULL AND expires_at_ms <= ?) OR (redeemed_outcome IS NOT NULL AND settled_at_ms IS NOT NULL AND settled_at_ms <= ${SQL_DB_NOW_MS.tidb} - ${RULE_IMPORT_TICKET_GC_GRACE_MS}) OR (consumed_at_ms IS NOT NULL AND redeemed_outcome IS NULL AND consumed_at_ms < ${SQL_DB_NOW_MS.tidb} - ${RULE_PENDING_APPROVAL_RETENTION_MS}) LIMIT ${RULE_REAP_BATCH}`, `DELETE FROM ${PERMISSION_RULE_TICKET_TABLE} WHERE ctid IN (SELECT ctid FROM ${PERMISSION_RULE_TICKET_TABLE} WHERE (consumed_at_ms IS NULL AND expires_at_ms <= $1) OR (redeemed_outcome IS NOT NULL AND settled_at_ms IS NOT NULL AND settled_at_ms <= ${SQL_DB_NOW_MS.pg} - ${RULE_IMPORT_TICKET_GC_GRACE_MS}) OR (consumed_at_ms IS NOT NULL AND redeemed_outcome IS NULL AND consumed_at_ms < ${SQL_DB_NOW_MS.pg} - ${RULE_PENDING_APPROVAL_RETENTION_MS}) LIMIT ${RULE_REAP_BATCH})`), [nowMs - RULE_IMPORT_TICKET_GC_GRACE_MS]);
|
|
741
|
+
const orphanPending = await db.query(q(`DELETE FROM ${PERMISSION_RULE_APPROVAL_TABLE} WHERE state = 'pending' AND created_at_ms < ? AND NOT EXISTS (SELECT 1 FROM ${PERMISSION_RULE_TICKET_TABLE} t WHERE t.approval_id = ${PERMISSION_RULE_APPROVAL_TABLE}.record_id) LIMIT ${RULE_REAP_BATCH}`, `DELETE FROM ${PERMISSION_RULE_APPROVAL_TABLE} WHERE ctid IN (SELECT a.ctid FROM ${PERMISSION_RULE_APPROVAL_TABLE} a WHERE a.state = 'pending' AND a.created_at_ms < $1 AND NOT EXISTS (SELECT 1 FROM ${PERMISSION_RULE_TICKET_TABLE} t WHERE t.approval_id = a.record_id) LIMIT ${RULE_REAP_BATCH})`), [nowMs - RULE_PENDING_APPROVAL_RETENTION_MS]);
|
|
640
742
|
return deadTickets.affected + orphanPending.affected;
|
|
641
743
|
},
|
|
642
744
|
countBuckets: async () => {
|
package/dist/plugins/pg-pool.js
CHANGED
|
@@ -8,6 +8,7 @@ import { ensurePgOutcomeLedgerSchema as ensureOutcomeLedgerSchema } from "./outc
|
|
|
8
8
|
import { ensureSchema as ensureImageIndexSchema } from "./image-index-sql.js";
|
|
9
9
|
import { ensurePgImageBakeSchema as ensureImageBakeSchema } from "./image-bake-store-sql.js";
|
|
10
10
|
import { ensurePgApprovalAskSchema } from "./approval-ask-store-sql.js";
|
|
11
|
+
import { ensurePgMemoryOptOutGrantSchema } from "./memory-optout-grant-store-sql.js";
|
|
11
12
|
import { ensurePgAdoptionLogSchema } from "./adoption-log-sql.js";
|
|
12
13
|
import { ensurePgPermissionRuleSchema } from "./permission-rule-store-sql.js";
|
|
13
14
|
import { ensurePgLeaderRunSchema } from "./leader-run-store-sql.js";
|
|
@@ -165,6 +166,63 @@ export const PG_SCHEMA_STATEMENTS = [
|
|
|
165
166
|
created_at TIMESTAMPTZ(3) NOT NULL
|
|
166
167
|
)`,
|
|
167
168
|
`CREATE INDEX IF NOT EXISTS idx_snapshot_blob_created ON snapshot_blob (created_at)`,
|
|
169
|
+
`CREATE TABLE IF NOT EXISTS file_history_scope (
|
|
170
|
+
scope VARCHAR(64) COLLATE "C" NOT NULL,
|
|
171
|
+
created_at_ms BIGINT NOT NULL,
|
|
172
|
+
PRIMARY KEY (scope)
|
|
173
|
+
)`,
|
|
174
|
+
`CREATE TABLE IF NOT EXISTS file_history_tracked (
|
|
175
|
+
scope VARCHAR(64) COLLATE "C" NOT NULL,
|
|
176
|
+
key_hash CHAR(64) COLLATE "C" NOT NULL,
|
|
177
|
+
track_key TEXT COLLATE "C" NOT NULL,
|
|
178
|
+
key_kind VARCHAR(16) COLLATE "C" NOT NULL,
|
|
179
|
+
key_family VARCHAR(8) COLLATE "C" NOT NULL,
|
|
180
|
+
base_available BOOLEAN NOT NULL,
|
|
181
|
+
observed_later BOOLEAN NOT NULL DEFAULT FALSE,
|
|
182
|
+
created_at_ms BIGINT NOT NULL,
|
|
183
|
+
PRIMARY KEY (scope, key_hash)
|
|
184
|
+
)`,
|
|
185
|
+
`CREATE TABLE IF NOT EXISTS file_history_version (
|
|
186
|
+
scope VARCHAR(64) COLLATE "C" NOT NULL,
|
|
187
|
+
key_hash CHAR(64) COLLATE "C" NOT NULL,
|
|
188
|
+
version INT NOT NULL,
|
|
189
|
+
blob_hash CHAR(64) COLLATE "C" NULL,
|
|
190
|
+
mode INT NULL,
|
|
191
|
+
parent_ident TEXT COLLATE "C" NULL,
|
|
192
|
+
backup_time VARCHAR(40) COLLATE "C" NOT NULL,
|
|
193
|
+
PRIMARY KEY (scope, key_hash, version)
|
|
194
|
+
)`,
|
|
195
|
+
`CREATE INDEX IF NOT EXISTS idx_file_history_version_blob ON file_history_version (blob_hash)`,
|
|
196
|
+
`CREATE TABLE IF NOT EXISTS file_history_boundary (
|
|
197
|
+
scope VARCHAR(64) COLLATE "C" NOT NULL,
|
|
198
|
+
entry_id VARCHAR(64) COLLATE "C" NOT NULL,
|
|
199
|
+
published_at_ms BIGINT NOT NULL,
|
|
200
|
+
publish_seq BIGINT NOT NULL,
|
|
201
|
+
PRIMARY KEY (scope, entry_id),
|
|
202
|
+
CONSTRAINT uq_file_history_boundary_seq UNIQUE (scope, publish_seq)
|
|
203
|
+
)`,
|
|
204
|
+
`CREATE TABLE IF NOT EXISTS file_history_boundary_file (
|
|
205
|
+
scope VARCHAR(64) COLLATE "C" NOT NULL,
|
|
206
|
+
entry_id VARCHAR(64) COLLATE "C" NOT NULL,
|
|
207
|
+
key_hash CHAR(64) COLLATE "C" NOT NULL,
|
|
208
|
+
version INT NOT NULL,
|
|
209
|
+
PRIMARY KEY (scope, entry_id, key_hash)
|
|
210
|
+
)`,
|
|
211
|
+
`CREATE INDEX IF NOT EXISTS idx_file_history_bf_key ON file_history_boundary_file (scope, key_hash)`,
|
|
212
|
+
`CREATE TABLE IF NOT EXISTS file_history_reaped_entry (
|
|
213
|
+
scope VARCHAR(64) COLLATE "C" NOT NULL,
|
|
214
|
+
entry_id VARCHAR(64) COLLATE "C" NOT NULL,
|
|
215
|
+
reaped_at_ms BIGINT NOT NULL,
|
|
216
|
+
PRIMARY KEY (scope, entry_id)
|
|
217
|
+
)`,
|
|
218
|
+
`CREATE TABLE IF NOT EXISTS file_history_blob (
|
|
219
|
+
blob_hash CHAR(64) COLLATE "C" PRIMARY KEY,
|
|
220
|
+
byte_len BIGINT NOT NULL,
|
|
221
|
+
-- bytes NULLABLE = the same two-backend (SQL inline / MinIO index-only) contract as snapshot_blob above.
|
|
222
|
+
bytes BYTEA NULL,
|
|
223
|
+
created_at TIMESTAMPTZ(3) NOT NULL
|
|
224
|
+
)`,
|
|
225
|
+
`CREATE INDEX IF NOT EXISTS idx_file_history_blob_created ON file_history_blob (created_at)`,
|
|
168
226
|
`CREATE TABLE IF NOT EXISTS workflow_journal (
|
|
169
227
|
run_id VARCHAR(191) COLLATE "C" NOT NULL,
|
|
170
228
|
ordinal INT NOT NULL,
|
|
@@ -305,6 +363,7 @@ export async function ensurePgSchema(pool) {
|
|
|
305
363
|
await ensureImageIndexSchema(client);
|
|
306
364
|
await ensureImageBakeSchema(client);
|
|
307
365
|
await ensurePgApprovalAskSchema((text, params) => client.query(text, params));
|
|
366
|
+
await ensurePgMemoryOptOutGrantSchema((text, params) => client.query(text, params));
|
|
308
367
|
await ensurePgAdoptionLogSchema((text, params) => client.query(text, params));
|
|
309
368
|
await ensurePgPermissionRuleSchema((text, params) => client.query(text, params));
|
|
310
369
|
await ensurePgLeaderRunSchema((text, params) => client.query(text, params));
|
|
@@ -10,7 +10,7 @@ export declare function pgLosslessJsonStringify(value: unknown): string;
|
|
|
10
10
|
* 剥离等),CAS/rev 语义的臂必须先 detect 再变换。 */
|
|
11
11
|
export declare function pgHasUnstorable(v: unknown): boolean;
|
|
12
12
|
/** codex R4→R7 终形:协议数据(approval args/checkpoint/ctx/outcome/rules/memory)一律**拒绝式**。
|
|
13
|
-
* 信封方案历经 R4-R7
|
|
13
|
+
* 信封方案历经 R4-R7 四轮交叉复审全灭:行内判别子恒可被 ≤1.240 老行/用户数据撞形(静默解错=approval
|
|
14
14
|
* 信任边界破),进程旗恒有混旗副本窗(写读不同解)。唯一无歧义解=**脏值不进库**:PG 存不了
|
|
15
15
|
* NUL 是后端能力边界,fail-loud 暴露(清晰文案)严格优于修前的 22P05 神秘崩——两者都存不进,
|
|
16
16
|
* 但拒绝式下 review==execute 恒成立(库里只有干净值,展示面=执行面永逐位一致)。 */
|
|
@@ -113,7 +113,7 @@ export declare class RemoteDeviceExecutionEnv implements RemoteExecutionEnv {
|
|
|
113
113
|
* 🔴 **为什么这条判据不能只守 `connect()`**(codex R2-F3,判真):`connect()` 一辈子只跑一两次,而
|
|
114
114
|
* 设备的 WS 可以**透明重连** —— 断开、以同一 deviceId 连回来,`this.connected` 全程不变,后续
|
|
115
115
|
* `dispatch` 直接用汇聚端的**当前**连接。于是「弱能力连接上铸的 env + 设备连回时带上了 guarded 面」
|
|
116
|
-
*
|
|
116
|
+
* 这一形跳过了 connect 期的闸:core 仍看不到 guarded 方法、仍走非原子写降级,而没有任何一层再问一次。
|
|
117
117
|
* 所以判据要落在**每一次非原子写之前**(那正是被降级的那条路),connect 期的检查是它的早期快门。
|
|
118
118
|
*/
|
|
119
119
|
private guardFaceDrift;
|
|
@@ -268,7 +268,7 @@ export declare class RemoteK8sExecutionEnv implements RemoteExecutionEnv, Backgr
|
|
|
268
268
|
maxLines?: number;
|
|
269
269
|
abortSignal?: AbortSignal;
|
|
270
270
|
}): Promise<Result<string[], FileError>>;
|
|
271
|
-
/** 父目录自建(与 host/local-docker/adb/ssh 同口径)。抽成共用是因为 `appendFile`
|
|
271
|
+
/** 父目录自建(与 host/local-docker/adb/ssh 同口径)。抽成共用是因为 `appendFile` 曾**跳过**它:
|
|
272
272
|
* 🔴 2026-07-25 实测的跨腿分叉 —— `appendFile` 直接调 `writeChunked`,整条路上没有 `mkdir -p`,于是
|
|
273
273
|
* 「直接 append 建一个新日志路径」在 k8s 腿上返 `not_found`,而其余 5 腿都成功(host/local-docker/adb 实测,
|
|
274
274
|
* ssh/e2b 由方法体判据)。core 的后台输出镜像正是"每拍 append 一次"的形,这条分叉会让它在 k8s 腿上从第一拍
|
|
@@ -147,7 +147,7 @@ export declare class SqlRetentionLaneStore implements RetentionLaneStore {
|
|
|
147
147
|
* 判据三合取写进**一条 CAS 的 WHERE**:holder 是我 ∧ fencing token 还是本轮那个 ∧ **尚未过期**。
|
|
148
148
|
* · 第三项是承重的:租约过期而 holder 还写着我,必须读成**不再属于我**(fail-closed)——另一个副本
|
|
149
149
|
* 随时可能抢走,两个副本同时跑三方法是这条腿最不能出的事。
|
|
150
|
-
* · 「复核」与「续租」合成一条语句而不是先读后写(codex
|
|
150
|
+
* · 「复核」与「续租」合成一条语句而不是先读后写(codex 交叉复审 R1-[high],验真后改):
|
|
151
151
|
* ① 读+写两条语句之间有窗口,而 CAS 的谓词与更新在引擎里是原子的;
|
|
152
152
|
* ② 更要紧的是**续租本身**:`startRetentionLane` 的重入守卫会跳过下一 tick,于是一轮的续租机会
|
|
153
153
|
* 只有轮首那一次 —— 一轮跑满 2×interval 之后租约自己到期,而本副本毫不知情、继续删下去。
|
|
@@ -196,7 +196,7 @@ export declare class SqlRetentionLaneStore implements RetentionLaneStore {
|
|
|
196
196
|
nowMs: number;
|
|
197
197
|
}): Promise<void>;
|
|
198
198
|
/**
|
|
199
|
-
* 放置/解除 **+ 治理审计行,同一个事务**(codex
|
|
199
|
+
* 放置/解除 **+ 治理审计行,同一个事务**(codex 交叉复审 R1-[high],验真后加)。
|
|
200
200
|
*
|
|
201
201
|
* 🔴 为什么必须原子(与 §6 F5 的「删除与审计同事务」是同一条判据,只是换了一张表):两次独立提交下,
|
|
202
202
|
* 第二步失败会留下一次**无审计的状态变更**。PUT 那一支是「冻结了但账上没有」;**DELETE 那一支更重**
|
|
@@ -231,7 +231,7 @@ export declare class SqlRetentionLaneStore implements RetentionLaneStore {
|
|
|
231
231
|
*
|
|
232
232
|
* `before` = 上一页最后一行的 `id`(严格小于)。
|
|
233
233
|
*
|
|
234
|
-
* 🔴 读上限是 `MAX_LIMIT + 1` 而不是 `MAX_LIMIT`(codex
|
|
234
|
+
* 🔴 读上限是 `MAX_LIMIT + 1` 而不是 `MAX_LIMIT`(codex 交叉复审 R1-[medium],验真属实):调用方要判
|
|
235
235
|
* 「还有没有下一页」就得多取一行,而**页**的上限是 `MAX_LIMIT`。旧形把 limit 无条件夹到 `MAX_LIMIT`
|
|
236
236
|
* ⇒ 请求 `limit=200` 时 scanned 恒 ≤ 200 ⇒ `hasMore` 恒 false ⇒ **第 201 行之后的审计对这条分页链
|
|
237
237
|
* 永久不可达**。这一格是「窗的上限」与「探路的那一行」两个不同的数被写成同一个数造成的,
|
|
@@ -217,7 +217,7 @@ export declare class SqlRetentionStore implements ManagedRetentionCapability {
|
|
|
217
217
|
* 一条既存的 hold(两方言的这两个动词都只在缺行时写),只保证**接下来那把行锁有东西可锁**;
|
|
218
218
|
* ② `SELECT held … FOR UPDATE` —— 现在这行必定存在,于是锁真的拿到了。
|
|
219
219
|
*
|
|
220
|
-
* 🔴 为什么必须先造行(codex
|
|
220
|
+
* 🔴 为什么必须先造行(codex 交叉复审 R1-[high],验真后改):`FOR UPDATE` 锁不住缺席行(TiDB 无 gap
|
|
221
221
|
* lock;PG READ COMMITTED 同样)。presence 形(「行在 = 冻结」)下,operator 的 PUT 与本事务可以**同时**
|
|
222
222
|
* 都读到「没有 hold」,PUT 提交返回成功之后本事务照删不误 —— 设计稿 §5 声称由行锁给出的串行化在那一形
|
|
223
223
|
* 上根本不存在。哨兵行把 hold 的写点与删除的读点压到**同一把行锁**上,那句承诺才成立。
|
|
@@ -309,7 +309,7 @@ export declare class SqlRetentionStore implements ManagedRetentionCapability {
|
|
|
309
309
|
/**
|
|
310
310
|
* **可过期**的候选 token(保护谓词写在 SQL 里、扣在 `LIMIT` **之前**)。
|
|
311
311
|
*
|
|
312
|
-
* 🔴 为什么保护必须下推(codex
|
|
312
|
+
* 🔴 为什么保护必须下推(codex 交叉复审 R1-[medium],验真后改):旧形先按时间取 N 行、再在 JS 里剔除
|
|
313
313
|
* pinned —— 只要最老的一批持续被占着,每轮取回的都是同一批、每轮删 0,**它们后面**那些同样过期、又
|
|
314
314
|
* 没有任何引用的行**永远轮不到**。那不是「下一拍自然收敛」,那是一个域的留存静默停摆(而且读数一直
|
|
315
315
|
* 是 deleted=0,看上去像"没有候选")。判据下推之后 LIMIT 扣的是**真能动的行**,头部被保护多少行都不
|
|
@@ -319,7 +319,7 @@ export declare class SqlRetentionStore implements ManagedRetentionCapability {
|
|
|
319
319
|
/**
|
|
320
320
|
* 过期视界之内、**当下被 live-pin 护住**的 pending 行数 = receipt 的 `skipped`。
|
|
321
321
|
*
|
|
322
|
-
* 🔴 账法定谳(
|
|
322
|
+
* 🔴 账法定谳(两轮交叉复审各推了一次,最终落在这一形):
|
|
323
323
|
* · 曾经用「候选 + pinned 两读相加当分母、差值当 skipped」——被 codex R2 抓到会把中途翻转 pin 的行数两遍;
|
|
324
324
|
* · 改成「视界总数当分母、差值当 skipped」——被 codex R3 抓到在 PG 的 READ COMMITTED 下可以**为负**
|
|
325
325
|
* (分母读完之后又有行被 put/reopen 成 pending,候选与 UPDATE 都看得见它 ⇒ expired > 分母),而一条
|
|
@@ -356,7 +356,7 @@ export declare class SqlRetentionStore implements ManagedRetentionCapability {
|
|
|
356
356
|
* **可删**的会话 id:本域、过视界、且四条活引用腿**一条都不命中**(保护谓词下推,扣在 LIMIT 之前 ——
|
|
357
357
|
* 理由与 {@link expiryCandidates} 逐字相同:头部被保护的树不许饿死它后面的树)。
|
|
358
358
|
*
|
|
359
|
-
* `lock = true`(删除事务里的读)追加 `FOR UPDATE`,这一格是**承重**的(codex
|
|
359
|
+
* `lock = true`(删除事务里的读)追加 `FOR UPDATE`,这一格是**承重**的(codex 交叉复审 R1-[high]):
|
|
360
360
|
* · 会话行是**存在**的行 ⇒ 这把锁与 hold 哨兵不同,它真的锁得住;
|
|
361
361
|
* · 锁住之后,并发的 `touch()`(把会话重新拉进视界)与 session-sync 的 **owner 改写**(importEntries /
|
|
362
362
|
* replaceEntries / staged commit 都会重写 `session_meta.owner`)必须排队等我们提交 —— 否则「按旧候选集
|
|
@@ -371,11 +371,11 @@ export declare class SqlRetentionStore implements ManagedRetentionCapability {
|
|
|
371
371
|
/**
|
|
372
372
|
* 本域**可归属**的孤儿卸载结果(属主 session 已亡 + 墓碑归属本域 + 归属**无歧义**)。
|
|
373
373
|
*
|
|
374
|
-
* 🔴 第三个条件是 fail-closed 的归属门(codex
|
|
374
|
+
* 🔴 第三个条件是 fail-closed 的归属门(codex 交叉复审 R3-[high],验真后加):`tool_result` 没有域列,
|
|
375
375
|
* 它的域完全靠「属主 sessionId 的墓碑」推。而会话 id 是**调用方可自选**的 ⇒ 两个租户先后用过同一个 id
|
|
376
376
|
* 完全合法;两边都删过之后,同一个 row_key 上会有**两个域**的墓碑,这时任何一边的 sweep 拿自己的墓碑
|
|
377
377
|
* 去认领这些字节都只是猜。判据因此是:**存在第二个域的同名墓碑 ⇒ 这批行不可归属 ⇒ 一根不删**,
|
|
378
|
-
* 实体清理退回既有的 TTL 腿(`reapOlderThan`)
|
|
378
|
+
* 实体清理退回既有的 TTL 腿(`reapOlderThan`)。少删一次是可接受的;删掉另一个租户的字节、还跳过它的
|
|
379
379
|
* legal hold 与审计域,不可接受。
|
|
380
380
|
*/
|
|
381
381
|
protected orphanToolResultCandidates(exec: {
|
|
@@ -394,7 +394,7 @@ export declare class SqlRetentionStore implements ManagedRetentionCapability {
|
|
|
394
394
|
* {@link SqlRetentionStore.sessionCandidates});
|
|
395
395
|
* ③ 按 E21 顺序逐表删 → 墓碑 → 审计行(同事务)→ COMMIT。
|
|
396
396
|
*
|
|
397
|
-
* 🔴 活引用重查为什么是**候选查询本身**而不是第二条查询(codex
|
|
397
|
+
* 🔴 活引用重查为什么是**候选查询本身**而不是第二条查询(codex 交叉复审 R1 的两条 high 合并采纳):
|
|
398
398
|
* · 两条查询之间有窗口,而下推之后「被查到的」按构造就是「四条腿一条都不命中的」;
|
|
399
399
|
* · `FOR UPDATE` 让这些会话行在本事务期间不可被 `touch()` 拉回视界、也不可被 session-sync 改写属主 ——
|
|
400
400
|
* 旧形(不加锁 + 尾部无条件删 `session_event`)可以在属主被并发改写后,把**别的租户**的历史删掉;
|
|
@@ -373,6 +373,12 @@ export class SqlRetentionStore {
|
|
|
373
373
|
["session_policy", "session_id"],
|
|
374
374
|
...(this.opts.taskAttachmentTable ? [["task_attachment", "session_id"]] : []),
|
|
375
375
|
["snapshot_manifest", "scope"],
|
|
376
|
+
["file_history_tracked", "scope"],
|
|
377
|
+
["file_history_version", "scope"],
|
|
378
|
+
["file_history_boundary", "scope"],
|
|
379
|
+
["file_history_boundary_file", "scope"],
|
|
380
|
+
["file_history_scope", "scope"],
|
|
381
|
+
["file_history_reaped_entry", "scope"],
|
|
376
382
|
]) {
|
|
377
383
|
const s = set(col, 1);
|
|
378
384
|
await conn.query(`DELETE FROM ${table} WHERE ${s.sql}`, s.params);
|
|
@@ -152,8 +152,14 @@ export declare class SqlRunStore {
|
|
|
152
152
|
* this the row sits at `suspended`/updated_at=suspend-time for the WHOLE resume execution, so a resume that
|
|
153
153
|
* crosses the TTL is marked `failed` mid-run and its session lock (`task_active`) is released. Returns true
|
|
154
154
|
* if it claimed the flip (false ⇒ the reaper already expired it, or it isn't suspended).
|
|
155
|
+
*
|
|
156
|
+
* S-51(codex R1 [medium],红先双库证):认领 = **接管 `instance_id`**。此前该列只在 createRun 铸、此处不动,
|
|
157
|
+
* 于是 A 创建、C 认领 resume 的行永远记 A —— 而 steer/interrupt 的「他副本」臂与 sendHandleMiss 都拿这列当
|
|
158
|
+
* 「当前持有者」证据:verify 形 resume 腿(不登记 steerable)在 C 上跑时,C 读到 A≠C 就谎报「run 在别的副本」。
|
|
159
|
+
* 语义自此成文:`instance_id` = **当前持有副本**(createRun 铸、markResuming 认领接管);CAS 输不改列
|
|
160
|
+
* (一个输掉的认领不许覆盖赢家的持有记录——同一条条件 UPDATE 天然保证)。
|
|
155
161
|
*/
|
|
156
|
-
markResuming(taskId: string): Promise<boolean>;
|
|
162
|
+
markResuming(taskId: string, instanceId: string): Promise<boolean>;
|
|
157
163
|
/** The active run's taskId for a session (the `task_active` claim) — lets resume find the suspended run row. */
|
|
158
164
|
getActiveTaskId(sessionId: string): Promise<string | undefined>;
|
|
159
165
|
/** Shared row→record mapper — byte-identical between dialects (both drivers hand back the same column
|
|
@@ -215,7 +221,7 @@ export declare class SqlRunStore {
|
|
|
215
221
|
* 右删失效**(anchors 腿的注逐字写着这句)。删除决策的属主门在**路由层**(DELETE /v1/sessions/:id 先
|
|
216
222
|
* `ownerOf` 再比对 scope,fleet-wide 臂同门),本方法的唯一生产调用点是那条路由后面的 purge 协调器。
|
|
217
223
|
*
|
|
218
|
-
* 🔴 **化身围栏**(`expectedSessionOwner`;codex
|
|
224
|
+
* 🔴 **化身围栏**(`expectedSessionOwner`;codex 交叉复审 R1-[high],验真后修)—— 属主门下来了,但会话
|
|
219
225
|
* **身份**必须在同一个事务里重新确认。会话 id 由调用方自选、且删除之后**可被别人重新登记**
|
|
220
226
|
* (`register` 是 INSERT … ON DUPLICATE KEY / ON CONFLICT,老行没了就是一条新行,新 owner):于是
|
|
221
227
|
* 「路由 `ownerOf` 已验权」这句话在两只并发 purge 交错时会过期 —— A 与 A' 同时通过属主门,A' 跑完整条
|
|
@@ -280,7 +286,7 @@ export declare class SqlRunStore {
|
|
|
280
286
|
* the run + RELEASE its task_active (unlock the session). Mirrors the checkpoint reaper's CAS-expire (≈ deny);
|
|
281
287
|
* idempotent across replicas. Returns count.
|
|
282
288
|
*
|
|
283
|
-
* 🔴 design/80 D-D (
|
|
289
|
+
* 🔴 design/80 D-D (blocker fix): SKIPS any row whose checkpoint is STILL pending — that row is
|
|
284
290
|
* owned by a checkpoint-state-driven sweep (the SLA deny-sweep for human/irreversible_ask, or
|
|
285
291
|
* failSuspendedWithExpiredCheckpoint once the checkpoint is expired). Without this guard, this time-based
|
|
286
292
|
* UPDATE fired at the SAME instant as the deny-sweep (deadline == createdAt + ttl, both keyed on the TTL),
|
|
@@ -132,8 +132,8 @@ export class SqlRunStore {
|
|
|
132
132
|
async setNeedsReview(taskId) {
|
|
133
133
|
await this.db.query(this.q("UPDATE task_run SET status = 'needs_review', updated_at = ? WHERE task_id = ? AND status IN ('running','needs_review')", "UPDATE task_run SET status = 'needs_review', updated_at = $1 WHERE task_id = $2 AND status IN ('running','needs_review')"), [new Date(), taskId]);
|
|
134
134
|
}
|
|
135
|
-
async markResuming(taskId) {
|
|
136
|
-
const { affected } = await this.db.query(this.q("UPDATE task_run SET status = 'running', cancel_requested = 0, preempt_requested = 0, updated_at = ? WHERE task_id = ? AND status IN ('suspended','needs_review')", "UPDATE task_run SET status = 'running', cancel_requested = 0, preempt_requested = 0,
|
|
135
|
+
async markResuming(taskId, instanceId) {
|
|
136
|
+
const { affected } = await this.db.query(this.q("UPDATE task_run SET status = 'running', cancel_requested = 0, preempt_requested = 0, instance_id = ?, updated_at = ? WHERE task_id = ? AND status IN ('suspended','needs_review')", "UPDATE task_run SET status = 'running', cancel_requested = 0, preempt_requested = 0, instance_id = $1, updated_at = $2 WHERE task_id = $3 AND status IN ('suspended','needs_review')"), [instanceId, new Date(), taskId]);
|
|
137
137
|
return affected === 1;
|
|
138
138
|
}
|
|
139
139
|
async getActiveTaskId(sessionId) {
|
|
@@ -8,7 +8,7 @@ export declare const PRESIGN_MAX_TTL_SEC = 604800;
|
|
|
8
8
|
* - keyed on the S3 SECRET KEY — server-held and stable, so the mapping is deterministic per deployment but
|
|
9
9
|
* unforgeable/unenumerable from outside (a URL holder cannot test principal guesses without the key);
|
|
10
10
|
* - the "sendfile-scope:" DOMAIN SEPARATOR pins this HMAC use — the same secret signs SigV4 requests, and a
|
|
11
|
-
* cross-protocol collision between "scope segment" and any other HMAC-of-
|
|
11
|
+
* cross-protocol collision between "scope segment" and any other HMAC-of-caller-controlled-string use is
|
|
12
12
|
* exactly what domain separation exists to prevent;
|
|
13
13
|
* - 32 hex chars (128 bits) is a NAMESPACE label, not a secret: the cross-tenant BIRTHDAY bound at even a
|
|
14
14
|
* billion tenants is ~n²/2^129 ≈ 2^-69 — negligible. (The earlier 16-hex/64-bit cut had a ~2.7% birthday
|
|
@@ -96,7 +96,7 @@ export interface PlacementAcquireOpts {
|
|
|
96
96
|
* · 方向①(claim 关门):`persisted` 在场 + 非 `requireExisting` + 调用方没带创建 placement ⇒ 拒。
|
|
97
97
|
* `requireExisting`(读/复活形,义务 5)与「创建调用及其幂等重取」(带 placement 的可信内部链)
|
|
98
98
|
* 是仅有的两个合法形。placement 不可变意味着带 placement 的参数**永远重写不了**任何东西——持久化
|
|
99
|
-
*
|
|
99
|
+
* 的那一份恒赢,所以放行它不开任何口子。
|
|
100
100
|
* · 方向②(镜像/squat):带 placement 的创建形回读到**缺席**或**异组**元组 ⇒ 拒。缺席 = 并发的
|
|
101
101
|
* claim 形抢先建出了普通会话(或带外写把元组抹了);异组 = 同 id 上坐着另一条委派链的转录。
|
|
102
102
|
* 两者都不许让「声明了 durable 分区」这句话在静默中变成谎。
|
|
@@ -50,7 +50,7 @@ export declare const MYSQL_ER_BAD_FIELD_CODE = "ER_BAD_FIELD_ERROR";
|
|
|
50
50
|
* 🔴 单一属主的理由与 {@link isDupKeyError} 逐字同源(本文件顶注的 single-semantic-multi-site-drift):
|
|
51
51
|
* 判据一旦复制,两处的识别集就会各自漂,而这条谓词的错判方向**特别贵** —— 它守的是一句**破坏性**的
|
|
52
52
|
* 错误指路(「去 DROP TABLE」)。超时、连接重置、取消、资源不足、列级权限被拒、表整个不存在,每一种
|
|
53
|
-
* 都会让探针抛错;把它们诊断成「删表重建」比它要挡的缺陷更贵(#340 codex
|
|
53
|
+
* 都会让探针抛错;把它们诊断成「删表重建」比它要挡的缺陷更贵(#340 codex 交叉复审 round2/round3
|
|
54
54
|
* [high] 两轮收窄后的终形)。认不出的一律**不是**缺列(fail-safe:宁可把一次真缺列报成原始错误)。
|
|
55
55
|
*
|
|
56
56
|
* MySQL 侧两个归因键都认(mysql2 的 `code`/`errno` 是同一件事的两种写法),与 dup-key 那条同姿势。
|