@trim21/personal-pi-extensions 0.1.628 → 0.1.630
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/package.json +1 -1
- package/src/bwrap/approval-rules.ts +104 -32
- package/src/bwrap/approval-suggest.ts +2 -16
- package/src/bwrap/runtime.ts +53 -28
- package/src/lib/lsp/client.ts +82 -50
package/package.json
CHANGED
|
@@ -3,9 +3,11 @@
|
|
|
3
3
|
* 用 tree-sitter 解析命令(含嵌套 `$(...)`),对每条命令的原文做通配
|
|
4
4
|
* 匹配,对 allow/deny 规则求值。规则匹配不经过任何命令归一——BashArity
|
|
5
5
|
* 归一模式只用于 "allow forever" 的建议规则(见 approval-suggest.ts)。
|
|
6
|
-
* 接入 bwrap 的 `dangerouslyDisableSandbox`
|
|
7
|
-
*
|
|
8
|
-
*
|
|
6
|
+
* 接入 bwrap 的 `dangerouslyDisableSandbox` 审批:审批规则集
|
|
7
|
+
* (createApprovalRuleSet)独占「自动放行 / 自动拒绝 / 交人审」的判定、
|
|
8
|
+
* allow 覆盖查询、待允许模式与规则追加,调用方只描述用户动作。
|
|
9
|
+
* 文件输出重定向(`>` / `>>` / `&>` 等)不会因命令规则自动放行,
|
|
10
|
+
* 避免 `echo *` 把 `echo '' > file` 带过。
|
|
9
11
|
*
|
|
10
12
|
* 参考实现:
|
|
11
13
|
* - opencode packages/core/src/util/wildcard.ts(通配匹配)
|
|
@@ -209,27 +211,17 @@ export function matchRule(input: string, pattern: string): boolean {
|
|
|
209
211
|
return new RegExp(`^${escaped}$`, "s").test(input);
|
|
210
212
|
}
|
|
211
213
|
|
|
212
|
-
// ──
|
|
214
|
+
// ── 命令树展平 ──────────────────────────────────────────────────────────────
|
|
213
215
|
|
|
214
216
|
/**
|
|
215
|
-
*
|
|
216
|
-
* -
|
|
217
|
-
*
|
|
218
|
-
* - allow 需全量:所有命令都命中 allow 规则才整体放行,否则返回
|
|
219
|
-
* undefined(有命令未命中规则,交给人审),避免未允许的命令被同链放行带过。
|
|
220
|
-
* - 匹配输入是命令原文(tree-sitter command 节点 text,含嵌套逐条展开),
|
|
221
|
-
* 对齐 opencode shell.ts 的 patterns;通配规则按字面写,`--` 与普通
|
|
222
|
-
* token 无区别。
|
|
223
|
-
* 规则内后写优先(findLast,对齐 opencode PermissionV2)。
|
|
217
|
+
* 展平命令树:每条命令本身 + 其所有嵌套命令(`$(...)` 内的),父在子前。
|
|
218
|
+
* 求值与建议模式生成(approval-suggest.ts)共用——后者依赖本模块,
|
|
219
|
+
* 反向 import 会成环,所以展平放在这里。
|
|
224
220
|
*/
|
|
225
|
-
export
|
|
226
|
-
|
|
227
|
-
rules: readonly ApprovalRule[],
|
|
228
|
-
): Promise<ApprovalAction | undefined> {
|
|
229
|
-
const parsed = await parseBashCommands(command);
|
|
230
|
-
const raws: string[] = [];
|
|
221
|
+
export function flattenCommands(parsed: ParsedBash): BashCommand[] {
|
|
222
|
+
const flat: BashCommand[] = [];
|
|
231
223
|
const visit = (cmd: BashCommand) => {
|
|
232
|
-
|
|
224
|
+
flat.push(cmd);
|
|
233
225
|
for (const nested of cmd.nested) {
|
|
234
226
|
visit(nested);
|
|
235
227
|
}
|
|
@@ -237,21 +229,101 @@ export async function evaluateBashApproval(
|
|
|
237
229
|
for (const cmd of parsed.commands) {
|
|
238
230
|
visit(cmd);
|
|
239
231
|
}
|
|
240
|
-
|
|
241
|
-
|
|
232
|
+
return flat;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// ── 审批规则集 ──────────────────────────────────────────────────────────────
|
|
236
|
+
|
|
237
|
+
export interface ApprovalRuleSetOptions {
|
|
238
|
+
/** 当前规则(读取时取最新,追加后无需重建规则集)。规则数组即优先级:靠后优先。 */
|
|
239
|
+
rules: () => readonly ApprovalRule[];
|
|
240
|
+
/** 命令 → 候选模式(BashArity);注入以免与 approval-suggest.ts 成环。 */
|
|
241
|
+
suggestPatterns: (command: string) => Promise<readonly string[]>;
|
|
242
|
+
/** 追加 allow 规则并持久化;resolve 后 rules() 必须能看到它们,抛错即视为未追加。 */
|
|
243
|
+
persist: (rules: readonly ApprovalRule[]) => Promise<void>;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
export interface ApprovalRuleSet {
|
|
247
|
+
/**
|
|
248
|
+
* 对命令(含所有嵌套命令)求值:deny 优先 / allow 需全量 / 含文件输出
|
|
249
|
+
* 重定向不自动放行。返回 undefined 表示未命中规则,交人工审批。
|
|
250
|
+
*/
|
|
251
|
+
evaluate(command: string): Promise<ApprovalAction | undefined>;
|
|
252
|
+
/**
|
|
253
|
+
* 该字符串(命令原文或候选模式)是否被最后一条匹配的规则允许。
|
|
254
|
+
* 只回答 allow 覆盖,不考虑 deny 与重定向——放行判定一律用 evaluate。
|
|
255
|
+
*/
|
|
256
|
+
isAllowed(pattern: string): boolean;
|
|
257
|
+
/** 命令的候选模式(去重)中尚未被 allow 规则覆盖的那些;审批子菜单的唯一来源。 */
|
|
258
|
+
pendingPatterns(command: string): Promise<string[]>;
|
|
259
|
+
/** 追加 allow 规则并持久化;persist 成功后立即生效。 */
|
|
260
|
+
addAllowRules(patterns: readonly string[]): Promise<void>;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* 审批规则集:命令自动判定、allow 覆盖查询、待允许模式与规则追加的唯一 owner。
|
|
265
|
+
* 规则数组的顺序即优先级:靠后优先(项目规则经 deepMerge 排在全局规则之后)。
|
|
266
|
+
* 三条协作方由调用方注入:规则 getter(reload 或追加后无需重建规则集)、
|
|
267
|
+
* 候选模式生成、持久化副作用(文件读写留在调用方)。
|
|
268
|
+
*/
|
|
269
|
+
export function createApprovalRuleSet(options: ApprovalRuleSetOptions): ApprovalRuleSet {
|
|
270
|
+
const { rules, suggestPatterns, persist } = options;
|
|
271
|
+
|
|
272
|
+
/** 最后一条匹配 input 的规则;数组靠后者优先(对齐 opencode PermissionV2)。 */
|
|
273
|
+
function lastMatch(input: string): ApprovalRule | undefined {
|
|
274
|
+
return rules().findLast((rule) => matchRule(input, rule.pattern));
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function isAllowed(pattern: string): boolean {
|
|
278
|
+
return lastMatch(pattern)?.action === "allow";
|
|
242
279
|
}
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
const
|
|
246
|
-
|
|
247
|
-
|
|
280
|
+
|
|
281
|
+
async function evaluate(command: string): Promise<ApprovalAction | undefined> {
|
|
282
|
+
const parsed = await parseBashCommands(command);
|
|
283
|
+
// 匹配输入是命令原文(tree-sitter command 节点 text,含嵌套逐条展开),
|
|
284
|
+
// 对齐 opencode shell.ts 的 patterns;通配规则按字面写,`--` 与普通 token 无区别。
|
|
285
|
+
const raws = flattenCommands(parsed).map((cmd) => cmd.raw);
|
|
286
|
+
if (raws.length === 0) {
|
|
287
|
+
return; // 空命令或解析失败:没有可匹配的命令,交人审
|
|
288
|
+
}
|
|
289
|
+
let allowed = 0;
|
|
290
|
+
for (const raw of raws) {
|
|
291
|
+
const rule = lastMatch(raw);
|
|
292
|
+
if (rule?.action === "deny") {
|
|
293
|
+
return "deny";
|
|
294
|
+
}
|
|
295
|
+
if (rule?.action === "allow") {
|
|
296
|
+
allowed++;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
// 文件输出重定向不自动放行:即使命令规则全匹配也交人审
|
|
300
|
+
if (parsed.hasFileOutputRedirect) {
|
|
301
|
+
return;
|
|
248
302
|
}
|
|
249
|
-
|
|
250
|
-
|
|
303
|
+
// allow 需全量:有命令未命中规则时交人审,避免未允许的命令被同链放行带过
|
|
304
|
+
return allowed === raws.length ? "allow" : undefined;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
async function pendingPatterns(command: string): Promise<string[]> {
|
|
308
|
+
const pending: string[] = [];
|
|
309
|
+
const seen = new Set<string>();
|
|
310
|
+
for (const pattern of await suggestPatterns(command)) {
|
|
311
|
+
if (seen.has(pattern) || isAllowed(pattern)) {
|
|
312
|
+
continue;
|
|
313
|
+
}
|
|
314
|
+
seen.add(pattern);
|
|
315
|
+
pending.push(pattern);
|
|
251
316
|
}
|
|
317
|
+
return pending;
|
|
252
318
|
}
|
|
253
|
-
|
|
254
|
-
|
|
319
|
+
|
|
320
|
+
async function addAllowRules(patterns: readonly string[]): Promise<void> {
|
|
321
|
+
const additions: ApprovalRule[] = patterns.map((pattern) => ({ action: "allow", pattern }));
|
|
322
|
+
if (additions.length === 0) {
|
|
323
|
+
return; // 解析失败或未勾选:本次处理,不写配置
|
|
324
|
+
}
|
|
325
|
+
await persist(additions);
|
|
255
326
|
}
|
|
256
|
-
|
|
327
|
+
|
|
328
|
+
return { evaluate, isAllowed, pendingPatterns, addAllowRules };
|
|
257
329
|
}
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
import { readFileSync } from "node:fs";
|
|
10
10
|
|
|
11
|
-
import { type BashCommand, parseBashCommands } from "./approval-rules.js";
|
|
11
|
+
import { type BashCommand, flattenCommands, parseBashCommands } from "./approval-rules.js";
|
|
12
12
|
|
|
13
13
|
/**
|
|
14
14
|
* 命令前缀 → 定义该命令的 token 数。`git checkout main` → `git` 的 arity 2,
|
|
@@ -41,21 +41,7 @@ export function commandPattern(command: BashCommand): string {
|
|
|
41
41
|
}
|
|
42
42
|
|
|
43
43
|
/** 命令(含所有嵌套命令)的 BashArity 建议模式列表(命令替换里的命令也展开)。 */
|
|
44
|
-
function patternsFromCommands(commands: BashCommand[]): string[] {
|
|
45
|
-
const flat: string[] = [];
|
|
46
|
-
const visit = (cmd: BashCommand) => {
|
|
47
|
-
flat.push(commandPattern(cmd));
|
|
48
|
-
for (const nested of cmd.nested) {
|
|
49
|
-
visit(nested);
|
|
50
|
-
}
|
|
51
|
-
};
|
|
52
|
-
for (const cmd of commands) {
|
|
53
|
-
visit(cmd);
|
|
54
|
-
}
|
|
55
|
-
return flat;
|
|
56
|
-
}
|
|
57
|
-
|
|
58
44
|
export async function commandPatternsFor(command: string): Promise<string[]> {
|
|
59
45
|
const parsed = await parseBashCommands(command);
|
|
60
|
-
return
|
|
46
|
+
return flattenCommands(parsed).map((cmd) => commandPattern(cmd));
|
|
61
47
|
}
|
package/src/bwrap/runtime.ts
CHANGED
|
@@ -14,17 +14,24 @@ import {
|
|
|
14
14
|
} from "@earendil-works/pi-coding-agent";
|
|
15
15
|
import { throttle } from "lodash-es";
|
|
16
16
|
import { type TObject, Type } from "typebox";
|
|
17
|
+
import { Value } from "typebox/value";
|
|
17
18
|
|
|
18
19
|
import { type CommandSpec, parseCommand } from "../lib/cli.js";
|
|
19
20
|
import { fenceCodeBlock } from "../lib/markdown.js";
|
|
20
21
|
import { isUnknownArray } from "../lib/narrow.js";
|
|
22
|
+
import { parseWithSchema } from "../lib/parse-with-schema.js";
|
|
21
23
|
import { formatDisplayPath } from "../lib/path.js";
|
|
22
24
|
import { createRequestPolicy, type RequestPolicy } from "../lib/request-policy.js";
|
|
23
25
|
import { type SelectAction, selectMultiple, selectWithOptionalInput } from "../lib/ui.js";
|
|
24
|
-
import {
|
|
26
|
+
import {
|
|
27
|
+
type ApprovalRule,
|
|
28
|
+
type ApprovalRuleSet,
|
|
29
|
+
createApprovalRuleSet,
|
|
30
|
+
} from "./approval-rules.js";
|
|
25
31
|
import { commandPatternsFor } from "./approval-suggest.js";
|
|
26
32
|
import {
|
|
27
33
|
type BwrapConfig,
|
|
34
|
+
bwrapConfigFileSchema,
|
|
28
35
|
findBwrap,
|
|
29
36
|
findMihomo,
|
|
30
37
|
type FsMode,
|
|
@@ -381,6 +388,20 @@ export function sandboxHintBlock(hint: string | undefined): { type: "text"; text
|
|
|
381
388
|
return hint === undefined ? [] : [{ type: "text", text: hint }];
|
|
382
389
|
}
|
|
383
390
|
|
|
391
|
+
/**
|
|
392
|
+
* 项目配置文件形状非法时的错误:消息带文件路径与字段位置,cause 保留校验错误
|
|
393
|
+
* (Value.Check 只回答是否合法,详情用同一 schema 解析一次取到)。
|
|
394
|
+
*/
|
|
395
|
+
function invalidConfigError(path: string, raw: unknown): Error {
|
|
396
|
+
try {
|
|
397
|
+
parseWithSchema(bwrapConfigFileSchema, raw);
|
|
398
|
+
} catch (error) {
|
|
399
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
400
|
+
return new Error(`Invalid bwrap configuration at ${path}: ${detail}`, { cause: error });
|
|
401
|
+
}
|
|
402
|
+
return new Error(`Invalid bwrap configuration at ${path}: schema validation failed`);
|
|
403
|
+
}
|
|
404
|
+
|
|
384
405
|
export class BwrapRuntime {
|
|
385
406
|
private resolved: ResolvedBwrap | undefined;
|
|
386
407
|
private bwrapUnavailable = false;
|
|
@@ -539,8 +560,14 @@ export class BwrapRuntime {
|
|
|
539
560
|
) {
|
|
540
561
|
throw new Error(UNSANDBOXED_DENIED);
|
|
541
562
|
}
|
|
542
|
-
// 先按 approvalRules 自动判定:allow 直接放行,deny
|
|
543
|
-
|
|
563
|
+
// 先按 approvalRules 自动判定:allow 直接放行,deny 直接拒绝,未命中才弹框。
|
|
564
|
+
// 规则集跟随 this.resolved,reload 或追加规则后无需重建
|
|
565
|
+
const ruleSet = createApprovalRuleSet({
|
|
566
|
+
rules: () => this.resolve(request.ctx).approvalRules,
|
|
567
|
+
suggestPatterns: commandPatternsFor,
|
|
568
|
+
persist: (rules) => this.persistAllowRules(request.ctx, rules),
|
|
569
|
+
});
|
|
570
|
+
const decision = await ruleSet.evaluate(request.command);
|
|
544
571
|
if (decision === "deny") {
|
|
545
572
|
throw new Error(`Command denied by bwrap approval rule: ${request.command}`);
|
|
546
573
|
}
|
|
@@ -551,6 +578,7 @@ export class BwrapRuntime {
|
|
|
551
578
|
request.command,
|
|
552
579
|
request.description,
|
|
553
580
|
execCwd,
|
|
581
|
+
ruleSet,
|
|
554
582
|
)) === "sandbox";
|
|
555
583
|
}
|
|
556
584
|
}
|
|
@@ -686,12 +714,13 @@ export class BwrapRuntime {
|
|
|
686
714
|
command: string,
|
|
687
715
|
reason: string | undefined,
|
|
688
716
|
execCwd: string,
|
|
717
|
+
ruleSet: ApprovalRuleSet,
|
|
689
718
|
): Promise<FullAccessGrant> {
|
|
690
719
|
// hasUI 判定推迟到审批时刻:无 UI 会话弹不了审批框,按用户点 Deny 的标准文案拒绝
|
|
691
720
|
if (!ctx.hasUI) {
|
|
692
721
|
throw new Error(UNSANDBOXED_DENIED);
|
|
693
722
|
}
|
|
694
|
-
const decision = await this.approveFullAccessUI(ctx, command, reason, execCwd);
|
|
723
|
+
const decision = await this.approveFullAccessUI(ctx, command, reason, execCwd, ruleSet);
|
|
695
724
|
// 关闭对话框 = 中断并拒绝,不循环重问
|
|
696
725
|
if (decision === undefined) {
|
|
697
726
|
ctx.abort();
|
|
@@ -706,13 +735,13 @@ export class BwrapRuntime {
|
|
|
706
735
|
}
|
|
707
736
|
case DENY: {
|
|
708
737
|
if (foreverApprovedPattern.length > 0) {
|
|
709
|
-
await
|
|
738
|
+
await ruleSet.addAllowRules(foreverApprovedPattern);
|
|
710
739
|
}
|
|
711
740
|
throw new Error(UNSANDBOXED_DENIED);
|
|
712
741
|
}
|
|
713
742
|
case DENY_WITH_REASON: {
|
|
714
743
|
if (foreverApprovedPattern.length > 0) {
|
|
715
|
-
await
|
|
744
|
+
await ruleSet.addAllowRules(foreverApprovedPattern);
|
|
716
745
|
}
|
|
717
746
|
const feedback = decision.reason?.trim() ?? "";
|
|
718
747
|
throw new Error(
|
|
@@ -723,7 +752,7 @@ export class BwrapRuntime {
|
|
|
723
752
|
}
|
|
724
753
|
case ALLOW_ONCE: {
|
|
725
754
|
if (foreverApprovedPattern.length > 0) {
|
|
726
|
-
await
|
|
755
|
+
await ruleSet.addAllowRules(foreverApprovedPattern);
|
|
727
756
|
}
|
|
728
757
|
return "full-access";
|
|
729
758
|
}
|
|
@@ -744,22 +773,14 @@ export class BwrapRuntime {
|
|
|
744
773
|
command: string,
|
|
745
774
|
reason: string | undefined,
|
|
746
775
|
execCwd: string,
|
|
776
|
+
ruleSet: ApprovalRuleSet,
|
|
747
777
|
): Promise<FullAccessUIDecision | undefined> {
|
|
748
778
|
// 弹框前解析命令的持久化规则:`echo 1 | head` → `echo *`、`head *`。
|
|
749
779
|
// 持久化规则的勾选折叠进 EDIT_RULES 子菜单,主决策列表只保留放行/拒绝,
|
|
750
780
|
// 避免一屏 checkbox 淹没决策项。
|
|
751
|
-
const patterns = await commandPatternsFor(command);
|
|
752
781
|
// 子菜单只列出未命中 allow 规则的 pattern:已提前允许的部分自动放行,
|
|
753
782
|
// 无需再展示或重复勾选持久化(deny 命中的命令在 evaluate 阶段已被拒绝)。
|
|
754
|
-
const
|
|
755
|
-
const unallowedPatterns = [
|
|
756
|
-
...new Set(
|
|
757
|
-
patterns.filter((pattern) => {
|
|
758
|
-
const rule = rules.findLast((r) => matchRule(pattern, r.pattern));
|
|
759
|
-
return rule?.action !== "allow";
|
|
760
|
-
}),
|
|
761
|
-
),
|
|
762
|
-
];
|
|
783
|
+
const unallowedPatterns = await ruleSet.pendingPatterns(command);
|
|
763
784
|
// dcg 扫描建议是可选的参考文本:未安装时静默跳过;已安装但扫描失败
|
|
764
785
|
// 时 notify 提示,弹窗本身与无 dcg 时一致
|
|
765
786
|
const outcome = await dcgSuggestion(command);
|
|
@@ -837,23 +858,27 @@ export class BwrapRuntime {
|
|
|
837
858
|
}
|
|
838
859
|
}
|
|
839
860
|
|
|
840
|
-
/**
|
|
841
|
-
|
|
861
|
+
/**
|
|
862
|
+
* 规则集的持久化实现:把勾选的 allow 规则追加进项目 sandbox.json 的
|
|
863
|
+
* approvalRules,并刷新缓存的规则使其立即生效。
|
|
864
|
+
*/
|
|
865
|
+
private async persistAllowRules(
|
|
842
866
|
ctx: ExtensionContext,
|
|
843
|
-
|
|
844
|
-
patterns?: string[],
|
|
867
|
+
newRules: readonly ApprovalRule[],
|
|
845
868
|
): Promise<void> {
|
|
846
|
-
const rulePatterns = patterns ?? (await commandPatternsFor(command));
|
|
847
|
-
if (rulePatterns.length === 0) {
|
|
848
|
-
return; // 解析失败:本次处理,不写规则
|
|
849
|
-
}
|
|
850
|
-
const newRules: ApprovalRule[] = rulePatterns.map((pattern) => ({ action: "allow", pattern }));
|
|
851
869
|
const { project } = getBwrapConfigPaths(ctx.cwd);
|
|
852
870
|
let config: Record<string, unknown> = {};
|
|
853
871
|
if (existsSync(project)) {
|
|
854
|
-
|
|
872
|
+
const raw: unknown = JSON.parse(readFileSync(project, "utf8"));
|
|
873
|
+
// 会话开始时已校验过配置文件:这里再查一次形状(Value.Check 同时收窄类型),
|
|
874
|
+
// 避免把新规则追加进一个后续加载必然失败的文件(只在配置文件被中途改坏时可达)
|
|
875
|
+
if (!Value.Check(bwrapConfigFileSchema, raw)) {
|
|
876
|
+
throw invalidConfigError(project, raw);
|
|
877
|
+
}
|
|
878
|
+
config = raw;
|
|
855
879
|
}
|
|
856
|
-
//
|
|
880
|
+
// 既有规则按不透明值原样保留(形状不认识也不丢),只追加本次允许的规则;
|
|
881
|
+
// 与规则无关的字段(含不认识的)同样原样写回,不经过 schema 往返
|
|
857
882
|
const existing = isUnknownArray(config.approvalRules) ? config.approvalRules : [];
|
|
858
883
|
config.approvalRules = [...existing, ...newRules];
|
|
859
884
|
await mkdir(dirname(project), { recursive: true });
|
package/src/lib/lsp/client.ts
CHANGED
|
@@ -1123,6 +1123,56 @@ export async function create(input: CreateInput): Promise<LspClient> {
|
|
|
1123
1123
|
});
|
|
1124
1124
|
}
|
|
1125
1125
|
|
|
1126
|
+
/** 轮询的归宿:谁先让等待收敛,以及没有收敛时是哪一种耗尽。 */
|
|
1127
|
+
type PollOutcome = "pulled" | "pushed" | "pullTimedOut" | "budgetExhausted";
|
|
1128
|
+
|
|
1129
|
+
/**
|
|
1130
|
+
* 等「当前文档版本」的诊断就绪,两种等待模式的唯一一份轮询实现。
|
|
1131
|
+
* pull 与 push 语义相同(都是等当前文档版本的诊断结果):先 pull,拿到就绪结论
|
|
1132
|
+
* 即返回;pull 超时说明服务器未响应,不再重试 pull,把「是否退回等 push」交给
|
|
1133
|
+
* 调用方;否则进入三路竞速——版本匹配的 push / 服务器注册表变化 / pull 重试间隔,
|
|
1134
|
+
* 除 push 命中外都再来一轮,直到预算耗尽、连接关闭或等待被中断。
|
|
1135
|
+
*/
|
|
1136
|
+
async function pollUntilSettled(request: {
|
|
1137
|
+
path: string;
|
|
1138
|
+
/** 本次等待的预算(毫秒)。 */
|
|
1139
|
+
budgetMs: number;
|
|
1140
|
+
/** 预算起点(调用方传 request.after ?? Date.now())。 */
|
|
1141
|
+
startedAt: number;
|
|
1142
|
+
/** 版本匹配的 push 兜底,由调用方按自己的预算创建。 */
|
|
1143
|
+
pushWait: Promise<boolean>;
|
|
1144
|
+
/** pull 入口(两种模式各自的那一个)。 */
|
|
1145
|
+
pull: (path: string) => Promise<PullResult>;
|
|
1146
|
+
/** 该模式的就绪判据。 */
|
|
1147
|
+
isSettled: (result: PullResult) => boolean;
|
|
1148
|
+
signal?: AbortSignal;
|
|
1149
|
+
}): Promise<PollOutcome> {
|
|
1150
|
+
while (!connectionClosed && !request.signal?.aborted) {
|
|
1151
|
+
const remaining = request.budgetMs - (Date.now() - request.startedAt);
|
|
1152
|
+
if (remaining <= 0) {
|
|
1153
|
+
return "budgetExhausted";
|
|
1154
|
+
}
|
|
1155
|
+
const result = await request.pull(request.path);
|
|
1156
|
+
if (request.isSettled(result)) {
|
|
1157
|
+
return "pulled";
|
|
1158
|
+
}
|
|
1159
|
+
if (result.timedOut) {
|
|
1160
|
+
return "pullTimedOut";
|
|
1161
|
+
}
|
|
1162
|
+
const next = await Promise.race([
|
|
1163
|
+
request.pushWait.then((ready) => (ready ? ("push" as const) : ("timeout" as const))),
|
|
1164
|
+
waitForRegistrationChange(remaining).then((changed) =>
|
|
1165
|
+
changed ? ("registration" as const) : ("timeout" as const),
|
|
1166
|
+
),
|
|
1167
|
+
sleep(Math.min(remaining, PULL_RETRY_INTERVAL_MS)).then(() => "interval" as const),
|
|
1168
|
+
]);
|
|
1169
|
+
if (next === "push") {
|
|
1170
|
+
return "pushed";
|
|
1171
|
+
}
|
|
1172
|
+
}
|
|
1173
|
+
return "budgetExhausted";
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1126
1176
|
/**
|
|
1127
1177
|
* 等待「当前文档版本」的诊断结论。返回值表示就绪是否被证实:true = 拿到了
|
|
1128
1178
|
* 当前版本的诊断结果(push / pull / 已有结论);false = 等满预算或连接关闭
|
|
@@ -1150,9 +1200,6 @@ export async function create(input: CreateInput): Promise<LspClient> {
|
|
|
1150
1200
|
files[request.path]?.lastPushEmpty === true
|
|
1151
1201
|
? Math.min(diagnosticsDocumentWaitTimeoutMs, diagnosticsSilentWaitTimeoutMs)
|
|
1152
1202
|
: diagnosticsDocumentWaitTimeoutMs;
|
|
1153
|
-
// pull 与 push 语义相同:都是等「当前文档版本」的诊断结果,统一一个循环。
|
|
1154
|
-
// 先 pull(拿到即返回);pull 超时说明服务器未响应,不再重试 pull,只等
|
|
1155
|
-
// 版本匹配的 push 兜底;版本不匹配的 push 一律忽略(防迟到旧结果)。
|
|
1156
1203
|
const pushWait = waitForFreshPush({
|
|
1157
1204
|
path: request.path,
|
|
1158
1205
|
version: request.version,
|
|
@@ -1160,30 +1207,24 @@ export async function create(input: CreateInput): Promise<LspClient> {
|
|
|
1160
1207
|
timeout: budget,
|
|
1161
1208
|
});
|
|
1162
1209
|
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
]);
|
|
1182
|
-
if (next === "push") {
|
|
1183
|
-
return true;
|
|
1184
|
-
}
|
|
1185
|
-
}
|
|
1186
|
-
return false;
|
|
1210
|
+
const outcome = await pollUntilSettled({
|
|
1211
|
+
path: request.path,
|
|
1212
|
+
budgetMs: budget,
|
|
1213
|
+
startedAt,
|
|
1214
|
+
pushWait,
|
|
1215
|
+
pull: requestDocumentDiagnostics,
|
|
1216
|
+
isSettled: (result) => result.matched,
|
|
1217
|
+
signal: request.signal,
|
|
1218
|
+
});
|
|
1219
|
+
// 对应重构前的返回路径:pulled / pushed → `return true`(pull 命中或等到
|
|
1220
|
+
// 版本匹配的 push);pullTimedOut → `return await pushWait`(pull 挂起不重试,
|
|
1221
|
+
// 但继续等 push 到预算结束,返回是否等到);budgetExhausted(含连接关闭 /
|
|
1222
|
+
// 中断)→ 循环尾 `return false`。
|
|
1223
|
+
return (
|
|
1224
|
+
outcome === "pulled" ||
|
|
1225
|
+
outcome === "pushed" ||
|
|
1226
|
+
(outcome === "pullTimedOut" && (await pushWait))
|
|
1227
|
+
);
|
|
1187
1228
|
}
|
|
1188
1229
|
|
|
1189
1230
|
async function waitForFullDiagnostics(request: {
|
|
@@ -1200,29 +1241,20 @@ export async function create(input: CreateInput): Promise<LspClient> {
|
|
|
1200
1241
|
timeout: diagnosticsFullWaitTimeoutMs,
|
|
1201
1242
|
});
|
|
1202
1243
|
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
pushWait.then((ready) => (ready ? ("push" as const) : ("timeout" as const))),
|
|
1218
|
-
waitForRegistrationChange(remaining).then((changed) =>
|
|
1219
|
-
changed ? ("registration" as const) : ("timeout" as const),
|
|
1220
|
-
),
|
|
1221
|
-
sleep(Math.min(remaining, PULL_RETRY_INTERVAL_MS)).then(() => "interval" as const),
|
|
1222
|
-
]);
|
|
1223
|
-
if (next === "push") {
|
|
1224
|
-
return;
|
|
1225
|
-
}
|
|
1244
|
+
const outcome = await pollUntilSettled({
|
|
1245
|
+
path: request.path,
|
|
1246
|
+
budgetMs: diagnosticsFullWaitTimeoutMs,
|
|
1247
|
+
startedAt,
|
|
1248
|
+
pushWait,
|
|
1249
|
+
pull: requestFullDiagnostics,
|
|
1250
|
+
isSettled: (result) => result.handled || result.matched,
|
|
1251
|
+
signal: request.signal,
|
|
1252
|
+
});
|
|
1253
|
+
// 对应重构前的返回路径:pulled / pushed / budgetExhausted 都对应 `return`
|
|
1254
|
+
// (就绪、等到 push 或耗尽预算);pullTimedOut → `await pushWait; return`,
|
|
1255
|
+
// 即 pull 挂起后仍等 push 兜底到预算结束,结果丢弃(本函数返回 void)。
|
|
1256
|
+
if (outcome === "pullTimedOut") {
|
|
1257
|
+
await pushWait;
|
|
1226
1258
|
}
|
|
1227
1259
|
}
|
|
1228
1260
|
|