@lqc123qwe/car-runtime 1.1.0 → 1.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 +13 -1
- package/package.json +1 -1
- package/scripts/release-pipeline.ts +8 -2
- package/src/cli.ts +41 -6
- package/src/runtime-core/telemetry.ts +113 -23
- package/src/session/indexStore.ts +178 -3
- package/test/s25-m8-runtime-core.spec.ts +2 -1
- package/test/s28-session-index-followup.spec.ts +236 -0
- package/test/s29-otel-producer.spec.ts +252 -0
package/README.md
CHANGED
|
@@ -165,12 +165,24 @@ node --experimental-transform-types --test test/*.spec.ts
|
|
|
165
165
|
|
|
166
166
|
- **1.1 工程收口(2026-09-27)**:S30 §8 工程侧清单签名收尾五子项全部兑现(plugin-sign CLI / doctor 签名行 / 配置文件通道 / mcp-serve 装载接线+采集全链 / keychain 专测)。全量 **249(246 PASS + 3 能力门控 skip / 0 fail)** + tsc strict 0 错 + secrets-scan 97 文件 0 命中(密钥/签名夹具仅运行期临时目录——R-1)。QA 两轮制执行。**CI 收官(run 36304931085):16/16 全绿**。
|
|
167
167
|
- **1.1 发布编排(S6,D-11b ①直发)**:13 门禁本地预演 **11 PASS / 2 DRY-RUN / 0 FAIL**(G-07 逃逸矩阵本机 WSL 实跑 20/20;G-09/G-10 远端口径 DRY-RUN);version 1.1.0(`3d8658e`)已推送 main;dist 六资产就绪(tgz/bundle/SHA256SUMS/SBOM/审计/公钥)。**Release v1.1.0 创建(用户)→ j16 自动 `npm publish --provenance --tag latest` → post-publish REMOTE_CHECK=1 复跑**。
|
|
168
|
+
- **1.1.0 正式发布(2026-09-27,S6 收官)**:npm `@lqc123qwe/car-runtime@1.1.0` 在架——**dist-tag latest → 1.1.0,rc 保留指向 1.0.0-rc.1**(§1.1 口径);SLSA provenance(slsa.dev/provenance/v1)。j16 run 36305729712 success;**post-publish 复跑 12 PASS / 1 DRY-RUN / 0 FAIL(G-09 实查 PASS)** + 外部验证者等价检查(Release 实下载件 SHA256 MATCH + cosign Verified OK)+ Release 六资产公开 API 实查齐备。发布工程事件:tag 实挂 `1.1.0`(无 v 前缀)→ G-10 双形态兼容修复(`3fdedf5`,4c84b17 同类)。收口报告:`car-docs/10-内核v1/1.1-签名收口终验报告.md`。
|
|
168
169
|
- **边界口径(防外推)**:mcp-serve 装载接线 = 「装载 + 采集全链」——插件 factory 执行 + bindCore 冲刷注册项真实发生;**宿主会话执行插件工具的执行接线属 W1 登记后续**(sessionTurn 仍为事件批归一化),「已实装」口径不外推。sigstore keyless 主轨 / anthropic 适配器 / W1 runTurn→chatStep 接线均为延后登记项(用户裁决 2026-09-27)。
|
|
169
170
|
- **翻转排期**:**2026-12-25 到期 enforce 缺省值翻转**落 12-25 后续发版(一行缺省值变更 + 决策记录归档,部署设计 §4.5.5 既有承诺);数据前提 T-1 阈值随本迭代宿主采集链落成可观测。规划:`car-docs/10-内核v1/1.1-迭代规划.md`;收口报告:`car-docs/10-内核v1/1.1-签名收口终验报告.md`。
|
|
170
171
|
|
|
172
|
+
## 1.2 状态(会话/遥测小项 W3)
|
|
173
|
+
|
|
174
|
+
| `src/session/indexStore.ts` +2 | 1.2-S1/S2:`lookupSession`(--session id → 文件;db 缺文件 CAR-E-INDEX 显式报错无建文件副作用;SQLite 惰性校验错误统一收口;未收录 = null「可索引 = 可验证」)+ `upsertSessionRow`(自举建库建表)+ `IndexUpdater`(**按行增量**——sink 先于事件入内存 fail-fast 时序下行自带 seq/hash;debounce 2s + close flush;**失败非致命**绝不阻塞 append;能力缺席显式登记一次后停用;file resolve 绝对化) | M7 §4/§5 登记后续出清 |
|
|
175
|
+
| `src/cli.ts` | 1.2:session verify/replay/export 三入口 `--session <id> [--db]`(与位置路径互斥显式拒绝;缺省 db = cwd sessions-index.db)+ `car run` sink 链接 IndexUpdater + mcp-serve **OTel meter 双写桥**(S17 counters 快照面不变 + shutdown flush) | 1.2 W3-1/2/3 |
|
|
176
|
+
| `src/runtime-core/telemetry.ts` | 1.2-S3 生产级策略:有界重试(429/5xx/网络 ≤2 退避 1s/2s,4xx 不重试,耗尽静默丢弃永不抛错——**D-12b 口径修订「0 重试」**,隐私三原则不变)+ 采样(always_on 缺省/always_off/{ratio} head 决策,spansSampledOut)+ 批上限(maxBatchSize 512 分批 / maxQueueSize 2048 溢出丢最旧 queueOverflows)+ `telemetryConfigFromEnv`(CAR_OTEL_ENDPOINT 唯一开关/CAR_OTEL_SAMPLING/CAR_OTEL_INTERVAL_MS;非法值保持缺省禁 fail-hard)+ **1.2-BUG-1/2 修复**(OTLP metric 名混入 labels JSON / res.ok 未判 5xx 计成功——均经生产调用点接入暴露) | M8 §4.4 登记后续出清 |
|
|
177
|
+
| `test/s28-session-index-followup.spec.ts` + `test/s29-otel-producer.spec.ts` | 1.2 测试:s28 6 test(lookup 四态 / CLI 三入口真进程 / 拒绝面四情形 / updater 自举+覆盖+close / 失败非致命 / car run 索引闭环)+ s29 8 test(env 七态 / 重试四路径 / res.ok 回归钉 / 采样四态 / 分批溢出 / 默认关 / 解耦静态断言 / **mcp-serve 真进程 OTLP 出站实证**) | 1.2-GO |
|
|
178
|
+
|
|
179
|
+
- **1.2 工程收口(2026-09-27)**:M7 §4/§5 + M8 §4.4 登记后续三子项全部出清。全量 **263(260 PASS + 3 能力门控 skip / 0 fail)** + tsc strict 0 错 + secrets-scan 101 文件 0 命中。QA 两轮制执行。**1.2-BUG-1(OTLP metric 名混 labels)为生产调用点核查直接产出**——meter 桥接入即暴露 M8 最小实现掩盖的缺陷。
|
|
180
|
+
- **边界口径(防外推)**:OTel span 面(turn/step span)**仍无生产调用点**(随 W1 登记后续);trace 传播(parentSpanId/共享 trace)随 W1 设计;mcp-serve 出站现仅 metrics(counter 桥)。
|
|
181
|
+
- **遗留登记**:W1 = 1.3 头号候选(含 D-12a TLS loopback 豁免实装、car run 装配面重做〔demo_tool 撞名观察项出清〕);car run demo_tool 撞名 DEPCYCLE 为预存在边缘(登记不修,W1 出清)。规划:`car-docs/10-内核v1/1.2-迭代规划.md`;收口报告:`car-docs/10-内核v1/1.2-收口终验报告.md`。
|
|
182
|
+
|
|
171
183
|
## 状态
|
|
172
184
|
|
|
173
|
-
- **版本主线**:S1-S4 48/48 | M2 89/89(v0.2.0)| M3 127/127(v0.3.0)| M4 139/139(**1.0.0-rc.1 已发布**)| M5 156/156(DEC-4 预埋项转正)| M6 179/179(2026-09-15 收口)| M7 全量 195(192 PASS + 3 能力门控 skip,2026-09-19 收口)| **M5-S28 装载签名门 全量 204(201 PASS + 3 skip,2026-09-26)** | **M8 运行时底座 全量 231(228 PASS + 3 skip,2026-09-26 收口,§3.2 八模块全部收口)** | **1.1 签名收尾最小集 全量 249(246 PASS + 3 skip,2026-09-27
|
|
185
|
+
- **版本主线**:S1-S4 48/48 | M2 89/89(v0.2.0)| M3 127/127(v0.3.0)| M4 139/139(**1.0.0-rc.1 已发布**)| M5 156/156(DEC-4 预埋项转正)| M6 179/179(2026-09-15 收口)| M7 全量 195(192 PASS + 3 能力门控 skip,2026-09-19 收口)| **M5-S28 装载签名门 全量 204(201 PASS + 3 skip,2026-09-26)** | **M8 运行时底座 全量 231(228 PASS + 3 skip,2026-09-26 收口,§3.2 八模块全部收口)** | **1.1 签名收尾最小集 全量 249(246 PASS + 3 skip,2026-09-27 收口,1.1.0 已发布,enforce 翻转准备就绪)** | **1.2 会话/遥测小项 全量 263(260 PASS + 3 skip,2026-09-27 收口,1.2.0 发布编排中)**
|
|
174
186
|
- **1.0.0 正式发布(2026-09-26,S29/S30)**:npm `@lqc123qwe/car-runtime@1.0.0` 在架——**dist-tag latest → 1.0.0,rc 保留指向 1.0.0-rc.1**(§1.1 口径);SLSA provenance(Actions OIDC,透明日志在案);GitHub Release v1.0.0 六资产(tgz/bundle/SHA256SUMS/SBOM/审计/公钥);**post-publish 全链 13 PASS / 0 DRY-RUN / 0 FAIL** + 外部验证者等价检查(Release 实下载件 SHA256 MATCH + cosign Verified OK)。**宽限期时钟:2026-09-26 起算,2026-12-25 到期 verifier 默认值 warn→enforce 翻转(计划内变更)**。终验报告:`car-docs/10-内核v1/M5-S30-1.0收口终验报告.md`。发布工程事件:j16 首跑 E403(npm 2FA 策略 vs granular token 无 bypass——清单预设回退路径兑现)→ bypass token re-run 绿;G-09 期望 tag 动态化(`4c84b17`)在真实发布兑现价值。
|
|
175
187
|
- **1.0.0-rc.1 已发布(2026-09-09)**:npm `@lqc123qwe/car-runtime@1.0.0-rc.1`(rc + latest 双 tag,SLSA provenance 在案)+ GitHub Release 6 制品;发布预演 **13 PASS / 0 DRY-RUN / 0 FAIL**(c762b72)。S22 GO 清单已全绿收口。
|
|
176
188
|
- **轨道 A 三项全部完成**:E-1 WSL2 逃逸矩阵 **20/20 PASS 零逃逸**(2026-09-07,G-07 转 PASS)| E-3 远端发布通道 **13/13 全门禁 PASS**(2026-09-09,G-08/09/10 转 PASS)| E-2 Windows koffi FFI **四判据双环境全 PASS**(S24/S25/S26,2026-09-09~10;本机非提权 + windows-latest 提权 runner 双绿,CI 门禁已接入)。
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lqc123qwe/car-runtime",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"description": "Composable Agent Runtime (CAR) - microkernel plugin system with sandboxed PTC, append-only hashed session log, and MCP host bridge",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -127,8 +127,14 @@ const record = (stage: string, gate: string, ok: boolean, detail = '', dryRun =
|
|
|
127
127
|
if (process.env.GH_TOKEN && process.env.REMOTE_CHECK === '1') {
|
|
128
128
|
try {
|
|
129
129
|
const ghBin = 'D:/WorkBuddy/agent/tools/bin/gh.exe'
|
|
130
|
-
|
|
131
|
-
|
|
130
|
+
// tag 形态双兼容:v 前缀(v1.0.0 惯例)与无前缀(1.1.0 实挂形态)都查,并集去重——
|
|
131
|
+
// 硬编码单形态会把真实归档误判 FAIL(4c84b17 G-09 期望 tag 动态化同类教训,2026-09-27)
|
|
132
|
+
const fetchAssets = (tag: string): string[] => {
|
|
133
|
+
try {
|
|
134
|
+
return execFileSync(ghBin, ['api', `repos/lqc624/car-runtime/releases/tags/${tag}`, '--jq', '.assets[].name'], { encoding: 'utf-8', env: process.env }).trim().split('\n').filter(Boolean)
|
|
135
|
+
} catch { return [] }
|
|
136
|
+
}
|
|
137
|
+
const assets = [...new Set([...fetchAssets(`v${VERSION}`), ...fetchAssets(VERSION)])]
|
|
132
138
|
const need = [`car-runtime-${VERSION}.tgz`, `car-runtime-${VERSION}.tgz.sig.bundle`, 'SHA256SUMS', 'sbom.json', 'release-audit.jsonl', 'car-release.pub']
|
|
133
139
|
const missing = need.filter(n => !assets.includes(n))
|
|
134
140
|
g10Ok = missing.length === 0
|
package/src/cli.ts
CHANGED
|
@@ -19,6 +19,7 @@ import { SessionLog, loadSessionLog, type SessionEvent } from './session/log.ts'
|
|
|
19
19
|
import { SessionFileStore } from './session/store.ts'
|
|
20
20
|
import { compressJsonlZstd, decodeLogBuffer, zstdAvailable } from './session/format.ts'
|
|
21
21
|
import { exportForensicsBundle, verifyBundle, type ExportMeta } from './session/export.ts'
|
|
22
|
+
import { lookupSession, IndexUpdater, type SessionIndexRow } from './session/indexStore.ts'
|
|
22
23
|
import { runTurn } from './loop/stop.ts'
|
|
23
24
|
import { Context } from './kernel/context.ts'
|
|
24
25
|
import { mountPlugin } from './load/loader.ts'
|
|
@@ -26,6 +27,8 @@ import { verifyPluginFile } from './load/sigGate.ts'
|
|
|
26
27
|
import { loadCarConfig, mergeSignatureGate } from './load/config.ts'
|
|
27
28
|
import { generateSigningKeypair, signPluginFile, resolveTrustRoot } from './load/sign.ts'
|
|
28
29
|
import { doctorCredentials, doctorConnectivity, doctorKeychain, doctorSignature } from './dx/doctor.ts'
|
|
30
|
+
import { createTelemetryFacade, telemetryConfigFromEnv } from './runtime-core/telemetry.ts'
|
|
31
|
+
import type { CounterName, AllowedLabels } from './telemetry/metrics.ts'
|
|
29
32
|
|
|
30
33
|
const HELP = `用法: car <command> [args]
|
|
31
34
|
run <plugin.ts> [--sig-enforce] [--config <path>]
|
|
@@ -38,6 +41,8 @@ const HELP = `用法: car <command> [args]
|
|
|
38
41
|
取证包导出(断链中止;落盘读回重验,离线自证)
|
|
39
42
|
session rebuild-index <root> [--db <path>]
|
|
40
43
|
从 JSONL 重建 SQLite 会话索引(幂等)
|
|
44
|
+
session verify/replay/export 均支持 --session <id> [--db <path>]
|
|
45
|
+
经 SQLite 索引定位会话(1.2;缺省 db = cwd sessions-index.db;索引未收录 = 显式报错)
|
|
41
46
|
doctor 环境自检
|
|
42
47
|
plugin-sign keygen [--out <前缀>] [--force]
|
|
43
48
|
生成 ed25519 签名密钥对(<前缀>.priv PKCS8 DER / <前缀>.pub SPKI base64;缺省前缀 car-release)
|
|
@@ -109,7 +114,10 @@ async function main(): Promise<number> {
|
|
|
109
114
|
const log = new SessionLog('S-' + Date.now().toString(36))
|
|
110
115
|
// §3.2.M7.5「先落日志后放行」:run 流全程逐事件 fsync 落盘(append 返回 = 已过掉电窗口)
|
|
111
116
|
const store = new SessionFileStore(`session-${log.sessionId}.jsonl`)
|
|
112
|
-
|
|
117
|
+
// 1.2-S2 W3-2「更新半边」:append 后按行增量入索引(sink 先于事件入内存——行自带 seq/hash;
|
|
118
|
+
// 失败非致命——索引为派生缓存,rebuild-index 兜底语义不变;SQLite 能力缺席 = 显式登记一次后停用)
|
|
119
|
+
const indexUpdater = new IndexUpdater({ dbPath: join(process.cwd(), 'sessions-index.db'), onError: e => console.error(`[index-updater] ${e.message}`) })
|
|
120
|
+
log.attachSink(line => { store.append(line); indexUpdater.record(log.sessionId, line, store.path) })
|
|
113
121
|
const hostTools: any[] = []
|
|
114
122
|
ctx.plugin({ name: plugin.manifest.name, apply: (c) => {
|
|
115
123
|
plugin.bindCore({
|
|
@@ -133,8 +141,9 @@ async function main(): Promise<number> {
|
|
|
133
141
|
model: async () => ({ stopReason: 'toolUse' as const, toolCalls: [{ id: 'c1', tool: 'demo_tool', args: {} }] }),
|
|
134
142
|
})
|
|
135
143
|
console.log(`[3/5 收口] turnEnd=${r.reason}(steps=${r.steps})`)
|
|
136
|
-
// 环节 4:落盘收口(全程逐事件 fsync
|
|
144
|
+
// 环节 4:落盘收口(全程逐事件 fsync 已发生,此处仅关句柄核对 + 索引 pending 落盘)
|
|
137
145
|
store.close()
|
|
146
|
+
await indexUpdater.close()
|
|
138
147
|
console.log(`[4/5 落盘] ${store.path}(${log.events.length} 事件逐事件 fsync,哈希链 ${log.verifyChain() === null ? '完整' : '断链!'})`)
|
|
139
148
|
// 环节 5:审计回放
|
|
140
149
|
const replay = log.deriveMessages()
|
|
@@ -182,7 +191,7 @@ async function main(): Promise<number> {
|
|
|
182
191
|
for (let i = 0; i < args.length; i++) {
|
|
183
192
|
const a = args[i]!
|
|
184
193
|
if (a === '--zstd') flags.zstd = true
|
|
185
|
-
else if (a === '--out' || a === '--db') flags[a.slice(2)] = args[++i] ?? ''
|
|
194
|
+
else if (a === '--out' || a === '--db' || a === '--session') flags[a.slice(2)] = args[++i] ?? ''
|
|
186
195
|
else positional.push(a)
|
|
187
196
|
}
|
|
188
197
|
// C-02 索引重建(§3.2.M7.5 Step 3「异步可容忍,丢失可 rebuild」;幂等单事务)
|
|
@@ -200,7 +209,20 @@ async function main(): Promise<number> {
|
|
|
200
209
|
return 0
|
|
201
210
|
} catch (e) { console.error((e as Error).message); return 2 }
|
|
202
211
|
}
|
|
203
|
-
|
|
212
|
+
// 1.2-S1 W3-1:--session <id> 经索引定位会话文件(与位置路径互斥,显式拒绝歧义)。
|
|
213
|
+
// db 缺省取 cwd sessions-index.db(缺文件 = CAR-E-INDEX 显式报错提示 rebuild-index);
|
|
214
|
+
// 索引未收录 = 显式报错(「可索引 = 可验证」:断链/坏格式会话不入索引)。
|
|
215
|
+
const sessionFlag = flags.session as string | undefined
|
|
216
|
+
let file = positional[0]
|
|
217
|
+
if (sessionFlag) {
|
|
218
|
+
if (file) { console.error('文件路径与 --session 互斥——请二选一'); return 2 }
|
|
219
|
+
const dbPath = (flags.db as string) || join(process.cwd(), 'sessions-index.db')
|
|
220
|
+
let row: SessionIndexRow | null
|
|
221
|
+
try { row = await lookupSession(dbPath, sessionFlag) } catch (e) { console.error((e as Error).message); return 2 }
|
|
222
|
+
if (!row) { console.error(`会话 "${sessionFlag}" 未收录索引(可索引 = 可验证;断链/坏格式会话不入索引)——可先 car session rebuild-index`); return 2 }
|
|
223
|
+
file = row.file
|
|
224
|
+
console.log(`[index] ${sessionFlag} → ${file}(events=${row.eventCount} encoding=${row.encoding})`)
|
|
225
|
+
}
|
|
204
226
|
if (!file) { console.error('缺少文件参数'); return 2 }
|
|
205
227
|
if (sub === 'export' && !flags.out) { console.error('缺少 --out 目录参数'); return 2 }
|
|
206
228
|
let loaded: ReturnType<typeof loadSessionLog>
|
|
@@ -293,14 +315,22 @@ async function main(): Promise<number> {
|
|
|
293
315
|
const pluginsArg = collectPluginSources(rest)
|
|
294
316
|
if (pluginsArg.error) { console.error(pluginsArg.error); return 2 }
|
|
295
317
|
const { createCounters, assertZeroContent } = await import('./telemetry/metrics.ts')
|
|
318
|
+
// 1.2-S3 W3-3:OTel 门面经 env 通道(CAR_OTEL_ENDPOINT 唯一开关;缺省关 = noop 零出站)+
|
|
319
|
+
// S17 counters **双写桥**——快照面(登记表兜底通道)不变,meter 出站为加法通道;
|
|
320
|
+
// labels 为零内容枚举(metrics.ts AllowedLabels),出站同口径
|
|
321
|
+
const otel = createTelemetryFacade(telemetryConfigFromEnv())
|
|
296
322
|
const counters = createCounters()
|
|
323
|
+
const onCount = (name: CounterName, labels?: AllowedLabels): void => {
|
|
324
|
+
counters.onCount(name, labels)
|
|
325
|
+
if (otel.enabled) otel.getMeter().createCounter(name).add(1, labels as Record<string, string> | undefined)
|
|
326
|
+
}
|
|
297
327
|
if (pluginsArg.sources.length) {
|
|
298
328
|
const { loadPlugins, formatLoadReport } = await import('./load/report.ts')
|
|
299
329
|
const { Context } = await import('./kernel/context.ts')
|
|
300
330
|
const ctx = new Context()
|
|
301
331
|
const hostTools: any[] = []
|
|
302
332
|
for (const src of pluginsArg.sources) {
|
|
303
|
-
const result = await loadPlugins({ source: src, signature: { ...sig.options, onCount: (n, l) =>
|
|
333
|
+
const result = await loadPlugins({ source: src, signature: { ...sig.options, onCount: (n, l) => onCount(n, l) } })
|
|
304
334
|
if (result.report.stages.some(s => s.status === 'FAIL')) {
|
|
305
335
|
// 加载期显式失败红线(两模式一致):FAIL = 插件未装上,服务不得静默缺插件启动。
|
|
306
336
|
// DEC-1 warn/enforce 之分仅在签名门缺签路径(warn=横幅放行,enforce=verify FAIL 走此分支)
|
|
@@ -326,13 +356,18 @@ async function main(): Promise<number> {
|
|
|
326
356
|
const { createStdioServer } = await import('./host/stdio.ts')
|
|
327
357
|
const profiles = new Map(HOST_MAPPINGS.map(h => [h.hostId, h]))
|
|
328
358
|
const facade = createRuntimeFacade({ profiles })
|
|
329
|
-
const gw = new HostGateway({ facade, audit: e =>
|
|
359
|
+
const gw = new HostGateway({ facade, audit: e => onCount('car_registry_decision', { source: 'registry' }) })
|
|
330
360
|
for (const h of HOST_MAPPINGS) gw.registerHost({ hostId: h.hostId, profile: h, transport: {} as never })
|
|
331
361
|
const server = createStdioServer((tool, args) => gw.handle(hostId, tool, args))
|
|
332
362
|
const n = await server.serve(process.stdin, process.stdout)
|
|
333
363
|
// 采集窗口收口(登记表兜底通道):快照写 stderr(stdout 为协议通道不可污染)
|
|
334
364
|
const snap = counters.snapshot()
|
|
335
365
|
console.error(`[car mcp-serve] host=${hostId} requests=${n} snapshot=${JSON.stringify(snap)} zeroContent=${assertZeroContent(snap)}`)
|
|
366
|
+
// 1.2-S3:OTel 显式开路径收口 flush(shutdown = flush + 停 interval;缺省关为空转)
|
|
367
|
+
if (otel.enabled) {
|
|
368
|
+
await otel.shutdown()
|
|
369
|
+
console.error(`[car mcp-serve] otel=enabled stats=${JSON.stringify(otel.stats())}`)
|
|
370
|
+
}
|
|
336
371
|
return 0
|
|
337
372
|
}
|
|
338
373
|
case 'plugin-sign': {
|
|
@@ -4,8 +4,17 @@
|
|
|
4
4
|
* 口径(§3.2.M8.5 关键约束,逐条落地):
|
|
5
5
|
* - 默认关:无端点配置时不初始化任何 exporter——无配置 = noop 句柄 + 零出站(可机器断言);
|
|
6
6
|
* - 显式开:用户配置端点即视为开启(CAR_OTEL_ENDPOINT 或显式 config 传入);
|
|
7
|
-
* - 端点归用户:数据只发往用户自配 E-05;OTLP/HTTP JSON(fetch),导出超时 5s
|
|
8
|
-
*
|
|
7
|
+
* - 端点归用户:数据只发往用户自配 E-05;OTLP/HTTP JSON(fetch),导出超时 5s;
|
|
8
|
+
* 1.2-S3 生产级策略(D-12b 口径修订,2026-09-27):**0 重试 → 有界重试 ≤2**(仅 429/5xx/
|
|
9
|
+
* 网络错误,退避 1s/2s;4xx 业务错不重试)——重试耗尽静默丢弃 + droppedExports 计数,
|
|
10
|
+
* 永不抛错(BD-05「不抛错、不落审计」收紧保持);隐私三原则不变(数据仍只发用户自配端点);
|
|
11
|
+
* - 采样(1.2-S3 加法):head 采样于 span.end() 决策——always_on(缺省,全量,1.1 行为不变)/
|
|
12
|
+
* always_off / { ratio }(概率);被采出计入 spansSampledOut;采样只减少出站数据(隐私正向);
|
|
13
|
+
* - 批处理上限(1.2-S3 加法):maxBatchSize 单请求分批(512)+ maxQueueSize buffer 上限
|
|
14
|
+
* (2048,溢出丢最旧 + queueOverflows 计数)——内存有界;
|
|
15
|
+
* - env 通道(1.2-S3 加法):telemetryConfigFromEnv——CAR_OTEL_ENDPOINT(唯一开关)/
|
|
16
|
+
* CAR_OTEL_SAMPLING(always_on|always_off|0..1,非法值保持缺省——遥测面禁 fail-hard)/
|
|
17
|
+
* CAR_OTEL_INTERVAL_MS / CAR_OTEL_SERVICE_NAME;
|
|
9
18
|
* - 严格解耦:本文件零 import 自 session/*(遥测与审计日志禁止混流)——测试做静态断言;
|
|
10
19
|
* - S17 零内容计数器(telemetry/metrics.ts)是登记表兜底通道,与本门面并存互补:
|
|
11
20
|
* 本面是 OTel 形态的出站通道,不承接 S17 的零内容红线语义(内容治理属调用方);
|
|
@@ -14,6 +23,8 @@
|
|
|
14
23
|
*/
|
|
15
24
|
import { randomBytes } from 'node:crypto'
|
|
16
25
|
|
|
26
|
+
export type SamplingSpec = 'always_on' | 'always_off' | { ratio: number }
|
|
27
|
+
|
|
17
28
|
export interface TelemetryConfig {
|
|
18
29
|
/** 用户自配 OTLP/HTTP 端点(如 https://otel.example.internal)——无值 = 默认关 */
|
|
19
30
|
endpoint?: string
|
|
@@ -23,6 +34,19 @@ export interface TelemetryConfig {
|
|
|
23
34
|
exportTimeoutMs?: number
|
|
24
35
|
/** 自动导出间隔 ms;0(缺省)= 仅手动 flush(测试/收口时点调用) */
|
|
25
36
|
intervalMs?: number
|
|
37
|
+
// —— 1.2-S3 生产级策略(加法;缺省值保持 1.1 行为)——
|
|
38
|
+
/** 有界重试上限(仅 429/5xx/网络错误;§3.5.4 基线 ≤2;D-12b 口径修订) */
|
|
39
|
+
retryLimit?: number
|
|
40
|
+
/** 重试退避序列 ms(缺省 [1000, 2000];测试可注入) */
|
|
41
|
+
retryBackoffMs?: number[]
|
|
42
|
+
/** 采样(缺省 always_on = 全量);head 决策于 span.end() */
|
|
43
|
+
sampling?: SamplingSpec
|
|
44
|
+
/** 单请求 spans 上限(超限分批;缺省 512) */
|
|
45
|
+
maxBatchSize?: number
|
|
46
|
+
/** span buffer 上限(溢出丢最旧 + queueOverflows 计数;缺省 2048) */
|
|
47
|
+
maxQueueSize?: number
|
|
48
|
+
/** 测试注入睡眠(退避可观测;缺省真实定时器) */
|
|
49
|
+
sleep?: (ms: number) => Promise<void>
|
|
26
50
|
}
|
|
27
51
|
|
|
28
52
|
export interface TelemetrySpan {
|
|
@@ -47,9 +71,14 @@ export interface TelemetryMeter {
|
|
|
47
71
|
|
|
48
72
|
export interface TelemetryStats {
|
|
49
73
|
spansEnded: number
|
|
74
|
+
/** 1.2-S3 加法:head 采样被采出的 span 数(缺省 always_on 下恒 0) */
|
|
75
|
+
spansSampledOut: number
|
|
76
|
+
/** 成功出站的 OTLP 请求计数(traces/metrics 分批后按请求计) */
|
|
50
77
|
tracesExported: number
|
|
51
78
|
metricsExported: number
|
|
52
79
|
droppedExports: number
|
|
80
|
+
/** 1.2-S3 加法:buffer 溢出丢最旧计数(maxQueueSize 上限) */
|
|
81
|
+
queueOverflows: number
|
|
53
82
|
}
|
|
54
83
|
|
|
55
84
|
export interface TelemetryFacade {
|
|
@@ -76,6 +105,33 @@ const noopSpan = (): TelemetrySpan => ({
|
|
|
76
105
|
})
|
|
77
106
|
const NOOP_TRACER: TelemetryTracer = { startSpan: () => noopSpan() }
|
|
78
107
|
const NOOP_METER: TelemetryMeter = { createCounter: () => ({ add() {} }) }
|
|
108
|
+
const NOOP_STATS: TelemetryStats = { spansEnded: 0, spansSampledOut: 0, tracesExported: 0, metricsExported: 0, droppedExports: 0, queueOverflows: 0 }
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* 1.2-S3 env 配置通道:CAR_OTEL_ENDPOINT(唯一开关)/ CAR_OTEL_SAMPLING(always_on|always_off|0..1,
|
|
112
|
+
* 非法值保持缺省 always_on——遥测面禁 fail-hard)/ CAR_OTEL_INTERVAL_MS / CAR_OTEL_SERVICE_NAME。
|
|
113
|
+
* 无 endpoint = null(默认关,noop 门面)。
|
|
114
|
+
*/
|
|
115
|
+
export function telemetryConfigFromEnv(env: NodeJS.ProcessEnv = process.env): TelemetryConfig | null {
|
|
116
|
+
const endpoint = env.CAR_OTEL_ENDPOINT
|
|
117
|
+
if (!endpoint) return null
|
|
118
|
+
const cfg: TelemetryConfig = { endpoint }
|
|
119
|
+
if (env.CAR_OTEL_SERVICE_NAME) cfg.serviceName = env.CAR_OTEL_SERVICE_NAME
|
|
120
|
+
if (env.CAR_OTEL_INTERVAL_MS) {
|
|
121
|
+
const n = Number(env.CAR_OTEL_INTERVAL_MS)
|
|
122
|
+
if (Number.isFinite(n) && n >= 0) cfg.intervalMs = n
|
|
123
|
+
}
|
|
124
|
+
if (env.CAR_OTEL_SAMPLING) {
|
|
125
|
+
const s = env.CAR_OTEL_SAMPLING
|
|
126
|
+
if (s === 'always_on' || s === 'always_off') cfg.sampling = s
|
|
127
|
+
else {
|
|
128
|
+
const r = Number(s)
|
|
129
|
+
if (Number.isFinite(r) && r >= 0 && r <= 1) cfg.sampling = { ratio: r }
|
|
130
|
+
// 非法采样值:保持缺省 always_on(登记口径,禁 fail-hard)
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return cfg
|
|
134
|
+
}
|
|
79
135
|
|
|
80
136
|
// ==================== 活动门面(显式开路径) ====================
|
|
81
137
|
|
|
@@ -104,36 +160,61 @@ export function createTelemetryFacade(
|
|
|
104
160
|
getMeter: () => NOOP_METER,
|
|
105
161
|
flush: async () => {},
|
|
106
162
|
shutdown: async () => {},
|
|
107
|
-
stats: () => ({
|
|
163
|
+
stats: () => ({ ...NOOP_STATS }),
|
|
108
164
|
}
|
|
109
165
|
}
|
|
110
166
|
|
|
111
167
|
const fetchImpl = opts.fetchImpl ?? fetch
|
|
112
168
|
const now = opts.now ?? Date.now
|
|
169
|
+
const sleep = config?.sleep ?? (ms => new Promise<void>(r => setTimeout(r, ms)))
|
|
113
170
|
const timeoutMs = config?.exportTimeoutMs ?? 5_000
|
|
171
|
+
const retryLimit = config?.retryLimit ?? 2
|
|
172
|
+
const backoff = config?.retryBackoffMs ?? [1_000, 2_000]
|
|
173
|
+
const sampling = config?.sampling ?? 'always_on'
|
|
174
|
+
const maxBatchSize = config?.maxBatchSize ?? 512
|
|
175
|
+
const maxQueueSize = config?.maxQueueSize ?? 2_048
|
|
114
176
|
const baseHeaders = { 'content-type': 'application/json', ...(config?.headers ?? {}) }
|
|
115
177
|
const serviceName = config?.serviceName ?? 'car-runtime'
|
|
116
178
|
|
|
117
179
|
const spans: SpanRecord[] = []
|
|
118
|
-
|
|
119
|
-
|
|
180
|
+
// 计数器键 = name+labels JSON(聚合去重);记录体分离保存 name/labels——
|
|
181
|
+
// OTLP metric 名必须是纯 name(1.2-BUG-3:键直当名会把 labels JSON 拼进 metric 名,
|
|
182
|
+
// M8 最小实现即存在,meter 零生产调用点掩盖至今,1.2-S3 meter 桥接入时暴露)
|
|
183
|
+
const counters = new Map<string, { name: string; labels: Record<string, string>; value: number }>()
|
|
184
|
+
const stats: TelemetryStats = { spansEnded: 0, spansSampledOut: 0, tracesExported: 0, metricsExported: 0, droppedExports: 0, queueOverflows: 0 }
|
|
120
185
|
let tracer: TelemetryTracer | null = null
|
|
121
186
|
let meter: TelemetryMeter | null = null
|
|
122
187
|
let timer: ReturnType<typeof setInterval> | null = null
|
|
123
188
|
let flushing = false
|
|
124
189
|
|
|
190
|
+
/**
|
|
191
|
+
* OTLP 出站(D-12b 口径:有界重试)——ok / 429/5xx/网络错误退避重试 ≤retryLimit /
|
|
192
|
+
* 4xx 业务错不重试(端点配置类问题,重试无益);重试耗尽返回 false 由调用方计入
|
|
193
|
+
* droppedExports(静默丢弃,永不抛错——BD-05「不抛错」收紧保持)。
|
|
194
|
+
*/
|
|
125
195
|
const exportOtlp = async (path: string, body: unknown): Promise<boolean> => {
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
196
|
+
for (let attempt = 0; ; attempt++) {
|
|
197
|
+
try {
|
|
198
|
+
const res = await fetchImpl(endpoint + path, {
|
|
199
|
+
method: 'POST',
|
|
200
|
+
headers: baseHeaders,
|
|
201
|
+
body: JSON.stringify(body),
|
|
202
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
203
|
+
})
|
|
204
|
+
if (res.ok) return true
|
|
205
|
+
if ((res.status === 429 || res.status >= 500) && attempt < retryLimit) {
|
|
206
|
+
await sleep(backoff[Math.min(attempt, backoff.length - 1)]!)
|
|
207
|
+
continue
|
|
208
|
+
}
|
|
209
|
+
return false
|
|
210
|
+
} catch {
|
|
211
|
+
// 网络错误/超时:可重试类
|
|
212
|
+
if (attempt < retryLimit) {
|
|
213
|
+
await sleep(backoff[Math.min(attempt, backoff.length - 1)]!)
|
|
214
|
+
continue
|
|
215
|
+
}
|
|
216
|
+
return false
|
|
217
|
+
}
|
|
137
218
|
}
|
|
138
219
|
}
|
|
139
220
|
|
|
@@ -141,13 +222,15 @@ export function createTelemetryFacade(
|
|
|
141
222
|
if (flushing) return
|
|
142
223
|
flushing = true
|
|
143
224
|
try {
|
|
144
|
-
const
|
|
145
|
-
|
|
225
|
+
const spanQueue = spans.splice(0, spans.length)
|
|
226
|
+
// maxBatchSize 分批(1.2-S3):tracesExported 按成功出站请求计数
|
|
227
|
+
for (let i = 0; i < spanQueue.length; i += maxBatchSize) {
|
|
228
|
+
const batch = spanQueue.slice(i, i + maxBatchSize)
|
|
146
229
|
const body = {
|
|
147
230
|
resource: { attributes: [{ key: 'service.name', value: { stringValue: serviceName } }] },
|
|
148
231
|
scopeSpans: [{
|
|
149
232
|
scope: { name: 'car-runtime' },
|
|
150
|
-
spans:
|
|
233
|
+
spans: batch.map(s => ({
|
|
151
234
|
traceId: s.traceId,
|
|
152
235
|
spanId: s.spanId,
|
|
153
236
|
...(s.parentSpanId ? { parentSpanId: s.parentSpanId } : {}),
|
|
@@ -170,8 +253,8 @@ export function createTelemetryFacade(
|
|
|
170
253
|
resource: { attributes: [{ key: 'service.name', value: { stringValue: serviceName } }] },
|
|
171
254
|
scopeMetrics: [{
|
|
172
255
|
scope: { name: 'car-runtime' },
|
|
173
|
-
metrics: batch.map(([
|
|
174
|
-
name,
|
|
256
|
+
metrics: batch.map(([, c]) => ({
|
|
257
|
+
name: c.name,
|
|
175
258
|
sum: {
|
|
176
259
|
aggregationTemporality: 'AGGREGATION_TEMPORALITY_CUMULATIVE',
|
|
177
260
|
dataPoints: Object.entries(c.labels).length
|
|
@@ -209,8 +292,15 @@ export function createTelemetryFacade(
|
|
|
209
292
|
recordException(err) { record.attributes['exception.message'] = String((err as Error)?.message ?? err); return this },
|
|
210
293
|
end() {
|
|
211
294
|
record.endMs = now()
|
|
212
|
-
spans.push(record)
|
|
213
295
|
stats.spansEnded++
|
|
296
|
+
// head 采样于 end() 决策(1.2-S3):缺省 always_on 全量(1.1 行为不变);采样只减少出站
|
|
297
|
+
const keep = sampling === 'always_on' ? true
|
|
298
|
+
: sampling === 'always_off' ? false
|
|
299
|
+
: Math.random() < sampling.ratio
|
|
300
|
+
if (!keep) { stats.spansSampledOut++; return }
|
|
301
|
+
// buffer 上限:溢出丢最旧 + 计数(内存有界)
|
|
302
|
+
if (spans.length >= maxQueueSize) { spans.shift(); stats.queueOverflows++ }
|
|
303
|
+
spans.push(record)
|
|
214
304
|
},
|
|
215
305
|
}
|
|
216
306
|
},
|
|
@@ -221,7 +311,7 @@ export function createTelemetryFacade(
|
|
|
221
311
|
return {
|
|
222
312
|
add(value, labels = {}) {
|
|
223
313
|
const key = name + JSON.stringify(labels)
|
|
224
|
-
const cur = counters.get(key) ?? { labels, value: 0 }
|
|
314
|
+
const cur = counters.get(key) ?? { name, labels, value: 0 }
|
|
225
315
|
cur.value += value
|
|
226
316
|
counters.set(key, cur)
|
|
227
317
|
},
|
|
@@ -8,8 +8,8 @@
|
|
|
8
8
|
* 索引只收录链完整会话(审计面口径:可索引 = 可验证);
|
|
9
9
|
* - 物理编码 layout-blind:plain / zstd 统一经 format.ts 装载,索引记录 encoding 供检索。
|
|
10
10
|
*/
|
|
11
|
-
import { readdirSync } from 'node:fs'
|
|
12
|
-
import { join } from 'node:path'
|
|
11
|
+
import { readdirSync, existsSync, realpathSync } from 'node:fs'
|
|
12
|
+
import { join, resolve } from 'node:path'
|
|
13
13
|
import { loadSessionLog, type SessionEvent } from './log.ts'
|
|
14
14
|
|
|
15
15
|
export interface SessionIndexRow {
|
|
@@ -31,14 +31,189 @@ export interface RebuildResult {
|
|
|
31
31
|
}
|
|
32
32
|
|
|
33
33
|
type SqliteValue = string | number | null
|
|
34
|
-
interface SqliteStmt { run(...v: SqliteValue[]): unknown }
|
|
34
|
+
interface SqliteStmt { run(...v: SqliteValue[]): unknown; get(...v: SqliteValue[]): unknown }
|
|
35
35
|
interface SqliteDb { exec(sql: string): void; prepare(sql: string): SqliteStmt; close(): void }
|
|
36
36
|
|
|
37
|
+
const CAR_E_SQLITE = 'CAR-E-SQLITE: 当前 Node 无 node:sqlite 能力(22.5+ 需 --experimental-sqlite,23.4+ 原生)——显式拒绝,不静默降级'
|
|
38
|
+
|
|
37
39
|
/** node:sqlite 能力探测(模块存在性;静态 import 在无能力 Node 上会崩模块加载,故动态) */
|
|
38
40
|
export async function sqliteAvailable(): Promise<boolean> {
|
|
39
41
|
try { await import('node:sqlite'); return true } catch { return false }
|
|
40
42
|
}
|
|
41
43
|
|
|
44
|
+
/** 打开索引库(db 文件必须已存在——lookup 不产生建文件副作用;open 失败 = 索引文件无效显式报错) */
|
|
45
|
+
async function openExistingDb(dbPath: string): Promise<SqliteDb> {
|
|
46
|
+
if (!existsSync(dbPath)) {
|
|
47
|
+
throw new Error(`CAR-E-INDEX: 会话索引不存在:${dbPath}——先执行 car session rebuild-index 建立索引`)
|
|
48
|
+
}
|
|
49
|
+
const mod = await import('node:sqlite').catch(() => null)
|
|
50
|
+
if (!mod) throw new Error(CAR_E_SQLITE)
|
|
51
|
+
const DatabaseSync = (mod as unknown as { DatabaseSync: new (path: string) => SqliteDb }).DatabaseSync
|
|
52
|
+
try {
|
|
53
|
+
return new DatabaseSync(dbPath)
|
|
54
|
+
} catch (e) {
|
|
55
|
+
throw new Error(`CAR-E-INDEX: 索引文件无效(打开失败:${(e as Error).message}):${dbPath}`)
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** 单会话检索(1.2-S1 W3-1):--session <id> 经索引解析 sessionId → 文件;未收录 = null(「可索引 = 可验证」口径——断链会话不入索引) */
|
|
60
|
+
export async function lookupSession(dbPath: string, sessionId: string): Promise<SessionIndexRow | null> {
|
|
61
|
+
const db = await openExistingDb(dbPath)
|
|
62
|
+
try {
|
|
63
|
+
const table = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='sessions'").get()
|
|
64
|
+
if (!table) throw new Error(`CAR-E-INDEX: 索引文件无效(无 sessions 表):${dbPath}`)
|
|
65
|
+
const row = db.prepare('SELECT * FROM sessions WHERE sessionId = ?').get(sessionId) as Record<string, SqliteValue> | undefined
|
|
66
|
+
if (!row) return null
|
|
67
|
+
return {
|
|
68
|
+
sessionId: String(row.sessionId),
|
|
69
|
+
file: String(row.file),
|
|
70
|
+
eventCount: Number(row.eventCount),
|
|
71
|
+
chainHead: row.chainHead == null ? null : String(row.chainHead),
|
|
72
|
+
chainTail: row.chainTail == null ? null : String(row.chainTail),
|
|
73
|
+
firstTs: row.firstTs == null ? null : Number(row.firstTs),
|
|
74
|
+
lastTs: row.lastTs == null ? null : Number(row.lastTs),
|
|
75
|
+
tornTail: Number(row.tornTail) === 1,
|
|
76
|
+
encoding: row.encoding === 'zstd' ? 'zstd' : 'plain',
|
|
77
|
+
}
|
|
78
|
+
} catch (e) {
|
|
79
|
+
const msg = (e as Error).message
|
|
80
|
+
if (msg.startsWith('CAR-E-')) throw e // 本层显式错误原样透传
|
|
81
|
+
// SQLite 惰性校验:非索引文件 open 不报错、query 才报(file is not a database)——统一收口为显式口径
|
|
82
|
+
throw new Error(`CAR-E-INDEX: 索引文件无效(${msg}):${dbPath}`)
|
|
83
|
+
} finally {
|
|
84
|
+
db.close()
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const CREATE_SESSIONS_SQL = `CREATE TABLE IF NOT EXISTS sessions (
|
|
89
|
+
sessionId TEXT PRIMARY KEY,
|
|
90
|
+
file TEXT NOT NULL,
|
|
91
|
+
eventCount INTEGER NOT NULL,
|
|
92
|
+
chainHead TEXT,
|
|
93
|
+
chainTail TEXT,
|
|
94
|
+
firstTs INTEGER,
|
|
95
|
+
lastTs INTEGER,
|
|
96
|
+
tornTail INTEGER NOT NULL DEFAULT 0,
|
|
97
|
+
encoding TEXT NOT NULL DEFAULT 'plain',
|
|
98
|
+
rebuiltAt TEXT NOT NULL
|
|
99
|
+
)`
|
|
100
|
+
|
|
101
|
+
/** 单行增量 upsert(1.2-S2 W3-2「更新半边」):库/表不存在则自举创建(活会话无需先 rebuild 即可入索引) */
|
|
102
|
+
export async function upsertSessionRow(dbPath: string, row: SessionIndexRow): Promise<void> {
|
|
103
|
+
const mod = await import('node:sqlite').catch(() => null)
|
|
104
|
+
if (!mod) throw new Error(CAR_E_SQLITE)
|
|
105
|
+
const DatabaseSync = (mod as unknown as { DatabaseSync: new (path: string) => SqliteDb }).DatabaseSync
|
|
106
|
+
try {
|
|
107
|
+
const db = new DatabaseSync(dbPath)
|
|
108
|
+
try {
|
|
109
|
+
db.exec(CREATE_SESSIONS_SQL)
|
|
110
|
+
db.prepare(
|
|
111
|
+
'INSERT OR REPLACE INTO sessions (sessionId, file, eventCount, chainHead, chainTail, firstTs, lastTs, tornTail, encoding, rebuiltAt) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
|
112
|
+
).run(row.sessionId, row.file, row.eventCount, row.chainHead, row.chainTail, row.firstTs, row.lastTs, row.tornTail ? 1 : 0, row.encoding, new Date().toISOString())
|
|
113
|
+
} finally {
|
|
114
|
+
db.close()
|
|
115
|
+
}
|
|
116
|
+
} catch (e) {
|
|
117
|
+
const msg = (e as Error).message
|
|
118
|
+
if (msg.startsWith('CAR-E-')) throw e
|
|
119
|
+
throw new Error(`CAR-E-INDEX: 索引更新失败(${msg}):${dbPath}`)
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* 运行时索引更新器(1.2-S2 W3-2):file-backed 会话 append 后的异步增量通道。
|
|
125
|
+
*
|
|
126
|
+
* 口径(M7 §4.2 登记 + 1.2 规划 R-2):
|
|
127
|
+
* - 按行增量:SessionLog.append 的 sink 先于事件入内存调用(fail-fast 语义),故 record
|
|
128
|
+
* 直接收行 JSON——seq/ts/hash 取自行数据(seq 权威:eventCount = seq+1),不读内存快照;
|
|
129
|
+
* - debounce 合并:窗口内多次 append 只落一次 upsert(行状态由最新 append 覆盖);
|
|
130
|
+
* - 失败非致命:upsert 任何错误经 onError 留痕并计数,绝不向 append 路径传播——
|
|
131
|
+
* 索引是派生缓存,丢失由 rebuild-index 兜底(语义自洽,M7 已登记);
|
|
132
|
+
* - SQLite 能力缺席:首次 flush 显式登记一次后停用(非静默,后续 record 不再调度);
|
|
133
|
+
* - 活会话行口径:tornTail=false(按构造完整)、encoding='plain'(zstd 定位归档/导出,M7 W3)。
|
|
134
|
+
*/
|
|
135
|
+
interface PendingState {
|
|
136
|
+
sessionId: string
|
|
137
|
+
file: string
|
|
138
|
+
eventCount: number
|
|
139
|
+
chainHead: string
|
|
140
|
+
chainTail: string
|
|
141
|
+
firstTs: number
|
|
142
|
+
lastTs: number
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export class IndexUpdater {
|
|
146
|
+
readonly #dbPath: string
|
|
147
|
+
readonly #debounceMs: number
|
|
148
|
+
readonly #onError: (e: Error) => void
|
|
149
|
+
#state: PendingState | null = null
|
|
150
|
+
#timer: ReturnType<typeof setTimeout> | null = null
|
|
151
|
+
#capabilityMissing = false
|
|
152
|
+
#updates = 0
|
|
153
|
+
#failures = 0
|
|
154
|
+
|
|
155
|
+
constructor(opts: { dbPath: string; debounceMs?: number; onError?: (e: Error) => void }) {
|
|
156
|
+
this.#dbPath = opts.dbPath
|
|
157
|
+
this.#debounceMs = opts.debounceMs ?? 2_000
|
|
158
|
+
this.#onError = opts.onError ?? (e => { try { console.error(`[index-updater] ${e.message}`) } catch { /* 收口期 stderr 不可用 */ } })
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
get stats(): { updates: number; failures: number } {
|
|
162
|
+
return { updates: this.#updates, failures: this.#failures }
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** append sink 链上调用(行 JSON):seq 权威增量;非预期行防御性忽略 */
|
|
166
|
+
record(sessionId: string, line: string, file: string): void {
|
|
167
|
+
if (this.#capabilityMissing) return
|
|
168
|
+
let e: { seq?: unknown; ts?: unknown; hash?: unknown }
|
|
169
|
+
try { e = JSON.parse(line) as { seq?: unknown; ts?: unknown; hash?: unknown } } catch { return }
|
|
170
|
+
const seq = Number(e.seq)
|
|
171
|
+
const ts = Number(e.ts)
|
|
172
|
+
const hash = typeof e.hash === 'string' ? e.hash : ''
|
|
173
|
+
if (!Number.isFinite(seq) || !Number.isFinite(ts) || !hash) return
|
|
174
|
+
// realpath 规范化(1.2-BUG-4):macOS tmpdir 为 symlink(/var → /private/var),子进程 cwd 由
|
|
175
|
+
// OS 返回物理路径——resolve() 不解析符号链接,父子进程会产出同一文件的两个别名形态;
|
|
176
|
+
// 入库统一为 realpath(文件刚被 append 过必存在;异常回退 resolve)
|
|
177
|
+
const canonical = (() => {
|
|
178
|
+
try { return realpathSync(file) } catch { return resolve(file) }
|
|
179
|
+
})()
|
|
180
|
+
const s = this.#state
|
|
181
|
+
if (!s || s.sessionId !== sessionId || s.file !== canonical) {
|
|
182
|
+
this.#state = { sessionId, file: canonical, eventCount: seq + 1, chainHead: hash, chainTail: hash, firstTs: ts, lastTs: ts }
|
|
183
|
+
} else {
|
|
184
|
+
s.eventCount = Math.max(s.eventCount, seq + 1)
|
|
185
|
+
s.chainTail = hash
|
|
186
|
+
s.lastTs = ts
|
|
187
|
+
}
|
|
188
|
+
this.#timer ??= setTimeout(() => { void this.flush() }, this.#debounceMs)
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** 立即落 pending(close/exit 时点调用;幂等——无 pending 即空转) */
|
|
192
|
+
async flush(): Promise<void> {
|
|
193
|
+
if (this.#timer) { clearTimeout(this.#timer); this.#timer = null }
|
|
194
|
+
if (!this.#state) return
|
|
195
|
+
const s = this.#state
|
|
196
|
+
const row: SessionIndexRow = { ...s, tornTail: false, encoding: 'plain' }
|
|
197
|
+
try {
|
|
198
|
+
await upsertSessionRow(this.#dbPath, row)
|
|
199
|
+
this.#updates++
|
|
200
|
+
} catch (e) {
|
|
201
|
+
this.#failures++
|
|
202
|
+
const msg = (e as Error).message
|
|
203
|
+
if (msg.startsWith('CAR-E-SQLITE')) {
|
|
204
|
+
// 能力缺席:登记一次后停用(后续 record 不再调度——禁每窗口重复刷屏)
|
|
205
|
+
this.#capabilityMissing = true
|
|
206
|
+
}
|
|
207
|
+
this.#onError(e as Error)
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** 收口:取消未决 timer + 落 pending(car run 关句柄前调用) */
|
|
212
|
+
async close(): Promise<void> {
|
|
213
|
+
await this.flush()
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
42
217
|
function scanLogFiles(root: string): string[] {
|
|
43
218
|
const rels = readdirSync(root, { recursive: true }) as string[]
|
|
44
219
|
return rels
|
|
@@ -198,7 +198,8 @@ test('M8: 遥测默认关——noop 句柄 + 零出站 + 零积累(可机器
|
|
|
198
198
|
t.getMeter().createCounter('c').add(1)
|
|
199
199
|
await t.flush()
|
|
200
200
|
await t.shutdown()
|
|
201
|
-
|
|
201
|
+
// 1.2-S3 stats 加法字段(spansSampledOut/queueOverflows)——noop 全零意图不变
|
|
202
|
+
assert.deepEqual(t.stats(), { spansEnded: 0, spansSampledOut: 0, tracesExported: 0, metricsExported: 0, droppedExports: 0, queueOverflows: 0 })
|
|
202
203
|
})
|
|
203
204
|
|
|
204
205
|
test('M8: 遥测显式开——OTLP/HTTP 形状出站 + stats + 端点不可达静默丢弃(BD-05)', async () => {
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 1.2 · 会话索引后续项(S28):--session id 经索引定位(W3-1)+ 运行时异步索引更新(W3-2)
|
|
3
|
+
*
|
|
4
|
+
* 覆盖(1.2-迭代规划 W3-1/W3-2;M7 §4/§5 登记后续出清):
|
|
5
|
+
* - lookupSession:命中全字段还原 / 未收录 null / 索引不存在 / 索引文件无效显式报错
|
|
6
|
+
* - CLI 三入口 --session 全链:verify / replay / export 经索引定位;互斥、缺 db、未收录显式报错
|
|
7
|
+
* - IndexUpdater:append → flush → 索引可见(自举建库建表);快照覆盖;close flush;失败非致命
|
|
8
|
+
* - 生产调用点:car run 真进程 append → sessions-index.db 自动可见(「更新半边」闭环)
|
|
9
|
+
*/
|
|
10
|
+
import { test } from 'node:test'
|
|
11
|
+
import assert from 'node:assert/strict'
|
|
12
|
+
import { spawn } from 'node:child_process'
|
|
13
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs'
|
|
14
|
+
import { tmpdir } from 'node:os'
|
|
15
|
+
import { join, dirname } from 'node:path'
|
|
16
|
+
import { fileURLToPath } from 'node:url'
|
|
17
|
+
import { SessionLog } from '../src/session/log.ts'
|
|
18
|
+
import { SessionFileStore } from '../src/session/store.ts'
|
|
19
|
+
import { rebuildIndex, lookupSession, IndexUpdater } from '../src/session/indexStore.ts'
|
|
20
|
+
|
|
21
|
+
const CLI = join(dirname(fileURLToPath(import.meta.url)), '..', 'src', 'cli.ts')
|
|
22
|
+
|
|
23
|
+
function withTempDir(name: string, fn: (dir: string) => Promise<void> | void): Promise<void> {
|
|
24
|
+
const dir = mkdtempSync(join(tmpdir(), `car-s28-${name}-`))
|
|
25
|
+
try {
|
|
26
|
+
const r = fn(dir)
|
|
27
|
+
const cleanup = () => { try { rmSync(dir, { recursive: true, force: true }) } catch { /* 红线 8 */ } }
|
|
28
|
+
if (r instanceof Promise) return r.finally(cleanup)
|
|
29
|
+
cleanup()
|
|
30
|
+
return Promise.resolve()
|
|
31
|
+
} catch (e) {
|
|
32
|
+
try { rmSync(dir, { recursive: true, force: true }) } catch { /* 红线 8 */ }
|
|
33
|
+
throw e
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** 写一个链完整会话文件(car run 同构:log + attachSink(fs store)),返回 sessionId */
|
|
38
|
+
function makeSession(dir: string, file: string, sessionId: string, turns = 2): string {
|
|
39
|
+
const log = new SessionLog(sessionId)
|
|
40
|
+
const store = new SessionFileStore(join(dir, file))
|
|
41
|
+
log.attachSink(line => store.append(line))
|
|
42
|
+
for (let i = 0; i < turns; i++) {
|
|
43
|
+
log.append('user', 'user', `T${i}`, `question ${i}`)
|
|
44
|
+
log.append('model', 'assistant', `T${i}`, `answer ${i}`)
|
|
45
|
+
}
|
|
46
|
+
store.close()
|
|
47
|
+
return sessionId
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function car(args: string[], opts: { cwd?: string } = {}): Promise<{ code: number; stdout: string; stderr: string }> {
|
|
51
|
+
return new Promise((resolve, reject) => {
|
|
52
|
+
const child = spawn(process.execPath, ['--experimental-transform-types', CLI, ...args], { cwd: opts.cwd, stdio: ['ignore', 'pipe', 'pipe'] })
|
|
53
|
+
let out = ''
|
|
54
|
+
let err = ''
|
|
55
|
+
child.stdout.on('data', d => { out += d })
|
|
56
|
+
child.stderr.on('data', d => { err += d })
|
|
57
|
+
child.on('error', reject)
|
|
58
|
+
child.on('exit', code => resolve({ code: code ?? -1, stdout: out, stderr: err }))
|
|
59
|
+
})
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ==================== lookupSession(W3-1) ====================
|
|
63
|
+
|
|
64
|
+
test('S28: lookupSession——命中全字段还原 / 未收录 null / 索引不存在 / 索引文件无效显式报错', async () => {
|
|
65
|
+
await withTempDir('lookup', async dir => {
|
|
66
|
+
makeSession(dir, 'session-S-lookup-a.jsonl', 'S-lookup-a')
|
|
67
|
+
makeSession(dir, 'session-S-lookup-b.jsonl', 'S-lookup-b')
|
|
68
|
+
const dbPath = join(dir, 'sessions-index.db')
|
|
69
|
+
const r = await rebuildIndex(dir, dbPath)
|
|
70
|
+
assert.equal(r.errors.length, 0)
|
|
71
|
+
|
|
72
|
+
const hit = await lookupSession(dbPath, 'S-lookup-a')
|
|
73
|
+
assert.ok(hit)
|
|
74
|
+
assert.equal(hit.sessionId, 'S-lookup-a')
|
|
75
|
+
assert.equal(hit.file, join(dir, 'session-S-lookup-a.jsonl'))
|
|
76
|
+
assert.equal(hit.eventCount, 4)
|
|
77
|
+
assert.equal(hit.tornTail, false)
|
|
78
|
+
assert.equal(hit.encoding, 'plain')
|
|
79
|
+
|
|
80
|
+
assert.equal(await lookupSession(dbPath, 'S-missing'), null, '未收录 = null(可索引 = 可验证)')
|
|
81
|
+
|
|
82
|
+
await assert.rejects(
|
|
83
|
+
() => lookupSession(join(dir, 'nope.db'), 'S-lookup-a'),
|
|
84
|
+
/CAR-E-INDEX: 会话索引不存在/,
|
|
85
|
+
'db 缺文件显式报错(lookup 不产生建文件副作用)',
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
const bad = join(dir, 'bad.db')
|
|
89
|
+
writeFileSync(bad, 'this is not a sqlite database')
|
|
90
|
+
await assert.rejects(() => lookupSession(bad, 'S-lookup-a'), /CAR-E-INDEX: 索引文件无效/)
|
|
91
|
+
})
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
test('S28: CLI --session 三入口——verify/replay/export 经索引定位全链(真进程)', async () => {
|
|
95
|
+
await withTempDir('cli-session-flag', async dir => {
|
|
96
|
+
const sid = makeSession(dir, 'session-S-cli-s1.jsonl', 'S-cli-s1')
|
|
97
|
+
await rebuildIndex(dir, join(dir, 'sessions-index.db'))
|
|
98
|
+
|
|
99
|
+
const v = await car(['session', 'verify', '--session', sid, '--db', 'sessions-index.db'], { cwd: dir })
|
|
100
|
+
assert.equal(v.code, 0, v.stderr)
|
|
101
|
+
assert.match(v.stdout, /哈希链完整:4 事件/)
|
|
102
|
+
|
|
103
|
+
const rp = await car(['session', 'replay', '--session', sid, '--db', 'sessions-index.db'], { cwd: dir })
|
|
104
|
+
assert.equal(rp.code, 0, rp.stderr)
|
|
105
|
+
assert.match(rp.stdout, /"role":"user"/)
|
|
106
|
+
assert.match(rp.stdout, /answer 1/)
|
|
107
|
+
|
|
108
|
+
const ex = await car(['session', 'export', '--session', sid, '--out', 'out-bundle', '--db', 'sessions-index.db'], { cwd: dir })
|
|
109
|
+
assert.equal(ex.code, 0, ex.stderr)
|
|
110
|
+
assert.match(ex.stdout, /取证包导出:4 事件/)
|
|
111
|
+
assert.ok(existsSync(join(dir, 'out-bundle', 'manifest.json')))
|
|
112
|
+
})
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
test('S28: CLI --session 显式拒绝面——与位置路径互斥 / 缺 db / 未收录 / 坏格式会话不入索引(真进程)', async () => {
|
|
116
|
+
await withTempDir('cli-session-flag-reject', async dir => {
|
|
117
|
+
const sid = makeSession(dir, 'session-S-cli-r1.jsonl', 'S-cli-r1')
|
|
118
|
+
// 与位置路径互斥
|
|
119
|
+
const both = await car(['session', 'verify', 's1.jsonl', '--session', sid, '--db', 'sessions-index.db'], { cwd: dir })
|
|
120
|
+
assert.equal(both.code, 2)
|
|
121
|
+
assert.match(both.stderr, /互斥/)
|
|
122
|
+
// 未建索引 + cwd 无 sessions-index.db → 显式报错提示 rebuild
|
|
123
|
+
const nodb = await car(['session', 'verify', '--session', sid], { cwd: dir })
|
|
124
|
+
assert.equal(nodb.code, 2)
|
|
125
|
+
assert.match(nodb.stderr, /CAR-E-INDEX: 会话索引不存在/)
|
|
126
|
+
// 建索引后查不存在的 id → 未收录显式报错
|
|
127
|
+
await rebuildIndex(dir, join(dir, 'sessions-index.db'))
|
|
128
|
+
const miss = await car(['session', 'verify', '--session', 'S-absent', '--db', 'sessions-index.db'], { cwd: dir })
|
|
129
|
+
assert.equal(miss.code, 2)
|
|
130
|
+
assert.match(miss.stderr, /未收录索引/)
|
|
131
|
+
// 坏格式会话(完整非法行)不入索引(「可索引 = 可验证」)→ --session 定位失败
|
|
132
|
+
const bf = join(dir, 'session-S-broken.jsonl')
|
|
133
|
+
makeSession(dir, 'session-S-broken.jsonl', 'S-broken')
|
|
134
|
+
writeFileSync(bf, readFileSync(bf, 'utf-8') + 'not-a-json-line\n')
|
|
135
|
+
const rb = await rebuildIndex(dir, join(dir, 'broken.db'))
|
|
136
|
+
assert.equal(rb.rows.some(r => r.sessionId === 'S-broken'), false, '坏格式文件不入索引(errors 通道)')
|
|
137
|
+
assert.equal(rb.errors.length, 1)
|
|
138
|
+
const broken = await car(['session', 'verify', '--session', 'S-broken', '--db', 'broken.db'], { cwd: dir })
|
|
139
|
+
assert.equal(broken.code, 2)
|
|
140
|
+
assert.match(broken.stderr, /未收录索引/)
|
|
141
|
+
})
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
// ==================== IndexUpdater(W3-2) ====================
|
|
145
|
+
|
|
146
|
+
test('S28: IndexUpdater——append 后 flush 索引可见(自举建库建表)/ 快照覆盖 / close flush(真 sqlite)', async () => {
|
|
147
|
+
await withTempDir('updater', async dir => {
|
|
148
|
+
const dbPath = join(dir, 'sessions-index.db')
|
|
149
|
+
const log = new SessionLog('S-updater-1')
|
|
150
|
+
const store = new SessionFileStore(join(dir, 'session-S-updater-1.jsonl'))
|
|
151
|
+
const file = join(dir, 'session-S-updater-1.jsonl')
|
|
152
|
+
const updater = new IndexUpdater({ dbPath, debounceMs: 30 })
|
|
153
|
+
// 单 sink 语义(SessionLog.attachSink 覆盖式)——更新器经链式包装接入,不改日志层;
|
|
154
|
+
// sink 先于事件入内存(fail-fast),故 record 按行增量(seq/hash 自行数据)
|
|
155
|
+
log.attachSink(line => { store.append(line); updater.record('S-updater-1', line, file) })
|
|
156
|
+
|
|
157
|
+
log.append('user', 'user', 'T0', 'q0')
|
|
158
|
+
await updater.flush()
|
|
159
|
+
assert.equal(updater.stats.updates, 1)
|
|
160
|
+
let row = await lookupSession(dbPath, 'S-updater-1')
|
|
161
|
+
assert.ok(row, 'flush 后索引可见(无 rebuild 自举)')
|
|
162
|
+
assert.equal(row!.eventCount, 1)
|
|
163
|
+
|
|
164
|
+
// debounce 窗口内多次 record 合并为一次 upsert,行快照 = 最新 append 时点全量
|
|
165
|
+
log.append('model', 'assistant', 'T0', 'a0')
|
|
166
|
+
log.append('user', 'user', 'T1', 'q1')
|
|
167
|
+
await updater.flush()
|
|
168
|
+
row = await lookupSession(dbPath, 'S-updater-1')
|
|
169
|
+
assert.equal(row!.eventCount, 3)
|
|
170
|
+
|
|
171
|
+
// close flush(car run 关句柄前收口)
|
|
172
|
+
log.append('user', 'user', 'T2', 'q2')
|
|
173
|
+
await updater.close()
|
|
174
|
+
assert.equal(updater.stats.updates, 3)
|
|
175
|
+
row = await lookupSession(dbPath, 'S-updater-1')
|
|
176
|
+
assert.equal(row!.eventCount, 4)
|
|
177
|
+
assert.equal(row!.tornTail, false, '活会话按构造完整(tornTail=false 口径)')
|
|
178
|
+
assert.equal(row!.encoding, 'plain')
|
|
179
|
+
store.close()
|
|
180
|
+
})
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
test('S28: IndexUpdater 失败非致命——db 路径不可用持续留痕不抛出(append 路径零影响)', async () => {
|
|
184
|
+
await withTempDir('updater-fail', async dir => {
|
|
185
|
+
// dbPath 指向目录 → open 必败
|
|
186
|
+
const badPath = join(dir, 'a-directory')
|
|
187
|
+
mkdirSync(badPath)
|
|
188
|
+
const errors: string[] = []
|
|
189
|
+
const updater = new IndexUpdater({ dbPath: badPath, onError: e => errors.push(e.message) })
|
|
190
|
+
const log = new SessionLog('S-fail-1')
|
|
191
|
+
const store = new SessionFileStore(join(dir, 'x.jsonl'))
|
|
192
|
+
log.attachSink(line => { store.append(line); updater.record('S-fail-1', line, join(dir, 'x.jsonl')) })
|
|
193
|
+
|
|
194
|
+
// 事件照常落盘(updater 错误绝不传播到 append 路径——R-2 钉死)
|
|
195
|
+
log.append('user', 'user', 'T0', 'q')
|
|
196
|
+
await updater.flush()
|
|
197
|
+
assert.equal(updater.stats.failures, 1)
|
|
198
|
+
assert.ok(errors[0], '错误经 onError 留痕(不向调用方抛出)')
|
|
199
|
+
assert.match(errors[0]!, /CAR-E-INDEX|CAR-E-SQLITE/)
|
|
200
|
+
|
|
201
|
+
// 继续使用不抛出
|
|
202
|
+
log.append('user', 'user', 'T1', 'q')
|
|
203
|
+
await updater.close()
|
|
204
|
+
assert.equal(updater.stats.failures, 2)
|
|
205
|
+
// 落盘本体未受影响:文件行数 = 事件数
|
|
206
|
+
assert.equal(readFileSync(join(dir, 'x.jsonl'), 'utf-8').trim().split('\n').length, 2)
|
|
207
|
+
store.close()
|
|
208
|
+
})
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
test('S28: 生产调用点——car run 真进程 append → cwd sessions-index.db 自动可见(「更新半边」闭环)', async () => {
|
|
212
|
+
await withTempDir('car-run-index', async dir => {
|
|
213
|
+
// 工具名避开 demo_tool:CLI 演示脚手架硬编码注册 demo_tool,同名插件工具触发预存在的 provide
|
|
214
|
+
// 撞名边缘(登记不修——W1 重做 car run 装配面时出清)
|
|
215
|
+
writeFileSync(join(dir, 'p.ts'), `export const manifest = { name: 'p', version: '1.0.0' }\nexport default function apply(api) {\n api.registerTool({ name: 't', run: async () => 'ok' })\n}\n`)
|
|
216
|
+
const r = await car(['run', 'p.ts'], { cwd: dir })
|
|
217
|
+
assert.equal(r.code, 0, r.stderr)
|
|
218
|
+
const dbPath = join(dir, 'sessions-index.db')
|
|
219
|
+
assert.ok(existsSync(dbPath), 'car run 运行后 cwd 索引自举在位')
|
|
220
|
+
// 经 sqlite 直查(run 会话 id 为 S-<time36> 运行期生成,全表核对)
|
|
221
|
+
const mod = await import('node:sqlite')
|
|
222
|
+
const DatabaseSync = (mod as unknown as { DatabaseSync: new (path: string) => { prepare(s: string): { all(...v: unknown[]): Record<string, unknown>[] }; close(): void } }).DatabaseSync
|
|
223
|
+
const db = new DatabaseSync(dbPath)
|
|
224
|
+
try {
|
|
225
|
+
const all = db.prepare('SELECT sessionId, eventCount, file FROM sessions').all()
|
|
226
|
+
assert.ok(all.length >= 1, 'run 会话入索引')
|
|
227
|
+
const hit = all.find(x => String(x.sessionId).startsWith('S-'))
|
|
228
|
+
assert.ok(hit, 'run 会话 id 在索引')
|
|
229
|
+
// realpath 比较(1.2-BUG-4):macOS 子进程 cwd 为物理路径 /private/var…,父进程持逻辑路径 /var…
|
|
230
|
+
assert.equal(realpathSync(String(hit!.file)), realpathSync(join(dir, `session-${hit!.sessionId}.jsonl`)))
|
|
231
|
+
assert.ok(Number(hit!.eventCount) >= 3, '事件计数 = run 全程 append 量(装配+对话+收口)')
|
|
232
|
+
} finally {
|
|
233
|
+
db.close()
|
|
234
|
+
}
|
|
235
|
+
})
|
|
236
|
+
})
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 1.2 · OTLP exporter 生产级策略(S29):有界重试 / 采样 / 批上限 / env 通道 / mcp-serve meter 桥
|
|
3
|
+
*
|
|
4
|
+
* 覆盖(1.2-迭代规划 W3-3;M8 §4.4 登记后续出清;D-12b 口径修订 0 重试 → 有界 ≤2):
|
|
5
|
+
* - 重试:429/5xx/网络错误退避重试(序列 1s/2s 可注入)→ 成功计数;耗尽 droppedExports 静默丢弃;
|
|
6
|
+
* 4xx 业务错不重试;res.ok 语义(旧实现 5xx 也计成功——本代修正为显式状态判定)
|
|
7
|
+
* - 采样:always_on 缺省行为不变 / always_off 全采出 / ratio 概率边界;spansSampledOut 计数
|
|
8
|
+
* - 批上限:maxBatchSize 分批(tracesExported 按请求计)/ maxQueueSize 溢出丢最旧 queueOverflows
|
|
9
|
+
* - env 通道:telemetryConfigFromEnv(endpoint 唯一开关 / sampling 三形态 / 非法值保持缺省)
|
|
10
|
+
* - 生产调用点:mcp-serve 真进程 CAR_OTEL_ENDPOINT → 本地 http 捕获端点断言 OTLP metrics 出站
|
|
11
|
+
* (S17 counters 双写桥;快照面不变);零依赖静态断言保持(telemetry 零 session import)
|
|
12
|
+
*/
|
|
13
|
+
import { test } from 'node:test'
|
|
14
|
+
import assert from 'node:assert/strict'
|
|
15
|
+
import { spawn } from 'node:child_process'
|
|
16
|
+
import { createServer, type Server } from 'node:http'
|
|
17
|
+
import { mkdtempSync, rmSync, readFileSync } from 'node:fs'
|
|
18
|
+
import { tmpdir } from 'node:os'
|
|
19
|
+
import { join, dirname } from 'node:path'
|
|
20
|
+
import { fileURLToPath } from 'node:url'
|
|
21
|
+
import { createTelemetryFacade, telemetryConfigFromEnv, type TelemetryConfig } from '../src/runtime-core/telemetry.ts'
|
|
22
|
+
|
|
23
|
+
const CLI = join(dirname(fileURLToPath(import.meta.url)), '..', 'src', 'cli.ts')
|
|
24
|
+
|
|
25
|
+
const sleepsOf = (log: number[]) => async (ms: number) => { log.push(ms) }
|
|
26
|
+
const okRes = () => ({ ok: true, status: 200 }) as unknown as Response
|
|
27
|
+
const statusRes = (status: number) => ({ ok: false, status }) as unknown as Response
|
|
28
|
+
|
|
29
|
+
function withTempDir(name: string, fn: (dir: string) => Promise<void> | void): Promise<void> {
|
|
30
|
+
const dir = mkdtempSync(join(tmpdir(), `car-s29-${name}-`))
|
|
31
|
+
try {
|
|
32
|
+
const r = fn(dir)
|
|
33
|
+
const cleanup = () => { try { rmSync(dir, { recursive: true, force: true }) } catch { /* 红线 8 */ } }
|
|
34
|
+
if (r instanceof Promise) return r.finally(cleanup)
|
|
35
|
+
cleanup()
|
|
36
|
+
return Promise.resolve()
|
|
37
|
+
} catch (e) {
|
|
38
|
+
try { rmSync(dir, { recursive: true, force: true }) } catch { /* 红线 8 */ }
|
|
39
|
+
throw e
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// ==================== env 通道 ====================
|
|
44
|
+
|
|
45
|
+
test('S29: telemetryConfigFromEnv——endpoint 唯一开关 / sampling 三形态 / 非法值保持缺省 / interval 解析', () => {
|
|
46
|
+
assert.equal(telemetryConfigFromEnv({}), null, '无 endpoint = null(默认关)')
|
|
47
|
+
const c1 = telemetryConfigFromEnv({ CAR_OTEL_ENDPOINT: 'https://otel.internal' })!
|
|
48
|
+
assert.equal(c1.endpoint, 'https://otel.internal')
|
|
49
|
+
assert.equal(c1.sampling, undefined, '缺省采样不写入(= always_on 行为)')
|
|
50
|
+
assert.equal(telemetryConfigFromEnv({ CAR_OTEL_ENDPOINT: 'https://x', CAR_OTEL_SAMPLING: 'always_off' })!.sampling, 'always_off')
|
|
51
|
+
assert.deepEqual(telemetryConfigFromEnv({ CAR_OTEL_ENDPOINT: 'https://x', CAR_OTEL_SAMPLING: '0.25' })!.sampling, { ratio: 0.25 })
|
|
52
|
+
assert.equal(telemetryConfigFromEnv({ CAR_OTEL_ENDPOINT: 'https://x', CAR_OTEL_SAMPLING: 'bogus' })!.sampling, undefined, '非法值保持缺省(禁 fail-hard)')
|
|
53
|
+
assert.equal(telemetryConfigFromEnv({ CAR_OTEL_ENDPOINT: 'https://x', CAR_OTEL_SAMPLING: '1.5' })!.sampling, undefined, '超界 ratio = 非法')
|
|
54
|
+
const c2 = telemetryConfigFromEnv({ CAR_OTEL_ENDPOINT: 'https://x', CAR_OTEL_INTERVAL_MS: '5000', CAR_OTEL_SERVICE_NAME: 'svc' })!
|
|
55
|
+
assert.equal(c2.intervalMs, 5000)
|
|
56
|
+
assert.equal(c2.serviceName, 'svc')
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
// ==================== 有界重试(D-12b 口径) ====================
|
|
60
|
+
|
|
61
|
+
test('S29: 重试——5xx 退避后成功计数;耗尽 droppedExports 静默丢弃;4xx 不重试直接丢', async () => {
|
|
62
|
+
await withTempDir('retry', async () => {
|
|
63
|
+
const sleeps: number[] = []
|
|
64
|
+
// 500 → 500 → 200:两次退避后成功
|
|
65
|
+
let calls = 0
|
|
66
|
+
const f1 = createTelemetryFacade({ endpoint: 'https://otel', retryBackoffMs: [10, 20], sleep: sleepsOf(sleeps) }, {
|
|
67
|
+
fetchImpl: (async () => (calls++ === 2 ? okRes() : statusRes(500))) as typeof fetch,
|
|
68
|
+
})
|
|
69
|
+
f1.getTracer().startSpan('s').end()
|
|
70
|
+
await f1.flush()
|
|
71
|
+
assert.deepEqual(sleeps, [10, 20], '退避序列可注入且按序生效')
|
|
72
|
+
assert.deepEqual(f1.stats(), { spansEnded: 1, spansSampledOut: 0, tracesExported: 1, metricsExported: 0, droppedExports: 0, queueOverflows: 0 })
|
|
73
|
+
|
|
74
|
+
// 恒 429:耗尽 2 次重试(3 次尝试)→ 静默丢弃,flush 不抛错
|
|
75
|
+
let tries = 0
|
|
76
|
+
const f2 = createTelemetryFacade({ endpoint: 'https://otel', retryLimit: 2, retryBackoffMs: [1, 1], sleep: sleepsOf([]) }, {
|
|
77
|
+
fetchImpl: (async () => (tries++, statusRes(429))) as typeof fetch,
|
|
78
|
+
})
|
|
79
|
+
f2.getTracer().startSpan('s').end()
|
|
80
|
+
await f2.flush()
|
|
81
|
+
assert.equal(tries, 3, '1 原始 + 2 重试')
|
|
82
|
+
assert.equal(f2.stats().droppedExports, 1)
|
|
83
|
+
assert.equal(f2.stats().tracesExported, 0)
|
|
84
|
+
|
|
85
|
+
// 400 业务错:不重试,立即丢弃
|
|
86
|
+
let tries4xx = 0
|
|
87
|
+
const f3 = createTelemetryFacade({ endpoint: 'https://otel', sleep: sleepsOf([]) }, {
|
|
88
|
+
fetchImpl: (async () => (tries4xx++, statusRes(400))) as typeof fetch,
|
|
89
|
+
})
|
|
90
|
+
f3.getTracer().startSpan('s').end()
|
|
91
|
+
await f3.flush()
|
|
92
|
+
assert.equal(tries4xx, 1, '4xx 不重试')
|
|
93
|
+
assert.equal(f3.stats().droppedExports, 1)
|
|
94
|
+
|
|
95
|
+
// 网络错误(fetch throw):可重试,耗尽后静默
|
|
96
|
+
let netTries = 0
|
|
97
|
+
const f4 = createTelemetryFacade({ endpoint: 'https://otel', retryLimit: 1, retryBackoffMs: [1], sleep: sleepsOf([]) }, {
|
|
98
|
+
fetchImpl: (async () => (netTries++, Promise.reject(new Error('ECONNREFUSED')))) as unknown as typeof fetch,
|
|
99
|
+
})
|
|
100
|
+
f4.getTracer().startSpan('s').end()
|
|
101
|
+
await f4.flush()
|
|
102
|
+
assert.equal(netTries, 2)
|
|
103
|
+
assert.equal(f4.stats().droppedExports, 1)
|
|
104
|
+
})
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
test('S29: res.ok 显式语义——5xx 不再被计为成功(旧实现口径修正的回归钉)', async () => {
|
|
108
|
+
await withTempDir('ok-semantics', async () => {
|
|
109
|
+
const f = createTelemetryFacade({ endpoint: 'https://otel', retryLimit: 0, sleep: sleepsOf([]) }, {
|
|
110
|
+
fetchImpl: (async () => statusRes(503)) as typeof fetch,
|
|
111
|
+
})
|
|
112
|
+
f.getTracer().startSpan('s').end()
|
|
113
|
+
await f.flush()
|
|
114
|
+
assert.equal(f.stats().tracesExported, 0, '非 2xx 不计成功')
|
|
115
|
+
assert.equal(f.stats().droppedExports, 1)
|
|
116
|
+
})
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
// ==================== 采样 ====================
|
|
120
|
+
|
|
121
|
+
test('S29: 采样——always_on 缺省全量(1.1 行为不变)/ always_off 全采出 / ratio=0 与 1 边界', async () => {
|
|
122
|
+
await withTempDir('sampling', async () => {
|
|
123
|
+
const posts: unknown[] = []
|
|
124
|
+
const mk = (cfg: TelemetryConfig) => createTelemetryFacade({ endpoint: 'https://otel', ...cfg }, {
|
|
125
|
+
fetchImpl: (async (_url: string | URL, init?: RequestInit) => { posts.push(init?.body); return okRes() }) as typeof fetch,
|
|
126
|
+
})
|
|
127
|
+
// always_on(缺省不写 sampling):全量出站
|
|
128
|
+
const f1 = mk({})
|
|
129
|
+
for (let i = 0; i < 3; i++) f1.getTracer().startSpan(`s${i}`).end()
|
|
130
|
+
await f1.flush()
|
|
131
|
+
assert.equal(f1.stats().spansSampledOut, 0)
|
|
132
|
+
assert.equal(f1.stats().tracesExported, 1)
|
|
133
|
+
// always_off:全采出,零出站
|
|
134
|
+
const f2 = mk({ sampling: 'always_off' })
|
|
135
|
+
for (let i = 0; i < 3; i++) f2.getTracer().startSpan(`s${i}`).end()
|
|
136
|
+
await f2.flush()
|
|
137
|
+
assert.equal(f2.stats().spansSampledOut, 3)
|
|
138
|
+
assert.equal(f2.stats().tracesExported, 0)
|
|
139
|
+
// ratio 0 / 1 边界
|
|
140
|
+
const f3 = mk({ sampling: { ratio: 0 } })
|
|
141
|
+
f3.getTracer().startSpan('s').end()
|
|
142
|
+
assert.equal(f3.stats().spansSampledOut, 1)
|
|
143
|
+
const f4 = mk({ sampling: { ratio: 1 } })
|
|
144
|
+
f4.getTracer().startSpan('s').end()
|
|
145
|
+
assert.equal(f4.stats().spansSampledOut, 0)
|
|
146
|
+
})
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
// ==================== 批上限 ====================
|
|
150
|
+
|
|
151
|
+
test('S29: 批上限——maxBatchSize 分批(tracesExported 按请求计)/ maxQueueSize 溢出丢最旧', async () => {
|
|
152
|
+
await withTempDir('batch', async () => {
|
|
153
|
+
const bodies: any[] = []
|
|
154
|
+
const f = createTelemetryFacade({ endpoint: 'https://otel', maxBatchSize: 2, maxQueueSize: 5 }, {
|
|
155
|
+
fetchImpl: (async (_u: string | URL, init?: RequestInit) => { bodies.push(JSON.parse(String(init?.body))); return okRes() }) as typeof fetch,
|
|
156
|
+
})
|
|
157
|
+
for (let i = 0; i < 5; i++) f.getTracer().startSpan(`s${i}`).end()
|
|
158
|
+
await f.flush()
|
|
159
|
+
assert.equal(bodies.length, 3, '5 spans / batch=2 → 3 请求')
|
|
160
|
+
assert.deepEqual(bodies.map(b => b.scopeSpans[0].spans.length), [2, 2, 1])
|
|
161
|
+
assert.equal(f.stats().tracesExported, 3, 'tracesExported 按成功出站请求计数')
|
|
162
|
+
|
|
163
|
+
// 溢出丢最旧:queue=3,end 5 个 → 丢最旧 2 个,flush 导出最新 3 个
|
|
164
|
+
const f2 = createTelemetryFacade({ endpoint: 'https://otel', maxQueueSize: 3 }, {
|
|
165
|
+
fetchImpl: (async (_u: string | URL, init?: RequestInit) => { bodies.push(JSON.parse(String(init?.body))); return okRes() }) as typeof fetch,
|
|
166
|
+
})
|
|
167
|
+
for (let i = 0; i < 5; i++) f2.getTracer().startSpan(`s${i}`).end()
|
|
168
|
+
assert.equal(f2.stats().queueOverflows, 2)
|
|
169
|
+
await f2.flush()
|
|
170
|
+
const names = bodies[bodies.length - 1]!.scopeSpans[0].spans.map((s: { name: string }) => s.name)
|
|
171
|
+
assert.deepEqual(names, ['s2', 's3', 's4'], '溢出丢最旧(保留最新)')
|
|
172
|
+
})
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
// ==================== 默认关(noop)不变 ====================
|
|
176
|
+
|
|
177
|
+
test('S29: 默认关三原则——无 endpoint = noop 零出站(fetch 不被调用)+ stats 全零', async () => {
|
|
178
|
+
await withTempDir('default-off', async () => {
|
|
179
|
+
let called = 0
|
|
180
|
+
const f = createTelemetryFacade(null, { fetchImpl: (async () => (called++, okRes())) as typeof fetch })
|
|
181
|
+
assert.equal(f.enabled, false)
|
|
182
|
+
f.getTracer().startSpan('s').end()
|
|
183
|
+
f.getMeter().createCounter('c').add(1)
|
|
184
|
+
await f.flush()
|
|
185
|
+
await f.shutdown()
|
|
186
|
+
assert.equal(called, 0, '零出站')
|
|
187
|
+
assert.deepEqual(f.stats(), { spansEnded: 0, spansSampledOut: 0, tracesExported: 0, metricsExported: 0, droppedExports: 0, queueOverflows: 0 })
|
|
188
|
+
})
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
// ==================== 零依赖解耦红线(静态断言保持) ====================
|
|
192
|
+
|
|
193
|
+
test('S29: 严格解耦——telemetry.ts 零 import 自 session/*(静态断言)', () => {
|
|
194
|
+
const src = readFileSync(join(dirname(fileURLToPath(import.meta.url)), '..', 'src', 'runtime-core', 'telemetry.ts'), 'utf-8')
|
|
195
|
+
assert.equal(/from\s+'\.\.\/session\//.test(src), false)
|
|
196
|
+
assert.equal(/from\s+'\.\/\.\.\/session\//.test(src), false)
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
// ==================== 生产调用点:mcp-serve meter 桥(真进程 + 本地捕获端点) ====================
|
|
200
|
+
|
|
201
|
+
test('S29: mcp-serve 生产接线——CAR_OTEL_ENDPOINT 显式开 → S17 counters 双写出站 OTLP metrics(快照面不变)', async () => {
|
|
202
|
+
await withTempDir('mcp-otel', async dir => {
|
|
203
|
+
const bodies: any[] = []
|
|
204
|
+
let done: (v: void) => void = () => {}
|
|
205
|
+
const closed = new Promise<void>(r => { done = r })
|
|
206
|
+
const server: Server = createServer((req, res) => {
|
|
207
|
+
let body = ''
|
|
208
|
+
req.on('data', c => { body += c })
|
|
209
|
+
req.on('end', () => {
|
|
210
|
+
if (req.url === '/v1/metrics') bodies.push(JSON.parse(body))
|
|
211
|
+
res.writeHead(200).end()
|
|
212
|
+
})
|
|
213
|
+
})
|
|
214
|
+
await new Promise<void>(r => server.listen(0, '127.0.0.1', r))
|
|
215
|
+
const port = (server.address() as { port: number }).port
|
|
216
|
+
try {
|
|
217
|
+
const child = spawn(process.execPath, ['--experimental-transform-types', CLI, 'mcp-serve'], {
|
|
218
|
+
cwd: dir,
|
|
219
|
+
env: { ...process.env, CAR_OTEL_ENDPOINT: `http://127.0.0.1:${port}`, CAR_OTEL_INTERVAL_MS: '0' },
|
|
220
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
221
|
+
})
|
|
222
|
+
let err = ''
|
|
223
|
+
child.stderr.on('data', d => { err += d })
|
|
224
|
+
child.stdin.write(JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name: 'session_start', arguments: { hostSessionId: 's29' } } }) + '\n')
|
|
225
|
+
child.stdin.end()
|
|
226
|
+
child.on('exit', () => done())
|
|
227
|
+
// macOS runner 慢启动容差(60s);落选的 race timer 必须 clear——未清时它把事件循环
|
|
228
|
+
// 挂满整个超时窗(本文件曾因此空转 20s,慢 runner 上叠加成 j03 假失败形态)
|
|
229
|
+
let raceTimer: ReturnType<typeof setTimeout> | undefined
|
|
230
|
+
try {
|
|
231
|
+
await Promise.race([
|
|
232
|
+
closed,
|
|
233
|
+
new Promise<never>((_, rej) => { raceTimer = setTimeout(() => rej(new Error(`mcp-serve 超时\n${err}`)), 60_000) }),
|
|
234
|
+
])
|
|
235
|
+
} finally {
|
|
236
|
+
if (raceTimer) clearTimeout(raceTimer)
|
|
237
|
+
}
|
|
238
|
+
// S17 快照面不变(双写非迁移)
|
|
239
|
+
assert.match(err, /snapshot=/)
|
|
240
|
+
assert.match(err, /zeroContent=true/)
|
|
241
|
+
assert.match(err, /otel=enabled/)
|
|
242
|
+
// OTLP metrics 出站:counter 名 + 零内容 labels 到达用户端点
|
|
243
|
+
assert.ok(bodies.length >= 1, 'metrics POST 到达捕获端点')
|
|
244
|
+
const names = bodies.flatMap(b => b.scopeMetrics[0].metrics.map((m: { name: string }) => m.name))
|
|
245
|
+
assert.ok(names.includes('car_registry_decision'), 'S17 counter 名出站')
|
|
246
|
+
assert.equal(/sk-|ghp_/.test(JSON.stringify(bodies)), false, '出站体无凭据形态(零内容口径)')
|
|
247
|
+
} finally {
|
|
248
|
+
server.closeAllConnections()
|
|
249
|
+
server.close()
|
|
250
|
+
}
|
|
251
|
+
})
|
|
252
|
+
})
|