@lqc123qwe/car-runtime 1.0.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.
@@ -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
+ })