@sema-agent/server 7.1.0 → 7.3.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 +3 -1
- package/README.zh-CN.md +1 -1
- package/USAGE.md +6 -1
- package/dist/approval-ask-machine.d.ts +39 -0
- package/dist/approval-ask-machine.js +101 -0
- package/dist/approval-card.d.ts +244 -0
- package/dist/approval-card.js +237 -0
- package/dist/approval-deny-reasons.d.ts +56 -0
- package/dist/approval-deny-reasons.js +54 -0
- package/dist/approval-reconciler.d.ts +167 -0
- package/dist/approval-reconciler.js +307 -0
- package/dist/boot/coordinators.d.ts +1 -0
- package/dist/boot/coordinators.js +36 -3
- package/dist/boot/lexical-path-env.d.ts +14 -0
- package/dist/boot/lexical-path-env.js +116 -0
- package/dist/boot/org-memory.d.ts +8 -5
- package/dist/boot/org-memory.js +23 -12
- package/dist/boot/reapers.d.ts +34 -0
- package/dist/boot/reapers.js +198 -23
- package/dist/boot/resolve-spec.d.ts +16 -1
- package/dist/boot/resolve-spec.js +104 -46
- package/dist/config-center/facade.d.ts +1 -1
- package/dist/config-center/facade.js +1 -1
- package/dist/config-center/http-client.d.ts +19 -0
- package/dist/config-center/http-client.js +81 -32
- package/dist/config-types.d.ts +76 -7
- package/dist/config.d.ts +1 -0
- package/dist/config.js +134 -4
- package/dist/elicitation.d.ts +4 -0
- package/dist/elicitation.js +2 -2
- package/dist/http/routes/capabilities.js +18 -2
- package/dist/http/routes/runs.d.ts +1 -0
- package/dist/http/routes/runs.js +548 -16
- package/dist/http/routes/tasks.js +190 -12
- package/dist/http/server.d.ts +1 -1
- package/dist/http/server.js +124 -8
- package/dist/http/sse-log.d.ts +51 -0
- package/dist/http/sse-log.js +64 -0
- package/dist/http/wire-types.d.ts +20 -5
- package/dist/leader/wire.js +4 -0
- package/dist/main.js +7 -4
- package/dist/org-memory-admission.d.ts +2 -1
- package/dist/org-memory-admission.js +10 -4
- package/dist/parked-decide.js +5 -2
- package/dist/plugins/approval-ask-store-memory.d.ts +38 -0
- package/dist/plugins/approval-ask-store-memory.js +299 -0
- package/dist/plugins/approval-ask-store-sql.d.ts +341 -0
- package/dist/plugins/approval-ask-store-sql.js +705 -0
- package/dist/plugins/background-agent-store-sql.js +20 -1
- package/dist/plugins/checkpoint-store-sql.d.ts +84 -9
- package/dist/plugins/checkpoint-store-sql.js +297 -16
- package/dist/plugins/local-checkpoint-store.d.ts +6 -5
- package/dist/plugins/local-checkpoint-store.js +4 -0
- package/dist/plugins/pg-pool.js +11 -0
- package/dist/plugins/store-backend.d.ts +18 -0
- package/dist/plugins/store-backend.js +10 -0
- package/dist/plugins/tidb-pool.js +27 -4
- package/dist/question.d.ts +18 -3
- package/dist/question.js +20 -6
- package/dist/runs.d.ts +16 -1
- package/dist/runs.js +61 -3
- package/dist/runtime-caps-resolver.d.ts +7 -1
- package/dist/runtime-caps-resolver.js +65 -3
- package/dist/runtime-governance.d.ts +11 -4
- package/dist/runtime-governance.js +16 -5
- package/dist/spec-fields.d.ts +4 -0
- package/dist/spec-fields.js +6 -0
- package/dist/task-settings.d.ts +35 -15
- package/dist/task-settings.js +19 -5
- package/dist/tool-approval.d.ts +296 -3
- package/dist/tool-approval.js +1066 -50
- package/dist/trace/core-keyset-guard.d.ts +2 -2
- package/dist/trace/ledger-sink.js +14 -1
- package/dist/trace/project.d.ts +55 -0
- package/dist/trace/project.js +135 -0
- package/package.json +4 -3
|
@@ -230,12 +230,27 @@ export interface TaskRequestBody {
|
|
|
230
230
|
/** [1144]/[1146] (core 1.328 R2): 提示词双形轴——simple(CC 212 短形,引擎缺省)| classic(长形,可按
|
|
231
231
|
* 任务/模型试分)。纯呈现轴无租户门;枚举 fail-loud at submit;缺省不挂键。 */
|
|
232
232
|
promptProfile?: "simple" | "classic";
|
|
233
|
+
/** [2856]②(core 5.14.0 座已在,`TaskSpec.toolMaterializeStrategy`):deferred 工具在**激活之后**怎么给
|
|
234
|
+
* 模型供 schema。`static` = 占位符形不变(激活只改可调用性,广告的 schema 仍是空对象);`swap` = 激活
|
|
235
|
+
* 后下一轮把占位符换成真 schema。
|
|
236
|
+
* 为什么要上 wire:cli 的 #181 取证表明,忠实跟随广告 schema 解码的 provider(openai-completions 车道)
|
|
237
|
+
* 在 `static` 下会**无界循环** —— 每轮发 `{}` → invalidArgumentsRejection → 下一轮广告仍空,纠错回路
|
|
238
|
+
* 结构上无效。core 的缺省是 `spec.toolMaterializeStrategy ?? env ?? "static"`,进程级 env 说不了
|
|
239
|
+
* 「每个模型」的话(模型目录车道 per-model api 可混),所以必须有一个**按任务**的位子。
|
|
240
|
+
* 缺省不挂键(交给 core 的 env/缺省链);未知词 400 fail-loud(与 promptProfile/permissionMode 同姿势 ——
|
|
241
|
+
* 静默折缺省会让调用方以为切了策略却没切)。 */
|
|
242
|
+
toolMaterializeStrategy?: "static" | "swap";
|
|
233
243
|
/** R4 (CC parity): the LIGHT top-level per-turn permission-mode intent (CC `permissionMode`). The shell sends
|
|
234
|
-
* the RAW mode (axis-agnostic, no client-side interpretation); the SERVICE interprets it tighten-only
|
|
235
|
-
*
|
|
236
|
-
* `
|
|
237
|
-
*
|
|
238
|
-
|
|
244
|
+
* the RAW mode (axis-agnostic, no client-side interpretation); the SERVICE interprets it tighten-only vs the
|
|
245
|
+
* deployment baseline. Post-[816] all FIVE modes are honored as gate-SHAPE choices (`plan` ⇒ read-only hands +
|
|
246
|
+
* `present_plan`; `acceptEdits`/`bypassPermissions`/`auto` select how much the mode-derived fs-write ask gate
|
|
247
|
+
* asks — they can never subtract from deployment/operator policy, tightenTaskSpec deny-wins; the old "LOOSEN →
|
|
248
|
+
* coerced to default" note was pre-[816] doc-rot). Folds onto the SAME tighten-only governance as
|
|
249
|
+
* `settings.permissions.defaultMode` (the heavier bundle), so it's a lighter alias, not a second path.
|
|
250
|
+
* #157-②: CLOSED enum on the wire — an unknown word 400s at submit (`request.field_invalid`, sibling of
|
|
251
|
+
* promptProfile) instead of silently coercing to `default`; resume replay of a stored body stays lenient
|
|
252
|
+
* (coercePermissionMode). */
|
|
253
|
+
permissionMode?: "default" | "acceptEdits" | "plan" | "bypassPermissions" | "auto";
|
|
239
254
|
/** MF-30 memory PAUSE (shell-host contract, option B per-request — clay 2026-06-27): `false` makes THIS run
|
|
240
255
|
* read-only over long-term memory (`TaskSpec.memory.writeScope:null` — the design/138 memory ENGINE materializes/
|
|
241
256
|
* reads but its harvest commits nothing). Absent/`true` ⇒ normal read+write. The shell's `/memory` pause carries
|
package/dist/leader/wire.js
CHANGED
|
@@ -445,6 +445,10 @@ export function createLeaderRunner(cfg) {
|
|
|
445
445
|
if (cfg.durable) {
|
|
446
446
|
const d = cfg.durable;
|
|
447
447
|
durableSpec = {
|
|
448
|
+
// AskUserQuestion 判决:本腿是 resolve-spec `createDurableQuestionGate` 单一属主声明的**登记豁免**
|
|
449
|
+
// (复审车B 上抛/属主裁定 2026-08-05)——leader worker 无活体问答面可装(不是 HTTP 活流腿,
|
|
450
|
+
// QuestionCoordinator 的 ALS 永不覆盖到这里),core 原形+下方 sentinel 即完整正确语义,且 leader
|
|
451
|
+
// 子系统不 import boot 装配层(分层)。⚠️ 若 leader 腿将来接活体面,必须改调该工厂并撤本注。
|
|
448
452
|
toolPolicy: combinePolicies(createDurableQuestionPolicy(), createDurableAskPolicy({ requireApproval: d.requireApproval, ...(d.deny ? { deny: d.deny } : {}), ...(d.autoBudget ? { autoBudget: d.autoBudget } : {}), ...(d.neverAuto ? { neverAuto: d.neverAuto } : {}) })),
|
|
449
453
|
checkpointStore: d.checkpointStore,
|
|
450
454
|
durableApproval: { scope: "_leader", ...(d.ttlMs ? { ttlMs: d.ttlMs } : {}) },
|
package/dist/main.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { readFileSync } from "node:fs";
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
3
|
import { join } from "node:path";
|
|
4
|
-
import { Runner, TtlSessionStore, uuidv7, defaultTaskRegistry, combinePolicies,
|
|
4
|
+
import { Runner, TtlSessionStore, uuidv7, defaultTaskRegistry, combinePolicies, createAllowDenyPolicy, workflowsCapability, createWebFetchSummarizer, resolveTaskModel as coreResolveTaskModel, probeSearchBackend } from "@sema-agent/core";
|
|
5
5
|
import { createSessionTitler } from "./session-titler.js";
|
|
6
6
|
import { posIntEnv } from "./session-watch.js";
|
|
7
7
|
import { selectEnvironmentTool } from "./capabilities/select-environment-tool.js";
|
|
@@ -37,7 +37,7 @@ import { createHookLlm } from "./hooks/hook-llm.js";
|
|
|
37
37
|
import { startFleetClientFromEnv } from "./fleet-client.js";
|
|
38
38
|
import { reclaimOrphansAtBoot } from "./boot-reclaim.js";
|
|
39
39
|
import { createConfigCenterRuntime } from "./boot/config-center.js";
|
|
40
|
-
import { createResolveSpec } from "./boot/resolve-spec.js";
|
|
40
|
+
import { createDurableQuestionGate, createResolveSpec } from "./boot/resolve-spec.js";
|
|
41
41
|
import { startReapers } from "./boot/reapers.js";
|
|
42
42
|
import { openStores } from "./boot/stores.js";
|
|
43
43
|
import { createBudgetAndTracing } from "./boot/budget-tracing.js";
|
|
@@ -621,10 +621,13 @@ async function main() {
|
|
|
621
621
|
// count=1,durableMandate=true(host 有 durableApproval 且 durable 车道无 live onAsk 的恒形)。
|
|
622
622
|
// exempt 探针锚 = row.rootSessionId——与 parked decide 的 remember grant 锚同键([1591] 候裁②),
|
|
623
623
|
// 重建链上的豁免语义与原链一致。重建不出(无 root 锚)= 不供席 = core pre-CAS 门诚实拒(现状)。
|
|
624
|
+
// #152 复审 A2:「同参重建」是本工厂自述的不变式,所以 question 门必须调 resolve-spec 的**同一个**
|
|
625
|
+
// 工厂并传同一个活体面,不能自折 core 原形——分腿改动只落一侧就是两份拷贝开始漂移的那一刻。
|
|
626
|
+
// 赎回腿今天无 question ALS ctx(/decide 路由触发,core driveParkedResume 内驱动)⇒ 判 ask,与原形同判。
|
|
624
627
|
const parkedReviveInheritedGate = parkedReviveTool && config.durableApproval
|
|
625
628
|
? (row) => ({
|
|
626
629
|
parentConstraints: [{
|
|
627
|
-
policy: combinePolicies(
|
|
630
|
+
policy: combinePolicies(createDurableQuestionGate(question), createDurableAskPolicy({
|
|
628
631
|
requireApproval: config.approvalRequire, deny: config.approvalDeny, autoBudget: config.approvalAutoBudget, neverAuto: config.approvalNeverAuto,
|
|
629
632
|
...(approvalExemptionStore && row.rootSessionId
|
|
630
633
|
? {
|
|
@@ -655,7 +658,7 @@ async function main() {
|
|
|
655
658
|
config, logger, metrics, localRoot, backend, subRunner, runStore, checkpointStore,
|
|
656
659
|
rateLimiter, costQuota, toolResultStore, fileSnapshotStore, taskAttachmentStore, imageBakes, worktreeReap,
|
|
657
660
|
workflowNotifyGate, workflowJournalStore, sqlWorkflowRunStore, workflowNotifyJournal, rosterStore,
|
|
658
|
-
backgroundAgentStore, mailboxStore,
|
|
661
|
+
backgroundAgentStore, mailboxStore, toolApproval,
|
|
659
662
|
getRunDenySweep: () => runDenySweep,
|
|
660
663
|
});
|
|
661
664
|
// Optional OTLP/HTTP metrics export (1.37). Periodically pushes the registry to an OTel collector;
|
|
@@ -78,7 +78,8 @@ export interface MemoryScopeAdmissionOptions {
|
|
|
78
78
|
onOutcome?: (outcome: string, details?: Record<string, unknown>) => void;
|
|
79
79
|
}
|
|
80
80
|
/** core 侧「瞬时不可用,重试」契约的载体:一个带 `retryAfterMs`(ms)属性的真 Error 子类,core 据此铸
|
|
81
|
-
* memory.admission_required
|
|
81
|
+
* **瞬时**码 memory.admission_required(503,重试可改判——与终局的 memory.admission_denied 恰好相对)
|
|
82
|
+
* 并透传该值。子类而非事后挂属性——避免任何宽松断言就能拿到正确类型。 */
|
|
82
83
|
export declare class OrgMemoryAdmissionRetryError extends Error {
|
|
83
84
|
readonly retryAfterMs: number;
|
|
84
85
|
constructor(message: string, retryAfterMs: number);
|
|
@@ -131,7 +131,8 @@ export function createOrgMemoryDirectory(opts) {
|
|
|
131
131
|
};
|
|
132
132
|
}
|
|
133
133
|
/** core 侧「瞬时不可用,重试」契约的载体:一个带 `retryAfterMs`(ms)属性的真 Error 子类,core 据此铸
|
|
134
|
-
* memory.admission_required
|
|
134
|
+
* **瞬时**码 memory.admission_required(503,重试可改判——与终局的 memory.admission_denied 恰好相对)
|
|
135
|
+
* 并透传该值。子类而非事后挂属性——避免任何宽松断言就能拿到正确类型。 */
|
|
135
136
|
export class OrgMemoryAdmissionRetryError extends Error {
|
|
136
137
|
retryAfterMs;
|
|
137
138
|
constructor(message, retryAfterMs) {
|
|
@@ -158,6 +159,11 @@ export function createMemoryScopeAdmission(directory, opts) {
|
|
|
158
159
|
}
|
|
159
160
|
};
|
|
160
161
|
return async ({ principal, requested, requestedWriteScope }) => {
|
|
162
|
+
/** 复审 D-3(7.1.0 后修):归因面。判决记录若不带 principal,运维只看得到「某个 org scope 被拒」而
|
|
163
|
+
* 无从知道**谁**被拒——「per-principal 归因走日志」这句设计承诺(与 CHANGELOG 7.0.0 同款公开陈述)
|
|
164
|
+
* 此前在代码里没有落点。principal 缺席时**不**塞键:干净 admit 那一路必须保持 `details === undefined`
|
|
165
|
+
* (装配点据此判「无事发生不打日志」;塞个 `principal: undefined` 会把每个成功任务变成一行 warn)。 */
|
|
166
|
+
const attributed = (details) => principal === undefined ? details : { principal, ...details };
|
|
161
167
|
const requestOriginRequested = requested.filter((r) => r.origin === "request");
|
|
162
168
|
const writeIsRequestOrigin = requestedWriteScope?.origin === "request";
|
|
163
169
|
const hasRequestOrigin = requestOriginRequested.length > 0 || writeIsRequestOrigin;
|
|
@@ -203,7 +209,7 @@ export function createMemoryScopeAdmission(directory, opts) {
|
|
|
203
209
|
// audit 模式对「读面通过但 write 未显式授」的判决只记 would-narrow,返回未收窄的全额;
|
|
204
210
|
// enforce 照常收窄。ok:true 分支的 writeScope 恒为 fullWriteScope 或 null(decide 只做这两值)。
|
|
205
211
|
if (opts.mode === "audit" && decision.verdict.ok && decision.verdict.writeScope !== fullWriteScope) {
|
|
206
|
-
emit("ok", { audited: true, wouldNarrowWriteScope: true });
|
|
212
|
+
emit("ok", attributed({ audited: true, wouldNarrowWriteScope: true }));
|
|
207
213
|
return admitFull();
|
|
208
214
|
}
|
|
209
215
|
emit("ok");
|
|
@@ -212,10 +218,10 @@ export function createMemoryScopeAdmission(directory, opts) {
|
|
|
212
218
|
if (opts.mode === "audit") {
|
|
213
219
|
// Zero behavior change: a would-deny (terminal or transient) is overridden to a full, UNNARROWED admit —
|
|
214
220
|
// the real decision is only ever recorded via onOutcome, never enforced.
|
|
215
|
-
emit(decision.outcome, { ...decision.details, audited: true, wouldDeny: true, ...(decision.kind === "unavailable" ? { retryAfterMs: decision.retryAfterMs } : { reason: decision.reason }) });
|
|
221
|
+
emit(decision.outcome, attributed({ ...decision.details, audited: true, wouldDeny: true, ...(decision.kind === "unavailable" ? { retryAfterMs: decision.retryAfterMs } : { reason: decision.reason }) }));
|
|
216
222
|
return admitFull();
|
|
217
223
|
}
|
|
218
|
-
emit(decision.outcome, decision.details);
|
|
224
|
+
emit(decision.outcome, attributed(decision.details));
|
|
219
225
|
if (decision.kind === "deny")
|
|
220
226
|
return { ok: false, reason: decision.reason };
|
|
221
227
|
throw new OrgMemoryAdmissionRetryError(`org memory directory unavailable (${decision.lookupReason})`, decision.retryAfterMs);
|
package/dist/parked-decide.js
CHANGED
|
@@ -79,9 +79,12 @@ export async function decideParkedAgent(deps, req) {
|
|
|
79
79
|
const pendingTool = req.pendingAction?.toolName;
|
|
80
80
|
if (pendingTool === "AskUserQuestion" && req.decision === "approve" && req.answer === undefined) {
|
|
81
81
|
// RB-459(core 5.7.0,F1/F6 翻案后唯一剩下的 claim 前拒):问题门的 approve 必须携 answer——
|
|
82
|
-
// parked 腿无 live answering face
|
|
83
|
-
// 并空跑一次 revive;这里 claim 前拒是同一判据的免损前置,不是 server 另立规则。带 answer 的
|
|
82
|
+
// parked 腿无 live answering face,消费 claim 后只会空跑一次 revive;claim 前拒是免损前置。带 answer 的
|
|
84
83
|
// approve 与任何 deny 都放行透传,core 是语义权威(header 逐字匹配等)。
|
|
84
|
+
// 🔴 #152:本判据**不再**有 core 侧同判兜底。core 的 pre-CAS 拒条件是「`taskConfig.onQuestion ??
|
|
85
|
+
// deps.onQuestion` 为 undefined 或 QUESTION_AWAITS_RESUME」;ASK_QUESTION_ENABLED 部署上 spec 不再 stamp
|
|
86
|
+
// 哨兵、deps.onQuestion 是 coordinator,条件恒不成立 ⇒ core 放行,赎回腿会拿 coordinator 的空答当人答
|
|
87
|
+
// 并消费掉 checkpoint。这一手现在是该腿唯一的拒绝点(任务级孪生:src/http/server.ts 的 decide 路由)。
|
|
85
88
|
return { status: 400, body: { error: "pending action is AskUserQuestion — approve requires body.answer (the operator's answers[]); deny needs none", errorCode: "decide.parked_answer_required", taskId: match.handle } };
|
|
86
89
|
}
|
|
87
90
|
if (row.name === undefined) {
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `InMemoryApprovalAskStore` —— #151 车1,`ApprovalAskStore` 的语义真源(twin 对拆基准)+ LOCAL
|
|
3
|
+
* 车道占位(File 形不在本车,见设计定稿 §7)。单进程内两个 `Map`,方法体全同步(不含 `await` 中断点)——
|
|
4
|
+
* Node 单线程下这就是天然原子:`Promise.all` 发起的并发调用不会在方法体内部交错执行,与
|
|
5
|
+
* `approval-ask-store-sql.ts` 的 CAS UPDATE 语义逐字段一致(SQL twin 对拆套件的基准就是这份实现)。
|
|
6
|
+
*
|
|
7
|
+
* 行为口径与 `SqlApprovalAskStore` 逐条对齐:
|
|
8
|
+
* - ensureAsk 幂等 upsert(ask_id 已在 ⇒ 返回既有行,不改写;batch 行若不存在则以 OPEN 补齐)。
|
|
9
|
+
* - transitionAsk 先过 machine 层 `canAskTransition`(非法转移 throw,不返回 false)。
|
|
10
|
+
* - decideAsk/expireAsk/bindBatch/abortBatch 的双/三步判定顺序与 SQL twin 的事务步骤一一对应
|
|
11
|
+
* (哪一步先判、判什么、失败时是否触碰另一半状态——照抄,不是「反正内存里原子就随便」)。
|
|
12
|
+
* - resolveProvisional 故意不经 canAskTransition(同 SQL twin 头注:这是版本化补偿的例外通道)。
|
|
13
|
+
*/
|
|
14
|
+
import { type AskState, type BatchState } from "../approval-ask-machine.js";
|
|
15
|
+
import type { AskDecision, AskRow, AskTransitionPatch, ApprovalAskStore, BatchRow, BindGateInput, BindResult, DecideAskInput, DecideResult, ExpireResult, NewAskRow } from "./approval-ask-store-sql.js";
|
|
16
|
+
export declare class InMemoryApprovalAskStore implements ApprovalAskStore {
|
|
17
|
+
private readonly asks;
|
|
18
|
+
private readonly batches;
|
|
19
|
+
ensureAsk(row: NewAskRow): Promise<AskRow>;
|
|
20
|
+
transitionAsk(askId: string, from: AskState, to: AskState, patch: AskTransitionPatch): Promise<boolean>;
|
|
21
|
+
decideAsk(askId: string, batchId: string, decision: DecideAskInput): Promise<DecideResult>;
|
|
22
|
+
expireAsk(askId: string, batchId: string): Promise<ExpireResult>;
|
|
23
|
+
bindBatch(batchId: string, askId: string, gate: BindGateInput): Promise<BindResult>;
|
|
24
|
+
deferReconcile(askId: string, expectedState: AskState, expectedVersion: number, nowMs: number): Promise<boolean>;
|
|
25
|
+
abortBatch(batchId: string): Promise<string[]>;
|
|
26
|
+
listByState(state: AskState, limit: number): Promise<AskRow[]>;
|
|
27
|
+
/** `signal` 的 twin 语义(车3 刀 3b):同步实现里没有「在飞」这回事,所以能做也只需做**入口复核** ——
|
|
28
|
+
* 已 abort 就一条都不读、如实抛,与 SQL twin 的第①条语义逐字一致(第②条在这里天然平凡成立)。 */
|
|
29
|
+
listPendingByTask(taskId: string, signal?: AbortSignal): Promise<AskRow[]>;
|
|
30
|
+
listPendingBySession(sessionId: string, owner: string | null, signal?: AbortSignal): Promise<AskRow[]>;
|
|
31
|
+
getAsk(askId: string): Promise<AskRow | null>;
|
|
32
|
+
getByIdempotencyKey(taskId: string, key: string): Promise<AskRow | null>;
|
|
33
|
+
resolveProvisional(askId: string, from: AskState, to: AskState, patch: AskTransitionPatch): Promise<boolean>;
|
|
34
|
+
deleteByTask(taskId: string): Promise<void>;
|
|
35
|
+
getBatch(batchId: string): Promise<BatchRow | null>;
|
|
36
|
+
}
|
|
37
|
+
export type { AskDecision, AskState, BatchState };
|
|
38
|
+
//# sourceMappingURL=approval-ask-store-memory.d.ts.map
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `InMemoryApprovalAskStore` —— #151 车1,`ApprovalAskStore` 的语义真源(twin 对拆基准)+ LOCAL
|
|
3
|
+
* 车道占位(File 形不在本车,见设计定稿 §7)。单进程内两个 `Map`,方法体全同步(不含 `await` 中断点)——
|
|
4
|
+
* Node 单线程下这就是天然原子:`Promise.all` 发起的并发调用不会在方法体内部交错执行,与
|
|
5
|
+
* `approval-ask-store-sql.ts` 的 CAS UPDATE 语义逐字段一致(SQL twin 对拆套件的基准就是这份实现)。
|
|
6
|
+
*
|
|
7
|
+
* 行为口径与 `SqlApprovalAskStore` 逐条对齐:
|
|
8
|
+
* - ensureAsk 幂等 upsert(ask_id 已在 ⇒ 返回既有行,不改写;batch 行若不存在则以 OPEN 补齐)。
|
|
9
|
+
* - transitionAsk 先过 machine 层 `canAskTransition`(非法转移 throw,不返回 false)。
|
|
10
|
+
* - decideAsk/expireAsk/bindBatch/abortBatch 的双/三步判定顺序与 SQL twin 的事务步骤一一对应
|
|
11
|
+
* (哪一步先判、判什么、失败时是否触碰另一半状态——照抄,不是「反正内存里原子就随便」)。
|
|
12
|
+
* - resolveProvisional 故意不经 canAskTransition(同 SQL twin 头注:这是版本化补偿的例外通道)。
|
|
13
|
+
*/
|
|
14
|
+
import { canAskTransition } from "../approval-ask-machine.js";
|
|
15
|
+
import { assertIdempotencyKeyShape, ApprovalAskReadAbortedError } from "./approval-ask-store-sql.js";
|
|
16
|
+
/** patch 里真出现的字段搬到行上(未出现的字段维持原值)——`transitionAsk`/`resolveProvisional` 共用。 */
|
|
17
|
+
function applyPatch(row, patch) {
|
|
18
|
+
if (patch.decision !== undefined)
|
|
19
|
+
row.decision = patch.decision;
|
|
20
|
+
if (patch.decisionActor !== undefined)
|
|
21
|
+
row.decisionActor = patch.decisionActor;
|
|
22
|
+
if (patch.decisionNote !== undefined)
|
|
23
|
+
row.decisionNote = patch.decisionNote;
|
|
24
|
+
if (patch.decidedAtMs !== undefined)
|
|
25
|
+
row.decidedAtMs = patch.decidedAtMs;
|
|
26
|
+
if (patch.deniedReason !== undefined)
|
|
27
|
+
row.deniedReason = patch.deniedReason;
|
|
28
|
+
if (patch.gateToken !== undefined)
|
|
29
|
+
row.gateToken = patch.gateToken;
|
|
30
|
+
if (patch.gateBoundCallId !== undefined)
|
|
31
|
+
row.gateBoundCallId = patch.gateBoundCallId;
|
|
32
|
+
if (patch.gateBoundInputHash !== undefined)
|
|
33
|
+
row.gateBoundInputHash = patch.gateBoundInputHash;
|
|
34
|
+
if (patch.provisional !== undefined)
|
|
35
|
+
row.provisional = patch.provisional;
|
|
36
|
+
}
|
|
37
|
+
export class InMemoryApprovalAskStore {
|
|
38
|
+
asks = new Map();
|
|
39
|
+
batches = new Map();
|
|
40
|
+
async ensureAsk(row) {
|
|
41
|
+
if (!this.batches.has(row.batchId)) {
|
|
42
|
+
this.batches.set(row.batchId, {
|
|
43
|
+
batchId: row.batchId,
|
|
44
|
+
taskId: row.taskId,
|
|
45
|
+
state: "OPEN",
|
|
46
|
+
boundAskId: null,
|
|
47
|
+
version: 0,
|
|
48
|
+
createdAtMs: row.createdAtMs,
|
|
49
|
+
updatedAtMs: row.createdAtMs,
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
const existing = this.asks.get(row.askId);
|
|
53
|
+
if (existing)
|
|
54
|
+
return { ...existing };
|
|
55
|
+
// 🔴 车4 §12-E:`idempotency_key` 已归**回决专用**,`NewAskRow` 的同名字段随之摘除 ⇒ 铸行不再有
|
|
56
|
+
// 唯一性可撞(列恒 NULL)。原先在这里镜像 UNIQUE 的那段扫描也随之下车;撞键判定搬到 `decideAsk`
|
|
57
|
+
// (SQL twin 那侧是 `(task_id, idempotency_key)` 索引在库层拒 ⇒ typed `idempotency_conflict`)。
|
|
58
|
+
const created = {
|
|
59
|
+
askId: row.askId,
|
|
60
|
+
taskId: row.taskId,
|
|
61
|
+
sourceTaskId: row.sourceTaskId,
|
|
62
|
+
sessionId: row.sessionId,
|
|
63
|
+
owner: row.owner ?? null,
|
|
64
|
+
batchId: row.batchId,
|
|
65
|
+
toolCallId: row.toolCallId,
|
|
66
|
+
legKey: row.legKey,
|
|
67
|
+
parentToolCallId: row.parentToolCallId ?? null,
|
|
68
|
+
boundInputHash: row.boundInputHash ?? null,
|
|
69
|
+
state: "STREAM_PENDING",
|
|
70
|
+
provisional: false,
|
|
71
|
+
version: 0,
|
|
72
|
+
decision: null,
|
|
73
|
+
decisionActor: null,
|
|
74
|
+
decisionNote: null,
|
|
75
|
+
decidedAtMs: null,
|
|
76
|
+
deniedReason: null,
|
|
77
|
+
gateToken: null,
|
|
78
|
+
gateBoundCallId: null,
|
|
79
|
+
gateBoundInputHash: null,
|
|
80
|
+
idempotencyKey: null, // 回决专用列(见上注):铸行恒 NULL,decideAsk 才落。
|
|
81
|
+
cardJson: row.cardJson,
|
|
82
|
+
schemaVersion: row.schemaVersion,
|
|
83
|
+
expiresAtMs: row.expiresAtMs,
|
|
84
|
+
createdAtMs: row.createdAtMs,
|
|
85
|
+
updatedAtMs: row.createdAtMs,
|
|
86
|
+
};
|
|
87
|
+
this.asks.set(row.askId, created);
|
|
88
|
+
return { ...created };
|
|
89
|
+
}
|
|
90
|
+
async transitionAsk(askId, from, to, patch) {
|
|
91
|
+
if (!canAskTransition(from, to)) {
|
|
92
|
+
throw new Error(`approval-ask-store.transitionAsk: illegal ask transition ${from} -> ${to} (askId=${askId})`);
|
|
93
|
+
}
|
|
94
|
+
const row = this.asks.get(askId);
|
|
95
|
+
if (!row || row.state !== from)
|
|
96
|
+
return false;
|
|
97
|
+
applyPatch(row, patch);
|
|
98
|
+
row.state = to;
|
|
99
|
+
row.version += 1;
|
|
100
|
+
row.updatedAtMs = patch.updatedAtMs;
|
|
101
|
+
return true;
|
|
102
|
+
}
|
|
103
|
+
async decideAsk(askId, batchId, decision) {
|
|
104
|
+
if (decision.idempotencyKey != null)
|
|
105
|
+
assertIdempotencyKeyShape(decision.idempotencyKey); // F2 卫生门(三 twin 同判)
|
|
106
|
+
const now = Date.now();
|
|
107
|
+
const batch = this.batches.get(batchId);
|
|
108
|
+
if (!batch || batch.state !== "OPEN") {
|
|
109
|
+
const row = this.asks.get(askId);
|
|
110
|
+
return { ok: false, reason: "batch_closed", row: row ? { ...row } : null };
|
|
111
|
+
}
|
|
112
|
+
const ask = this.asks.get(askId);
|
|
113
|
+
if (!ask || ask.state !== "STREAM_PENDING") {
|
|
114
|
+
return { ok: false, reason: "ask_not_pending", row: ask ? { ...ask } : null };
|
|
115
|
+
}
|
|
116
|
+
// 🔴 UNIQUE `(task_id, idempotency_key)` 的内存镜像(车4 §12-E)——SQL twin 靠索引在库层拒并把方言
|
|
117
|
+
// 错误翻成 typed 臂;这里必须同答,否则这个店就不是 SQL twin 的语义真源。判定顺序也照抄 SQL:
|
|
118
|
+
// 批 CAS(已过)→ ask CAS(已过)→ 才轮到撞键 ⇒ 一条非 STREAM_PENDING 的 ask 拿到的仍是
|
|
119
|
+
// `ask_not_pending` 而不是 `idempotency_conflict`(哪个先判决定了输者看到的 reason)。
|
|
120
|
+
if (decision.idempotencyKey != null) {
|
|
121
|
+
for (const [id, r] of this.asks) {
|
|
122
|
+
if (id !== askId && r.taskId === ask.taskId && r.idempotencyKey === decision.idempotencyKey) {
|
|
123
|
+
return { ok: false, reason: "idempotency_conflict", row: { ...ask } };
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
batch.version += 1;
|
|
128
|
+
batch.updatedAtMs = now;
|
|
129
|
+
ask.state = "DECIDED";
|
|
130
|
+
ask.decision = decision.decision;
|
|
131
|
+
ask.decisionActor = decision.decisionActor ?? null;
|
|
132
|
+
ask.decisionNote = decision.decisionNote ?? null;
|
|
133
|
+
ask.decidedAtMs = now;
|
|
134
|
+
ask.idempotencyKey = decision.idempotencyKey ?? null;
|
|
135
|
+
ask.version += 1;
|
|
136
|
+
ask.updatedAtMs = now;
|
|
137
|
+
return { ok: true, row: { ...ask } };
|
|
138
|
+
}
|
|
139
|
+
async expireAsk(askId, batchId) {
|
|
140
|
+
const now = Date.now();
|
|
141
|
+
// 批行存在性先判(镜像 SQL twin 死锁修后的步⓪,§6-4c 钉):批行缺席=ensureAsk 从未为它建批
|
|
142
|
+
// (错配 batchId 的编程错误形),如实不赢——不在半空中把 ask 铸成 PARKING(它真正的批还 OPEN,
|
|
143
|
+
// 状态会被污染)。批 state 不设谓词,与 SQL twin 一致(ROUTING_UNBOUND 兄弟先到属合法续走)。
|
|
144
|
+
const batch = this.batches.get(batchId);
|
|
145
|
+
if (!batch)
|
|
146
|
+
return { won: false, voidedSiblings: [] };
|
|
147
|
+
const ask = this.asks.get(askId);
|
|
148
|
+
if (!ask || ask.state !== "STREAM_PENDING")
|
|
149
|
+
return { won: false, voidedSiblings: [] };
|
|
150
|
+
ask.state = "PARKING";
|
|
151
|
+
ask.version += 1;
|
|
152
|
+
ask.updatedAtMs = now;
|
|
153
|
+
if (batch.state === "OPEN") {
|
|
154
|
+
batch.state = "ROUTING_UNBOUND";
|
|
155
|
+
batch.version += 1;
|
|
156
|
+
batch.updatedAtMs = now;
|
|
157
|
+
}
|
|
158
|
+
const voided = [];
|
|
159
|
+
for (const [id, r] of this.asks) {
|
|
160
|
+
if (r.batchId === batchId && r.state === "STREAM_PENDING" && id !== askId) {
|
|
161
|
+
r.state = "VOID";
|
|
162
|
+
r.version += 1;
|
|
163
|
+
r.updatedAtMs = now;
|
|
164
|
+
voided.push(id);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return { won: true, voidedSiblings: voided };
|
|
168
|
+
}
|
|
169
|
+
async bindBatch(batchId, askId, gate) {
|
|
170
|
+
const now = Date.now();
|
|
171
|
+
const batch = this.batches.get(batchId);
|
|
172
|
+
// 判别式失败臂逐条对齐 SQL twin(见 `BindResult` 头注):批行缺席 = "MISSING";批态不对 ⇒ 回真实态
|
|
173
|
+
// (ABORTED / ROUTING_BOUND 两条处置完全相反,调用方靠它分臂)。
|
|
174
|
+
if (!batch)
|
|
175
|
+
return { ok: false, batchState: "MISSING" };
|
|
176
|
+
if (batch.state !== "ROUTING_UNBOUND" || batch.boundAskId !== null) {
|
|
177
|
+
return { ok: false, batchState: batch.state, ...(batch.boundAskId !== null ? { boundAskId: batch.boundAskId } : {}) };
|
|
178
|
+
}
|
|
179
|
+
const ask = this.asks.get(askId);
|
|
180
|
+
// ask 不在 PARKING ⇒ SQL twin 整事务回滚,批停在 ROUTING_UNBOUND;这里同样一个字节都还没改。
|
|
181
|
+
if (!ask || ask.state !== "PARKING")
|
|
182
|
+
return { ok: false, batchState: batch.state };
|
|
183
|
+
batch.state = "ROUTING_BOUND";
|
|
184
|
+
batch.boundAskId = askId;
|
|
185
|
+
batch.version += 1;
|
|
186
|
+
batch.updatedAtMs = now;
|
|
187
|
+
ask.state = "PARKED";
|
|
188
|
+
ask.gateToken = gate.gateToken;
|
|
189
|
+
ask.gateBoundCallId = gate.gateBoundCallId ?? null;
|
|
190
|
+
ask.gateBoundInputHash = gate.gateBoundInputHash ?? null;
|
|
191
|
+
ask.version += 1;
|
|
192
|
+
ask.updatedAtMs = now;
|
|
193
|
+
const voidedSiblings = [];
|
|
194
|
+
for (const [id, r] of this.asks) {
|
|
195
|
+
if (r.batchId === batchId && r.state === "PARKING" && id !== askId) {
|
|
196
|
+
r.state = "VOID";
|
|
197
|
+
r.version += 1;
|
|
198
|
+
r.updatedAtMs = now;
|
|
199
|
+
voidedSiblings.push(id);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
return { ok: true, voidedSiblings };
|
|
203
|
+
}
|
|
204
|
+
async deferReconcile(askId, expectedState, expectedVersion, nowMs) {
|
|
205
|
+
// 纯 touch,**不碰 state**,故意不经 canAskTransition(同态转移在转移表上无出边——见接口注)。
|
|
206
|
+
const row = this.asks.get(askId);
|
|
207
|
+
if (!row || row.state !== expectedState || row.version !== expectedVersion)
|
|
208
|
+
return false;
|
|
209
|
+
row.version += 1;
|
|
210
|
+
row.updatedAtMs = nowMs;
|
|
211
|
+
return true;
|
|
212
|
+
}
|
|
213
|
+
async abortBatch(batchId) {
|
|
214
|
+
const now = Date.now();
|
|
215
|
+
const batch = this.batches.get(batchId);
|
|
216
|
+
if (!batch || (batch.state !== "OPEN" && batch.state !== "ROUTING_UNBOUND"))
|
|
217
|
+
return [];
|
|
218
|
+
batch.state = "ABORTED";
|
|
219
|
+
batch.version += 1;
|
|
220
|
+
batch.updatedAtMs = now;
|
|
221
|
+
const voided = [];
|
|
222
|
+
for (const [id, r] of this.asks) {
|
|
223
|
+
if (r.batchId === batchId && r.state === "STREAM_PENDING") {
|
|
224
|
+
r.state = "VOID";
|
|
225
|
+
r.version += 1;
|
|
226
|
+
r.updatedAtMs = now;
|
|
227
|
+
voided.push(id);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
return voided;
|
|
231
|
+
}
|
|
232
|
+
async listByState(state, limit) {
|
|
233
|
+
// 排序键与 SQL twin 逐字同形:`updated_at_ms ASC, created_at_ms ASC`(队列轮转,见 SQL 侧头注)。
|
|
234
|
+
return [...this.asks.values()]
|
|
235
|
+
.filter((r) => r.state === state)
|
|
236
|
+
.sort((a, b) => a.updatedAtMs - b.updatedAtMs || a.createdAtMs - b.createdAtMs)
|
|
237
|
+
.slice(0, limit)
|
|
238
|
+
.map((r) => ({ ...r }));
|
|
239
|
+
}
|
|
240
|
+
/** `signal` 的 twin 语义(车3 刀 3b):同步实现里没有「在飞」这回事,所以能做也只需做**入口复核** ——
|
|
241
|
+
* 已 abort 就一条都不读、如实抛,与 SQL twin 的第①条语义逐字一致(第②条在这里天然平凡成立)。 */
|
|
242
|
+
async listPendingByTask(taskId, signal) {
|
|
243
|
+
if (signal?.aborted === true)
|
|
244
|
+
throw new ApprovalAskReadAbortedError("listPendingByTask");
|
|
245
|
+
return [...this.asks.values()]
|
|
246
|
+
.filter((r) => r.taskId === taskId && r.state === "STREAM_PENDING")
|
|
247
|
+
.sort((a, b) => a.createdAtMs - b.createdAtMs)
|
|
248
|
+
.map((r) => ({ ...r }));
|
|
249
|
+
}
|
|
250
|
+
async listPendingBySession(sessionId, owner, signal) {
|
|
251
|
+
if (signal?.aborted === true)
|
|
252
|
+
throw new ApprovalAskReadAbortedError("listPendingBySession");
|
|
253
|
+
// 租户门与 SQL twin 逐字同语义:`owner` 必填,`null` 只匹配 owner 为 null 的行(不是「不过滤」)。
|
|
254
|
+
return [...this.asks.values()]
|
|
255
|
+
.filter((r) => r.sessionId === sessionId && r.state === "STREAM_PENDING" && r.owner === owner)
|
|
256
|
+
.sort((a, b) => a.createdAtMs - b.createdAtMs)
|
|
257
|
+
.map((r) => ({ ...r }));
|
|
258
|
+
}
|
|
259
|
+
async getAsk(askId) {
|
|
260
|
+
const r = this.asks.get(askId);
|
|
261
|
+
return r ? { ...r } : null;
|
|
262
|
+
}
|
|
263
|
+
async getByIdempotencyKey(taskId, key) {
|
|
264
|
+
assertIdempotencyKeyShape(key); // F2 卫生门(三 twin 同判)
|
|
265
|
+
// 键的两维都要匹配(UNIQUE 已改 `(task_id, idempotency_key)`)——租户门是读口自己的责任。
|
|
266
|
+
for (const r of this.asks.values()) {
|
|
267
|
+
if (r.taskId === taskId && r.idempotencyKey === key)
|
|
268
|
+
return { ...r };
|
|
269
|
+
}
|
|
270
|
+
return null;
|
|
271
|
+
}
|
|
272
|
+
async resolveProvisional(askId, from, to, patch) {
|
|
273
|
+
// 🔴 与 SQL twin 同注:故意不经 canAskTransition——版本化补偿的例外通道。
|
|
274
|
+
const row = this.asks.get(askId);
|
|
275
|
+
if (!row || !row.provisional || row.state !== from)
|
|
276
|
+
return false;
|
|
277
|
+
applyPatch(row, patch);
|
|
278
|
+
row.state = to;
|
|
279
|
+
row.provisional = false;
|
|
280
|
+
row.version += 1;
|
|
281
|
+
row.updatedAtMs = patch.updatedAtMs;
|
|
282
|
+
return true;
|
|
283
|
+
}
|
|
284
|
+
async deleteByTask(taskId) {
|
|
285
|
+
for (const [id, r] of this.asks) {
|
|
286
|
+
if (r.taskId === taskId)
|
|
287
|
+
this.asks.delete(id);
|
|
288
|
+
}
|
|
289
|
+
for (const [id, b] of this.batches) {
|
|
290
|
+
if (b.taskId === taskId)
|
|
291
|
+
this.batches.delete(id);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
async getBatch(batchId) {
|
|
295
|
+
const b = this.batches.get(batchId);
|
|
296
|
+
return b ? { ...b } : null;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
//# sourceMappingURL=approval-ask-store-memory.js.map
|