@lqc123qwe/car-runtime 1.5.0 → 1.7.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 +29 -2
- package/package.json +2 -1
- package/scripts/release-pipeline.ts +19 -2
- package/src/cli.ts +36 -7
- package/src/host/facade.ts +44 -7
- package/src/host/hostGateway.ts +2 -1
- package/src/host/stdio.ts +11 -4
- package/src/load/config.ts +18 -13
- package/src/runtime-core/telemetry.ts +45 -4
- package/src/runtime-core/trace.ts +13 -2
- package/src/session/log.ts +22 -4
- package/test/s36-host-tool-surface.spec.ts +250 -0
- package/test/s37-projection-dedupe.spec.ts +161 -0
- package/test/s38-config-jiti.spec.ts +116 -0
- package/test/s39-otlp-span-semantics.spec.ts +100 -0
- package/test/s40-metrics-cumulative.spec.ts +67 -0
- package/test/s41-mcp-serve-turn-span.spec.ts +148 -0
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 1.7-S2 · metrics CUMULATIVE 时序语义修正(D-27):累计基数保持
|
|
3
|
+
*
|
|
4
|
+
* 覆盖(1.7-迭代规划 TELE-2;1.2-S3 起 clear() 与 AGGREGATION_TEMPORALITY_CUMULATIVE 标注矛盾出清):
|
|
5
|
+
* - 同计数器双 flush:第二次导出仍为全量累计(5 → 7),非增量(5 → 2)——真 CUMULATIVE;
|
|
6
|
+
* intervalMs>0 自动导出场景下下游(Prometheus 转换器等)不再解读为总量回落;
|
|
7
|
+
* - labels 形态与 temporality 标注不变;DELTA 备选不采纳(D-27:生态主流 + S17 计数语义即累计);
|
|
8
|
+
* - 内存有界由 label 基数保证(零内容枚举红线——AllowedLabels 3 计数器)。
|
|
9
|
+
*/
|
|
10
|
+
import { test } from 'node:test'
|
|
11
|
+
import assert from 'node:assert/strict'
|
|
12
|
+
import { createTelemetryFacade } from '../src/runtime-core/telemetry.ts'
|
|
13
|
+
|
|
14
|
+
const okRes = () => ({ ok: true, status: 200 }) as unknown as Response
|
|
15
|
+
|
|
16
|
+
test('S40: 累计基数保持——同计数器双 flush 导出全量累计快照(真 CUMULATIVE,非增量)', async () => {
|
|
17
|
+
const bodies: any[] = []
|
|
18
|
+
const f = createTelemetryFacade({ endpoint: 'https://otel' }, {
|
|
19
|
+
fetchImpl: (async (_u: string | URL, init?: RequestInit) => { bodies.push(JSON.parse(String(init?.body))); return okRes() }) as typeof fetch,
|
|
20
|
+
})
|
|
21
|
+
const c = f.getMeter().createCounter('car_test_total')
|
|
22
|
+
c.add(5, { result: 'ok' })
|
|
23
|
+
await f.flush()
|
|
24
|
+
c.add(2, { result: 'ok' })
|
|
25
|
+
await f.flush()
|
|
26
|
+
assert.equal(bodies.length, 2, '两次 flush 各一次 metrics 出站')
|
|
27
|
+
const values = bodies.map(b => b.scopeMetrics[0].metrics[0].sum.dataPoints[0].asInt)
|
|
28
|
+
assert.deepEqual(values, [5, 7], '第二次导出 = 累计 7(1.7 前为增量 2——CUMULATIVE 语义错误)')
|
|
29
|
+
for (const b of bodies) {
|
|
30
|
+
const m = b.scopeMetrics[0].metrics[0]
|
|
31
|
+
assert.equal(m.name, 'car_test_total')
|
|
32
|
+
assert.equal(m.sum.aggregationTemporality, 'AGGREGATION_TEMPORALITY_CUMULATIVE')
|
|
33
|
+
const dp = m.sum.dataPoints[0]
|
|
34
|
+
assert.equal(dp.attributes[0].key, 'result', 'labels 形态不变')
|
|
35
|
+
assert.equal(dp.attributes[0].value.stringValue, 'ok')
|
|
36
|
+
}
|
|
37
|
+
assert.equal(f.stats().metricsExported, 2)
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
test('S40: 无新增计数 interval 下重复 flush——全量快照幂等重出(CUMULATIVE 口径正确形态)', async () => {
|
|
41
|
+
const bodies: any[] = []
|
|
42
|
+
const f = createTelemetryFacade({ endpoint: 'https://otel' }, {
|
|
43
|
+
fetchImpl: (async (_u: string | URL, init?: RequestInit) => { bodies.push(JSON.parse(String(init?.body))); return okRes() }) as typeof fetch,
|
|
44
|
+
})
|
|
45
|
+
f.getMeter().createCounter('car_load_total').add(3, { result: 'ok' })
|
|
46
|
+
await f.flush()
|
|
47
|
+
await f.flush()
|
|
48
|
+
const values = bodies.map(b => b.scopeMetrics[0].metrics[0].sum.dataPoints[0].asInt)
|
|
49
|
+
assert.deepEqual(values, [3, 3], '无新增 = 同值重出(累计快照,非清零/缺席)')
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
test('S40: 多计数器并存——各自独立累计(互不串值)', async () => {
|
|
53
|
+
const bodies: any[] = []
|
|
54
|
+
const f = createTelemetryFacade({ endpoint: 'https://otel' }, {
|
|
55
|
+
fetchImpl: (async (_u: string | URL, init?: RequestInit) => { bodies.push(JSON.parse(String(init?.body))); return okRes() }) as typeof fetch,
|
|
56
|
+
})
|
|
57
|
+
const c1 = f.getMeter().createCounter('car_load_total')
|
|
58
|
+
const c2 = f.getMeter().createCounter('car_registry_decision')
|
|
59
|
+
c1.add(1, { result: 'ok' })
|
|
60
|
+
c2.add(2, { source: 'registry' })
|
|
61
|
+
c1.add(1, { result: 'ok' })
|
|
62
|
+
await f.flush()
|
|
63
|
+
const metrics = bodies[0].scopeMetrics[0].metrics
|
|
64
|
+
assert.equal(metrics.length, 2)
|
|
65
|
+
const byName = Object.fromEntries(metrics.map((m: any) => [m.name, m.sum.dataPoints[0].asInt]))
|
|
66
|
+
assert.deepEqual(byName, { car_load_total: 2, car_registry_decision: 2 })
|
|
67
|
+
})
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 1.7-S3 · mcp-serve turn span 面(D-28):sessionTurn 挂 turn 级 span(装配层拦截)
|
|
3
|
+
*
|
|
4
|
+
* 覆盖(1.7-迭代规划 TELE-3;1.2-S3「span 面随 W1」mcp-serve 半边出清):
|
|
5
|
+
* - spawn E2E:mcp-serve 真进程 + 本地 http 捕获端点——session_turn 产 car.turn span 出站
|
|
6
|
+
* (car.turn_id=T1 回填〔facade 内部自增派生,装配层调用前不可知〕/ outcome=归一化 reason /
|
|
7
|
+
* traceId 生成 / parentSpanId 缺席=根 span);turn span 落 /v1/traces;
|
|
8
|
+
* - 非会话工具(session_start)不产 span——拦截面仅 session_turn(D-28 口径);
|
|
9
|
+
* - 既有 meter 桥与 S17 快照面不受影响(s29 全量回归保证);遥测审计禁混流红线(静态断言)s29 保持。
|
|
10
|
+
*/
|
|
11
|
+
import { test } from 'node:test'
|
|
12
|
+
import assert from 'node:assert/strict'
|
|
13
|
+
import { spawn } from 'node:child_process'
|
|
14
|
+
import { createServer, type Server } from 'node:http'
|
|
15
|
+
import { mkdtempSync, rmSync } from 'node:fs'
|
|
16
|
+
import { tmpdir } from 'node:os'
|
|
17
|
+
import { join, dirname } from 'node:path'
|
|
18
|
+
import { fileURLToPath } from 'node:url'
|
|
19
|
+
import { deriveSessionId } from '../src/host/mappings.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>): Promise<void> {
|
|
24
|
+
const dir = mkdtempSync(join(tmpdir(), `car-s41-${name}-`))
|
|
25
|
+
return fn(dir).finally(() => { try { rmSync(dir, { recursive: true, force: true }) } catch { /* 红线 8 */ } })
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
test('S41: spawn E2E——mcp-serve sessionTurn → car.turn span 出站(turnId 回填 / outcome=reason)', async () => {
|
|
29
|
+
await withTempDir('e2e', async dir => {
|
|
30
|
+
const traceBodies: any[] = []
|
|
31
|
+
let done: (v: void) => void = () => {}
|
|
32
|
+
const closed = new Promise<void>(r => { done = r })
|
|
33
|
+
const server: Server = createServer((req, res) => {
|
|
34
|
+
let body = ''
|
|
35
|
+
req.on('data', c => { body += c })
|
|
36
|
+
req.on('end', () => {
|
|
37
|
+
if (req.url === '/v1/traces') traceBodies.push(JSON.parse(body))
|
|
38
|
+
res.writeHead(200).end()
|
|
39
|
+
})
|
|
40
|
+
})
|
|
41
|
+
await new Promise<void>(r => server.listen(0, '127.0.0.1', r))
|
|
42
|
+
const port = (server.address() as { port: number }).port
|
|
43
|
+
try {
|
|
44
|
+
const child = spawn(process.execPath, ['--experimental-transform-types', CLI, 'mcp-serve'], {
|
|
45
|
+
cwd: dir,
|
|
46
|
+
env: { ...process.env, CAR_OTEL_ENDPOINT: `http://127.0.0.1:${port}`, CAR_OTEL_INTERVAL_MS: '0' },
|
|
47
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
48
|
+
})
|
|
49
|
+
let err = ''
|
|
50
|
+
child.stderr.on('data', d => { err += d })
|
|
51
|
+
// 确定性派生(s36 同款):session_start 返回的 CAR 会话 id 进程内预计算——双请求可并发下发
|
|
52
|
+
const sid = deriveSessionId('claude-code', 's41-span')
|
|
53
|
+
child.stdin.write(JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name: 'session_start', arguments: { hostSessionId: 's41-span' } } }) + '\n')
|
|
54
|
+
child.stdin.write(JSON.stringify({
|
|
55
|
+
jsonrpc: '2.0', id: 2, method: 'tools/call',
|
|
56
|
+
params: {
|
|
57
|
+
name: 'session_turn',
|
|
58
|
+
arguments: { sessionId: sid, input: { events: [{ hostEvent: 'user_message', payload: { content: 'hi' } }, { hostEvent: 'turn_complete' }] } },
|
|
59
|
+
},
|
|
60
|
+
}) + '\n')
|
|
61
|
+
child.stdin.end()
|
|
62
|
+
child.on('exit', () => done())
|
|
63
|
+
// macOS runner 慢启动容差(s29 同款);落选 race timer 必须 clear
|
|
64
|
+
let raceTimer: ReturnType<typeof setTimeout> | undefined
|
|
65
|
+
try {
|
|
66
|
+
await Promise.race([
|
|
67
|
+
closed,
|
|
68
|
+
new Promise<never>((_, rej) => { raceTimer = setTimeout(() => rej(new Error(`mcp-serve 超时\n${err}`)), 60_000) }),
|
|
69
|
+
])
|
|
70
|
+
} finally {
|
|
71
|
+
if (raceTimer) clearTimeout(raceTimer)
|
|
72
|
+
}
|
|
73
|
+
const spans = traceBodies.flatMap(b => b.scopeSpans[0].spans)
|
|
74
|
+
const turnSpan = spans.find(s => s.name === 'car.turn')
|
|
75
|
+
assert.ok(turnSpan, 'session_turn 产 car.turn span 出站(v1/traces 面可达)')
|
|
76
|
+
const attrs = Object.fromEntries(turnSpan.attributes.map((a: any) => [a.key, a.value.stringValue]))
|
|
77
|
+
assert.equal(attrs['car.turn_id'], 'T1', 'turnId 事后回填(facade 内部自增派生)')
|
|
78
|
+
assert.equal(attrs['car.outcome'], 'completed', 'outcome = 归一化 turnEnd reason')
|
|
79
|
+
assert.ok(turnSpan.traceId && turnSpan.spanId, 'traceId/spanId 生成')
|
|
80
|
+
assert.equal('parentSpanId' in turnSpan, false, '根 span 无 parent')
|
|
81
|
+
assert.equal('events' in turnSpan, false, '正常路径无 exception 事件')
|
|
82
|
+
assert.equal(turnSpan.status.code, 'STATUS_CODE_UNSET')
|
|
83
|
+
// 非会话工具(session_start)不产 span——拦截面仅 session_turn(D-28)
|
|
84
|
+
assert.equal(spans.length, 1, '单 session_turn = 单 turn span(session_start 不产 span)')
|
|
85
|
+
// 零内容口径:span 出站体无会话内容(user_message payload 不出站)
|
|
86
|
+
assert.equal(/hi|user_message/.test(JSON.stringify(traceBodies)), false, 'span 出站体零内容(事件批内容不出站)')
|
|
87
|
+
} finally {
|
|
88
|
+
server.closeAllConnections()
|
|
89
|
+
server.close()
|
|
90
|
+
}
|
|
91
|
+
})
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
test('S41: session_turn 未知会话显式报错——span 收口 error + exception 事件出站', async () => {
|
|
95
|
+
await withTempDir('err', async dir => {
|
|
96
|
+
const traceBodies: any[] = []
|
|
97
|
+
let done: (v: void) => void = () => {}
|
|
98
|
+
const closed = new Promise<void>(r => { done = r })
|
|
99
|
+
const server: Server = createServer((req, res) => {
|
|
100
|
+
let body = ''
|
|
101
|
+
req.on('data', c => { body += c })
|
|
102
|
+
req.on('end', () => {
|
|
103
|
+
if (req.url === '/v1/traces') traceBodies.push(JSON.parse(body))
|
|
104
|
+
res.writeHead(200).end()
|
|
105
|
+
})
|
|
106
|
+
})
|
|
107
|
+
await new Promise<void>(r => server.listen(0, '127.0.0.1', r))
|
|
108
|
+
const port = (server.address() as { port: number }).port
|
|
109
|
+
try {
|
|
110
|
+
const child = spawn(process.execPath, ['--experimental-transform-types', CLI, 'mcp-serve'], {
|
|
111
|
+
cwd: dir,
|
|
112
|
+
env: { ...process.env, CAR_OTEL_ENDPOINT: `http://127.0.0.1:${port}`, CAR_OTEL_INTERVAL_MS: '0' },
|
|
113
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
114
|
+
})
|
|
115
|
+
let err = ''
|
|
116
|
+
child.stderr.on('data', d => { err += d })
|
|
117
|
+
child.on('exit', () => done())
|
|
118
|
+
child.stdin.write(JSON.stringify({
|
|
119
|
+
jsonrpc: '2.0', id: 1, method: 'tools/call',
|
|
120
|
+
params: { name: 'session_turn', arguments: { sessionId: 'SH-nonexistent', input: { events: [] } } },
|
|
121
|
+
}) + '\n')
|
|
122
|
+
child.stdin.end()
|
|
123
|
+
let raceTimer: ReturnType<typeof setTimeout> | undefined
|
|
124
|
+
try {
|
|
125
|
+
await Promise.race([
|
|
126
|
+
closed,
|
|
127
|
+
new Promise<never>((_, rej) => { raceTimer = setTimeout(() => rej(new Error(`mcp-serve 超时\n${err}`)), 60_000) }),
|
|
128
|
+
])
|
|
129
|
+
} finally {
|
|
130
|
+
if (raceTimer) clearTimeout(raceTimer)
|
|
131
|
+
}
|
|
132
|
+
const spans = traceBodies.flatMap(b => b.scopeSpans[0].spans)
|
|
133
|
+
const turnSpan = spans.find(s => s.name === 'car.turn')
|
|
134
|
+
assert.ok(turnSpan, '失败路径 turn span 仍出站')
|
|
135
|
+
assert.equal(turnSpan.status.code, 'STATUS_CODE_ERROR', 'handle 异常 → status ERROR')
|
|
136
|
+
const ev = turnSpan.events?.find((e: any) => e.name === 'exception')
|
|
137
|
+
assert.ok(ev, 'exception 事件出站(D-26 形态在 mcp-serve 路径成立)')
|
|
138
|
+
const evAttrs = Object.fromEntries(ev.attributes.map((a: any) => [a.key, a.value.stringValue]))
|
|
139
|
+
assert.match(evAttrs['exception.message'] ?? '', /CAR-E-HOST/, '异常消息进 exception.message(内容治理属调用方)')
|
|
140
|
+
const attrs = Object.fromEntries(turnSpan.attributes.map((a: any) => [a.key, a.value.stringValue]))
|
|
141
|
+
assert.equal(attrs['car.outcome'], 'error')
|
|
142
|
+
assert.equal('car.turn_id' in attrs, false, '回填前失败:无占位 turnId(startTurn(\'\') 空值不设属性)')
|
|
143
|
+
} finally {
|
|
144
|
+
server.closeAllConnections()
|
|
145
|
+
server.close()
|
|
146
|
+
}
|
|
147
|
+
})
|
|
148
|
+
})
|