@lqc123qwe/car-runtime 1.6.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 CHANGED
@@ -213,9 +213,22 @@ node --experimental-transform-types --test test/*.spec.ts
213
213
  - **1.5 工程收口(2026-09-28,`ec5a4c4`)**:三大登记项全部兑现(1.4 冻结表「登记后续」清零)。全量 **300(297 PASS + 3 能力门控 skip / 0 fail)** + tsc strict 0 + secrets-scan commit 后复跑 114 文件 0 命中 + contract-check 4/4(golden 不动)。**1.5-BUG-1 为生产调用点核查直接产出**(1.4 的 E2E 假服务不校验 tool_calls 历史故未暴露)。**1.5-插曲-1(HELP 模板串 `${ENV_VAR}` 未转义 → CLI 全 spawn 挂)**——模板字面量内插值转义教训。**1.5-CI-1:test/dx-smoke jobs 补 npm install**(零依赖 CI 前提随 D-22 失效——j02/j03/j04/j10/j11/j12;改 workflow 后 PyYAML 复验执行)。
214
214
  - **1.5.0 正式发布(2026-09-28,S6 收官)**:npm `@lqc123qwe/car-runtime@1.5.0` 在架——**dist-tags latest → 1.5.0、rc 保留 1.0.0-rc.1** + provenance(slsa.dev/provenance/v1)。Release `1.5.0`(agent GCM 凭据 API 创建)→ **j16 run 36394117668 全步 success**(构建/keyless 签名/自动上传五件套/npm publish)→ **post-publish 12 PASS / 1 DRY-RUN / 0 FAIL(G-09 实查)**;外部等价检查 **SHA256 MATCH(`2edae513…`)+ Rekor 透明日志交叉验证**(logIndex 2981593515,bundle↔API 双端一致;cosign 二进制本地缺席,以 Rekor API 对拍替代——登记口径)。**G-04 门禁口径随 D-22 更新**:零依赖断言 → npm audit(omit=dev high,0 findings)。
215
215
  - **遗留登记**:jiti 首载;host 工具面;写类工具双 toolResult 投影观察项;MCP legacy HTTP+SSE(2024-11-05)不追溯。规划:`car-docs/10-内核v1/1.5-迭代规划.md`;收口报告:`car-docs/10-内核v1/1.5-收口终验报告.md`。
216
+
217
+ ## 1.6 状态(宿主工具面 + 投影收敛 + jiti 载体)
218
+
219
+ | `src/session/log.ts` | 1.6-S1(D-23)**双 toolResult 投影收敛**:deriveMessages 同 id toolResult last-wins——写类工具授权决策(runtime/toolResult)与执行结果(plugin/toolResult)成对落链(M4 既有语义冻结:审计全量留痕不动),模型可见流只保留最终结果(真实 provider 重复 tool_call_id/tool_use_id 拒绝或未定义面出清);**id 缺席不收敛**(hostRaw/无 id 载荷形态不变,golden ④ 兼容);授权拒绝单条即唯一结果原样保留;适配器侧零改动 | 1.4 观察项出清 |
220
+ | `src/host/facade.ts` + `src/host/hostGateway.ts` | 1.6-S2(D-25)**宿主工具面透传真实接线**(S13 占位退役):RuntimeFacade opts 加 `tools?: Map<string, ToolReg>`(缺省行为不变)——toolList 出 name/description/parameters(**declaredSideEffect 不出站**:权限面只进权限门红线;未装配 = 空面兼容)+ toolCall 权限门(T-22 缺省 write 收口;write 类 policy 拒绝——mcp-serve 无人审回路 deny-by-default;readonly 执行;未知工具显式报错;工具异常=成对错误结果 US-5 同款)+ 成对落链 actor='user'(宿主=用户代理)turnId='T0'(会话级能力面,sessionTurn 从 T1 起不冲突) | 1.4 冻结表 host 工具面出清 |
221
+ | `src/host/stdio.ts` | **1.6-插曲-1 修复:tools/call 异步并发派发响应乱序**(请求 id 4 响应晚于 id 7——JSON-RPC 合法但宿主按序解析 + 会话态竞态面)→ callChain 请求序串行派发 + serve 收口 await callChain(响应写完才退出——win32 退出同源预防) | s36 spawn E2E 七请求全序断言钉死 |
222
+ | `src/cli.ts` | 1.6-S2 装配:mcp-serve 插件工具跨源同名 **CAR-E-DUP fail-closed** 启动中止(1.4-S1 纪律同款,禁静默覆盖)+ toolMap 注入 facade + 装载文案更新(「会话内执行接线属 W1 登记后续」口径退役) | mcp-serve 工具面闭环 |
223
+ | `src/load/config.ts` + package.json | 1.6-S3(D-24)**car.config.ts jiti 载体**:loadTsConfigFile 改 jiti 自包含转译(每 epoch 新实例 + moduleCache 关 = 击穿语义保持;**interopDefault 显式关**——jiti 缺省 true 把无 default 模块的 exports 整体当 default,「缺 default export」fail-visible 失效);Node 22.19+ 无需 --experimental-transform-types 载配置;非可擦除语法(enum)可用——**erasable-only 预检登记项出清(被取代)**;jiti ^2 第二个常规运行时依赖(零传递依赖 bundled;D-11a ① 二次修订;G-04 npm audit 0 findings);首载时延实测 292ms(spawn 冷启) | 1.5 报告 §7 jiti 候选出清 |
224
+ | `test/s36~s38` | s36 9 测(声明面权限面不出站/readonly 执行落链/write+未声明双路径拒绝/未知工具/工具异常/未装配 fail-visible/spawn E2E ×3 含 CAR-E-DUP exit 1)+ s37 6 测(granted 双事件单投影/denied 单条/id 缺席 golden ④ 兼容/N1 一致性/**双适配器出站体单 tool 消息**)+ s38 3 测(enum 真载/strip-only spawn 差分含负控+首载时延/CLI 真路径) | 1.6-GO |
225
+
226
+ - **1.6 工程收口(2026-09-28,`6e0315d`)**:候选池三块全部兑现(1.4/1.5 冻结表「登记后续」全数出清)。全量 **318(315 PASS + 3 能力门控 skip / 0 fail)** + tsc strict 0 + secrets-scan commit 后复跑 117 文件 0 命中 + contract-check 4/4(golden 不动)。**1.6-插曲-1(stdio 响应乱序)为 spawn E2E 按序断言直接产出**;**1.6-BUG-1(jiti interopDefault 缺省 true)被 s35 零改动验收线捕获**。
227
+ - **1.6.0 正式发布(2026-09-28,S5 收官)**:npm `@lqc123qwe/car-runtime@1.6.0` 在架——**dist-tags latest → 1.6.0、rc 保留 1.0.0-rc.1** + provenance。Release `1.6.0`(id 398135305,agent GCM 凭据 API 创建)→ **j16 run 36408091143 全步 success**(构建/keyless 签名/自动上传五件套/npm publish)→ **post-publish 13 PASS / 0 DRY-RUN / 0 FAIL(REMOTE_CHECK=1 全实查)**;外部等价检查 **SHA256 MATCH(`dadc1a3c…`)+ Rekor 交叉验证**(logIndex 2981799459 双端一致 + 透明日志 body 内制品哈希三方一致——tree 键形态差异登记见收口报告 §5)。
228
+ - **遗留登记(1.7 候选池)**:遥测 OTLP 增强(两度延后);MCP legacy HTTP+SSE 维持不追溯;写类工具人审 confirm 通道宿主面外推(D-25 口径);插件 jiti 轨收敛观察项(插件仍走原生剥离,可选演进非缺陷)。规划:`car-docs/10-内核v1/1.6-迭代规划.md`;收口报告:`car-docs/10-内核v1/1.6-收口终验报告.md`。
216
229
  ## 状态
217
230
 
218
- - **版本主线**: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 已发布)** | **1.3 发布资产自动化 全量 270(267 PASS + 3 skip,2026-09-27 收口,1.3.0 已发布,全链零人工拖拽兑现)** | **1.4 真实模型接入 全量 282(279 PASS + 3 skip,2026-09-27 收口,1.4.0 已发布)** | **1.5 provider 扩展+远程 MCP+配置完整形态 全量 300(297 PASS + 3 skip,2026-09-28 收口,1.5.0 已发布)**
231
+ - **版本主线**: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 已发布)** | **1.3 发布资产自动化 全量 270(267 PASS + 3 skip,2026-09-27 收口,1.3.0 已发布,全链零人工拖拽兑现)** | **1.4 真实模型接入 全量 282(279 PASS + 3 skip,2026-09-27 收口,1.4.0 已发布)** | **1.5 provider 扩展+远程 MCP+配置完整形态 全量 300(297 PASS + 3 skip,2026-09-28 收口,1.5.0 已发布)** | **1.6 宿主工具面+投影收敛+jiti 载体 全量 318(315 PASS + 3 skip,2026-09-28 收口,1.6.0 已发布)**
219
232
  - **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`)在真实发布兑现价值。
220
233
  - **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 清单已全绿收口。
221
234
  - **轨道 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.6.0",
3
+ "version": "1.7.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",
@@ -79,13 +79,13 @@ const record = (stage: string, gate: string, ok: boolean, detail = '', dryRun =
79
79
  if (process.env.REMOTE_CHECK === '1') {
80
80
  try {
81
81
  execSync('npm audit --omit=dev --audit-level=high', { cwd: ROOT, stdio: 'pipe' })
82
- auditDetail = `运行时依赖 ${depCount} 项(D-22:zod ^3);npm audit --omit=dev --audit-level=high 0 findings`
82
+ auditDetail = `运行时依赖 ${depCount} 项(D-22/D-24:zod ^3 + jiti ^2);npm audit --omit=dev --audit-level=high 0 findings`
83
83
  } catch {
84
84
  auditOk = false
85
85
  auditDetail = `运行时依赖 ${depCount} 项;npm audit --audit-level=high 有 findings(阻塞——G-04 D-22 口径)`
86
86
  }
87
87
  } else {
88
- auditDetail = `运行时依赖 ${depCount} 项(D-22:zod ^3);npm audit 在 REMOTE_CHECK=1 实跑(DRY-RUN)`
88
+ auditDetail = `运行时依赖 ${depCount} 项(D-22/D-24:zod ^3 + jiti ^2);npm audit 在 REMOTE_CHECK=1 实跑(DRY-RUN)`
89
89
  }
90
90
  }
91
91
  record(stage, 'G-04 依赖审计', auditOk, auditDetail)
package/src/cli.ts CHANGED
@@ -526,7 +526,31 @@ async function main(): Promise<number> {
526
526
  const facade = createRuntimeFacade({ profiles, ...(toolMap.size ? { tools: toolMap } : {}) })
527
527
  const gw = new HostGateway({ facade, audit: e => onCount('car_registry_decision', { source: 'registry' }) })
528
528
  for (const h of HOST_MAPPINGS) gw.registerHost({ hostId: h.hostId, profile: h, transport: {} as never })
529
- const server = createStdioServer((tool, args) => gw.handle(hostId, tool, args))
529
+ // 1.7-S3(D-28):sessionTurn 挂 turn 级 span(无 step span——mcp-serve 无 model() 循环)。
530
+ // 挂点在装配层(1.4-S6 纪律同款);facade 内部自增派生 turnId(T{n})——调用前不可知,
531
+ // 经 setTurnId 回填;outcome = 归一化 turnEnd reason(枚举面零内容)。缺省关 = noop 句柄零出站。
532
+ const tracer = new TurnTracer(otel)
533
+ const server = createStdioServer(async (tool, args) => {
534
+ if (tool !== 'session_turn') return gw.handle(hostId, tool, args)
535
+ const turn = tracer.startTurn('')
536
+ try {
537
+ const r = await gw.handle(hostId, tool, args)
538
+ if (!r.ok) {
539
+ // handle 层吞错为 {ok:false}(显式错误结果不抛出)——span 面以 error 收口留痕
540
+ turn.recordException(new Error(r.error ?? 'host call failed'))
541
+ turn.end('error')
542
+ return r
543
+ }
544
+ const payload = (r.result ?? {}) as { reason?: string; steps?: number }
545
+ turn.setTurnId(`T${payload.steps ?? 0}`)
546
+ turn.end(String(payload.reason ?? 'completed'))
547
+ return r
548
+ } catch (e) {
549
+ turn.recordException(e)
550
+ turn.end('error')
551
+ throw e
552
+ }
553
+ })
530
554
  const n = await server.serve(process.stdin, process.stdout)
531
555
  // 采集窗口收口(登记表兜底通道):快照写 stderr(stdout 为协议通道不可污染)
532
556
  const snap = counters.snapshot()
@@ -21,6 +21,17 @@
21
21
  * 本面是 OTel 形态的出站通道,不承接 S17 的零内容红线语义(内容治理属调用方);
22
22
  * - 零依赖:OTel「标准句柄形态」自研(startSpan/attributes/end、createCounter/add),
23
23
  * 不引入 @opentelemetry/* 依赖(项目零依赖红线,README engines 纪律)。
24
+ *
25
+ * 1.7(D-26 span 语义标准化 / D-27 metrics 时序修正):
26
+ * - recordException → OTel 规范形态:span event(name='exception',exception.type/exception.message
27
+ * 于事件属性)+ span status → STATUS_CODE_ERROR;stacktrace 缺省不出站(防路径/源码面外泄——
28
+ * 隐私三原则正向收紧;可选开关不随本迭代引入);正常 span 恒 STATUS_CODE_UNSET(行为不变面);
29
+ * - addEvent → 标准 events 数组编码(name + attributes 原形态 + 事件时刻 timeUnixNano——
30
+ * 替换 1.1 起的 `event.<name>` JSON 属性串非标准形态);
31
+ * - 出站体 spans[] 加法字段 events/status(空形态合法——无事件 span 不出 events 键);
32
+ * - metrics 累计基数保持:flush 不再清空 counters——每次导出全量累计快照(真 CUMULATIVE)。
33
+ * 1.2-S3 起 clear() 与 AGGREGATION_TEMPORALITY_CUMULATIVE 标注矛盾:intervalMs>0 自动导出时
34
+ * 下游把增量解读为总量回落;内存有界由 label 基数保证(零内容枚举红线——AllowedLabels 3 计数器)。
24
35
  */
