@sema-agent/client-core 0.40.0 → 0.42.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/CHANGELOG.md +450 -0
- package/README.md +1 -1
- package/dist/adapter/downstream/eventToSdkMessage.js +24 -0
- package/dist/attachmentsWireCaps.d.ts +43 -8
- package/dist/attachmentsWireCaps.js +64 -14
- package/dist/classifierVerdictWire.d.ts +0 -25
- package/dist/classifierVerdictWire.js +27 -7
- package/dist/engineAgentPanelStore.js +102 -13
- package/dist/hitl/approvalsFeed.d.ts +2 -2
- package/dist/hitl/approvalsFeed.js +57 -2
- package/dist/hitl/askGateWire.d.ts +4 -2
- package/dist/hitl/askGateWire.js +2 -0
- package/dist/hitl/editedRuleTextPrecheck.d.ts +102 -0
- package/dist/hitl/editedRuleTextPrecheck.js +91 -0
- package/dist/hitl/frameRouter.d.ts +21 -2
- package/dist/hitl/frameRouter.js +114 -12
- package/dist/hitl/gateLedger.d.ts +27 -0
- package/dist/hitl/gateLedger.js +64 -9
- package/dist/hitl/hitlBridge.d.ts +70 -14
- package/dist/hitl/hitlBridge.js +117 -34
- package/dist/hitl/hitlHostSurface.d.ts +33 -3
- package/dist/hitl/hitlHostSurface.js +33 -3
- package/dist/hitl/parkResolver.js +39 -13
- package/dist/hitl/toolApprovalWire.d.ts +133 -3
- package/dist/hitl/toolApprovalWire.js +143 -6
- package/dist/index.d.ts +1 -0
- package/dist/index.js +7 -0
- package/dist/model/catalogLoader.js +151 -45
- package/dist/printToolResultFrame.d.ts +19 -0
- package/dist/seatContract.d.ts +25 -1
- package/dist/seatContract.js +29 -2
- package/dist/subagent/engineDelegatedPrompt.js +10 -3
- package/dist/systemReminderTag.d.ts +52 -0
- package/dist/systemReminderTag.js +73 -0
- package/docs/INTEGRATION-CLIENTS.md +123 -7
- package/package.json +2 -2
|
@@ -130,8 +130,27 @@ export interface ApprovalCardAllowDecision {
|
|
|
130
130
|
allowSession: boolean;
|
|
131
131
|
updatedInput?: unknown;
|
|
132
132
|
/** #225 件1:卡上选中的持久规则候选**原文**({@link ApprovalCardRequest.ruleSuggestions} 之一);
|
|
133
|
-
* 缺席/空串 = 本次不兑付。表外文本会在编排层被丢键留痕(server 亦拒 rule_not_offered)。
|
|
133
|
+
* 缺席/空串 = 本次不兑付。表外文本会在编排层被丢键留痕(server 亦拒 rule_not_offered)。
|
|
134
|
+
* ⚠️ 0.42.0 起本位有一个**兄弟位** {@link persistRuleEdited} —— 带上它就是「这段文本是人手改的
|
|
135
|
+
* 自由文本」,此时本位**不再**要求是候选之一(见该位注)。本位自身的类型与字节一字未动。 */
|
|
134
136
|
persistRule?: string;
|
|
137
|
+
/**
|
|
138
|
+
* #225 编辑臂(0.42.0;server #340 `respondFreeFormRules`,[5071] wire 形):{@link persistRule}
|
|
139
|
+
* 里那段文本是**人在卡上手打/改过的自由文本**,不是帧候选表里的原文。
|
|
140
|
+
*
|
|
141
|
+
* 🔴 **缺席 ≠ false**:缺席 = 既有候选臂(表内核对照旧、语义与 0.41.0 逐字节相同);
|
|
142
|
+
* `true` = 编辑臂。刻意只收 `true` 一个值(机读位是二值的,「在场但不是 true」没有语义)。
|
|
143
|
+
* 🔴 **它不是放行凭据**,是**出身声明**:带上它只会让编排层放弃「必须是候选之一」那道表核
|
|
144
|
+
* (见 {@link surfaceToolApprovalFrameAndRespond} 的兑付段),真正的判官仍在引擎侧
|
|
145
|
+
* (core `confirmRuleApproval` 同函数体)。客户端**绝不**在这里替引擎预判文本合不合法 ——
|
|
146
|
+
* 在边界复读解析器就是装第二个更严的判官,`Bash(adb *)` 这类肌肉记忆形会被当场误杀
|
|
147
|
+
* ([5071] 定界②,core [5075] 复核确认)。要「边打字边校验」请用
|
|
148
|
+
* {@link import('./editedRuleTextPrecheck.js').precheckEditedRuleText} 的注入口 —— 那是**引擎
|
|
149
|
+
* 自己那只**判官,不是第二份。
|
|
150
|
+
* 🔴 **能力位在场才发**:`true` 而 {@link ToolApprovalFrameLaneOpts.respondFreeFormRulesCapable}
|
|
151
|
+
* 未确认 ⇒ 编排层**整条丢 persistRule**(决断照送),见该位注。
|
|
152
|
+
*/
|
|
153
|
+
persistRuleEdited?: true;
|
|
135
154
|
}
|
|
136
155
|
/**
|
|
137
156
|
* deny 决断臂(命名形,typeshape B4 口径;0.28.0 因 `reason` 位抽名,与 0.26.0
|
|
@@ -344,7 +363,21 @@ export declare function surfaceApprovalCard(req: ApprovalCardRequest): Promise<A
|
|
|
344
363
|
*
|
|
345
364
|
* @param argsByCall tool_start.args(toolCallId → args)——题干/diff 的一手源;缺则 pending.input。
|
|
346
365
|
*/
|
|
347
|
-
export declare function surfaceFsApprovalAndDecide(deps: FsApprovalWireDeps, taskId: string, argsByCall: Map<string, unknown>, signal?: AbortSignal
|
|
366
|
+
export declare function surfaceFsApprovalAndDecide(deps: FsApprovalWireDeps, taskId: string, argsByCall: Map<string, unknown>, signal?: AbortSignal,
|
|
367
|
+
/**
|
|
368
|
+
* 这张 park 的**待批 call 身份**(server ≥7.41.0 的 `done{suspended}.result.toolCallId` /
|
|
369
|
+
* durable `suspended` 事件同键;供给见 `frameRouter.parkGatedCallId`,缺席是常态)。
|
|
370
|
+
*
|
|
371
|
+
* 🔴 它必须从**取件**这一步就起作用,不能只拿去删扣留帧(0.41.0 件④ 的异源复审 finding①):
|
|
372
|
+
* 取件按 taskId + 族谓词选**第一行**,同 task 同族多行排队时那可能是旁观者 —— 只用身份删帧就成了
|
|
373
|
+
* split-brain(决断绑的是旁观者行、删掉的是主角帧):用户其实在批准/拒绝**另一个**工具调用,
|
|
374
|
+
* 而真 park 原地不动、反复重挂。
|
|
375
|
+
* 🔴 **不 fail-closed**:身份在场但队列里没有对应行时**照旧回落**既有两条腿(taskId + 族谓词)。
|
|
376
|
+
* 这个 join(checkpoint 的 `pendingAction.toolCallId` ↔ `/v1/approvals` 行的 `toolCallId`/
|
|
377
|
+
* `boundCallId`)本仓没有对真 server 的实证,而 fail-closed 的失效面是**每一次审批都决断不了**;
|
|
378
|
+
* 回落的失效面则与本键出现之前逐字节相同。方向按代价不对称取:宁可退回原状,不赌一个没实证的键。
|
|
379
|
+
*/
|
|
380
|
+
parkGatedCallId?: string): Promise<FsApprovalOutcome>;
|
|
348
381
|
/** server 包 src/tool-approval.ts 头注的帧契约(redactDeep + 16KiB 帽;超帽 argsOmitted 仍出帧)。 */
|
|
349
382
|
export interface ToolApprovalFrame {
|
|
350
383
|
type: 'tool_approval' | 'tool_approval_complete';
|
|
@@ -584,8 +617,22 @@ export type ToolApprovalRespondDecision = 'allow' | 'allow_session' | 'deny';
|
|
|
584
617
|
export interface RespondToolApprovalOpts {
|
|
585
618
|
signal?: AbortSignal;
|
|
586
619
|
updatedInput?: unknown;
|
|
587
|
-
/** #225 件1:兑付键 —— 帧候选之一的**原文**(编排层已做表内核对与 deny 剥除)。
|
|
620
|
+
/** #225 件1:兑付键 —— 帧候选之一的**原文**(编排层已做表内核对与 deny 剥除)。
|
|
621
|
+
* ⚠️ 带 {@link persistRuleEdited} 时表核已让位,本位是人手改的自由文本(类型不变)。 */
|
|
588
622
|
persistRule?: string;
|
|
623
|
+
/**
|
|
624
|
+
* #225 编辑臂(0.42.0;server #340,[5071]):{@link persistRule} 是**自由文本**而非候选原文。
|
|
625
|
+
*
|
|
626
|
+
* 🔴 **注入面的映射义务**(本包只到这一格,wire 体由宿主/SDK 铸):server 的 respond 体形是
|
|
627
|
+
* `persistRule: { rule, edited: true }`。本包刻意保持**扁平兄弟位**(`persistRule: string`
|
|
628
|
+
* 字节不变 + 一个可选布尔),由注入面把两位合成那个嵌套形:
|
|
629
|
+
* `persistRule !== undefined ? { rule: persistRule, ...(persistRuleEdited === true ? { edited: true } : {}) } : undefined`
|
|
630
|
+
* 改成嵌套形会是既有 `persistRule: string` 消费者的 BREAKING,而这一批的纲领是 additive。
|
|
631
|
+
* 🔴 **缺席 = 候选臂**(server 侧 `rule_not_offered` 语义一字不变);老 server 收到带 `edited`
|
|
632
|
+
* 的体会诚实降级为 `rule_not_offered`,这正是 [5071] 选这个键名而不是顶层新键的理由
|
|
633
|
+
* (顶层新键在老 server 上是静默 200 什么都不落,坏于诚实降级)。
|
|
634
|
+
*/
|
|
635
|
+
persistRuleEdited?: true;
|
|
589
636
|
/** #229(server ≥7.15.0):回决备注 —— 与 durable 腿 `AskDecisionBody.note` **同词同源同一列**
|
|
590
637
|
* (`decision_note`,≤2048)。任何 decision 都可带(deny 的「为什么拒」正是审计面上最值钱的
|
|
591
638
|
* 一条);真落行与否看 ack 的 {@link surfaceToolApprovalFrameAndRespond} 消费的 `noteRecorded`。
|
|
@@ -615,7 +662,79 @@ export interface ToolApprovalFrameOutcome {
|
|
|
615
662
|
decision: ToolApprovalRespondDecision | 'unresolved';
|
|
616
663
|
/** server 的 200 ack。**缺席 = 未知**(注入面回 void / 旧 server / respond 失败),绝不当成 false。 */
|
|
617
664
|
ack?: ToolApprovalRespondAck;
|
|
665
|
+
/**
|
|
666
|
+
* #225 件5(0.42.0):respond **抛错**那一支的原文交还位。
|
|
667
|
+
*
|
|
668
|
+
* 🔴 修的是一条真断链:此前本函数的 catch 只写一行 `debug` 然后返 `{decision:'unresolved'}`,
|
|
669
|
+
* 于是 server 的响亮 400(`persistRule.rule` 空/超长、`edited` 非布尔、顶层 `scope`、
|
|
670
|
+
* `edit-rejected` 的拒句 …… [5071] G4/G5/G6)在**包边界上被吞掉** —— 宿主的错误反馈面
|
|
671
|
+
* 无论怎么写都拿不到那句话,人在卡上改了规则被拒,屏上什么都不会说。
|
|
672
|
+
* 🔴 **不改 `decision` 的语义**:`'unresolved'` 仍是 `'unresolved'`(respond 没落定 = 引擎按
|
|
673
|
+
* TTL/abort 自决,这一位的含义一字未动);本位是**附加**的诊断面,不是新的决断态。
|
|
674
|
+
* 🔴 **缺席 = 没有拒绝原文可交**(respond 成功 / 抛的东西上**三位皆读不出**),绝不造一句。
|
|
675
|
+
* 在场时**三位都可能缺席其二** —— 有 `status` 没文本(应答体为空)、有文本没 `status`
|
|
676
|
+
* (传输层失败)都是真实形。
|
|
677
|
+
*/
|
|
678
|
+
respondRefusal?: ToolApprovalRespondRefusal;
|
|
679
|
+
}
|
|
680
|
+
/**
|
|
681
|
+
* #225 件5:respond 抛错的**结构化原文**(不是新的决断态,见
|
|
682
|
+
* {@link ToolApprovalFrameOutcome.respondRefusal})。
|
|
683
|
+
*
|
|
684
|
+
* 三位**各自独立**防御读、各自缺席不铸:`status` 只认有限数、`errorCode` 只认非空串、
|
|
685
|
+
* `message` 只认真读得出来的文本(取值序与 try 保护见 {@link readToolApprovalRespondRefusal})。
|
|
686
|
+
* 🔴 **三位皆缺席时整只不铸**(异源复审 [medium] 采纳,0.42.0):此前 `message` 是必填、读不出时
|
|
687
|
+
* 无条件退 `String(err)`,于是 `throw {}` 会得到一句 `"[object Object]"` —— 那不是 server 说的话,
|
|
688
|
+
* 是本层编的([honest-absence-not-fabricated-zero])。
|
|
689
|
+
* 🔴 **原样交还,零加工**:不 trim、不截断、不改写、不按识别表过滤。server 的拒句是给人看的
|
|
690
|
+
* 指路文本([5071] 三条 400 拒句各自成文),包边界任何一次改写都会让宿主呈的不再是引擎说的那句;
|
|
691
|
+
* 长度夹取与呈现归端(与 `message`/`approver` 同族口径)。
|
|
692
|
+
* 🔴 **UNTRUSTED-for-display**:它是 HTTP 应答体上的文本,只渲染,绝不回喂模型/工具入参。
|
|
693
|
+
*/
|
|
694
|
+
interface ToolApprovalRespondRefusalFields {
|
|
695
|
+
/** HTTP 状态(在场 = 引擎真应答了;缺席 = 传输层失败/注入面自抛,不许反推成 0)。 */
|
|
696
|
+
status?: number;
|
|
697
|
+
/** 机读码(开集,原样;缺席 = 应答没带码)。 */
|
|
698
|
+
errorCode?: string;
|
|
699
|
+
/**
|
|
700
|
+
* 拒句原文(server 的响亮拒文案)。
|
|
701
|
+
* 🔴 **缺席 = 这个抛出物上读不出任何可用文本**(异源复审 [medium] 采纳,0.42.0)——
|
|
702
|
+
* 此前本位是必填、读不出时无条件铸 `String(err)`,于是 `throw {}` 会得到一句
|
|
703
|
+
* `"[object Object]"`、`throw null` 得到 `"null"`:那**不是** server 说的话,是本层编的,
|
|
704
|
+
* 与本形头注承诺的「读不出原文就缺席」直接冲突([honest-absence-not-fabricated-zero])。
|
|
705
|
+
*/
|
|
706
|
+
message?: string;
|
|
618
707
|
}
|
|
708
|
+
/**
|
|
709
|
+
* 🔴 **「在场即至少有一位」写进类型**(异源复审 [medium] 采纳,0.42.0):三位全 optional 的
|
|
710
|
+
* interface 在类型上允许 `{}`,而实现明确承诺「三位皆缺席时整只返 `undefined`」——
|
|
711
|
+
* 消费端于是既不能依赖「对象在场 = 至少有一条诊断信息」,也没法穷举安全渲染。
|
|
712
|
+
* 用**三选一联合**把那条运行期不变量抬到编译期:`{}` 从此不可赋值。
|
|
713
|
+
*/
|
|
714
|
+
export type ToolApprovalRespondRefusal = (ToolApprovalRespondRefusalFields & {
|
|
715
|
+
status: number;
|
|
716
|
+
}) | (ToolApprovalRespondRefusalFields & {
|
|
717
|
+
errorCode: string;
|
|
718
|
+
}) | (ToolApprovalRespondRefusalFields & {
|
|
719
|
+
message: string;
|
|
720
|
+
});
|
|
721
|
+
/**
|
|
722
|
+
* 从 respond 抛出来的东西上读 {@link ToolApprovalRespondRefusal}。**永不抛、永不造**。
|
|
723
|
+
*
|
|
724
|
+
* 键位口径与 {@link import('../wireErrorTriage.js').classifyTurnWireError} 同源([2055] 死键
|
|
725
|
+
* 纪律:只认活键 `errorCode`,退役的 `code` 槽不做兼容)。
|
|
726
|
+
*
|
|
727
|
+
* @returns 三位**全缺席**时返回 `undefined` —— 一个三位皆空的 refusal 对象是「有拒句」的假象。
|
|
728
|
+
*
|
|
729
|
+
* 🔴 **文本取值序**(异源复审 [medium] 修):`err.message` 非空串 > 抛出物本身是非空串 >
|
|
730
|
+
* 受保护的 `String(err)`,且**只接受**真有内容的结果 —— `[object Object]` / `null` /
|
|
731
|
+
* `undefined` 三种占位串一律当作「读不出」。
|
|
732
|
+
* 🔴 **`String(err)` 用 try 包住**:抛出物可以自带 `Symbol.toPrimitive` / `toString` 钩子并在里面
|
|
733
|
+
* 抛错。本函数的唯一调用点在 `surfaceToolApprovalFrameAndRespond` 的 catch 里,那里的契约是
|
|
734
|
+
* 「respond 失败 ⇒ 返回 unresolved」;让一个不可信的转换钩子把这条收敛路径变成 reject,
|
|
735
|
+
* 等于给注入面开了一个「让整次审批消费抛出去」的口。
|
|
736
|
+
*/
|
|
737
|
+
export declare function readToolApprovalRespondRefusal(err: unknown): ToolApprovalRespondRefusal | undefined;
|
|
619
738
|
/** 结构性识别流上的 tool_approval 帧(named SSE frame,payload.type === 帧名)。
|
|
620
739
|
* ⚠️ 口径更正(2026-08-08):此处原写「非 AgentEvent arm」—— SDK #185a 起这两个帧**是**
|
|
621
740
|
* `AgentEvent` 的臂了(durable 腿也回放),所以结构识别与 union 收窄两条路都成立;本函数仍按
|
|
@@ -630,6 +749,16 @@ export interface ToolApprovalFrameLaneOpts {
|
|
|
630
749
|
* 缺席/false ⇒ 不发(fail-closed 到「不发」侧;决断本身照常送达,现状字节不变)。
|
|
631
750
|
*/
|
|
632
751
|
approvalDecisionNoteCapable?: boolean;
|
|
752
|
+
/**
|
|
753
|
+
* server 能力位 `capabilities.respondFreeFormRules` 的读数(宿主从自己的 caps 缓存供给;
|
|
754
|
+
* server ≥7.44,#340 [5071] 恒真版本位)。
|
|
755
|
+
* 🔴 形与判据**逐字照** {@link approvalDecisionNoteCapable} 既有形(同一条 SDK 6.16 成文纪律:
|
|
756
|
+
* **位缺席就别发**)—— 老 server 对未知请求键静默忽略且照回 200,「这台不认识自由文本臂」与
|
|
757
|
+
* 「记上了」在响应上不可分,发了只造「规则已存」的错觉。
|
|
758
|
+
* 缺席/false ⇒ 编辑臂的 `persistRule` **整条不发**(fail-closed 到「不发」侧;决断本身照常送达,
|
|
759
|
+
* 现状字节不变,老引擎零受迫)。候选臂**不受本位影响**(它是 0.25.0 起就有的既有通道)。
|
|
760
|
+
*/
|
|
761
|
+
respondFreeFormRulesCapable?: boolean;
|
|
633
762
|
/**
|
|
634
763
|
* 这条帧是不是**当下**从 live 流上收到的(异源对抗复审 P1 采纳;壳孪生帧族同名闸的
|
|
635
764
|
* 本包席位 —— 壳 `approvalStreamWire` 头注逐字:「账本重放的历史帧带的是铸帧时刻的旧余量,
|
|
@@ -642,3 +771,4 @@ export interface ToolApprovalFrameLaneOpts {
|
|
|
642
771
|
windowIsCurrent?: boolean;
|
|
643
772
|
}
|
|
644
773
|
export declare function surfaceToolApprovalFrameAndRespond(frame: ToolApprovalFrame, respond: RespondToolApprovalFn, streamArgs: unknown | undefined, signal?: AbortSignal, lane?: ToolApprovalFrameLaneOpts): Promise<ToolApprovalFrameOutcome>;
|
|
774
|
+
export {};
|
|
@@ -221,7 +221,21 @@ function cardPortMissReason() {
|
|
|
221
221
|
*
|
|
222
222
|
* @param argsByCall tool_start.args(toolCallId → args)——题干/diff 的一手源;缺则 pending.input。
|
|
223
223
|
*/
|
|
224
|
-
export async function surfaceFsApprovalAndDecide(deps, taskId, argsByCall, signal
|
|
224
|
+
export async function surfaceFsApprovalAndDecide(deps, taskId, argsByCall, signal,
|
|
225
|
+
/**
|
|
226
|
+
* 这张 park 的**待批 call 身份**(server ≥7.41.0 的 `done{suspended}.result.toolCallId` /
|
|
227
|
+
* durable `suspended` 事件同键;供给见 `frameRouter.parkGatedCallId`,缺席是常态)。
|
|
228
|
+
*
|
|
229
|
+
* 🔴 它必须从**取件**这一步就起作用,不能只拿去删扣留帧(0.41.0 件④ 的异源复审 finding①):
|
|
230
|
+
* 取件按 taskId + 族谓词选**第一行**,同 task 同族多行排队时那可能是旁观者 —— 只用身份删帧就成了
|
|
231
|
+
* split-brain(决断绑的是旁观者行、删掉的是主角帧):用户其实在批准/拒绝**另一个**工具调用,
|
|
232
|
+
* 而真 park 原地不动、反复重挂。
|
|
233
|
+
* 🔴 **不 fail-closed**:身份在场但队列里没有对应行时**照旧回落**既有两条腿(taskId + 族谓词)。
|
|
234
|
+
* 这个 join(checkpoint 的 `pendingAction.toolCallId` ↔ `/v1/approvals` 行的 `toolCallId`/
|
|
235
|
+
* `boundCallId`)本仓没有对真 server 的实证,而 fail-closed 的失效面是**每一次审批都决断不了**;
|
|
236
|
+
* 回落的失效面则与本键出现之前逐字节相同。方向按代价不对称取:宁可退回原状,不赌一个没实证的键。
|
|
237
|
+
*/
|
|
238
|
+
parkGatedCallId) {
|
|
225
239
|
// REF-CC-031(2026-08-02):卡口缺席判据不在此前置重复一遍 —— `surfaceApprovalCard`(下方调用)
|
|
226
240
|
// 已经计 miss + 出 typed `{kind:'failed', reason:...}`,card.kind==='failed' 走既有汇流分支
|
|
227
241
|
// (下面 switch 的 'failed' 臂);两处各写一份文案会在 SEMA_DEBUG 里出现两种措辞,取决于走的是
|
|
@@ -232,7 +246,11 @@ export async function surfaceFsApprovalAndDecide(deps, taskId, argsByCall, signa
|
|
|
232
246
|
// 二次 list(),消掉 hitl2-01/hitl2-08 的 TOCTOU 窗。
|
|
233
247
|
// 🔴 常驻判据不是「看得见 pending 这个实参」,是 pure 门那四条「approvals.list 全程恰好 1 次」——
|
|
234
248
|
// 漏传一条腿,形状断言全绿而计数当场翻红(回炉车前的实况:只有 aborted 臂传了)。
|
|
235
|
-
const found = await findPendingForTask(deps.client, taskId, (toolName) => toolName !== undefined && (toolNameIsFsWrite(toolName) || toolNameIsShellExec(toolName)), signal ? { signal } : undefined
|
|
249
|
+
const found = await findPendingForTask(deps.client, taskId, (toolName) => toolName !== undefined && (toolNameIsFsWrite(toolName) || toolNameIsShellExec(toolName)), signal ? { signal } : undefined, parkGatedCallId, // 见本参数头注:身份在场 ⇒ 逐字命中优先于「同族第一行」;缺席/不命中 ⇒ 原两腿
|
|
250
|
+
// 身份腿的族闸走**整行**判据:本腿的族是 `isToolApprovalGate` 的**两条腿**(一等 kind 放行任意
|
|
251
|
+
// toolName + fs/shell 名字腿),而上面那个 `matches` 只有名字腿那半 —— 拿它当身份闸会把合法的
|
|
252
|
+
// kind-only 行判出局(五审 finding①)。gateKind = 行上的扁平投影,与 gate.kind 同一权威。
|
|
253
|
+
(row) => isToolApprovalGate({ kind: row.gateKind, toolName: row.toolName }));
|
|
236
254
|
if (!found.ok)
|
|
237
255
|
return { kind: 'failed', reason: found.reason };
|
|
238
256
|
const { pending, gatedCallId } = found;
|
|
@@ -279,7 +297,9 @@ export async function surfaceFsApprovalAndDecide(deps, taskId, argsByCall, signa
|
|
|
279
297
|
case 'failed':
|
|
280
298
|
return { kind: 'failed', gatedCallId, reason: card.reason };
|
|
281
299
|
case 'aborted':
|
|
282
|
-
// turn 被中断(Esc/Ctrl+C)
|
|
300
|
+
// turn 被中断(Esc/Ctrl+C):把挂着的 ask 用一次 **TOOL 级 deny** 结算掉(server
|
|
301
|
+
// `ASSISTANT-WIRE-CONTRACT.md` §4a;0.42.0 撤稿 —— 原文写的是「cancel-by-deny(contract/04
|
|
302
|
+
// §2.4)」,两条前提均已作废,逐条见 `parkResolver.ts` 同臂的撤稿段)。REF-CC-023(2026-08-02):
|
|
283
303
|
// 有界观察(observeCancelByDeny)取代裸 `.catch(()=>{})` 全吞 —— 与 askGateWire.ts 同款
|
|
284
304
|
// reason 文案,同一次事故(件3,中断事故修复批 G,2026-07-15)的两条腿现在共用同一个观察器。
|
|
285
305
|
observeCancelByDeny(bridge.decideTool({ decision: 'deny', reason: 'Interrupted by user' }, gatedCallId, undefined, pending), taskId);
|
|
@@ -497,6 +517,88 @@ export function readToolApprovalRespondAck(v) {
|
|
|
497
517
|
...(typeof o.noteRecorded === 'boolean' ? { noteRecorded: o.noteRecorded } : {}),
|
|
498
518
|
};
|
|
499
519
|
}
|
|
520
|
+
/**
|
|
521
|
+
* 从 respond 抛出来的东西上读 {@link ToolApprovalRespondRefusal}。**永不抛、永不造**。
|
|
522
|
+
*
|
|
523
|
+
* 键位口径与 {@link import('../wireErrorTriage.js').classifyTurnWireError} 同源([2055] 死键
|
|
524
|
+
* 纪律:只认活键 `errorCode`,退役的 `code` 槽不做兼容)。
|
|
525
|
+
*
|
|
526
|
+
* @returns 三位**全缺席**时返回 `undefined` —— 一个三位皆空的 refusal 对象是「有拒句」的假象。
|
|
527
|
+
*
|
|
528
|
+
* 🔴 **文本取值序**(异源复审 [medium] 修):`err.message` 非空串 > 抛出物本身是非空串 >
|
|
529
|
+
* 受保护的 `String(err)`,且**只接受**真有内容的结果 —— `[object Object]` / `null` /
|
|
530
|
+
* `undefined` 三种占位串一律当作「读不出」。
|
|
531
|
+
* 🔴 **`String(err)` 用 try 包住**:抛出物可以自带 `Symbol.toPrimitive` / `toString` 钩子并在里面
|
|
532
|
+
* 抛错。本函数的唯一调用点在 `surfaceToolApprovalFrameAndRespond` 的 catch 里,那里的契约是
|
|
533
|
+
* 「respond 失败 ⇒ 返回 unresolved」;让一个不可信的转换钩子把这条收敛路径变成 reject,
|
|
534
|
+
* 等于给注入面开了一个「让整次审批消费抛出去」的口。
|
|
535
|
+
*/
|
|
536
|
+
export function readToolApprovalRespondRefusal(err) {
|
|
537
|
+
// 🔴 **取属性本身就可能抛**(异源复审 [medium] 的形比它自己说的还宽一档,施工时实撞):
|
|
538
|
+
// 抛出物可以是一个带 `get message() { throw }` 的对象 —— 连 `typeof e.message` 这一步都会炸,
|
|
539
|
+
// 根本走不到下面的转换。所以逐位读**各自**包 try,读不动就当那一位缺席。
|
|
540
|
+
const pick = (key) => {
|
|
541
|
+
try {
|
|
542
|
+
return err?.[key];
|
|
543
|
+
}
|
|
544
|
+
catch {
|
|
545
|
+
return undefined;
|
|
546
|
+
}
|
|
547
|
+
};
|
|
548
|
+
const rawStatus = pick('status');
|
|
549
|
+
const rawCode = pick('errorCode');
|
|
550
|
+
const status = typeof rawStatus === 'number' && Number.isFinite(rawStatus) ? rawStatus : undefined;
|
|
551
|
+
const errorCode = typeof rawCode === 'string' && rawCode.length > 0 ? rawCode : undefined;
|
|
552
|
+
const message = refusalTextOf(err, pick('message'));
|
|
553
|
+
if (status === undefined && errorCode === undefined && message === undefined)
|
|
554
|
+
return undefined;
|
|
555
|
+
const rest = {
|
|
556
|
+
...(status !== undefined ? { status } : {}),
|
|
557
|
+
...(errorCode !== undefined ? { errorCode } : {}),
|
|
558
|
+
...(message !== undefined ? { message } : {}),
|
|
559
|
+
};
|
|
560
|
+
// 三选一联合的**构造侧证明**:哪一位在场就从哪一位收窄(与上面的 early-return 合起来,
|
|
561
|
+
// 「至少有一位」在编译期与运行期是同一条不变量,不是两份各自维护的承诺)。
|
|
562
|
+
if (status !== undefined)
|
|
563
|
+
return { ...rest, status };
|
|
564
|
+
if (errorCode !== undefined)
|
|
565
|
+
return { ...rest, errorCode };
|
|
566
|
+
return { ...rest, message: message };
|
|
567
|
+
}
|
|
568
|
+
/** 无信息量的占位串:`String()` 对这几类抛出物的产物,它们不是「拒句」。 */
|
|
569
|
+
const USELESS_REFUSAL_TEXTS = new Set(['[object Object]', 'null', 'undefined', '']);
|
|
570
|
+
/** 见 {@link readToolApprovalRespondRefusal} 的取值序与 try 保护。读不出 ⇒ `undefined`。 */
|
|
571
|
+
function refusalTextOf(err, message) {
|
|
572
|
+
if (typeof message === 'string' && message.length > 0)
|
|
573
|
+
return message;
|
|
574
|
+
if (typeof err === 'string' && err.length > 0)
|
|
575
|
+
return err;
|
|
576
|
+
let coerced;
|
|
577
|
+
try {
|
|
578
|
+
coerced = String(err);
|
|
579
|
+
}
|
|
580
|
+
catch {
|
|
581
|
+
// 不可信的 toString/Symbol.toPrimitive 钩子抛了 —— 诚实缺席,绝不让它逃出 catch。
|
|
582
|
+
return undefined;
|
|
583
|
+
}
|
|
584
|
+
return USELESS_REFUSAL_TEXTS.has(coerced) ? undefined : coerced;
|
|
585
|
+
}
|
|
586
|
+
/**
|
|
587
|
+
* 机读码的**日志安全形**(异源复审 [medium] 采纳,0.42.0):`errorCode` 来自 UNTRUSTED 应答体且
|
|
588
|
+
* 无长度/控制字符/换行约束,把它原样插进宿主日志 = 可伪造日志行、可注入终端控制序列、还能把一段
|
|
589
|
+
* 服务端正文当「码」漏进 sink。
|
|
590
|
+
*
|
|
591
|
+
* 🔴 **只有形合的才照写**(真机读码的字符集):`[A-Za-z0-9._:-]{1,64}`。形不合 ⇒ 只报**长度**,
|
|
592
|
+
* 正文一个字节都不进日志(与本文件 note 那条「留痕只写元数据」逐字同纪律)。
|
|
593
|
+
* 🔴 这**不影响**交还调用方的那一份:{@link ToolApprovalRespondRefusal.errorCode} 仍是**原样**
|
|
594
|
+
* ——净化的是日志这条外溢面,不是契约。
|
|
595
|
+
*/
|
|
596
|
+
const MACHINE_CODE_SHAPE = /^[A-Za-z0-9._:-]{1,64}$/;
|
|
597
|
+
function logSafeErrorCode(code) {
|
|
598
|
+
if (code === undefined)
|
|
599
|
+
return 'none';
|
|
600
|
+
return MACHINE_CODE_SHAPE.test(code) ? code : `<unprintable len=${code.length}>`;
|
|
601
|
+
}
|
|
500
602
|
/** 结构性识别流上的 tool_approval 帧(named SSE frame,payload.type === 帧名)。
|
|
501
603
|
* ⚠️ 口径更正(2026-08-08):此处原写「非 AgentEvent arm」—— SDK #185a 起这两个帧**是**
|
|
502
604
|
* `AgentEvent` 的臂了(durable 腿也回放),所以结构识别与 union 收窄两条路都成立;本函数仍按
|
|
@@ -732,9 +834,33 @@ export async function surfaceToolApprovalFrameAndRespond(frame, respond, streamA
|
|
|
732
834
|
// 按表拒(rule_not_offered),这道核对把坏卡口实现挡在包边界并留痕(丢键不丢决断:人的
|
|
733
835
|
// allow/deny 已经落定,一条对不上的规则文本不该反过来污染整次回决)。deny 恒不带(server
|
|
734
836
|
// 宽收后忽略,发了只留「像是记住了」的错觉)。
|
|
837
|
+
//
|
|
838
|
+
// ── #225 编辑臂(0.42.0,[5071] wire 形):自由文本时**表核让位**,但能力位缺席就别发 ────────
|
|
839
|
+
// 🔴 让位的判据锚在**出身声明** `card.persistRuleEdited === true`,不锚「文本恰好不在表里」——
|
|
840
|
+
// 后者会让一个坏卡口实现(拼错了候选原文)自动升级成「自由文本臂」,把这道核对彻底架空。
|
|
841
|
+
// 🔴 让位的**理由**:表核的全部意义是「绝不把没被 offer 过的文本送进规则店」,而编辑臂的定义
|
|
842
|
+
// 就是人自己打的文本 —— 对它做表核,等于把这条 wire 上刚开出来的能力在包边界原样关掉
|
|
843
|
+
// ([5111] 探针实测的闭集三处之②:自由文本恒丢键)。真判官在引擎侧同一函数体
|
|
844
|
+
// (core `confirmRuleApproval`),不在这里。
|
|
845
|
+
// 🔴 能力位闸(`lane.respondFreeFormRulesCapable`)与 note 那条**同一条纪律**:位缺席就别发。
|
|
846
|
+
// 编辑臂在老 server 上会诚实降级 `rule_not_offered`(不是静默 200),但那仍然是一次
|
|
847
|
+
// 「人以为存上了、其实拿回一个拒绝」的往返 —— 能提前知道这台不供,就别把人的编辑送出去。
|
|
735
848
|
let persistRule;
|
|
849
|
+
let persistRuleEdited;
|
|
736
850
|
if (card.kind === 'allow' && typeof card.persistRule === 'string' && card.persistRule !== '' && decision !== 'deny') {
|
|
737
|
-
|
|
851
|
+
const edited = card.persistRuleEdited === true;
|
|
852
|
+
if (edited) {
|
|
853
|
+
if (lane?.respondFreeFormRulesCapable === true) {
|
|
854
|
+
persistRule = card.persistRule;
|
|
855
|
+
persistRuleEdited = true;
|
|
856
|
+
}
|
|
857
|
+
else {
|
|
858
|
+
hostLog('debug', `liveToolApprovalWire: DROPPING edited persistRule for ${frame.approvalId} (len=${card.persistRule.length}) — the ` +
|
|
859
|
+
'respondFreeFormRules capability is not confirmed for this engine, so the free-form arm is not sent at all ' +
|
|
860
|
+
'(the decision itself still goes through unchanged)');
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
else if (ruleSuggestions?.some(sug => sug.rule === card.persistRule) === true) {
|
|
738
864
|
persistRule = card.persistRule;
|
|
739
865
|
}
|
|
740
866
|
else {
|
|
@@ -745,6 +871,7 @@ export async function surfaceToolApprovalFrameAndRespond(frame, respond, streamA
|
|
|
745
871
|
...(signal && !signal.aborted ? { signal } : {}),
|
|
746
872
|
...(card.kind === 'allow' && card.updatedInput !== undefined ? { updatedInput: card.updatedInput } : {}),
|
|
747
873
|
...(persistRule !== undefined ? { persistRule } : {}),
|
|
874
|
+
...(persistRuleEdited !== undefined ? { persistRuleEdited } : {}),
|
|
748
875
|
...(note !== undefined ? { note } : {}),
|
|
749
876
|
});
|
|
750
877
|
// 🔴 相关性门(对抗复审二轮):ack 必须是**这一次**审批的回执 —— id 与决断词都要对上。
|
|
@@ -783,7 +910,17 @@ export async function surfaceToolApprovalFrameAndRespond(frame, respond, streamA
|
|
|
783
910
|
return ack !== undefined ? { decision, ack } : { decision };
|
|
784
911
|
}
|
|
785
912
|
catch (e) {
|
|
786
|
-
|
|
787
|
-
|
|
913
|
+
// #225 件5(0.42.0):**原文交还调用方**,不再只留一行 debug 就把它吞掉。
|
|
914
|
+
// 🔴 `decision:'unresolved'` 逐字保留(respond 没落定这件事没变);新增的只是 `respondRefusal`
|
|
915
|
+
// 这一格诊断面 —— 宿主拿它去渲「你的规则被拒了,引擎说:…」,而不是把一次响亮 400 呈成
|
|
916
|
+
// 「什么也没发生」([5111] 探针实测的闭集三处之③)。
|
|
917
|
+
// 🔴 留痕仍只写**元数据**(状态码 + 码 + 长度),正文不进日志:它是 UNTRUSTED 的应答体文本,
|
|
918
|
+
// 回喂宿主 sink 零诊断增量、还是外溢面(与 note 那条同一条纪律)。
|
|
919
|
+
const respondRefusal = readToolApprovalRespondRefusal(e);
|
|
920
|
+
hostLog('debug', `liveToolApprovalWire: respond(${decision}) failed for ${frame.approvalId} ` +
|
|
921
|
+
`(status=${respondRefusal?.status ?? 'none'} errorCode=${logSafeErrorCode(respondRefusal?.errorCode)} ` +
|
|
922
|
+
`messageLen=${respondRefusal?.message?.length ?? 0}) — engine self-settles (TTL/abort); ` +
|
|
923
|
+
'the refusal text is handed back on outcome.respondRefusal for the host to surface');
|
|
924
|
+
return respondRefusal !== undefined ? { decision: 'unresolved', respondRefusal } : { decision: 'unresolved' };
|
|
788
925
|
}
|
|
789
926
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -224,6 +224,7 @@ export { toolEndOutputText, ENGINE_ABORT_TOOL_RESULT } from './hitl/frameRouter.
|
|
|
224
224
|
export { isAskTool } from './hitl/frameRouter.js';
|
|
225
225
|
export * from './hitl/hitlHostSurface.js';
|
|
226
226
|
export * from './hitl/toolApprovalWire.js';
|
|
227
|
+
export * from './hitl/editedRuleTextPrecheck.js';
|
|
227
228
|
export * from './hitl/askGateWire.js';
|
|
228
229
|
export * from './hitl/planReviewWire.js';
|
|
229
230
|
export * from './hitl/gateIdentity.js';
|
package/dist/index.js
CHANGED
|
@@ -326,6 +326,13 @@ export { toolEndOutputText, ENGINE_ABORT_TOOL_RESULT } from './hitl/frameRouter.
|
|
|
326
326
|
export { isAskTool } from './hitl/frameRouter.js';
|
|
327
327
|
export * from './hitl/hitlHostSurface.js';
|
|
328
328
|
export * from './hitl/toolApprovalWire.js';
|
|
329
|
+
// ── #225 编辑臂预检的转出口([5076] 自领件,0.42.0)————————————————————————————————————
|
|
330
|
+
// core 5.57.0 导出了 `precheckEditedRuleText`(与 `confirmRuleApproval` **同一函数体**的 text×command
|
|
331
|
+
// 三步门)。本包**不能**做 value 级 re-export —— `@sema-agent/core` 的 barrel 值级拉 `node:crypto`/
|
|
332
|
+
// `node:fs`/`node:path`,而 portability 门的 `EXPECTED_PACKAGES_INDEX` 是等值门 + ③ 段拿 esbuild
|
|
333
|
+
// `--platform=browser` 真打包,一条这样的边会把整台引擎焊进 web/desktop 的产物。
|
|
334
|
+
// ⇒ 转出口取「类型面 + 注入口 + 诚实缺席读口」三件(逐条理由见模块头注)。
|
|
335
|
+
export * from './hitl/editedRuleTextPrecheck.js';
|
|
329
336
|
export * from './hitl/askGateWire.js';
|
|
330
337
|
export * from './hitl/planReviewWire.js';
|
|
331
338
|
// ── A-028.2/.3(#244 族A,2026-08-12):决断卡链的键词汇/呈现台账/归属判据三件 ————————————
|
|
@@ -234,10 +234,41 @@ export async function loadCatalogWithSources(opts) {
|
|
|
234
234
|
const timeoutMs = opts?.timeoutMs ?? DEFAULT_CATALOG_TIMEOUT_MS;
|
|
235
235
|
const attempts = [];
|
|
236
236
|
const warnings = [];
|
|
237
|
-
let hit = null;
|
|
238
237
|
let lastDialed;
|
|
238
|
+
// ── 委托:载荷判决 / 三层合并 / 来源标注全在 resolveModelCatalog ──────────────────────────
|
|
239
|
+
// 传给它的 `fetchJson` 只是「把已经拿到的这一份交出去」的闭包 —— 候选链与传输门是本文件的活,
|
|
240
|
+
// 校验与合并是它的活,两边不重叠也不互相重写。
|
|
241
|
+
const resolveWith = async (p) => {
|
|
242
|
+
const onlineUrl = p?.url ?? lastDialed ?? sources[0];
|
|
243
|
+
const resolveOpts = {
|
|
244
|
+
...(onlineUrl !== undefined ? { onlineUrl } : {}),
|
|
245
|
+
fetchJson: async () => {
|
|
246
|
+
if (p === null)
|
|
247
|
+
throw new Error(`all ${sources.length} catalog source(s) failed; see attempts[]`);
|
|
248
|
+
return p.doc;
|
|
249
|
+
},
|
|
250
|
+
...(opts?.overrides !== undefined ? { overrides: opts.overrides } : {}),
|
|
251
|
+
...(opts?.nowMs !== undefined ? { nowMs: opts.nowMs } : {}),
|
|
252
|
+
};
|
|
253
|
+
return resolveModelCatalog(resolveOpts);
|
|
254
|
+
};
|
|
255
|
+
// ══ 🔴 [4982] 候选② 的**同形存量**(0.42.0 异源复审 [high] 采纳)══════════════════════════
|
|
256
|
+
//
|
|
257
|
+
// 病根与「缓存腿从没被问过」是**同一条**:候选链的停止判据锚在**传输层**(`hit !== null`)而不是
|
|
258
|
+
// **内容判决**。后果比缓存那一格更重 —— 首源发得下来但内容不合格时,`break` 让**后续健康源
|
|
259
|
+
// 永远不会被请求**:一次坏发布、或一个 CDN 的半更新窗,就能把整条后备链压掉。
|
|
260
|
+
// ⇒ 停止判据换成 `accepted !== null`(内容判决),逐源「传输 → 旁签 → **内容校验**」,
|
|
261
|
+
// 只有真被接受的那一份才停链。
|
|
262
|
+
//
|
|
263
|
+
// 🔴 `rejected` 是**诚实兜底**不是备选源:全链都被拒时,拿**第一份被拒的**去出结果,
|
|
264
|
+
// 这样 `online.reason` 说的是真话(`schema-version-unsupported`),而不是退成
|
|
265
|
+
// 「一个源都没连上」(`fetch-failed`)—— 那会把「内容坏了」谎报成「网络坏了」。
|
|
266
|
+
// 它**绝不**参与「用哪份目录」的竞争:被拒就是被拒,`online.ok === false` 时合并结果本就
|
|
267
|
+
// 回落内置表。
|
|
268
|
+
let accepted = null;
|
|
269
|
+
let rejected = null;
|
|
239
270
|
for (const src of sources) {
|
|
240
|
-
if (
|
|
271
|
+
if (accepted !== null)
|
|
241
272
|
break;
|
|
242
273
|
const got = await guardedGet(src, allowedHosts, fetchImpl, timeoutMs);
|
|
243
274
|
if (got.outcome !== 'insecure-url' && got.outcome !== 'host-not-allowed')
|
|
@@ -289,7 +320,17 @@ export async function loadCatalogWithSources(opts) {
|
|
|
289
320
|
warnings.push(`catalog sha256 sidecar unavailable for ${finalUrl} (${shaGot.outcome}) — payload accepted without the checksum`);
|
|
290
321
|
}
|
|
291
322
|
attempts.push({ url: src, outcome: 'ok', ...(got.status !== undefined ? { status: got.status } : {}), shaChecked });
|
|
292
|
-
|
|
323
|
+
const candidate = { url: finalUrl, doc, raw: got.text, shaChecked };
|
|
324
|
+
// 内容判决就在这里做 —— 传输成功**不等于**这份目录能用。
|
|
325
|
+
const verdict = await resolveWith(candidate);
|
|
326
|
+
if (verdict.online.ok) {
|
|
327
|
+
accepted = { hit: candidate, base: verdict };
|
|
328
|
+
continue;
|
|
329
|
+
}
|
|
330
|
+
if (rejected === null)
|
|
331
|
+
rejected = { hit: candidate, base: verdict };
|
|
332
|
+
warnings.push(`catalog source ${finalUrl} was fetched but REJECTED by content validation ` +
|
|
333
|
+
`(${verdict.online.reason ?? 'unknown'}) — trying the next source`);
|
|
293
334
|
}
|
|
294
335
|
// ── 缓存腿(口缺席 ⇒ 整条不启用,cacheHit 键缺席)────────────────────────────────────────
|
|
295
336
|
const cache = opts?.cache;
|
|
@@ -297,7 +338,15 @@ export async function loadCatalogWithSources(opts) {
|
|
|
297
338
|
let cacheHit = cache !== undefined ? false : undefined;
|
|
298
339
|
let cacheStale;
|
|
299
340
|
let cachedSourceUrl;
|
|
300
|
-
|
|
341
|
+
/**
|
|
342
|
+
* 读一次缓存信封 → `SourceHit`(+ 陈旧判)。**永不抛**;坏形/缺件记 warn 返 null。
|
|
343
|
+
* 🔴 [4982] 候选②(0.42.0)提出来的:此前这段是内联的,只有「传输全败」那一条路走得到它。
|
|
344
|
+
* 现在有**两个**调用点(传输全败 / 传输 ok 但内容被拒),提成一处才不会有两份读法。
|
|
345
|
+
* `alreadyWarned` = 第二次调用时不重复堆同一批 warn(同一份坏缓存说两遍是噪音)。
|
|
346
|
+
*/
|
|
347
|
+
const readCachedHit = async (alreadyWarned) => {
|
|
348
|
+
if (cache === undefined || cachePath === undefined)
|
|
349
|
+
return null;
|
|
301
350
|
let text = null;
|
|
302
351
|
let readError;
|
|
303
352
|
try {
|
|
@@ -306,52 +355,109 @@ export async function loadCatalogWithSources(opts) {
|
|
|
306
355
|
catch (e) {
|
|
307
356
|
readError = shortError(e);
|
|
308
357
|
}
|
|
309
|
-
if (readError !== undefined)
|
|
358
|
+
if (readError !== undefined && !alreadyWarned)
|
|
310
359
|
warnings.push(`catalog cache read failed: ${readError}`);
|
|
311
|
-
if (typeof text
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
360
|
+
if (typeof text !== 'string' || text.length === 0)
|
|
361
|
+
return null;
|
|
362
|
+
let env;
|
|
363
|
+
let envError;
|
|
364
|
+
try {
|
|
365
|
+
env = JSON.parse(text);
|
|
366
|
+
}
|
|
367
|
+
catch (e) {
|
|
368
|
+
envError = shortError(e);
|
|
369
|
+
}
|
|
370
|
+
if (envError !== undefined) {
|
|
371
|
+
if (!alreadyWarned)
|
|
321
372
|
warnings.push(`catalog cache is not parsable JSON (ignored): ${envError}`);
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
cacheHit = true;
|
|
329
|
-
cachedSourceUrl = url;
|
|
330
|
-
if (opts?.nowMs !== undefined && typeof env?.fetchedAt === 'number') {
|
|
331
|
-
cacheStale = opts.nowMs - env.fetchedAt > CATALOG_CACHE_STALE_MS;
|
|
332
|
-
}
|
|
333
|
-
}
|
|
334
|
-
else if (envError === undefined) {
|
|
373
|
+
return null;
|
|
374
|
+
}
|
|
375
|
+
const doc = env?.catalog;
|
|
376
|
+
const url = env?.sourceUrl;
|
|
377
|
+
if (doc === undefined || typeof url !== 'string' || url.length === 0) {
|
|
378
|
+
if (!alreadyWarned)
|
|
335
379
|
warnings.push('catalog cache envelope missing sourceUrl/catalog (ignored)');
|
|
336
|
-
|
|
380
|
+
return null;
|
|
337
381
|
}
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
if (payload === null)
|
|
348
|
-
throw new Error(`all ${sources.length} catalog source(s) failed; see attempts[]`);
|
|
349
|
-
return payload.doc;
|
|
350
|
-
},
|
|
351
|
-
...(opts?.overrides !== undefined ? { overrides: opts.overrides } : {}),
|
|
352
|
-
...(opts?.nowMs !== undefined ? { nowMs: opts.nowMs } : {}),
|
|
382
|
+
// 缓存里那份**就是**当初的线上载荷 —— 照样过 validateOnlineCatalog(下面同一条路),
|
|
383
|
+
// 不给它开后门:一份当年合法、今天已超区间的文档必须照样被拒。
|
|
384
|
+
const stale = opts?.nowMs !== undefined && typeof env?.fetchedAt === 'number'
|
|
385
|
+
? opts.nowMs - env.fetchedAt > CATALOG_CACHE_STALE_MS
|
|
386
|
+
: undefined;
|
|
387
|
+
return {
|
|
388
|
+
hit: { url, doc, raw: text, shaChecked: false },
|
|
389
|
+
...(stale !== undefined ? { stale } : {}),
|
|
390
|
+
};
|
|
353
391
|
};
|
|
354
|
-
|
|
392
|
+
// ══ 缓存腿:**内容判决**缺席时才问它(传输全败 与 内容被拒 两条路共用这一格)═══════════════
|
|
393
|
+
//
|
|
394
|
+
// 🔴 [4982] 候选②(P0-KPI,0.42.0):此前这条腿的门槛是 `hit === null`,而 `hit` 只记录**传输层**
|
|
395
|
+
// 结果(200 + 旁签过就算 hit)。于是「线上目录发得下来、但内容被 validateOnlineCatalog 拒」
|
|
396
|
+
// 这条缝里,loader 认为「有 hit」⇒ **永不查缓存**(即便缓存里躺着一份上次真正被接受过的
|
|
397
|
+
// 合法目录),用户只拿到内置精简表;而 `cacheHit:false` 这个**诚实位反过来说谎** —— 它读起来
|
|
398
|
+
// 是「没有缓存」,真相是「有缓存,但从头到尾没人问过它」。
|
|
399
|
+
// 判据锚在**真正决定结果的量**上:决定「用不用得上目录」的是**内容判决**(`accepted`),
|
|
400
|
+
// 不是传输判决(`hit !== null`)。异源复审 [high] 之后,候选链的停止判据也换成了同一个量
|
|
401
|
+
// (见上方 `accepted` 头注)—— 两处同根同治,不留同形存量。
|
|
402
|
+
// 🔴 缓存那份**不开后门**:它照样过同一个 `resolveModelCatalog`。也被拒时**不拿它顶替**
|
|
403
|
+
// (一份同样不合格的文档顶替不了什么),`cacheHit` 诚实留在 `false` —— 本位的语义是
|
|
404
|
+
// 「这次的目录**是从缓存来的**」,不是「问过缓存」;抬成 true 会让「缓存救场了」与
|
|
405
|
+
// 「缓存也坏了」在读数上不可分。「问过但没用上」那件事由 warning 说。
|
|
406
|
+
/**
|
|
407
|
+
* 线上腿的**归因汇总**(异源复审 [medium] 采纳,0.42.0)。
|
|
408
|
+
*
|
|
409
|
+
* 🔴 修的是一句会指错方向的话:此前只要**任一**源产生 `rejected`,回落 warning 就写
|
|
410
|
+
* 「every online catalog source was fetched but REJECTED」。而其余源完全可能是网络失败、
|
|
411
|
+
* 畸形 JSON、sha 不匹配、或被域白名单挡下 —— 把一次 CDN/网络事故说成「目录发布损坏」,
|
|
412
|
+
* 运维会照着错误方向查,恢复时间平白变长。
|
|
413
|
+
* ⇒ 按 `attempts` 的**真实计数**分两类措辞:全部进过内容校验且全被拒 ⇒ 说 "every … REJECTED";
|
|
414
|
+
* 混合故障 ⇒ 逐类报数,并保留内容拒绝的 reason。
|
|
415
|
+
*/
|
|
416
|
+
const onlineLegSummary = () => {
|
|
417
|
+
const contentRejected = attempts.filter((a) => a.outcome === 'ok').length;
|
|
418
|
+
const otherFailures = attempts.length - contentRejected;
|
|
419
|
+
const reason = rejected?.base.online.reason ?? 'unknown';
|
|
420
|
+
if (otherFailures === 0 && contentRejected > 0) {
|
|
421
|
+
return `every online catalog source (${contentRejected}) was fetched but REJECTED by content validation (${reason})`;
|
|
422
|
+
}
|
|
423
|
+
return (`the online catalog leg failed in more than one way: ${contentRejected} source(s) were fetched but ` +
|
|
424
|
+
`REJECTED by content validation (${reason}), ${otherFailures} source(s) failed before content ` +
|
|
425
|
+
'validation (transport / integrity / policy — see attempts[])');
|
|
426
|
+
};
|
|
427
|
+
let cached = null;
|
|
428
|
+
if (accepted === null)
|
|
429
|
+
cached = await readCachedHit(false);
|
|
430
|
+
let payload;
|
|
431
|
+
let base;
|
|
432
|
+
if (accepted !== null) {
|
|
433
|
+
payload = accepted.hit;
|
|
434
|
+
base = accepted.base;
|
|
435
|
+
}
|
|
436
|
+
else if (cached !== null) {
|
|
437
|
+
const fromCache = await resolveWith(cached.hit);
|
|
438
|
+
if (fromCache.online.ok) {
|
|
439
|
+
payload = cached.hit;
|
|
440
|
+
base = fromCache;
|
|
441
|
+
cacheHit = true;
|
|
442
|
+
cachedSourceUrl = cached.hit.url;
|
|
443
|
+
if (cached.stale !== undefined)
|
|
444
|
+
cacheStale = cached.stale;
|
|
445
|
+
if (rejected !== null)
|
|
446
|
+
warnings.push(`${onlineLegSummary()} — fell back to the cached catalog (${cached.hit.url})`);
|
|
447
|
+
}
|
|
448
|
+
else {
|
|
449
|
+
// 缓存那份也不合格 ⇒ 诚实回落:`online` 报的仍是**线上腿自己**的判词(有被拒的那一份就用
|
|
450
|
+
// 它的,一个都没连上就是 fetch-failed),绝不拿缓存的判词冒充线上的。
|
|
451
|
+
payload = rejected?.hit ?? null;
|
|
452
|
+
base = rejected?.base ?? (await resolveWith(null));
|
|
453
|
+
warnings.push(`${onlineLegSummary()}, and the cached catalog was rejected by content validation too — ` +
|
|
454
|
+
'falling back to the built-in table');
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
else {
|
|
458
|
+
payload = rejected?.hit ?? null;
|
|
459
|
+
base = rejected?.base ?? (await resolveWith(null));
|
|
460
|
+
}
|
|
355
461
|
// 线上腿真的被接受了才写缓存(被 validateOnlineCatalog 拒掉的载荷绝不进缓存 ——
|
|
356
462
|
// 否则下一次断网时我们会把一份已知不合格的文档当兜底)。
|
|
357
463
|
if (base.online.ok && payload !== null && cacheHit !== true && cache !== undefined && cachePath !== undefined) {
|