@zhushanwen/pi-network-proxy 0.0.0-stage → 0.2.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 +33 -2
- package/index.ts +1 -0
- package/package.json +66 -3
- package/skills/network-proxy-ext-config/SKILL.md +75 -0
- package/src/__tests__/config.test.ts +234 -0
- package/src/__tests__/dispatcher-construction.test.ts +110 -0
- package/src/__tests__/dispatcher.test.ts +156 -0
- package/src/__tests__/index.test.ts +157 -0
- package/src/__tests__/installer.test.ts +313 -0
- package/src/__tests__/match.test.ts +86 -0
- package/src/__tests__/startup-config-declaration.test.ts +38 -0
- package/src/__tests__/stats.test.ts +171 -0
- package/src/config.ts +176 -0
- package/src/dispatcher.ts +169 -0
- package/src/index.ts +86 -0
- package/src/installer.ts +165 -0
- package/src/match.ts +33 -0
- package/src/stats.ts +127 -0
- package/vitest.config.ts +7 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @zhushanwen/pi-network-proxy 入口:配置驱动的 undici 全局分流分发器安装与事件接线。
|
|
3
|
+
*
|
|
4
|
+
* 行为序列(设计 .tmp/tech-design/llm-network-proxy.md §3.1):
|
|
5
|
+
* - 加载时:读配置校验,enabled 且校验过 → 捕获原分发器(pi 启动已自装 EnvHttpProxyAgent,
|
|
6
|
+
* setGlobalDispatcher 是接管而非向空闲槽安装——pi dist/core/http-dispatcher.js:73 构造、
|
|
7
|
+
* :85 装入全局槽,首次安装 dist/main.js:468 早于 extension 加载)→ 构造分流器安装;
|
|
8
|
+
* 校验失败 / 构造失败 →
|
|
9
|
+
* extension error(文案含恢复动作:配置文件路径),不安装不阻断 pi(全部直连)。
|
|
10
|
+
* - 每 turn 开始:重读配置(llm-shared 读时刷新热重载),指纹变化才重建;enabled true→false
|
|
11
|
+
* 恢复原分发器(installer.ts 状态机)。
|
|
12
|
+
* - 每 turn 结束:计数增量 flush 追加写入 stats JSONL(stats.ts);进程退出兜底 flush。
|
|
13
|
+
*
|
|
14
|
+
* 覆盖面含 subagent 的 pi 进程(subagent 的 LLM 请求同样分流计数,设计前提 P4)——不设
|
|
15
|
+
* subagent 排除门。配置写入侧 = taiji runtime RMW(settings 页 RPC)或 pi CLI 用户手编。
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
19
|
+
import { getGlobalDispatcher, setGlobalDispatcher } from "undici";
|
|
20
|
+
|
|
21
|
+
import { toErrorMessage } from "@zhushanwen/pi-ext-guards";
|
|
22
|
+
import { getLogger, setPiHandle } from "@zhushanwen/pi-extension-logger";
|
|
23
|
+
import { getConfigPath } from "@zhushanwen/pi-llm-shared";
|
|
24
|
+
|
|
25
|
+
import { loadNetworkProxyConfig } from "./config.js";
|
|
26
|
+
import { buildProxyDispatcher } from "./dispatcher.js";
|
|
27
|
+
import {
|
|
28
|
+
applyNetworkProxyConfig,
|
|
29
|
+
detachResidualDispatcher,
|
|
30
|
+
type InstalledState,
|
|
31
|
+
} from "./installer.js";
|
|
32
|
+
import { ensureExitFlush, flushStats } from "./stats.js";
|
|
33
|
+
|
|
34
|
+
const logger = getLogger("network-proxy");
|
|
35
|
+
|
|
36
|
+
/** 进程级安装状态(模块级——全局分发器槽跨 session 存续,session 替换重跑 factory 不丢引用)。 */
|
|
37
|
+
let installed: InstalledState | null = null;
|
|
38
|
+
|
|
39
|
+
/** 恢复动作文案:所有 error 形态共用的收尾指引(指向配置文件)。 */
|
|
40
|
+
function recoveryHint(): string {
|
|
41
|
+
return `配置文件: ${getConfigPath("network-proxy")},修正该文件后自下一轮对话起生效,或删除该文件恢复默认禁用态。`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** 读配置并推进安装状态机(加载时与每 turn 开始共用同一入口);推进前先摘除 reload 残留。 */
|
|
45
|
+
function refreshFromConfig(): void {
|
|
46
|
+
const config = loadNetworkProxyConfig((warning) => {
|
|
47
|
+
// 非法配置 / 坏 JSON:loadConfig 已回落 defaults(enabled=false → 不安装,全部直连),
|
|
48
|
+
// 这里把具体错误记录到 extension 日志通道(appendEntry 持久化 + <agentDir>/logs/
|
|
49
|
+
// network-proxy-<day>.log 文件日志;验收场景 10 以文件日志为归因口径)。
|
|
50
|
+
logger.error(`${warning} ${recoveryHint()}`);
|
|
51
|
+
});
|
|
52
|
+
try {
|
|
53
|
+
detachResidualDispatcher(installed, { getGlobalDispatcher, setGlobalDispatcher });
|
|
54
|
+
installed = applyNetworkProxyConfig(config, installed, {
|
|
55
|
+
getGlobalDispatcher,
|
|
56
|
+
setGlobalDispatcher,
|
|
57
|
+
buildProxy: buildProxyDispatcher,
|
|
58
|
+
onError: (message) => logger.error(`${message} ${recoveryHint()}`),
|
|
59
|
+
});
|
|
60
|
+
} catch (error) {
|
|
61
|
+
// 辅助功能降级契约(本扩展为 P2 辅助功能,故障不得拖垮 pi 主流程):状态机推进的
|
|
62
|
+
// 意外异常不外抛——外抛会升为 pi 用户可见 extension error 且每 turn 重复出现;
|
|
63
|
+
// 此处保持未安装态(全部直连),错误归因到本扩展日志。
|
|
64
|
+
logger.error(`network-proxy installer failed: ${toErrorMessage(error)} ${recoveryHint()}`);
|
|
65
|
+
installed = null;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** pi-network-proxy extension 工厂函数。 */
|
|
70
|
+
export default function networkProxyExtension(pi: ExtensionAPI): void {
|
|
71
|
+
// 日志通道注入(extension-logger 两阶段初始化:工厂拿 pi → setPiHandle,最早期调用)。
|
|
72
|
+
setPiHandle(pi);
|
|
73
|
+
|
|
74
|
+
ensureExitFlush();
|
|
75
|
+
refreshFromConfig();
|
|
76
|
+
|
|
77
|
+
// turn 粒度热生效:turn 开始重读配置(mtime+size 未变时零 IO),指纹变化重建 / 禁用恢复。
|
|
78
|
+
pi.on("turn_start", () => {
|
|
79
|
+
refreshFromConfig();
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
// turn 结束 flush 计数增量(单行 JSONL 追加,写失败 log 不阻断)。
|
|
83
|
+
pi.on("turn_end", () => {
|
|
84
|
+
flushStats();
|
|
85
|
+
});
|
|
86
|
+
}
|
package/src/installer.ts
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 分流器安装状态机(设计 §3.1 行为序列 1/2 + §3.3 决策 5/8)。
|
|
3
|
+
*
|
|
4
|
+
* 进程级单一安装状态(全局分发器槽是进程级资源,状态必须模块级持有——session 替换重跑
|
|
5
|
+
* factory 时不因闭包重建丢失 originalDispatcher 引用)。转移表:
|
|
6
|
+
*
|
|
7
|
+
* | 当前态 | 事件(新配置) | 动作 | 次态 |
|
|
8
|
+
* |-----------------|--------------------|----------------------------------------|------|
|
|
9
|
+
* | 任意 | enabled=false | 槽上仍是分流器则恢复 original(仅限槽位归属核查通过) | null |
|
|
10
|
+
* | null | enabled+新指纹 | 捕获 original → 构造 → 安装 | 装载 |
|
|
11
|
+
* | 装载 | enabled+同指纹+槽位仍归本扩展 | 无动作(不重建不重复安装) | 装载 |
|
|
12
|
+
* | 装载 | enabled+同指纹+槽位被外部覆盖 | 撤旧装新(pi 启动重装覆盖形态,original=覆盖者) | 装载 |
|
|
13
|
+
* | 锁定 | enabled+同指纹 | 无动作(首败即停不重试) | 锁定 |
|
|
14
|
+
* | 任意 | enabled+新指纹 | 构造成功 → 撤旧装新 | 装载 |
|
|
15
|
+
* | 任意 | enabled+构造失败 | error 上报 + 撤旧(全部直连),记指纹锁定 | 锁定 |
|
|
16
|
+
*
|
|
17
|
+
* 锁定态(current=null):同指纹不重试构造(fail-fast 首败即停,AGENTS.md 事实驱动原则⑧);
|
|
18
|
+
* 用户修正配置 → 指纹变化 → 自然重试。依赖注入(get/setGlobalDispatcher、buildProxy、
|
|
19
|
+
* onError)使状态机在单测中以 fake 依赖驱动,不 mock undici 全局槽。
|
|
20
|
+
*
|
|
21
|
+
* 状态机前置伴随入口 = detachResidualDispatcher:jiti 全量 reload 后 installed=null 但槽上
|
|
22
|
+
* 仍挂着旧环境残留分流器,状态机自身无法区分「真无状态」与「reload 残留」,由它在推进前
|
|
23
|
+
* 摘除(转移表各分支才从干净基线成立;pi reload 链实装锚点见该函数注释)。
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import type { Dispatcher as IDispatcher } from "undici";
|
|
27
|
+
|
|
28
|
+
import { networkProxyFingerprint, type NetworkProxyConfig } from "./config.js";
|
|
29
|
+
import { isRuleDispatchDispatcher, RuleDispatchDispatcher } from "./dispatcher.js";
|
|
30
|
+
import { recordDispatch } from "./stats.js";
|
|
31
|
+
|
|
32
|
+
/** 当前安装状态;null = 未安装(槽上是 pi 原生分发器)。 */
|
|
33
|
+
export interface InstalledState { // oe-exempt:20261019:framework:安装状态机载体形状(状态机唯一状态类型,单实现为常态)
|
|
34
|
+
|
|
35
|
+
/** 安装前捕获的原分发器引用(首次捕获后跨重建保留——恢复与 fallback 都用它)。 */
|
|
36
|
+
originalDispatcher: IDispatcher;
|
|
37
|
+
/** 已安装(或已锁定失败)配置的指纹。 */
|
|
38
|
+
fingerprint: string;
|
|
39
|
+
/** 槽上的分流器;null = 构造失败锁定态(未安装)。 */
|
|
40
|
+
current: IDispatcher | null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface InstallerDeps { // oe-exempt:20261019:framework:安装状态机依赖注入接缝(单测注入 get/setGlobalDispatcher 与可抛 buildProxy)
|
|
44
|
+
|
|
45
|
+
getGlobalDispatcher: () => IDispatcher;
|
|
46
|
+
setGlobalDispatcher: (dispatcher: IDispatcher) => void;
|
|
47
|
+
/** 代理分发器构造(可注入抛错——「构造失败 → 不安装 → 全直连」单测入口)。 */
|
|
48
|
+
buildProxy: (config: NetworkProxyConfig) => IDispatcher;
|
|
49
|
+
/** extension error 呈现(logger.error;文案含恢复动作)。 */
|
|
50
|
+
onError: (message: string) => void;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* reload 残留摘除(设计 §3.1 行为序列 2 在 reload 窗口的兑现):jiti 全量 reload 后本扩展
|
|
55
|
+
* 换了新模块环境,installed=null 但全局槽仍挂着旧环境安装的 RuleDispatchDispatcher——
|
|
56
|
+
* 不摘除则 enabled=false 状态机判「无状态」零动作(旧分流器继续生效,禁用失效),或
|
|
57
|
+
* enabled=true 时旧分流器被捕获为 original 嵌套(同一请求经新旧两层各计一次,统计双计)。
|
|
58
|
+
* 摘除 = 恢复残留分流器安装时捕获的原分发器(fallback 引用),状态机从干净基线推进。
|
|
59
|
+
* installed 非 null(同环境 factory 重跑)早退:槽上就是 state.current,摘掉会与
|
|
60
|
+
* 「指纹未变无动作」分支冲突(分流器被摘而状态机不重装)。
|
|
61
|
+
*
|
|
62
|
+
* pi reload 链实装锚点(@earendil-works/pi-coding-agent 1.0.0 dist,逐文件读码核实):
|
|
63
|
+
* agent-session.js:2899 reload() → :2903 oldRunner.invalidate()(extensions/loader.js:141-149
|
|
64
|
+
* 退订旧 runtime 的全部 eventBus 订阅)→ :2908 resourceLoader.reload()(resource-loader.js:350,
|
|
65
|
+
* :353 clearExtensionCache() 清空 jiti 模块缓存并递增缓存代际)→ loader.js:456-484 缓存代际
|
|
66
|
+
* 失效后 :472-476 新建 jiti 实例(moduleCache: false)重新 import 本扩展、:505-509 重跑工厂
|
|
67
|
+
* ——新环境模块级 installed 重置为 null,旧环境分流器滞留全局槽无人恢复,即本函数的前提。
|
|
68
|
+
*/
|
|
69
|
+
export function detachResidualDispatcher(
|
|
70
|
+
installed: InstalledState | null,
|
|
71
|
+
deps: Pick<InstallerDeps, "getGlobalDispatcher" | "setGlobalDispatcher">,
|
|
72
|
+
): void {
|
|
73
|
+
if (installed !== null) return;
|
|
74
|
+
const current = deps.getGlobalDispatcher();
|
|
75
|
+
if (!isRuleDispatchDispatcher(current)) return;
|
|
76
|
+
deps.setGlobalDispatcher(current.fallback);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* 禁用态收口(转移表首行):恢复仅限槽位仍归本扩展时执行——槽位已被外部覆盖(pi 启动重装,
|
|
81
|
+
* 见下)时槽上已是 pi 原生分发器,回写 state.originalDispatcher 会把它挤掉(无害但多余),
|
|
82
|
+
* 语义上禁用 = 摘除自己的安装,不是改写别人的。
|
|
83
|
+
*/
|
|
84
|
+
function restoreOriginalIfOwned(
|
|
85
|
+
state: InstalledState | null,
|
|
86
|
+
deps: Pick<InstallerDeps, "getGlobalDispatcher" | "setGlobalDispatcher">,
|
|
87
|
+
): void {
|
|
88
|
+
if (state?.current && deps.getGlobalDispatcher() === state.current) {
|
|
89
|
+
deps.setGlobalDispatcher(state.originalDispatcher);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* 槽位归属核查(pi 启动时序缺陷的根因修复):pi 1.0.0 启动序列在 extension 安装之后
|
|
95
|
+
* 仍会重跑 configureHttpDispatcher() 整槽覆盖(dist main.js:707 实证)——指纹未变但槽上
|
|
96
|
+
* 已非本扩展分发器时按「撤旧装新」重装。original 统一取槽上现持有者:覆盖场景下
|
|
97
|
+
* state.originalDispatcher 是上一代 pi 原生分发器,fallback 必须指向当前覆盖者才是
|
|
98
|
+
* 「未命中委托 = 未安装本扩展时 pi 的确切行为」。
|
|
99
|
+
*/
|
|
100
|
+
function resolveOriginal(state: InstalledState | null, slotNow: IDispatcher): IDispatcher {
|
|
101
|
+
return state && state.current !== null && slotNow === state.current
|
|
102
|
+
? state.originalDispatcher
|
|
103
|
+
: slotNow;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** 构造失败收口(转移表末行):error 上报 + 撤旧(全部直连),返回记指纹的锁定态。 */
|
|
107
|
+
function lockOnBuildFailure(
|
|
108
|
+
config: NetworkProxyConfig,
|
|
109
|
+
state: InstalledState | null,
|
|
110
|
+
original: IDispatcher,
|
|
111
|
+
fingerprint: string,
|
|
112
|
+
deps: InstallerDeps,
|
|
113
|
+
error: unknown,
|
|
114
|
+
): InstalledState {
|
|
115
|
+
deps.onError(
|
|
116
|
+
`网络代理分流器构造失败(协议 ${config.proxy.protocol}),已保持全部直连,不影响 pi 运行;` +
|
|
117
|
+
`可核对代理配置后保存重试,或关闭网络代理开关。构造错误: ${error instanceof Error ? error.message : String(error)}`,
|
|
118
|
+
);
|
|
119
|
+
if (state?.current) {
|
|
120
|
+
deps.setGlobalDispatcher(original);
|
|
121
|
+
}
|
|
122
|
+
return { originalDispatcher: original, fingerprint, current: null };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* 按新配置推进安装状态机(见模块头转移表)。config 契约 = loadNetworkProxyConfig 产物
|
|
127
|
+
* (normalize 已校验合法——非法配置在读取层回落 defaults+error,构造性到达本函数恒合法)。
|
|
128
|
+
*/
|
|
129
|
+
export function applyNetworkProxyConfig(
|
|
130
|
+
config: NetworkProxyConfig,
|
|
131
|
+
state: InstalledState | null,
|
|
132
|
+
deps: InstallerDeps,
|
|
133
|
+
): InstalledState | null {
|
|
134
|
+
if (!config.enabled) {
|
|
135
|
+
restoreOriginalIfOwned(state, deps);
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const fingerprint = networkProxyFingerprint(config);
|
|
140
|
+
if (state && state.fingerprint === fingerprint && state.current === null) {
|
|
141
|
+
return state; // 锁定态:同指纹不重试构造(首败即停,决策 8)
|
|
142
|
+
}
|
|
143
|
+
const slotNow = deps.getGlobalDispatcher();
|
|
144
|
+
if (state && state.fingerprint === fingerprint && slotNow === state.current) {
|
|
145
|
+
return state; // 同指纹且槽位仍归本扩展:无动作(不重建不重复安装)
|
|
146
|
+
}
|
|
147
|
+
const original = resolveOriginal(state, slotNow);
|
|
148
|
+
|
|
149
|
+
let proxy: IDispatcher;
|
|
150
|
+
try {
|
|
151
|
+
proxy = deps.buildProxy(config);
|
|
152
|
+
} catch (error) {
|
|
153
|
+
return lockOnBuildFailure(config, state, original, fingerprint, deps, error);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const dispatcher = new RuleDispatchDispatcher({
|
|
157
|
+
fallback: original,
|
|
158
|
+
proxy,
|
|
159
|
+
noProxy: config.noProxy,
|
|
160
|
+
rules: config.rules,
|
|
161
|
+
onCount: recordDispatch,
|
|
162
|
+
});
|
|
163
|
+
deps.setGlobalDispatcher(dispatcher);
|
|
164
|
+
return { originalDispatcher: original, fingerprint, current: dispatcher };
|
|
165
|
+
}
|
package/src/match.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 主机名匹配语义(normalizeHostname + suffix 点边界)。
|
|
3
|
+
*
|
|
4
|
+
* 语义 SSOT = packages/shared/src/network-proxy.ts 的同名函数——extension bundle 自包含
|
|
5
|
+
* 不 import shared 运行时代码(extensions 对 shared 的既有惯例:结构类型本地声明 + 行为
|
|
6
|
+
* 真值表镜像锁定,见 rename-session RENAME_INSTRUCTION_MAX_LENGTH 先例),本文件是复制体;
|
|
7
|
+
* 漂移时以 shared 为准,__tests__/match.test.ts 的真值表锁两侧行为一致。
|
|
8
|
+
*
|
|
9
|
+
* 匹配语义(设计 .tmp/tech-design/llm-network-proxy.md §3.1 配置 schema):
|
|
10
|
+
* suffix = 主机名等于或以 `.pattern` 结尾;点边界必须对齐——`xapi.anthropic.com` 不命中
|
|
11
|
+
* `api.anthropic.com`(无点前缀拼接),`api-anthropic.com.evil.com` 不命中(恶意后缀)。
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* 主机名归一化:转小写、去末尾点(FQDN 根点形态与裸主机名同义)。
|
|
16
|
+
* 刻意不 trim 首尾空白(语义镜像 shared normalizeHostname):带空白的条目由校验层
|
|
17
|
+
* 显式拒绝(config.ts collectHostEntryErrors,失败显式上报优于静默容忍),
|
|
18
|
+
* 匹配层保持严格、带空白 pattern 恒不命中。
|
|
19
|
+
*/
|
|
20
|
+
export function normalizeHostname(host: string): string {
|
|
21
|
+
return host.toLowerCase().replace(/\.+$/, "");
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* suffix 语义匹配(noProxy 与 rules 条目同一匹配函数):
|
|
26
|
+
* 主机名等于 pattern 或以 `.pattern` 结尾。空主机名 / 空 pattern 不命中任何条目。
|
|
27
|
+
*/
|
|
28
|
+
export function matchesHostPattern(host: string, pattern: string): boolean {
|
|
29
|
+
const h = normalizeHostname(host);
|
|
30
|
+
const p = normalizeHostname(pattern);
|
|
31
|
+
if (h === "" || p === "") return false;
|
|
32
|
+
return h === p || h.endsWith(`.${p}`);
|
|
33
|
+
}
|
package/src/stats.ts
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 请求计数与落盘(设计 .tmp/tech-design/llm-network-proxy.md §3.1 行为序列 4 + §3.3 决策 4)。
|
|
3
|
+
*
|
|
4
|
+
* 形态 = 进程内内存增量计数 + turn_end / 进程退出时追加式 JSONL flush:
|
|
5
|
+
* - 计数按主机名聚合,进程级(分流分发器是全局槽资源,与 session 无关);
|
|
6
|
+
* - 每行一个完整 JSON 片段 `{ ts, host, proxied, direct }`,ts = flush 时刻——增量行代表
|
|
7
|
+
* 「自上次 flush 以来的计数」,跨统计范围边界的微小归属偏差可接受(观测辅助非计费依据);
|
|
8
|
+
* - 落点 `<getAgentDir()>/network-proxy-stats.jsonl`(agentDir 根,与 runtime 读侧
|
|
9
|
+
* getPiAgentDir() 单一推导同源);runtime 侧负责聚合与重置水位/截断,本侧纯追加;
|
|
10
|
+
* - 单行 = 单次 appendFileSync 调用(O_APPEND 单次 write,行内容完整 JSON——并发原子性
|
|
11
|
+
* 前提,多 pi 进程并发追加不交错;实现改为分片 write 会静默交错,单测锁定);
|
|
12
|
+
* - 写失败 log 不阻断:失败行的增量保留在内存,下次 turn_end flush 重试(统计宁重试不丢,
|
|
13
|
+
* 连续失败不逐行刷 warn)。
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { appendFileSync, mkdirSync } from "node:fs";
|
|
17
|
+
import { dirname, join } from "node:path";
|
|
18
|
+
|
|
19
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
20
|
+
import { toErrorMessage } from "@zhushanwen/pi-ext-guards";
|
|
21
|
+
import { getLogger } from "@zhushanwen/pi-extension-logger";
|
|
22
|
+
import { withFileLockSync } from "@zhushanwen/pi-file-lock";
|
|
23
|
+
|
|
24
|
+
const logger = getLogger("network-proxy");
|
|
25
|
+
|
|
26
|
+
interface HostCounter { // oe-exempt:20261019:framework:进程内计数行形状(Map 值类型,聚合输出 NetworkProxyStats 的内存态)
|
|
27
|
+
|
|
28
|
+
proxied: number;
|
|
29
|
+
direct: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** 进程内增量计数:host → 自上次 flush 以来的次数。 */
|
|
33
|
+
const counters = new Map<string, HostCounter>();
|
|
34
|
+
|
|
35
|
+
/** stats 文件完整路径(每次现算——getAgentDir 尊重 PI_CODING_AGENT_DIR,测试注入 tmp)。 */
|
|
36
|
+
export function statsFilePath(): string {
|
|
37
|
+
return join(getAgentDir(), "network-proxy-stats.jsonl");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** dispatch 拦截点的计数回调(请求发起时点)。 */
|
|
41
|
+
export function recordDispatch(host: string, proxied: boolean): void {
|
|
42
|
+
const entry = counters.get(host) ?? { proxied: 0, direct: 0 };
|
|
43
|
+
if (proxied) {
|
|
44
|
+
entry.proxied += 1;
|
|
45
|
+
} else {
|
|
46
|
+
entry.direct += 1;
|
|
47
|
+
}
|
|
48
|
+
counters.set(host, entry);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** 测试用:清空内存增量(不落盘)。 */
|
|
52
|
+
export function resetCounters(): void {
|
|
53
|
+
counters.clear();
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** 测试用:当前增量快照(只读拷贝)。 */
|
|
57
|
+
export function snapshotCounters(): Map<string, HostCounter> {
|
|
58
|
+
return new Map([...counters].map(([host, c]) => [host, { ...c }]));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
let exitFlushInstalled = false;
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* 进程退出 flush(turn 进行中被退出时 turn_end 未到,增量在此兜住)。exit handler 内只允许
|
|
65
|
+
* 同步操作——appendFileSync 同步写满足;模块级 once flag 防工厂重跑(多 session)重复注册。
|
|
66
|
+
* jiti 全量 reload 产生的新模块环境会再注册一个 listener,但旧环境的计数 Map 已停更,旧
|
|
67
|
+
* listener flush 空增量为 no-op,无重复行(新环境再注册 = extensions/loader.js:472-476 重新
|
|
68
|
+
* import 重置模块级 once flag 后重跑工厂;旧计数停更 = 旧 runtime 的 turn 订阅被 invalidate
|
|
69
|
+
* 退订(extensions/loader.js:147-149)+ 旧分流器摘除后旧 recordDispatch 不再触发;pi reload
|
|
70
|
+
* 链总锚点见 installer.ts detachResidualDispatcher 注释)。
|
|
71
|
+
*/
|
|
72
|
+
export function ensureExitFlush(): void {
|
|
73
|
+
if (exitFlushInstalled) return;
|
|
74
|
+
exitFlushInstalled = true;
|
|
75
|
+
process.on("exit", () => {
|
|
76
|
+
try {
|
|
77
|
+
flushStats();
|
|
78
|
+
} catch (error) {
|
|
79
|
+
// flushStats 内部已逐行 try/catch + warn——能到这里的外层抛错只有 mkdir/logger 自身
|
|
80
|
+
// 故障;exit 通道内不能阻断退出,留文件日志痕即可。
|
|
81
|
+
logger.error(`exit flush failed: ${toErrorMessage(error)}`);
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* 把内存增量逐行追加写入 stats 文件;写成功的条目从内存清零,失败条目保留待下次重试。
|
|
88
|
+
* 增量为空时零 IO 直接返回(turn_end 空转不碰磁盘)。
|
|
89
|
+
* 持与 runtime 截断/重置同一把 <stats> 跨进程文件锁(设计 §3.1「截断不丢在途增量」):
|
|
90
|
+
* runtime 保尾截断临界区内并发追加的行会被 rename 整文件替换吞掉,无锁单侧互斥不成立。
|
|
91
|
+
* ts = epoch ms(Date.now(),与 runtime 读侧 parseStatsLine number 契约、reset 水位行
|
|
92
|
+
* 及读侧时间范围过滤一致;ISO 字符串形态产生的行会被读侧判坏行丢弃)。
|
|
93
|
+
*/
|
|
94
|
+
export function flushStats(): void {
|
|
95
|
+
if (counters.size === 0) return;
|
|
96
|
+
const ts = Date.now();
|
|
97
|
+
const filePath = statsFilePath();
|
|
98
|
+
try {
|
|
99
|
+
withFileLockSync(filePath, () => {
|
|
100
|
+
try {
|
|
101
|
+
mkdirSync(dirname(filePath), { recursive: true });
|
|
102
|
+
} catch (error) {
|
|
103
|
+
logger.warn("stats mkdir failed, keeping counters for retry", {
|
|
104
|
+
detail: { err: toErrorMessage(error) },
|
|
105
|
+
});
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
for (const [host, counter] of counters) {
|
|
109
|
+
// 单次 appendFileSync 调用写一行完整 JSON——并发原子性前提,禁止分片 write
|
|
110
|
+
const line = `${JSON.stringify({ ts, host, proxied: counter.proxied, direct: counter.direct })}\n`;
|
|
111
|
+
try {
|
|
112
|
+
appendFileSync(filePath, line);
|
|
113
|
+
counters.delete(host);
|
|
114
|
+
} catch (error) {
|
|
115
|
+
logger.warn("stats flush failed, keeping counters for retry", {
|
|
116
|
+
detail: { err: toErrorMessage(error), path: filePath },
|
|
117
|
+
});
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}, {});
|
|
122
|
+
} catch (error) {
|
|
123
|
+
logger.warn("stats flush lock failed, keeping counters for retry", {
|
|
124
|
+
detail: { err: toErrorMessage(error), path: filePath },
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
}
|