25
36
  import { randomBytes } from 'node:crypto'
26
37
  import { request as httpRequest } from 'node:http'
@@ -153,6 +164,10 @@ interface SpanRecord {
153
164
  startMs: number
154
165
  endMs: number
155
166
  attributes: Record<string, string | number | boolean>
167
+ /** 1.7-S1(D-26):标准 span events(name + 原形态属性 + 事件时刻) */
168
+ events: Array<{ name: string; atMs: number; attributes: Record<string, string | number | boolean> }>
169
+ /** 1.7-S1(D-26):'STATUS_CODE_UNSET'(缺省)| 'STATUS_CODE_ERROR'(recordException) */
170
+ status: 'STATUS_CODE_UNSET' | 'STATUS_CODE_ERROR'
156
171
  }
157
172
 
158
173
  const attrValue = (v: string | number | boolean) =>
@@ -273,7 +288,15 @@ export function createTelemetryFacade(
273
288
  startTimeUnixNano: String(s.startMs * 1e6),
274
289
  endTimeUnixNano: String(s.endMs * 1e6),
275
290
  attributes: Object.entries(s.attributes).map(([key, v]) => ({ key, value: attrValue(v) })),
276
- status: { code: 'STATUS_CODE_UNSET' },
291
+ // 1.7-S1(D-26):标准 events/status 加法字段(无事件 span 不出 events 键——空形态合法)
292
+ ...(s.events.length ? {
293
+ events: s.events.map(e => ({
294
+ timeUnixNano: String(e.atMs * 1e6),
295
+ name: e.name,
296
+ attributes: Object.entries(e.attributes).map(([key, v]) => ({ key, value: attrValue(v) })),
297
+ })),
298
+ } : {}),
299
+ status: { code: s.status },
277
300
  })),
