@sema-agent/server 7.30.0 → 7.31.0-rc.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/boot/session-faces.js +31 -1
- package/dist/http/route-ctx.d.ts +6 -1
- package/dist/http/routes/approvals-assistant.js +14 -2
- package/dist/http/routes/sessions-list.js +8 -4
- package/dist/http/routes/sessions.js +11 -5
- package/dist/http/server.js +44 -3
- package/dist/observability/fail-open.d.ts +4 -0
- package/dist/observability/fail-open.js +4 -0
- package/dist/plugins/caching-session-store.d.ts +1 -0
- package/dist/plugins/file-run-store.d.ts +11 -3
- package/dist/plugins/file-run-store.js +28 -7
- package/dist/plugins/local-session-store.d.ts +2 -0
- package/dist/plugins/local-session-store.js +2 -2
- package/dist/plugins/memory-run-store.d.ts +13 -2
- package/dist/plugins/memory-run-store.js +17 -5
- package/dist/plugins/permission-rule-store-file.d.ts +3 -3
- package/dist/plugins/permission-rule-store-file.js +8 -7
- package/dist/plugins/permission-rule-store-sql.d.ts +24 -2
- package/dist/plugins/permission-rule-store-sql.js +29 -7
- package/dist/plugins/pg-session-storage.d.ts +1 -0
- package/dist/plugins/pg-session-storage.js +2 -1
- package/dist/plugins/retention-store-sql.d.ts +9 -1
- package/dist/plugins/retention-store-sql.js +13 -8
- package/dist/plugins/run-store-sql.d.ts +31 -7
- package/dist/plugins/run-store-sql.js +42 -13
- package/dist/plugins/store-backend.d.ts +9 -2
- package/dist/plugins/store-backend.js +30 -7
- package/dist/plugins/tidb-session-store.d.ts +1 -0
- package/dist/plugins/tidb-session-store.js +3 -1
- package/dist/rules-consent.d.ts +7 -4
- package/dist/rules-consent.js +77 -2
- package/dist/security.d.ts +52 -1
- package/dist/security.js +51 -0
- package/dist/wall-clock-jump-guard.d.ts +91 -0
- package/dist/wall-clock-jump-guard.js +118 -0
- package/package.json +1 -1
- package/dist/boot/lexical-path-env.d.ts +0 -10
- package/dist/boot/lexical-path-env.js +0 -88
- package/dist/capabilities/oa-tools.d.ts +0 -15
- package/dist/capabilities/oa-tools.js +0 -54
- package/dist/finance/cost-taxonomy.d.ts +0 -34
- package/dist/finance/cost-taxonomy.js +0 -26
- package/dist/plugins/approval-store-sql.d.ts +0 -116
- package/dist/plugins/approval-store-sql.js +0 -151
- package/dist/plugins/file-workflow-journal-store.d.ts +0 -12
- package/dist/plugins/file-workflow-journal-store.js +0 -12
- package/dist/plugins/pg-approval-store.d.ts +0 -9
- package/dist/plugins/pg-approval-store.js +0 -9
- package/dist/plugins/pg-breaker-state.d.ts +0 -8
- package/dist/plugins/pg-breaker-state.js +0 -8
- package/dist/plugins/pg-checkpoint-store.d.ts +0 -10
- package/dist/plugins/pg-checkpoint-store.js +0 -10
- package/dist/plugins/pg-file-snapshot-store.d.ts +0 -8
- package/dist/plugins/pg-file-snapshot-store.js +0 -8
- package/dist/plugins/pg-image-bake.d.ts +0 -12
- package/dist/plugins/pg-image-bake.js +0 -11
- package/dist/plugins/pg-image-index.d.ts +0 -12
- package/dist/plugins/pg-image-index.js +0 -11
- package/dist/plugins/pg-outcome-ledger.d.ts +0 -12
- package/dist/plugins/pg-outcome-ledger.js +0 -11
- package/dist/plugins/pg-resume-anchor-store.d.ts +0 -7
- package/dist/plugins/pg-resume-anchor-store.js +0 -7
- package/dist/plugins/pg-run-store.d.ts +0 -9
- package/dist/plugins/pg-run-store.js +0 -9
- package/dist/plugins/pg-session-policy-store.d.ts +0 -7
- package/dist/plugins/pg-session-policy-store.js +0 -7
- package/dist/plugins/pg-session-store.d.ts +0 -12
- package/dist/plugins/pg-session-store.js +0 -12
- package/dist/plugins/pg-tool-result-store.d.ts +0 -9
- package/dist/plugins/pg-tool-result-store.js +0 -9
- package/dist/plugins/pg-workflow-journal-store.d.ts +0 -9
- package/dist/plugins/pg-workflow-journal-store.js +0 -9
- package/dist/plugins/pg-workflow-run-store.d.ts +0 -9
- package/dist/plugins/pg-workflow-run-store.js +0 -9
- package/dist/plugins/tidb-approval-store.d.ts +0 -8
- package/dist/plugins/tidb-approval-store.js +0 -8
- package/dist/plugins/tidb-breaker-state.d.ts +0 -7
- package/dist/plugins/tidb-breaker-state.js +0 -7
- package/dist/plugins/tidb-checkpoint-store.d.ts +0 -9
- package/dist/plugins/tidb-checkpoint-store.js +0 -9
- package/dist/plugins/tidb-file-snapshot-store.d.ts +0 -8
- package/dist/plugins/tidb-file-snapshot-store.js +0 -8
- package/dist/plugins/tidb-image-bake.d.ts +0 -12
- package/dist/plugins/tidb-image-bake.js +0 -11
- package/dist/plugins/tidb-image-index.d.ts +0 -12
- package/dist/plugins/tidb-image-index.js +0 -11
- package/dist/plugins/tidb-outcome-ledger.d.ts +0 -12
- package/dist/plugins/tidb-outcome-ledger.js +0 -12
- package/dist/plugins/tidb-resume-anchor-store.d.ts +0 -7
- package/dist/plugins/tidb-resume-anchor-store.js +0 -7
- package/dist/plugins/tidb-run-store.d.ts +0 -10
- package/dist/plugins/tidb-run-store.js +0 -9
- package/dist/plugins/tidb-session-policy-store.d.ts +0 -7
- package/dist/plugins/tidb-session-policy-store.js +0 -7
- package/dist/plugins/tidb-tool-result-store.d.ts +0 -8
- package/dist/plugins/tidb-tool-result-store.js +0 -10
- package/dist/plugins/tidb-workflow-journal-store.d.ts +0 -9
- package/dist/plugins/tidb-workflow-journal-store.js +0 -9
- package/dist/plugins/tidb-workflow-run-store.d.ts +0 -10
- package/dist/plugins/tidb-workflow-run-store.js +0 -10
- package/dist/plugins/workflow-journal-limits.d.ts +0 -12
- package/dist/plugins/workflow-journal-limits.js +0 -12
- package/dist/sema-registry.d.ts +0 -41
- package/dist/sema-registry.js +0 -40
|
@@ -39,7 +39,7 @@ export function createSessionFaces(ctx) {
|
|
|
39
39
|
: undefined;
|
|
40
40
|
// §0.5 E21 — DELETE /v1/sessions/:id purge coordinator. Deletes the session abstraction's CONVERSATION HISTORY
|
|
41
41
|
// (sessionStorage.deleteSession → session_meta/session_event) PLUS the service-owned operational rows for that
|
|
42
|
-
// session: the runs ledger (runStore.deleteBySession → task_run/task_active/task_event,
|
|
42
|
+
// session: the runs ledger (runStore.deleteBySession → task_run/task_active/task_event, session_id-scoped), the
|
|
43
43
|
// durable checkpoints + resume-ctx (checkpointStore.deleteBySession, present only under DURABLE_APPROVAL), and
|
|
44
44
|
// offloaded tool results (toolResultStore.deleteBySession). Wired only when the session store can actually
|
|
45
45
|
// delete (a DB backend).
|
|
@@ -74,6 +74,36 @@ export function createSessionFaces(ctx) {
|
|
|
74
74
|
// window it returns `{ active }` and deletes nothing → we bubble it up so the route 409s, BEFORE touching
|
|
75
75
|
// any other table (no partial purge of a live session). Each leg propagates its error so a child failure
|
|
76
76
|
// aborts the purge BEFORE the history-delete gate is removed (keeping the idempotent retry convergent).
|
|
77
|
+
// 🔴 run 账本按 session_id 单键删,**没有**行级属主门(F-A 修,2026-08-17 合并码扫描):`task_run.owner`
|
|
78
|
+
// 是每次提交的 principal,与会话的 canonical owner 会分叉(无主存量会话被带 principal 的壳续聊 ⇒ 新行
|
|
79
|
+
// owner="default",而这里递的是会话行的 owner=null)。行级门于是只删一半、把具名行连同 task_event 留成
|
|
80
|
+
// 孤儿(session_meta 已删 ⇒ 留存腿的 sessionCandidates 永远枚举不到它们),路由却回 {deleted:true} ——
|
|
81
|
+
// 与下面 anchors 腿注里那句 "a right-to-delete violation reported as success" 是同一个病,理由也同一条:
|
|
82
|
+
// 会话所有权已在路由的 DELETE 门证过。
|
|
83
|
+
// 🔴 **化身围栏**(codex R1-[high] 验真后加):属主门下来了,「路由验过权」这句话就必须还**成立到此刻**。
|
|
84
|
+
// 会话 id 由调用方自选、删除后可被别人 `register` 重新登记 ⇒ 两只并发 purge 交错时(A 与 A' 同时过门,
|
|
85
|
+
// A' 跑完整条 purge、租户 B 用同一 uuid 重登记并跑完一次 run),慢的那只会按 session_id 把 **B 的**行删掉。
|
|
86
|
+
// 复读一次 canonical owner,不齐即整条中止(一行不删、回 {deleted:false} —— 属于调用方的那个化身已经不在)。
|
|
87
|
+
// 这一读是**所有后端**都有的那道围栏;SQL 双生在 store 事务里还有一道 `session_meta … FOR UPDATE` 的
|
|
88
|
+
// 原子复核(那才是真正消灭竞态的一道),local 车道靠它 + 数据根独占 BootLock。
|
|
89
|
+
// 「行不在」(undefined)与「换了主」一律中止(codex R2 采纳):meta 不在 = 调用方验过权的那个化身
|
|
90
|
+
// 已经没了,再按 session_id 往下删,删到的只可能是**别人**重新登记后写的行。E21 的次序契约保证
|
|
91
|
+
// meta 是**最后**删的(子腿失败就不删 meta,让幂等重试收敛)⇒ 「meta 不在却还有本会话的孤儿子行」
|
|
92
|
+
// 在协调器这条路上构造不出来,中止不会丢掉任何该删的东西。
|
|
93
|
+
// ⚠️ `ownerOf` **方法缺席**的后端(可选 seam)与「行不在」必须分开判:合起来会让那些后端的删除
|
|
94
|
+
// 整条恒中止(fail-closed 到把功能关掉),那是另一种缺陷。
|
|
95
|
+
if (ownerAware.ownerOf) {
|
|
96
|
+
const stillOwner = await ownerAware.ownerOf(sessionId);
|
|
97
|
+
if (stillOwner !== owner)
|
|
98
|
+
return { deleted: false };
|
|
99
|
+
}
|
|
100
|
+
// 📌 **残余,如实登记**(codex R2 判真但**射程外**,不在本批修):本围栏钉住的是「purge 开始那一刻
|
|
101
|
+
// 化身是对的」+ run 腿的事务内原子复核。其余按 session_id 单键删的腿(anchors / exemption / policy /
|
|
102
|
+
// attachment / snapshot / tool_result 的 provenance 形)在**本批之前就是**这个 scoping(理由逐字见下方
|
|
103
|
+
// anchors 腿注,可追到公开快照那一版),它们各自开始的时刻理论上仍可能落在一次化身漂移之后。
|
|
104
|
+
// 要真正消灭它得让**整条**级联化身原子(SQL 单事务横跨十余张表 + 对象存储,即留存腿那个形),而在线
|
|
105
|
+
// 删除面刻意不是那个形:它要跨事务崩溃收敛(meta 最后删 ⇒ 幂等重试),还要动 MinIO 字节。那是设计级
|
|
106
|
+
// 改动(属主裁决 + 三问),不是复审批能顺手做的事;已作后续件登记,别把这条注读成「已解」。
|
|
77
107
|
const runs = runStore ? await runStore.deleteBySession(sessionId, owner) : { removed: 0 };
|
|
78
108
|
if ("active" in runs)
|
|
79
109
|
return { active: runs.active };
|
package/dist/http/route-ctx.d.ts
CHANGED
|
@@ -163,7 +163,12 @@ export interface RouteLegs {
|
|
|
163
163
|
body: object;
|
|
164
164
|
}>;
|
|
165
165
|
/** assistant plan_review 三态腿。 */
|
|
166
|
-
resumePlanReview(sessionId: string, decision: "approve" | "edit" | "reject", editedPlan: string | undefined, reason: string | undefined, req: IncomingMessage | undefined
|
|
166
|
+
resumePlanReview(sessionId: string, decision: "approve" | "edit" | "reject", editedPlan: string | undefined, reason: string | undefined, req: IncomingMessage | undefined,
|
|
167
|
+
/** [4277]/[4288] 件5:`decide_404` 判别日志的附加位(只进日志,不上 wire;身份只记有无不记值)。 */
|
|
168
|
+
log?: {
|
|
169
|
+
taskId?: string;
|
|
170
|
+
principalPresent?: boolean;
|
|
171
|
+
}): Promise<{
|
|
167
172
|
status: number;
|
|
168
173
|
body: object;
|
|
169
174
|
}>;
|
|
@@ -451,6 +451,12 @@ async function handleApprovalsAssistantBody(req, res, url, ctx, miss) {
|
|
|
451
451
|
sendError(res, 401, "principal_unverified", "principal proof required");
|
|
452
452
|
return;
|
|
453
453
|
}
|
|
454
|
+
// [4268]/[4274] 件5(#43 P0 联调观测件):**只进日志,不上 wire**。两支 404 在 wire 面必须逐字同形
|
|
455
|
+
// (反枚举:「不存在」与「不属于你」不可区分是本门的要求,不是疏漏),代价是服务端自己也说不清撞的
|
|
456
|
+
// 是哪一支 —— 联调一次 404 只能靠猜。判别位放在服务端日志:运维/联调拿得到成因,枚举者拿不到。
|
|
457
|
+
// `principalPresent` 只记**有无**([4288]):principal / owner 两个身份串一个都不许进日志 ——
|
|
458
|
+
// 日志的读者面比 wire 宽,把值写进去等于在日志面重建一条本来被封住的枚举通道。
|
|
459
|
+
deps.logger?.info?.("decide_404", { reason: "no-row", taskId, principalPresent: principal !== undefined });
|
|
454
460
|
sendError(res, 404, "not_found.run", "task not found");
|
|
455
461
|
return;
|
|
456
462
|
}
|
|
@@ -472,10 +478,16 @@ async function handleApprovalsAssistantBody(req, res, url, ctx, miss) {
|
|
|
472
478
|
const operators = deps.config.operatorPrincipals;
|
|
473
479
|
const explicitOperator = explicitOperatorOk(deciderPrincipal, operators); // E1: single mint point (principal-gate)
|
|
474
480
|
if (!explicitOperator && run.owner !== null && run.owner !== deciderPrincipal) {
|
|
481
|
+
// no existence oracle (verified principal on a direct worker) —— wire 面与上面的「查无此行」逐字同形;
|
|
482
|
+
// 判别位同样只进服务端日志(件5,理由见上一处注)。刻意**不记** owner/principal 两个值:判别成因用
|
|
483
|
+
// reason 一个词就够,把两个身份写进日志等于在日志面重建一条本来被封住的枚举通道。
|
|
484
|
+
deps.logger?.info?.("decide_404", { reason: "owner-mismatch", taskId, principalPresent: principal !== undefined });
|
|
475
485
|
sendError(res, 404, "not_found.run", "task not found");
|
|
476
486
|
return;
|
|
477
|
-
}
|
|
478
|
-
const out = await resumePlanReview(run.sessionId, decision, decision === "edit" ? body.editedPlan : undefined, typeof body.reason === "string" ? body.reason : undefined, req
|
|
487
|
+
}
|
|
488
|
+
const out = await resumePlanReview(run.sessionId, decision, decision === "edit" ? body.editedPlan : undefined, typeof body.reason === "string" ? body.reason : undefined, req,
|
|
489
|
+
// [4277]/[4288] 件5:让四个 reason 的判别日志共用同一个 id 字段 + 同一个 principalPresent 口径。
|
|
490
|
+
{ taskId, principalPresent: principal !== undefined });
|
|
479
491
|
sendJson(res, out.status, out.body);
|
|
480
492
|
return;
|
|
481
493
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { isUuidV7 } from "../../security.js";
|
|
1
|
+
import { isUuidV7, legacyUnownedAmnestyOpen } from "../../security.js";
|
|
2
2
|
import { sendJson, sendError } from "../send.js";
|
|
3
3
|
export async function handleSessionsList(req, res, url, ctx) {
|
|
4
4
|
const miss = { fell: false };
|
|
@@ -30,7 +30,11 @@ async function handleSessionsListBody(req, res, url, ctx, miss) {
|
|
|
30
30
|
sendError(res, 501, "capability.session_store_required", "session list requires a durable session store (SESSION_BACKEND=tidb)");
|
|
31
31
|
return;
|
|
32
32
|
}
|
|
33
|
-
|
|
33
|
+
// [4198] 窄互认第二臂(列表面)—— 条件①③ 在此判,条件②(`owner === null`)由 store 的
|
|
34
|
+
// `owner = ? OR owner IS NULL` 谓词承载(见 legacyUnownedAmnestyOpen 的注)。fleet-wide 面不经本臂:
|
|
35
|
+
// 它本来就不钉 owner ⇒ 无主行早已在结果集内,多带位是噪声。
|
|
36
|
+
const includeUnowned = !fleetWide && legacyUnownedAmnestyOpen(deps.config.requirePrincipal, gateOwner);
|
|
37
|
+
await handleSessionList(res, lister, new URL(req.url ?? "", "http://x").searchParams, gateOwner, includeUnowned);
|
|
34
38
|
return;
|
|
35
39
|
}
|
|
36
40
|
// GET /v1/sendfile-links — the SendUserFile ledger's thin read face (multi-tenant governance: which links
|
|
@@ -130,7 +134,7 @@ function decodeSessionCursor(s) {
|
|
|
130
134
|
return undefined;
|
|
131
135
|
}
|
|
132
136
|
}
|
|
133
|
-
async function handleSessionList(res, lister, query, forceOwner) {
|
|
137
|
+
async function handleSessionList(res, lister, query, forceOwner, includeUnowned = false) {
|
|
134
138
|
const owner = forceOwner != null ? forceOwner : (query.get("owner") ?? undefined);
|
|
135
139
|
const limit = Math.min(100, Math.max(1, Number(query.get("limit") ?? 50) || 50));
|
|
136
140
|
const cursor = decodeSessionCursor(query.get("cursor"));
|
|
@@ -138,7 +142,7 @@ async function handleSessionList(res, lister, query, forceOwner) {
|
|
|
138
142
|
// synthesized preview). Trimmed + bounded (256 — a search box, not a payload); empty after trim = no filter.
|
|
139
143
|
const qRaw = (query.get("q") ?? "").trim();
|
|
140
144
|
const q = qRaw ? qRaw.slice(0, 256) : undefined;
|
|
141
|
-
const rows = await lister({ ...(owner ? { owner } : {}), ...(cursor ? { cursor } : {}), limit: limit + 1, ...(q ? { q } : {}) });
|
|
145
|
+
const rows = await lister({ ...(owner ? { owner } : {}), ...(includeUnowned ? { includeUnowned: true } : {}), ...(cursor ? { cursor } : {}), limit: limit + 1, ...(q ? { q } : {}) });
|
|
142
146
|
const hasMore = rows.length > limit;
|
|
143
147
|
const page = hasMore ? rows.slice(0, limit) : rows;
|
|
144
148
|
const last = page[page.length - 1];
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { materializeA2aTools, materializeMcpTools, sanitizePathComponent, SessionPolicyError } from "@sema-agent/core";
|
|
2
2
|
import { a2aForScenario, mcpForScenario } from "../../config-center/facade.js";
|
|
3
|
-
import { isUuidV7, isUuidShape } from "../../security.js";
|
|
3
|
+
import { isUuidV7, isUuidShape, legacyUnownedSessionOk } from "../../security.js";
|
|
4
4
|
import { windowMessages, truncateMessageBlobs } from "../../audit.js";
|
|
5
5
|
import { redactSecrets } from "../../trace/redact.js";
|
|
6
6
|
import { MAX_SETTINGS_OUTPUT_STYLE_CHARS, MAX_SETTINGS_PERMISSION_RULES, MAX_SETTINGS_ENV_VARS, MAX_SETTINGS_ENV_KEY_CHARS, MAX_SETTINGS_ENV_VALUE_CHARS } from "../../task-settings.js";
|
|
@@ -602,13 +602,16 @@ async function handleSessionsBody(req, res, url, ctx, miss) {
|
|
|
602
602
|
}
|
|
603
603
|
// Owner gate (no existence oracle for non-owners — 404, parity with runOwnerOk): a principal caller may
|
|
604
604
|
// only fork its OWN session; a fleet-wide (system/trace) caller may fork any (ops).
|
|
605
|
-
|
|
605
|
+
// [4198] 窄互认第二臂:单机形下 default 主对**存量无主行**放行(三条件齐,见 legacyUnownedSessionOk)。
|
|
606
|
+
if (!scope.fleetWide && owner !== scope.gateOwner && !legacyUnownedSessionOk(deps.config.requirePrincipal, scope.gateOwner, owner)) {
|
|
606
607
|
sendError(res, 404, "not_found.session", "session not found");
|
|
607
608
|
return;
|
|
608
609
|
}
|
|
609
610
|
// New-session ownership — DELIBERATE (no security issue either way; documented per adversarial-review):
|
|
610
611
|
// • principal caller → the NEW session is owned by the CALLER (scope.gateOwner): you fork your own session,
|
|
611
612
|
// you own the copy.
|
|
613
|
+
// • [4198] 窄臂放行的 default 主 → 走 principal 支 ⇒ 副本铸给 `default`(具名行)。刻意如此:副本
|
|
614
|
+
// 离开无主池,本臂**不被 fork 传播延寿**,墓碑注(存量随生命周期消亡后可整删)仍然成立。
|
|
612
615
|
// • fleet-wide (ops, fork-on-behalf) → the NEW session INHERITS the SOURCE owner (`owner ?? null`) so the
|
|
613
616
|
// fork is TENANT-owned (an ops/system fork-on-behalf hands the copy to the same tenant as the source,
|
|
614
617
|
// NOT to the ownerless/system pool) — a later attach is then governed by the normal authorizer for that
|
|
@@ -658,7 +661,9 @@ async function handleSessionsBody(req, res, url, ctx, miss) {
|
|
|
658
661
|
// Foreign-owner ⇒ the SAME response as missing (a 404 here vs 200 above was an existence
|
|
659
662
|
// oracle — a prober could distinguish "absent" from "another tenant's"). deleted:false is also honest
|
|
660
663
|
// (nothing was deleted) and keeps the route idempotent-shaped for both cases.
|
|
661
|
-
|
|
664
|
+
// [4198] 窄互认第二臂:不放行时,存量无主会话对 default 主是**静默假成功**(用户永远删不掉自己的历史)。
|
|
665
|
+
// 放行后 purge 的 owner 守卫仍传**行的真 owner**(下面的 `owner`,即 null)——SQL 侧 null-safe 属主门不被放宽。
|
|
666
|
+
if (!scope.fleetWide && owner !== scope.gateOwner && !legacyUnownedSessionOk(deps.config.requirePrincipal, scope.gateOwner, owner)) {
|
|
662
667
|
sendJson(res, 200, { deleted: false });
|
|
663
668
|
return;
|
|
664
669
|
}
|
|
@@ -799,10 +804,11 @@ async function handleSessionsBody(req, res, url, ctx, miss) {
|
|
|
799
804
|
return;
|
|
800
805
|
}
|
|
801
806
|
const wsOwner = await ownerOf(wsSession);
|
|
802
|
-
|
|
807
|
+
// PULL 门:必须存在且属己,无 oracle。[4198] 窄互认第二臂同样覆盖本读面(存量会话的工作区浏览)。
|
|
808
|
+
if (wsOwner === undefined || (!scope.fleetWide && wsOwner !== scope.gateOwner && !legacyUnownedSessionOk(deps.config.requirePrincipal, scope.gateOwner, wsOwner))) {
|
|
803
809
|
sendError(res, 404, "not_found.session", "session not found");
|
|
804
810
|
return;
|
|
805
|
-
}
|
|
811
|
+
}
|
|
806
812
|
const q = new URL(req.url ?? "", "http://x").searchParams;
|
|
807
813
|
const keys = (await fs.listKeys(wsSession)).sort().reverse(); // uuidv7 entryId:字典序 desc = 时间序 新→旧
|
|
808
814
|
const sub = wm[3];
|
package/dist/http/server.js
CHANGED
|
@@ -21,6 +21,7 @@ import { HANDS_BAND_TOOL_NAMES } from "../capabilities/hands-lane.js";
|
|
|
21
21
|
import { fleetRunPublisher, fleetRunLabels, fleetRunResiduals, isFleetAgentTerminalNotification } from "../fleet/fleet-bus.js";
|
|
22
22
|
import { defaultSubagentTailBus, projectTailFrame } from "../fleet/subagent-tail-bus.js";
|
|
23
23
|
import { createApprovalCardEmitter, isApprovalGateKind, resolveApprovalLeg, resolveStreamApprovalGate } from "../tool-approval.js";
|
|
24
|
+
import { createWallClockJumpGuard } from "../wall-clock-jump-guard.js";
|
|
24
25
|
import {} from "../observability/rate-limit.js";
|
|
25
26
|
import { withPrincipal } from "../observability/principal-context.js";
|
|
26
27
|
import { turnEndEventData, contextUsageEventData, toolStartEventData, toolEndEventData, taskProgressEventData, taskNotificationEventData, compactedEventData, diagnosticsEventData, brainStatusEventData, steeringInjectedEventData, compactionOutcomeEventData, workspaceChangedEventData, wiringManifestEventData, humanInputEventData, appendModelUsageDelta, appendPromptManifest, attachModelUsage } from "../trace/project.js";
|
|
@@ -2775,11 +2776,26 @@ export function createHttpServer(rawDeps) {
|
|
|
2775
2776
|
* suspend (that would bypass the correct /decide allow-deny + action binding). Rejects PRE-CAS so a wrong-gate
|
|
2776
2777
|
* call never consumes the checkpoint (core's resume gate-match is the fail-closed backstop).
|
|
2777
2778
|
*/
|
|
2778
|
-
async function resumePlanReview(sessionId, decision, editedPlan, reason, req
|
|
2779
|
+
async function resumePlanReview(sessionId, decision, editedPlan, reason, req,
|
|
2780
|
+
/** [4277]/[4288] 件5:判别日志的两个附加位。调用方(plan_review 端点)手上有 taskId 与 gatedPrincipal,
|
|
2781
|
+
* 本函数只有 sessionId —— 穿进来是为了让 `decide_404` 的**四个 reason 共用同一套字段**(联调 grep 一条
|
|
2782
|
+
* msg 就拿到同一个 id 键与同一个 principalPresent 口径,不必按 reason 分两种形)。
|
|
2783
|
+
* 缺席 ⇒ id 退回 sessionId 并如实换键名(不假装那是 taskId)。 */
|
|
2784
|
+
log) {
|
|
2779
2785
|
const cs = deps.checkpointStore;
|
|
2786
|
+
// [4268]/[4274]/[4277]/[4288] 件5:404 成因判别位——**只进服务端日志,不上 wire**(响应体一字不改,
|
|
2787
|
+
// 反枚举不破)。词表四值全在本函数与 approvals-assistant.ts 两处铸,合起来覆盖 plan_review 一条决裁
|
|
2788
|
+
// 路径的全部 404 出口。`principalPresent` 只记**有无**:身份串一个都不许进日志(日志读者面比 wire 宽)。
|
|
2789
|
+
const log404 = (r) => deps.logger?.info?.("decide_404", {
|
|
2790
|
+
reason: r,
|
|
2791
|
+
...(log?.taskId !== undefined ? { taskId: log.taskId } : { sessionId }),
|
|
2792
|
+
...(log?.principalPresent !== undefined ? { principalPresent: log.principalPresent } : {}),
|
|
2793
|
+
});
|
|
2780
2794
|
const token = await cs.findPendingTokenBySession(sessionId);
|
|
2781
|
-
if (!token)
|
|
2795
|
+
if (!token) {
|
|
2796
|
+
log404("no-pending-checkpoint");
|
|
2782
2797
|
return { status: 404, body: { error: "no pending plan_review for this session (already decided or expired)", errorCode: "not_found.plan_review" } };
|
|
2798
|
+
}
|
|
2783
2799
|
let cp;
|
|
2784
2800
|
try {
|
|
2785
2801
|
cp = await cs.get(token); // throws if the checkpoint format is newer than this build (version guard)
|
|
@@ -2787,8 +2803,10 @@ export function createHttpServer(rawDeps) {
|
|
|
2787
2803
|
catch (e) {
|
|
2788
2804
|
return { status: 409, body: { error: e instanceof Error ? e.message : String(e), errorCode: "conflict.checkpoint_unreadable" } };
|
|
2789
2805
|
}
|
|
2790
|
-
if (!cp)
|
|
2806
|
+
if (!cp) {
|
|
2807
|
+
log404("checkpoint-missing");
|
|
2791
2808
|
return { status: 404, body: { error: "checkpoint not found", errorCode: "not_found.checkpoint" } };
|
|
2809
|
+
}
|
|
2792
2810
|
// 🔴 GATE-KIND GUARD: a plan_review decision must ONLY resolve a plan_review gate. A human/irreversible_ask
|
|
2793
2811
|
// (tool approval) or resource_limit/needs_review gate routed here would let approve/edit/reject bypass the
|
|
2794
2812
|
// correct wire (a tool approval needs /decide's allow/deny + action binding; resource_limit needs /resume).
|
|
@@ -3080,10 +3098,33 @@ export function createHttpServer(rawDeps) {
|
|
|
3080
3098
|
* pending and is retried next tick, with the terminal_at_ms backstop as the eventual abort if the deny never
|
|
3081
3099
|
* succeeds. Bounded per tick by the store query's LIMIT. Wired into main.ts's reaper.
|
|
3082
3100
|
*/
|
|
3101
|
+
const denySweepClockGuard = createWallClockJumpGuard({
|
|
3102
|
+
slaMs: deps.config.approvalTimeoutSec > 0 ? deps.config.approvalTimeoutSec * 1000 : undefined,
|
|
3103
|
+
onJump: ({ jumpMs, graceMs }) => deps.logger?.warn?.("deny_sweep_wall_clock_jump", {
|
|
3104
|
+
jumpMs,
|
|
3105
|
+
graceMs,
|
|
3106
|
+
note: jumpMs > 0
|
|
3107
|
+
? "the wall clock stepped FORWARD relative to the monotonic clock; the approval SLA deny-sweep stands down for graceMs of MONOTONIC time so no pending gate is denied earlier than one full SLA of REAL time ([4232]①/[4235]). The checkpoint terminal_at_ms backstop is unaffected — nothing can hang forever."
|
|
3108
|
+
: "the wall clock stepped BACKWARD relative to the monotonic clock; the deny-sweep needs no grace for this direction (a receding deadline only DELAYS a deny) but the step itself is an operational fact worth recording.",
|
|
3109
|
+
}),
|
|
3110
|
+
onSuppressed: ({ remainingMs }) => deps.logger?.info?.("deny_sweep_clock_grace", { remainingMs }),
|
|
3111
|
+
// codex R2 [high] 五:连续跳变撞上累计上限 ⇒ 守卫**响亮**恢复判定。这一行是运维必须看见的:
|
|
3112
|
+
// 它说的是「本机墙钟在持续抖,我不再为它让路了,接下来的 deny 判定基于一个不稳的钟」。
|
|
3113
|
+
onGraceCapped: ({ totalGraceMs }) => deps.logger?.warn?.("deny_sweep_clock_grace_capped", {
|
|
3114
|
+
totalGraceMs,
|
|
3115
|
+
note: "the wall clock kept stepping forward; the approval SLA deny-sweep has stood down for the maximum cumulative grace and now RESUMES on a demonstrably unstable clock — a fail-closed leg that never runs is worse than one running on a bad clock. Fix NTP on this host.",
|
|
3116
|
+
}),
|
|
3117
|
+
});
|
|
3083
3118
|
async function denyExpiredApprovals(now) {
|
|
3084
3119
|
const cs = deps.checkpointStore;
|
|
3085
3120
|
if (!cs)
|
|
3086
3121
|
return;
|
|
3122
|
+
// [4232]①/[4235]:**判定**钟的单调佐证。`now` 是墙钟(生产由 reaper 传 `Date.now()`),而
|
|
3123
|
+
// `checkpoint.deadline` 是 core 按铸卡当时的墙钟算的绝对时刻 —— 墙钟凭空前跳 ⇒ 判定钟凭空多走同样的量,
|
|
3124
|
+
// 人还没走开窗就没了(真机实测:SLA 45s,真实等 14s 即被自动拒)。守卫按单调钟宽限整拍让开;
|
|
3125
|
+
// **记账钟一律不动**(下面 resumeCheckpoint 的审计/时间戳仍是真墙钟,core 的 totalWaitMs 账面同样不碰)。
|
|
3126
|
+
if (!denySweepClockGuard.admit(now))
|
|
3127
|
+
return;
|
|
3087
3128
|
const expired = await cs.listExpiredApprovalGates(now).catch(() => []);
|
|
3088
3129
|
// [1591] 候裁③ 静默臂:parked 后台子代的过期 cp 不走 sweep 的重活赎回(deadline→terminal_at_ms 窗内
|
|
3089
3130
|
// 每行每 tick 一条 warn 的噪声源)——其收割属 reaper 的 expire+reconcileParkedAgents 车道。判别在
|
|
@@ -9,6 +9,10 @@ export interface FailOpenTagEntry {
|
|
|
9
9
|
}
|
|
10
10
|
/** 闭集 tag 词表。形=`<repo>.<domain>.<site>`(跨仓同形,便于三仓遥测并表)。 */
|
|
11
11
|
export declare const FAIL_OPEN_TAGS: {
|
|
12
|
+
readonly "server.run-store.file-hydrate-unreadable": {
|
|
13
|
+
readonly cls: "P-DEBT";
|
|
14
|
+
readonly note: "件6(#43 案 [4281]):`FileRunStore.hydrate()` 的 `readdirSync(runs/)` 失败 ⇒ 引擎以**空账**起动。旧形是空 catch + return:data root 不可读(权限/挂载丢失/路径被换走)时零告警,此后每一次 getRun 恒 miss —— 而「恒 miss」正是 #43 现场看到的形,一次环境故障被伪装成「本来就没有这条 run」。方向问题在于吞掉之后被读成的是「证不出有行 ⇒ 没有行」,两句话不等价。**不拒启是刻意保留的行为**(拒启是行为变更,另立),所以这条留作债:响亮化(error 级启动日志带 runs 绝对路径+异常文案)+ 本计数,让它在遥测里显形而不是只活在一行日志里。终局=给 file 后端一条可选的 fail-closed 启动门。";
|
|
15
|
+
};
|
|
12
16
|
readonly "server.hitl.frame-undelivered-stream-closed": {
|
|
13
17
|
readonly cls: "P-DEBT";
|
|
14
18
|
readonly note: "HITL 的 **open** 帧写向一条已断/已关的 SSE 连接 ⇒ 静默丢弃,上游据此把投递记成成功,该 ask 挂到 TTL 才按无人应答结算。⚠️ #173 后 question 开帧改走断流 THROW(立即结算 unavailable),因此本 tag 在产的只剩 **elicitation** 一族——读计数时勿把它当作 question 的人在环缺口。no-op 而非 throw 对 elicitation 仍是既有的刻意决定,本条只保证它不再无声。";
|
|
@@ -32,6 +32,10 @@
|
|
|
32
32
|
import { appendFileSync } from "node:fs";
|
|
33
33
|
/** 闭集 tag 词表。形=`<repo>.<domain>.<site>`(跨仓同形,便于三仓遥测并表)。 */
|
|
34
34
|
export const FAIL_OPEN_TAGS = {
|
|
35
|
+
"server.run-store.file-hydrate-unreadable": {
|
|
36
|
+
cls: "P-DEBT",
|
|
37
|
+
note: "件6(#43 案 [4281]):`FileRunStore.hydrate()` 的 `readdirSync(runs/)` 失败 ⇒ 引擎以**空账**起动。旧形是空 catch + return:data root 不可读(权限/挂载丢失/路径被换走)时零告警,此后每一次 getRun 恒 miss —— 而「恒 miss」正是 #43 现场看到的形,一次环境故障被伪装成「本来就没有这条 run」。方向问题在于吞掉之后被读成的是「证不出有行 ⇒ 没有行」,两句话不等价。**不拒启是刻意保留的行为**(拒启是行为变更,另立),所以这条留作债:响亮化(error 级启动日志带 runs 绝对路径+异常文案)+ 本计数,让它在遥测里显形而不是只活在一行日志里。终局=给 file 后端一条可选的 fail-closed 启动门。",
|
|
38
|
+
},
|
|
35
39
|
"server.hitl.frame-undelivered-stream-closed": {
|
|
36
40
|
cls: "P-DEBT",
|
|
37
41
|
note: "HITL 的 **open** 帧写向一条已断/已关的 SSE 连接 ⇒ 静默丢弃,上游据此把投递记成成功,该 ask 挂到 TTL 才按无人应答结算。⚠️ #173 后 question 开帧改走断流 THROW(立即结算 unavailable),因此本 tag 在产的只剩 **elicitation** 一族——读计数时勿把它当作 question 的人在环缺口。no-op 而非 throw 对 elicitation 仍是既有的刻意决定,本条只保证它不再无声。",
|
|
@@ -40,6 +40,7 @@ export declare class CachingSessionStore implements SessionStore {
|
|
|
40
40
|
* warm cache by design; a fork/delete must read/write durable state, not a possibly-stale cached view). */
|
|
41
41
|
readonly listSessions?: (opts: {
|
|
42
42
|
owner?: string;
|
|
43
|
+
includeUnowned?: boolean;
|
|
43
44
|
cursor?: {
|
|
44
45
|
lastActivityAt: string;
|
|
45
46
|
sessionId: string;
|
|
@@ -53,7 +53,11 @@ export declare class FileRunStore {
|
|
|
53
53
|
private readonly claimOwner;
|
|
54
54
|
/** #213 —— sessionId → 该会话 claim 获取的临界区链尾(见 {@link withClaimLock})。 */
|
|
55
55
|
private readonly claimLocks;
|
|
56
|
-
|
|
56
|
+
/** 件6(#43 案 [4281]):启动期告警座(可选)。既有调用方不传 ⇒ 只走 fail-open 遥测那一半,不炸。 */
|
|
57
|
+
private readonly logger;
|
|
58
|
+
constructor(root: string, logger?: {
|
|
59
|
+
error?(msg: string, fields?: Record<string, unknown>): void;
|
|
60
|
+
});
|
|
57
61
|
private runDir;
|
|
58
62
|
private runJsonPath;
|
|
59
63
|
private eventsPath;
|
|
@@ -118,6 +122,7 @@ export declare class FileRunStore {
|
|
|
118
122
|
/** DISTINCT sessions newest-first by last activity (the CC /resume picker): latest run's preview/status + first/last/count. */
|
|
119
123
|
listSessions(opts: {
|
|
120
124
|
owner?: string;
|
|
125
|
+
includeUnowned?: boolean;
|
|
121
126
|
cursor?: {
|
|
122
127
|
lastActivityAt: string;
|
|
123
128
|
sessionId: string;
|
|
@@ -125,8 +130,11 @@ export declare class FileRunStore {
|
|
|
125
130
|
limit: number;
|
|
126
131
|
q?: string;
|
|
127
132
|
}): Promise<SessionSummary[]>;
|
|
128
|
-
/** E21 (§0.5 delete) — purge the runs ledger for one session,
|
|
129
|
-
|
|
133
|
+
/** E21 (§0.5 delete) — purge the runs ledger for one session, scoped by `session_id` ALONE (per-row owner is the
|
|
134
|
+
* submitting principal and diverges from the session owner — full 判据 see the SQL twin's head-note, F-A);
|
|
135
|
+
* aborts (returns {active}) if a run is live。`expectedSessionOwner` = SQL 双生的化身围栏入参,本孪生执行不了
|
|
136
|
+
* (账本里没有 session_meta),理由与替代围栏逐字见 {@link MemoryRunStore.deleteBySession}。 */
|
|
137
|
+
deleteBySession(sessionId: string, expectedSessionOwner: string | null): Promise<{
|
|
130
138
|
removed: number;
|
|
131
139
|
} | {
|
|
132
140
|
active: string;
|
|
@@ -104,7 +104,10 @@ export class FileRunStore {
|
|
|
104
104
|
claimOwner = { nonce: randomBytes(12).toString("hex"), ...PROCESS_FACTS };
|
|
105
105
|
/** #213 —— sessionId → 该会话 claim 获取的临界区链尾(见 {@link withClaimLock})。 */
|
|
106
106
|
claimLocks = new Map();
|
|
107
|
-
|
|
107
|
+
/** 件6(#43 案 [4281]):启动期告警座(可选)。既有调用方不传 ⇒ 只走 fail-open 遥测那一半,不炸。 */
|
|
108
|
+
logger;
|
|
109
|
+
constructor(root, logger) {
|
|
110
|
+
this.logger = logger;
|
|
108
111
|
this.runsDir = join(root, "runs");
|
|
109
112
|
this.activeDir = join(root, "runs", "active");
|
|
110
113
|
this.tmpDir = join(root, "tmp"); // atomicWriteFile temp dir (same FS as runs → rename is atomic)
|
|
@@ -160,7 +163,20 @@ export class FileRunStore {
|
|
|
160
163
|
try {
|
|
161
164
|
entries = readdirSync(this.runsDir);
|
|
162
165
|
}
|
|
163
|
-
catch {
|
|
166
|
+
catch (err) {
|
|
167
|
+
// 🔴 件6(#43 案 [4281],静默降级纪律):旧形是**空 catch + return** —— data root 不可读时引擎以
|
|
168
|
+
// 空账起动、零告警,此后每一次 getRun 恒 miss,而「恒 miss」正是 #43 现场看到的形。方向错在这里:
|
|
169
|
+
// 吞掉之后被读成的是「**证不出有行 ⇒ 没有行**」,而这两句话不等价,一次环境故障就此被伪装成
|
|
170
|
+
// 「本来就没有这条 run」。
|
|
171
|
+
// **行为不变**(仍不拒启 —— 拒启是行为变更,另立),只从静默变响亮:一行 error 级日志带 runs 目录
|
|
172
|
+
// **绝对路径** + 异常文案(运维一眼能判是权限还是路径),外加登记进 fail-open 遥测让这条债显形。
|
|
173
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
174
|
+
this.logger?.error?.("file_run_store_hydrate_unreadable", {
|
|
175
|
+
runsDir: this.runsDir,
|
|
176
|
+
error: detail,
|
|
177
|
+
note: "the file run-store booted with an EMPTY ledger because its runs/ directory could not be listed — every getRun will miss until this is fixed. This is NOT 'no such run'; check permissions/mount on the path above.",
|
|
178
|
+
});
|
|
179
|
+
recordFailOpen("server.run-store.file-hydrate-unreadable", detail);
|
|
164
180
|
return;
|
|
165
181
|
}
|
|
166
182
|
for (const name of entries) {
|
|
@@ -493,8 +509,9 @@ export class FileRunStore {
|
|
|
493
509
|
async listSessions(opts) {
|
|
494
510
|
const bySession = new Map();
|
|
495
511
|
for (const r of this.runs.values()) {
|
|
496
|
-
|
|
497
|
-
|
|
512
|
+
// exact owner filter (SQL `owner = ?`); "" is a valid exact owner (B10). `includeUnowned` = [4198] 窄臂第三条件(SQL `OR owner IS NULL`)。
|
|
513
|
+
if (opts.owner !== undefined && r.owner !== opts.owner && !(opts.includeUnowned === true && r.owner === null))
|
|
514
|
+
continue;
|
|
498
515
|
const arr = bySession.get(r.sessionId) ?? [];
|
|
499
516
|
arr.push(r);
|
|
500
517
|
bySession.set(r.sessionId, arr);
|
|
@@ -535,14 +552,18 @@ export class FileRunStore {
|
|
|
535
552
|
.sort((a, b) => Date.parse(b.lastActivityAt) - Date.parse(a.lastActivityAt) || (a.sessionId < b.sessionId ? 1 : a.sessionId > b.sessionId ? -1 : 0))
|
|
536
553
|
.slice(0, opts.limit);
|
|
537
554
|
}
|
|
538
|
-
/** E21 (§0.5 delete) — purge the runs ledger for one session,
|
|
539
|
-
|
|
555
|
+
/** E21 (§0.5 delete) — purge the runs ledger for one session, scoped by `session_id` ALONE (per-row owner is the
|
|
556
|
+
* submitting principal and diverges from the session owner — full 判据 see the SQL twin's head-note, F-A);
|
|
557
|
+
* aborts (returns {active}) if a run is live。`expectedSessionOwner` = SQL 双生的化身围栏入参,本孪生执行不了
|
|
558
|
+
* (账本里没有 session_meta),理由与替代围栏逐字见 {@link MemoryRunStore.deleteBySession}。 */
|
|
559
|
+
async deleteBySession(sessionId, expectedSessionOwner) {
|
|
560
|
+
void expectedSessionOwner; // 见上:围栏在协调器那一侧(local 无 session_meta)
|
|
540
561
|
const claimed = this.active.get(sessionId);
|
|
541
562
|
if (claimed !== undefined)
|
|
542
563
|
return { active: claimed };
|
|
543
564
|
let removed = 0;
|
|
544
565
|
for (const [taskId, r] of [...this.runs]) {
|
|
545
|
-
if (r.sessionId !== sessionId
|
|
566
|
+
if (r.sessionId !== sessionId)
|
|
546
567
|
continue;
|
|
547
568
|
this.dropRun(taskId);
|
|
548
569
|
removed += 1;
|
|
@@ -161,6 +161,8 @@ export declare class LocalSessionStore implements SessionStore {
|
|
|
161
161
|
*/
|
|
162
162
|
listSessions(opts: {
|
|
163
163
|
owner?: string;
|
|
164
|
+
/** [4198] 窄互认第二臂的第三条件(SQL 孪生里是 `owner IS NULL` 谓词;此处是同义的 `owner === null` 放行)。 */
|
|
165
|
+
includeUnowned?: boolean;
|
|
164
166
|
cursor?: {
|
|
165
167
|
lastActivityAt: string;
|
|
166
168
|
sessionId: string;
|
|
@@ -443,8 +443,8 @@ export class LocalSessionStore {
|
|
|
443
443
|
const all = [];
|
|
444
444
|
for (const meta of metas) {
|
|
445
445
|
const owner = this.owners.has(meta.id) ? (this.owners.get(meta.id) ?? null) : null;
|
|
446
|
-
if (opts.owner !== undefined && owner !== opts.owner)
|
|
447
|
-
continue; // owner-scope (best-effort, process-local map)
|
|
446
|
+
if (opts.owner !== undefined && owner !== opts.owner && !(opts.includeUnowned === true && owner === null))
|
|
447
|
+
continue; // owner-scope (best-effort, process-local map) + [4198] 窄臂第三条件
|
|
448
448
|
const firstActivityAt = meta.createdAt || new Date(0).toISOString();
|
|
449
449
|
const lastActivityAt = this.lastActivity.get(meta.id) ?? firstActivityAt;
|
|
450
450
|
all.push({
|
|
@@ -90,6 +90,7 @@ export declare class MemoryRunStore {
|
|
|
90
90
|
/** DISTINCT sessions newest-first by last activity (the CC /resume picker): latest run's preview/status + first/last/count. */
|
|
91
91
|
listSessions(opts: {
|
|
92
92
|
owner?: string;
|
|
93
|
+
includeUnowned?: boolean;
|
|
93
94
|
cursor?: {
|
|
94
95
|
lastActivityAt: string;
|
|
95
96
|
sessionId: string;
|
|
@@ -97,8 +98,18 @@ export declare class MemoryRunStore {
|
|
|
97
98
|
limit: number;
|
|
98
99
|
q?: string;
|
|
99
100
|
}): Promise<SessionSummary[]>;
|
|
100
|
-
/**
|
|
101
|
-
|
|
101
|
+
/**
|
|
102
|
+
* E21 (§0.5 delete) — purge the runs ledger for one session, scoped by `session_id` ALONE (per-row owner is the
|
|
103
|
+
* submitting principal and diverges from the session owner — full 判据 see the SQL twin's head-note, F-A);
|
|
104
|
+
* aborts (returns {active}) if a run is live.
|
|
105
|
+
*
|
|
106
|
+
* `expectedSessionOwner` 是 SQL 双生的**化身围栏**入参(事务内复核 `session_meta.owner`,防「A 验权后 A' 删完、
|
|
107
|
+
* 别人用同一 uuid 重新登记」那一形)。本孪生**执行不了**它:local 车道的会话表在另一只店里,这只账本没有
|
|
108
|
+
* session_meta 可读 —— 参数只作契约对齐(签名不齐会让 RunStore 联合类型的调用点直接不可调)。local 的替代
|
|
109
|
+
* 围栏是两条:①协调器在本调用**紧前**复读一次 canonical owner(不齐即整条中止);②数据根的独占 BootLock
|
|
110
|
+
* 保证一个数据根恒一个进程,交错只可能发生在同进程的 await 缝里。这条不对称是**明写**的,不是遗漏。
|
|
111
|
+
*/
|
|
112
|
+
deleteBySession(sessionId: string, expectedSessionOwner: string | null): Promise<{
|
|
102
113
|
removed: number;
|
|
103
114
|
} | {
|
|
104
115
|
active: string;
|
|
@@ -207,8 +207,9 @@ export class MemoryRunStore {
|
|
|
207
207
|
async listSessions(opts) {
|
|
208
208
|
const bySession = new Map();
|
|
209
209
|
for (const r of this.runs.values()) {
|
|
210
|
-
|
|
211
|
-
|
|
210
|
+
// exact owner filter (SQL `owner = ?`); "" is a valid exact owner (B10). `includeUnowned` = [4198] 窄臂第三条件(SQL `OR owner IS NULL`)。
|
|
211
|
+
if (opts.owner !== undefined && r.owner !== opts.owner && !(opts.includeUnowned === true && r.owner === null))
|
|
212
|
+
continue;
|
|
212
213
|
const arr = bySession.get(r.sessionId) ?? [];
|
|
213
214
|
arr.push(r);
|
|
214
215
|
bySession.set(r.sessionId, arr);
|
|
@@ -247,14 +248,25 @@ export class MemoryRunStore {
|
|
|
247
248
|
.sort((a, b) => Date.parse(b.lastActivityAt) - Date.parse(a.lastActivityAt) || (a.sessionId < b.sessionId ? 1 : a.sessionId > b.sessionId ? -1 : 0))
|
|
248
249
|
.slice(0, opts.limit);
|
|
249
250
|
}
|
|
250
|
-
/**
|
|
251
|
-
|
|
251
|
+
/**
|
|
252
|
+
* E21 (§0.5 delete) — purge the runs ledger for one session, scoped by `session_id` ALONE (per-row owner is the
|
|
253
|
+
* submitting principal and diverges from the session owner — full 判据 see the SQL twin's head-note, F-A);
|
|
254
|
+
* aborts (returns {active}) if a run is live.
|
|
255
|
+
*
|
|
256
|
+
* `expectedSessionOwner` 是 SQL 双生的**化身围栏**入参(事务内复核 `session_meta.owner`,防「A 验权后 A' 删完、
|
|
257
|
+
* 别人用同一 uuid 重新登记」那一形)。本孪生**执行不了**它:local 车道的会话表在另一只店里,这只账本没有
|
|
258
|
+
* session_meta 可读 —— 参数只作契约对齐(签名不齐会让 RunStore 联合类型的调用点直接不可调)。local 的替代
|
|
259
|
+
* 围栏是两条:①协调器在本调用**紧前**复读一次 canonical owner(不齐即整条中止);②数据根的独占 BootLock
|
|
260
|
+
* 保证一个数据根恒一个进程,交错只可能发生在同进程的 await 缝里。这条不对称是**明写**的,不是遗漏。
|
|
261
|
+
*/
|
|
262
|
+
async deleteBySession(sessionId, expectedSessionOwner) {
|
|
263
|
+
void expectedSessionOwner; // 见上:本孪生无 session_meta 可复核,围栏在协调器那一侧
|
|
252
264
|
const claimed = this.active.get(sessionId);
|
|
253
265
|
if (claimed !== undefined)
|
|
254
266
|
return { active: claimed };
|
|
255
267
|
let removed = 0;
|
|
256
268
|
for (const [taskId, r] of [...this.runs]) {
|
|
257
|
-
if (r.sessionId !== sessionId
|
|
269
|
+
if (r.sessionId !== sessionId)
|
|
258
270
|
continue;
|
|
259
271
|
this.events.delete(taskId);
|
|
260
272
|
this.runs.delete(taskId);
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { FilePermissionRuleStoreProvider, type RuleApprovalRecord, type RuleApprovalRecordStore, type RuleScope } from "@sema-agent/core";
|
|
2
|
-
import { type RuleImportTicket, type RuleTicketRedeemResult } from "./permission-rule-store-sql.js";
|
|
2
|
+
import { type RuleImportTicket, type RuleTicketDecisionClock, type RuleTicketRedeemResult } from "./permission-rule-store-sql.js";
|
|
3
3
|
import type { PermissionRuleStoreBundle } from "../rules-consent.js";
|
|
4
4
|
/**
|
|
5
5
|
* `RuleApprovalRecordStore` 的 File 形。
|
|
@@ -52,10 +52,10 @@ export declare class FileRuleImportTicketStore {
|
|
|
52
52
|
}): Promise<RuleImportTicket>;
|
|
53
53
|
/** 一次性**认领**。四条否定项的判序与 SQL 形逐字相同(unknown → wrong-principal → consumed → expired),
|
|
54
54
|
* 于是两形的服务端日志归因可比;wire 面把四类折成同一个 404(零存在性 oracle)。 */
|
|
55
|
-
consume(ticketId: string, principal: string): Promise<RuleTicketRedeemResult>;
|
|
55
|
+
consume(ticketId: string, principal: string, opts?: RuleTicketDecisionClock): Promise<RuleTicketRedeemResult>;
|
|
56
56
|
/** 把认领**放回去**。`mustRemainValidMs` = 放回之后至少还要能用多久 —— 撑不过就不算放回成功
|
|
57
57
|
* (理由逐字见 SQL 侧 `release` 的头注:承诺一次必然兑现不了的重试比不承诺更坏)。 */
|
|
58
|
-
release(ticketId: string, principal: string, mustRemainValidMs?: number): Promise<boolean>;
|
|
58
|
+
release(ticketId: string, principal: string, mustRemainValidMs?: number, opts?: RuleTicketDecisionClock): Promise<boolean>;
|
|
59
59
|
}
|
|
60
60
|
/** local 车道的三面束 + 生命周期。`dispose()` 释放 core File provider 的写锁(见 {@link createFilePermissionRuleStores})。 */
|
|
61
61
|
export interface FilePermissionRuleStores extends PermissionRuleStoreBundle {
|
|
@@ -294,7 +294,7 @@ export class FileRuleImportTicketStore {
|
|
|
294
294
|
}
|
|
295
295
|
/** 一次性**认领**。四条否定项的判序与 SQL 形逐字相同(unknown → wrong-principal → consumed → expired),
|
|
296
296
|
* 于是两形的服务端日志归因可比;wire 面把四类折成同一个 404(零存在性 oracle)。 */
|
|
297
|
-
async consume(ticketId, principal) {
|
|
297
|
+
async consume(ticketId, principal, opts) {
|
|
298
298
|
this.gate.assertUsable();
|
|
299
299
|
const row = this.rows.get(ticketId);
|
|
300
300
|
if (row === undefined)
|
|
@@ -303,21 +303,22 @@ export class FileRuleImportTicketStore {
|
|
|
303
303
|
return { ok: false, reason: "wrong-principal" };
|
|
304
304
|
if (row.consumedAtMs !== undefined)
|
|
305
305
|
return { ok: false, reason: "consumed" };
|
|
306
|
-
|
|
307
|
-
|
|
306
|
+
// [4232]③:判定钟(可被同意车道的单调锚覆写)与记账钟(恒真墙钟,写进日志/行)分家 —— 逐字同 SQL 形。
|
|
307
|
+
const stampMs = this.now();
|
|
308
|
+
if (row.expiresAtMs <= (opts?.decisionNowMs ?? stampMs))
|
|
308
309
|
return { ok: false, reason: "expired" };
|
|
309
|
-
this.log.append({ op: "consume", ticketId, atMs:
|
|
310
|
-
row.consumedAtMs =
|
|
310
|
+
this.log.append({ op: "consume", ticketId, atMs: stampMs }, true);
|
|
311
|
+
row.consumedAtMs = stampMs;
|
|
311
312
|
return { ok: true, approvalId: row.approvalId, payloadHash: row.payloadHash };
|
|
312
313
|
}
|
|
313
314
|
/** 把认领**放回去**。`mustRemainValidMs` = 放回之后至少还要能用多久 —— 撑不过就不算放回成功
|
|
314
315
|
* (理由逐字见 SQL 侧 `release` 的头注:承诺一次必然兑现不了的重试比不承诺更坏)。 */
|
|
315
|
-
async release(ticketId, principal, mustRemainValidMs = 0) {
|
|
316
|
+
async release(ticketId, principal, mustRemainValidMs = 0, opts) {
|
|
316
317
|
this.gate.assertUsable();
|
|
317
318
|
const row = this.rows.get(ticketId);
|
|
318
319
|
if (row === undefined || row.ownerKey !== principal || row.consumedAtMs === undefined)
|
|
319
320
|
return false;
|
|
320
|
-
if (row.expiresAtMs <= this.now() + mustRemainValidMs)
|
|
321
|
+
if (row.expiresAtMs <= (opts?.decisionNowMs ?? this.now()) + mustRemainValidMs)
|
|
321
322
|
return false;
|
|
322
323
|
this.log.append({ op: "release", ticketId }, true);
|
|
323
324
|
delete row.consumedAtMs;
|
|
@@ -111,6 +111,28 @@ export type RuleTicketRedeemResult = {
|
|
|
111
111
|
ok: false;
|
|
112
112
|
reason: "unknown" | "wrong-principal" | "expired" | "consumed";
|
|
113
113
|
};
|
|
114
|
+
/**
|
|
115
|
+
* [4232]③/[4235]③ —— 票的**判定钟**覆写(记账钟永远不覆写,见 `consume` 内注)。
|
|
116
|
+
*
|
|
117
|
+
* 缺席 = 墙钟,既有行为逐字不变。在场 = 调用方(同意车道)按**单调锚**投影出来的那个时刻:
|
|
118
|
+
* 铸票时刻的墙钟 + 自铸票起单调钟走过的量。它只能让判定更贴近**真实经过的时间**,给不了票额外寿命
|
|
119
|
+
* (投影按单调钟前进,走满 TTL 就必然越过 `expires_at_ms`)。
|
|
120
|
+
*/
|
|
121
|
+
export interface RuleTicketDecisionClock {
|
|
122
|
+
decisionNowMs?: number;
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* [4232]③ 同族的 **GC 宽限**:`reapExpired` 只收「按墙钟至少已死一个完整 TTL」的票行。
|
|
126
|
+
*
|
|
127
|
+
* 为什么需要它:判定侧改单调锚之后,一张「按墙钟已过期、按单调钟还在窗内」的票是**可兑付**的;
|
|
128
|
+
* 若 GC 仍按 `expires_at_ms <= now` 收行,判定修好了而行先没了 —— 用户拿到的从 `expired` 变成 `unknown`,
|
|
129
|
+
* 缺陷只是换了个归因。宽限一个 TTL 覆盖住「铸票时钟慢了不到一个 TTL」这一族(含 [4232]③ 的真实构造)。
|
|
130
|
+
*
|
|
131
|
+
* 残余(如实登记,不假称已解):墙钟被拨慢**超过**一个 TTL 时,行仍可能在单调窗内被收。彻底解需要行上
|
|
132
|
+
* 存单调基准,而单调基准跨进程无意义(core [4235] 的判词)。补偿是现成的:prepare 廉价,重铸一张即可
|
|
133
|
+
* ——这正是 core 建议的姿态(「接受 + 票廉价重铸」)。
|
|
134
|
+
*/
|
|
135
|
+
export declare const RULE_IMPORT_TICKET_GC_GRACE_MS: number;
|
|
114
136
|
/** prepare 时刻候选集的规范摘要(载荷绑定 F1 ④ 的判据)。纯数据 ⇒ `build*`。 */
|
|
115
137
|
export declare function buildRulePayloadHash(candidates: ReadonlyArray<{
|
|
116
138
|
rule: string;
|
|
@@ -146,7 +168,7 @@ export declare class SqlRuleImportTicketStore {
|
|
|
146
168
|
* expired/consumed 四类给服务端日志;wire 面把四类折成同一个 404)。预读说「可以」而 UPDATE 命中零行,
|
|
147
169
|
* 只可能是我们在这两跳之间输给了一个并发认领者 —— 如实报 `consumed`,而不是把这次失败当成成功。
|
|
148
170
|
*/
|
|
149
|
-
consume(ticketId: string, principal: string): Promise<RuleTicketRedeemResult>;
|
|
171
|
+
consume(ticketId: string, principal: string, opts?: RuleTicketDecisionClock): Promise<RuleTicketRedeemResult>;
|
|
150
172
|
/**
|
|
151
173
|
* **认领的释放**(codex 交叉复审 round1 [high] 一,验真后修)。
|
|
152
174
|
*
|
|
@@ -163,7 +185,7 @@ export declare class SqlRuleImportTicketStore {
|
|
|
163
185
|
* 期间属主可以重新走 prepare 拿一张新票(导入是幂等的:同一条规则再兑付只是同一个 dot 的重放)。
|
|
164
186
|
* 要彻底消灭它需要一条带租约到期的认领状态机,属协议改动,列为后续件。
|
|
165
187
|
*/
|
|
166
|
-
release(ticketId: string, principal: string, mustRemainValidMs?: number): Promise<boolean>;
|
|
188
|
+
release(ticketId: string, principal: string, mustRemainValidMs?: number, opts?: RuleTicketDecisionClock): Promise<boolean>;
|
|
167
189
|
private readRow;
|
|
168
190
|
}
|
|
169
191
|
/**
|