278
301
  }],
279
302
  }
@@ -282,7 +305,9 @@ export function createTelemetryFacade(
282
305
  }
283
306
  if (counters.size) {
284
307
  const batch = [...counters.entries()]
285
- counters.clear()
308
+ // 1.7-S2(D-27):累计基数保持——不清空,每次导出全量累计快照(真 CUMULATIVE)。
309
+ // 1.2-S3 起 clear() 使后续导出仅含增量却标 CUMULATIVE——intervalMs>0 时下游解读为总量回落;
310
+ // 内存有界由 label 基数保证(零内容枚举红线:AllowedLabels 3 计数器)
286
311
  const body = {
287
312
  resource: { attributes: [{ key: 'service.name', value: { stringValue: serviceName } }] },
288
313
  scopeMetrics: [{
@@ -319,13 +344,29 @@ export function createTelemetryFacade(
319
344
  startMs: now(),
320
345
  endMs: 0,
321
346
  attributes: { ...startOpts?.attributes },
347
+ events: [],
348
+ status: 'STATUS_CODE_UNSET',
322
349
  }
323
350
  return {
324
351
  get traceId() { return record.traceId },
325
352
  get spanId() { return record.spanId },
326
353
  setAttribute(key, value) { record.attributes[key] = value; return this },
327
- addEvent(evtName, attrs) { record.attributes[`event.${evtName}`] = attrs ? JSON.stringify(attrs) : evtName; return this },
328
- recordException(err) { record.attributes['exception.message'] = String((err as Error)?.message ?? err); return this },
354
+ // 1.7-S1(D-26):标准 events 编码——原形态属性 + 事件时刻(替换 event.<name> JSON 串)
355
+ addEvent(evtName, attrs) { record.events.push({ name: evtName, atMs: now(), attributes: attrs ? { ...attrs } : {} }); return this },
356
+ // 1.7-S1(D-26):OTel 规范 exception 形态——exception.type/exception.message 事件属性
357
+ // + status ERROR;stacktrace 缺省不出站(D-26 口径:防路径/源码面外泄)
358
+ recordException(err) {
359
+ record.events.push({
360
+ name: 'exception',
361
+ atMs: now(),
362
+ attributes: {
363
+ 'exception.type': String((err as Error)?.name ?? 'Error'),
364
+ 'exception.message': String((err as Error)?.message ?? err),
365
+ },
366
+ })
367
+ record.status = 'STATUS_CODE_ERROR'
368
+ return this
369
+ },
329
370
  end() {
330
371
  record.endMs = now()
331
372
  stats.spansEnded++
@@ -7,6 +7,11 @@
7
7
  * 「内容治理属调用方」口径);设计微调登记:规划原文「runTurn opts.telemetry 注入面」改为装配层
8
8
  * 闭包挂点,语义等价(turn/step 层级与出站面不变),loop 层保持纯停止语义;
9
9
  * - attributes 零内容面:turn_id/step 序号/outcome 枚举——无会话内容/提示词/路径。
10
+ *
11
+ * 1.7-S3(D-28):mcp-serve turn span 面——sessionTurn 挂 turn 级 span(无 step span:mcp-serve
12
+ * 无 model() 循环,step span 面外推登记为不适用)。facade 内部自增派生 turnId(T{n}),装配层
13
+ * 调用前不可知——TurnSpanHandle 加法 `setTurnId` 回填面(car run 直传 turnId 路径不受影响);
14
+ * startTurn('') 空值不设 car.turn_id 属性(回填前出站形态无占位内容)。
10
15
  */
11
16
  import { randomBytes } from 'node:crypto'
12
17
  import type { TelemetryFacade } from './telemetry.ts'
@@ -19,6 +24,10 @@ export interface StepSpanHandle {
19
24
  export interface TurnSpanHandle {
20
25
  traceId: string
21
26
  spanId: string
27
+ /** 1.7-D28:turnId 事后回填(mcp-serve——facade 内部自增派生,调用前不可知;car run 直传不受影响) */
28
+ setTurnId(turnId: string): void
29
+ /** 1.7-D28:turn 级异常留痕(D-26 exception 形态在 turn span 同样成立) */
30
+ recordException(e: unknown): void
22
31
  end(outcome: string): void
23
32
  stepSpan(step: number): StepSpanHandle
24
33
  }
@@ -33,14 +42,16 @@ export class TurnTracer {
33
42
 
34
43
  startTurn(turnId: string): TurnSpanHandle {
35
44
  if (!this.#facade) {
36
- return { traceId: '', spanId: '', end() {}, stepSpan: () => ({ end() {}, recordException() {} }) }
45
+ return { traceId: '', spanId: '', setTurnId() {}, recordException() {}, end() {}, stepSpan: () => ({ end() {}, recordException() {} }) }
37
46
  }
38
47
  const tracer = this.#facade.getTracer()
39
48
  const traceId = randomBytes(16).toString('hex')
40
- const turn = tracer.startSpan('car.turn', { attributes: { 'car.turn_id': turnId }, trace: { traceId } })
49
+ const turn = tracer.startSpan('car.turn', { attributes: turnId ? { 'car.turn_id': turnId } : {}, trace: { traceId } })
41
50
  return {
42
51
  traceId,
43
52
  spanId: turn.spanId,
53
+ setTurnId(id) { turn.setAttribute('car.turn_id', id) },
54
+ recordException(e) { turn.recordException(e) },
44
55
  end(outcome) {
45
56
  turn.setAttribute('car.outcome', outcome)
46
57
  turn.end()
@@ -0,0 +1,100 @@
1
+ /**
2
+ * 1.7-S1 · OTLP span 语义标准化(D-26):exception 标准事件 + status / addEvent 标准 events 编码
3
+ *
4
+ * 覆盖(1.7-迭代规划 TELE-1;1.4 遗留「遥测 OTLP 增强」出清主体):
5
+ * - recordException → OTel 规范形态:span event(name='exception',exception.type/exception.message
6
+ * 事件属性 + timeUnixNano)+ span status → STATUS_CODE_ERROR——替换 1.1 起「仅 exception.message
7
+ * 属性 + 恒 UNSET」非标准形态;stacktrace 缺省不出站(D-26 口径:防路径/源码面外泄)
8
+ * - addEvent → 标准 events 数组编码(name + 原形态属性 + 时刻)——替换 `event.<name>` JSON 属性串
9
+ * - 出站体加法字段:无事件 span 不出 events 键(空形态合法);正常 span 恒 STATUS_CODE_UNSET(行为不变面)
10
+ * - noop 门面零出站不变(默认关三原则;s29/s32 回归由全量保证)
11
+ */
12
+ import { test } from 'node:test'
13
+ import assert from 'node:assert/strict'
14
+ import { createTelemetryFacade } from '../src/runtime-core/telemetry.ts'
15
+
16
+ const okRes = () => ({ ok: true, status: 200 }) as unknown as Response
17
+
18
+ function capture() {
19
+ const bodies: any[] = []
20
+ const f = createTelemetryFacade({ endpoint: 'https://otel' }, {
21
+ fetchImpl: (async (_u: string | URL, init?: RequestInit) => { bodies.push(JSON.parse(String(init?.body))); return okRes() }) as typeof fetch,
22
+ })
23
+ return { f, bodies }
24
+ }
25
+
26
+ const spansOf = (bodies: any[]) => bodies.filter(b => b.scopeSpans).flatMap(b => b.scopeSpans[0].spans)
27
+
28
+ test('S39: recordException → 标准 exception 事件 + STATUS_CODE_ERROR(stacktrace 不出站)', async () => {
29
+ const { f, bodies } = capture()
30
+ const s = f.getTracer().startSpan('car.step', { attributes: { 'car.step': 1 } })
31
+ s.recordException(new TypeError('boom'))
32
+ s.end('error')
33
+ await f.flush()
34
+ const span = spansOf(bodies)[0]
35
+ assert.equal(span.status.code, 'STATUS_CODE_ERROR', 'recordException → status ERROR(1.7 前恒 UNSET)')
36
+ assert.ok(Array.isArray(span.events) && span.events.length === 1, 'exception 为 span event')
37
+ const ev = span.events[0]
38
+ assert.equal(ev.name, 'exception')
39
+ assert.ok(ev.timeUnixNano, '事件时刻出站')
40
+ const attrs = Object.fromEntries(ev.attributes.map((a: any) => [a.key, a.value.stringValue]))
41
+ assert.equal(attrs['exception.type'], 'TypeError', 'err.name → exception.type')
42
+ assert.equal(attrs['exception.message'], 'boom', 'err.message → exception.message')
43
+ const allKeys = [...span.attributes, ...span.events.flatMap((e: any) => e.attributes)].map((a: any) => a.key)
44
+ assert.equal(allKeys.some((k: string) => /stack/i.test(k)), false, 'stacktrace 缺省不出站(D-26)')
45
+ assert.equal(allKeys.includes('exception.message') && span.attributes.some((a: any) => a.key === 'exception.message'), false,
46
+ 'exception.message 不再落于 span attributes(1.1 非标准形态退役)')
47
+ })
48
+
49
+ test('S39: recordException 非 Error 值兜底——type=Error / message=String(err)', async () => {
50
+ const { f, bodies } = capture()
51
+ const s = f.getTracer().startSpan('car.step')
52
+ s.recordException('plain failure')
53
+ s.end('error')
54
+ await f.flush()
55
+ const ev = spansOf(bodies)[0].events[0]
56
+ const attrs = Object.fromEntries(ev.attributes.map((a: any) => [a.key, a.value.stringValue]))
57
+ assert.deepEqual(attrs, { 'exception.type': 'Error', 'exception.message': 'plain failure' })
58
+ })
59
+
60
+ test('S39: addEvent → 标准 events 编码(原形态属性 + 时刻;attributes 不再承载事件)', async () => {
61
+ const { f, bodies } = capture()
62
+ const s = f.getTracer().startSpan('car.turn')
63
+ s.addEvent('cache-hit', { key: 'x', n: 1, flag: true })
64
+ s.end('completed')
65
+ await f.flush()
66
+ const span = spansOf(bodies)[0]
67
+ assert.equal(span.status.code, 'STATUS_CODE_UNSET', '正常 span 恒 UNSET(行为不变面)')
68
+ assert.equal(span.events.length, 1)
69
+ const ev = span.events[0]
70
+ assert.equal(ev.name, 'cache-hit')
71
+ assert.ok(ev.timeUnixNano)
72
+ const map = Object.fromEntries(ev.attributes.map((a: any) => [a.key, a.value]))
73
+ assert.deepEqual(map, { key: { stringValue: 'x' }, n: { doubleValue: 1 }, flag: { boolValue: true } },
74
+ '原形态属性出站(1.7 前为 event.<name>=JSON 串)')
75
+ const attrKeys = span.attributes.map((a: any) => a.key)
76
+ assert.equal(attrKeys.includes('event.cache-hit'), false, 'attributes 不再承载事件(非标准形态退役)')
77
+ })
78
+
79
+ test('S39: 无事件 span 不出 events 键(出站体加法字段——空形态合法)', async () => {
80
+ const { f, bodies } = capture()
81
+ f.getTracer().startSpan('plain').end()
82
+ await f.flush()
83
+ const span = spansOf(bodies)[0]
84
+ assert.equal('events' in span, false, '无事件不出 events 键')
85
+ assert.equal(span.status.code, 'STATUS_CODE_UNSET')
86
+ })
87
+
88
+ test('S39: 多事件按序出站(exception 与 addEvent 混排——事件数组保序)', async () => {
89
+ const { f, bodies } = capture()
90
+ const s = f.getTracer().startSpan('car.step')
91
+ s.addEvent('retry')
92
+ s.recordException(new Error('net'))
93
+ s.addEvent('fallback')
94
+ s.end('completed')
95
+ await f.flush()
96
+ const names = spansOf(bodies)[0].events.map((e: any) => e.name)
97
+ assert.deepEqual(names, ['retry', 'exception', 'fallback'])
98
+ // exception 事件混排下 status 仍 ERROR
99
+ assert.equal(spansOf(bodies)[0].status.code, 'STATUS_CODE_ERROR')
100
+ })
@@ -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
+ })