@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.
- package/README.md +36 -1
- package/package.json +1 -1
- package/scripts/release-pipeline.ts +8 -2
- package/src/cli.ts +235 -22
- package/src/dx/doctor.ts +54 -0
- package/src/kernel/context.ts +15 -0
- package/src/load/config.ts +129 -0
- package/src/load/sign.ts +60 -0
- package/src/runtime-core/chatStep.ts +121 -0
- package/src/runtime-core/credentials.ts +135 -0
- package/src/runtime-core/errors.ts +47 -0
- package/src/runtime-core/llm.ts +304 -0
- package/src/runtime-core/redaction.ts +94 -0
- package/src/runtime-core/telemetry.ts +333 -0
- package/src/runtime-core/types.ts +79 -0
- package/src/session/indexStore.ts +178 -3
- package/test/s18.spec.ts +9 -0
- package/test/s25-m8-runtime-core.spec.ts +600 -0
- package/test/s26-signature-closeout.spec.ts +349 -0
- package/test/s27-host-load-wiring.spec.ts +134 -0
- package/test/s28-session-index-followup.spec.ts +236 -0
- package/test/s29-otel-producer.spec.ts +252 -0
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* M8 · 遥测门面(getTracer/getMeter ——《系统设计》§3.2.M8.2 遥测行 / §3.2.M8.5 隐私三原则)
|
|
3
|
+
*
|
|
4
|
+
* 口径(§3.2.M8.5 关键约束,逐条落地):
|
|
5
|
+
* - 默认关:无端点配置时不初始化任何 exporter——无配置 = noop 句柄 + 零出站(可机器断言);
|
|
6
|
+
* - 显式开:用户配置端点即视为开启(CAR_OTEL_ENDPOINT 或显式 config 传入);
|
|
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;
|
|
18
|
+
* - 严格解耦:本文件零 import 自 session/*(遥测与审计日志禁止混流)——测试做静态断言;
|
|
19
|
+
* - S17 零内容计数器(telemetry/metrics.ts)是登记表兜底通道,与本门面并存互补:
|
|
20
|
+
* 本面是 OTel 形态的出站通道,不承接 S17 的零内容红线语义(内容治理属调用方);
|
|
21
|
+
* - 零依赖:OTel「标准句柄形态」自研(startSpan/attributes/end、createCounter/add),
|
|
22
|
+
* 不引入 @opentelemetry/* 依赖(项目零依赖红线,README engines 纪律)。
|
|
23
|
+
*/
|
|
24
|
+
import { randomBytes } from 'node:crypto'
|
|
25
|
+
|
|
26
|
+
export type SamplingSpec = 'always_on' | 'always_off' | { ratio: number }
|
|
27
|
+
|
|
28
|
+
export interface TelemetryConfig {
|
|
29
|
+
/** 用户自配 OTLP/HTTP 端点(如 https://otel.example.internal)——无值 = 默认关 */
|
|
30
|
+
endpoint?: string
|
|
31
|
+
serviceName?: string
|
|
32
|
+
headers?: Record<string, string>
|
|
33
|
+
/** 导出超时(§3.5.4 基线 5s) */
|
|
34
|
+
exportTimeoutMs?: number
|
|
35
|
+
/** 自动导出间隔 ms;0(缺省)= 仅手动 flush(测试/收口时点调用) */
|
|
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>
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface TelemetrySpan {
|
|
53
|
+
setAttribute(key: string, value: string | number | boolean): this
|
|
54
|
+
addEvent(name: string, attrs?: Record<string, string | number | boolean>): this
|
|
55
|
+
recordException(err: unknown): this
|
|
56
|
+
end(): void
|
|
57
|
+
readonly traceId: string
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface TelemetryTracer {
|
|
61
|
+
startSpan(name: string, opts?: { attributes?: Record<string, string | number | boolean> }): TelemetrySpan
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface TelemetryCounter {
|
|
65
|
+
add(value: number, labels?: Record<string, string>): void
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface TelemetryMeter {
|
|
69
|
+
createCounter(name: string): TelemetryCounter
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface TelemetryStats {
|
|
73
|
+
spansEnded: number
|
|
74
|
+
/** 1.2-S3 加法:head 采样被采出的 span 数(缺省 always_on 下恒 0) */
|
|
75
|
+
spansSampledOut: number
|
|
76
|
+
/** 成功出站的 OTLP 请求计数(traces/metrics 分批后按请求计) */
|
|
77
|
+
tracesExported: number
|
|
78
|
+
metricsExported: number
|
|
79
|
+
droppedExports: number
|
|
80
|
+
/** 1.2-S3 加法:buffer 溢出丢最旧计数(maxQueueSize 上限) */
|
|
81
|
+
queueOverflows: number
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export interface TelemetryFacade {
|
|
85
|
+
/** 默认关 = false(无端点配置时) */
|
|
86
|
+
readonly enabled: boolean
|
|
87
|
+
/** 幂等句柄(§3.2.M8.2:天然幂等) */
|
|
88
|
+
getTracer(): TelemetryTracer
|
|
89
|
+
getMeter(): TelemetryMeter
|
|
90
|
+
/** 手动导出(BD-05:端点不可达静默丢弃——resolve 不 reject) */
|
|
91
|
+
flush(): Promise<void>
|
|
92
|
+
shutdown(): Promise<void>
|
|
93
|
+
/** 观察面(测试/收口;不含内容字段) */
|
|
94
|
+
stats(): TelemetryStats
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ==================== noop 句柄(默认关路径:零出站、零积累) ====================
|
|
98
|
+
|
|
99
|
+
const noopSpan = (): TelemetrySpan => ({
|
|
100
|
+
setAttribute() { return noopSpan() },
|
|
101
|
+
addEvent() { return noopSpan() },
|
|
102
|
+
recordException() { return noopSpan() },
|
|
103
|
+
end() {},
|
|
104
|
+
traceId: '',
|
|
105
|
+
})
|
|
106
|
+
const NOOP_TRACER: TelemetryTracer = { startSpan: () => noopSpan() }
|
|
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
|
+
}
|
|
135
|
+
|
|
136
|
+
// ==================== 活动门面(显式开路径) ====================
|
|
137
|
+
|
|
138
|
+
interface SpanRecord {
|
|
139
|
+
traceId: string
|
|
140
|
+
spanId: string
|
|
141
|
+
parentSpanId: string | null
|
|
142
|
+
name: string
|
|
143
|
+
startMs: number
|
|
144
|
+
endMs: number
|
|
145
|
+
attributes: Record<string, string | number | boolean>
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const attrValue = (v: string | number | boolean) =>
|
|
149
|
+
typeof v === 'string' ? { stringValue: v } : typeof v === 'boolean' ? { boolValue: v } : { doubleValue: v }
|
|
150
|
+
|
|
151
|
+
export function createTelemetryFacade(
|
|
152
|
+
config: TelemetryConfig | null | undefined,
|
|
153
|
+
opts: { fetchImpl?: typeof fetch; now?: () => number } = {},
|
|
154
|
+
): TelemetryFacade {
|
|
155
|
+
const endpoint = config?.endpoint?.replace(/\/+$/, '')
|
|
156
|
+
if (!endpoint) {
|
|
157
|
+
return {
|
|
158
|
+
enabled: false,
|
|
159
|
+
getTracer: () => NOOP_TRACER,
|
|
160
|
+
getMeter: () => NOOP_METER,
|
|
161
|
+
flush: async () => {},
|
|
162
|
+
shutdown: async () => {},
|
|
163
|
+
stats: () => ({ ...NOOP_STATS }),
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const fetchImpl = opts.fetchImpl ?? fetch
|
|
168
|
+
const now = opts.now ?? Date.now
|
|
169
|
+
const sleep = config?.sleep ?? (ms => new Promise<void>(r => setTimeout(r, ms)))
|
|
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
|
|
176
|
+
const baseHeaders = { 'content-type': 'application/json', ...(config?.headers ?? {}) }
|
|
177
|
+
const serviceName = config?.serviceName ?? 'car-runtime'
|
|
178
|
+
|
|
179
|
+
const spans: SpanRecord[] = []
|
|
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 }
|
|
185
|
+
let tracer: TelemetryTracer | null = null
|
|
186
|
+
let meter: TelemetryMeter | null = null
|
|
187
|
+
let timer: ReturnType<typeof setInterval> | null = null
|
|
188
|
+
let flushing = false
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* OTLP 出站(D-12b 口径:有界重试)——ok / 429/5xx/网络错误退避重试 ≤retryLimit /
|
|
192
|
+
* 4xx 业务错不重试(端点配置类问题,重试无益);重试耗尽返回 false 由调用方计入
|
|
193
|
+
* droppedExports(静默丢弃,永不抛错——BD-05「不抛错」收紧保持)。
|
|
194
|
+
*/
|
|
195
|
+
const exportOtlp = async (path: string, body: unknown): Promise<boolean> => {
|
|
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
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const flush = async (): Promise<void> => {
|
|
222
|
+
if (flushing) return
|
|
223
|
+
flushing = true
|
|
224
|
+
try {
|
|
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)
|
|
229
|
+
const body = {
|
|
230
|
+
resource: { attributes: [{ key: 'service.name', value: { stringValue: serviceName } }] },
|
|
231
|
+
scopeSpans: [{
|
|
232
|
+
scope: { name: 'car-runtime' },
|
|
233
|
+
spans: batch.map(s => ({
|
|
234
|
+
traceId: s.traceId,
|
|
235
|
+
spanId: s.spanId,
|
|
236
|
+
...(s.parentSpanId ? { parentSpanId: s.parentSpanId } : {}),
|
|
237
|
+
name: s.name,
|
|
238
|
+
kind: 'SPAN_KIND_INTERNAL',
|
|
239
|
+
startTimeUnixNano: String(s.startMs * 1e6),
|
|
240
|
+
endTimeUnixNano: String(s.endMs * 1e6),
|
|
241
|
+
attributes: Object.entries(s.attributes).map(([key, v]) => ({ key, value: attrValue(v) })),
|
|
242
|
+
status: { code: 'STATUS_CODE_UNSET' },
|
|
243
|
+
})),
|
|
244
|
+
}],
|
|
245
|
+
}
|
|
246
|
+
if (await exportOtlp('/v1/traces', body)) stats.tracesExported++
|
|
247
|
+
else stats.droppedExports++
|
|
248
|
+
}
|
|
249
|
+
if (counters.size) {
|
|
250
|
+
const batch = [...counters.entries()]
|
|
251
|
+
counters.clear()
|
|
252
|
+
const body = {
|
|
253
|
+
resource: { attributes: [{ key: 'service.name', value: { stringValue: serviceName } }] },
|
|
254
|
+
scopeMetrics: [{
|
|
255
|
+
scope: { name: 'car-runtime' },
|
|
256
|
+
metrics: batch.map(([, c]) => ({
|
|
257
|
+
name: c.name,
|
|
258
|
+
sum: {
|
|
259
|
+
aggregationTemporality: 'AGGREGATION_TEMPORALITY_CUMULATIVE',
|
|
260
|
+
dataPoints: Object.entries(c.labels).length
|
|
261
|
+
? Object.entries(c.labels).map(([k, v]) => ({ asInt: c.value, attributes: [{ key: k, value: { stringValue: v } }] }))
|
|
262
|
+
: [{ asInt: c.value }],
|
|
263
|
+
},
|
|
264
|
+
})),
|
|
265
|
+
}],
|
|
266
|
+
}
|
|
267
|
+
if (await exportOtlp('/v1/metrics', body)) stats.metricsExported++
|
|
268
|
+
else stats.droppedExports++
|
|
269
|
+
}
|
|
270
|
+
} finally {
|
|
271
|
+
flushing = false
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
if ((config?.intervalMs ?? 0) > 0) timer = setInterval(() => { void flush() }, config!.intervalMs!)
|
|
276
|
+
|
|
277
|
+
const activeTracer: TelemetryTracer = {
|
|
278
|
+
startSpan(name, startOpts) {
|
|
279
|
+
const record: SpanRecord = {
|
|
280
|
+
traceId: randomBytes(16).toString('hex'),
|
|
281
|
+
spanId: randomBytes(8).toString('hex'),
|
|
282
|
+
parentSpanId: null,
|
|
283
|
+
name,
|
|
284
|
+
startMs: now(),
|
|
285
|
+
endMs: 0,
|
|
286
|
+
attributes: { ...startOpts?.attributes },
|
|
287
|
+
}
|
|
288
|
+
return {
|
|
289
|
+
get traceId() { return record.traceId },
|
|
290
|
+
setAttribute(key, value) { record.attributes[key] = value; return this },
|
|
291
|
+
addEvent(evtName, attrs) { record.attributes[`event.${evtName}`] = attrs ? JSON.stringify(attrs) : evtName; return this },
|
|
292
|
+
recordException(err) { record.attributes['exception.message'] = String((err as Error)?.message ?? err); return this },
|
|
293
|
+
end() {
|
|
294
|
+
record.endMs = now()
|
|
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)
|
|
304
|
+
},
|
|
305
|
+
}
|
|
306
|
+
},
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const activeMeter: TelemetryMeter = {
|
|
310
|
+
createCounter(name) {
|
|
311
|
+
return {
|
|
312
|
+
add(value, labels = {}) {
|
|
313
|
+
const key = name + JSON.stringify(labels)
|
|
314
|
+
const cur = counters.get(key) ?? { name, labels, value: 0 }
|
|
315
|
+
cur.value += value
|
|
316
|
+
counters.set(key, cur)
|
|
317
|
+
},
|
|
318
|
+
}
|
|
319
|
+
},
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
return {
|
|
323
|
+
enabled: true,
|
|
324
|
+
getTracer() { tracer ??= activeTracer; return tracer },
|
|
325
|
+
getMeter() { meter ??= activeMeter; return meter },
|
|
326
|
+
flush,
|
|
327
|
+
async shutdown() {
|
|
328
|
+
if (timer) { clearInterval(timer); timer = null }
|
|
329
|
+
await flush()
|
|
330
|
+
},
|
|
331
|
+
stats: () => ({ ...stats }),
|
|
332
|
+
}
|
|
333
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* M8 · 运行时底座共享 DTO(《系统设计》§3.2.M8.3 / §3.3.1 O-10/O-11/O-13/O-14)
|
|
3
|
+
*
|
|
4
|
+
* 口径:
|
|
5
|
+
* - LlmChunk.finishReason 为不可变透传(readonly 语义,ADR-001;vLLM #53269 教训 SR-17)——
|
|
6
|
+
* 唯一信号源,禁止中间层改写/吞没;llm.ts 的流包装层对此做防御性断言;
|
|
7
|
+
* - LlmRequest.tools 含 declaredSideEffect(权限门依据;readonly 是 ADR-001 补错重试白名单前置);
|
|
8
|
+
* - ToolDefinition 为 M8 共享 DTO(O-10):M3 授权门/M5 MCP 网关/M8 模型请求三方复用;
|
|
9
|
+
* 与 loop/stop.ts 的 ToolDef 分工——ToolDefinition 是声明面(进模型 schema),ToolDef 是执行面;
|
|
10
|
+
* - CredentialRef 不落明文(O-13):ref 只含来源定位,值经 credentials.reveal 按需取用;
|
|
11
|
+
* - 规格外扩展字段(adapterId / error)以加法演进登记——与 hostRaw 同纪律,不改动规格最小集语义。
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** 模型请求(Provider 无关) */
|
|
15
|
+
export interface LlmRequest {
|
|
16
|
+
model: string
|
|
17
|
+
messages: Array<{ role: 'system' | 'user' | 'assistant' | 'toolResult'; content: unknown }>
|
|
18
|
+
/** 工具 schema(缓存失效因素:工具集合/模型切换/compaction,D3 §14.6) */
|
|
19
|
+
tools: ToolDefinition[]
|
|
20
|
+
maxTokens?: number
|
|
21
|
+
metadata?: { sessionId: string; turnId: string; traceId: string }
|
|
22
|
+
// —— 规格外加法扩展(登记:M8 收口文档 §4)——
|
|
23
|
+
/** 多适配器在册时显式路由;缺省用默认适配器 */
|
|
24
|
+
adapterId?: string
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** 工具调用流式增量(跨 chunk 聚合出完整 ToolCall) */
|
|
28
|
+
export interface ToolCallDelta {
|
|
29
|
+
index?: number
|
|
30
|
+
id?: string
|
|
31
|
+
name?: string
|
|
32
|
+
/** JSON 增量片段,聚合后整体 JSON.parse(半截 JSON 不解析——ADR-001 同纪律) */
|
|
33
|
+
argumentsDelta?: string
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** 流式块(finishReason 一经产生不可被任何中间层改写) */
|
|
37
|
+
export interface LlmChunk {
|
|
38
|
+
delta?: string
|
|
39
|
+
toolCallDelta?: ToolCallDelta
|
|
40
|
+
finishReason?: 'stop' | 'length' | 'toolUse' | 'error' | 'aborted'
|
|
41
|
+
// —— 规格外加法扩展(登记:M8 收口文档 §4)——
|
|
42
|
+
/** finishReason='error' 时的结构化原因(消费方落 turnEnd meta,不进模型消息流) */
|
|
43
|
+
error?: { code: string; message: string }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Provider 适配器(ACL,C-02):统一签名,消解 Provider 差异 */
|
|
47
|
+
export interface LlmAdapter {
|
|
48
|
+
/** 如 openai-compat / anthropic */
|
|
49
|
+
id: string
|
|
50
|
+
chat(req: LlmRequest): AsyncIterable<LlmChunk>
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** 可逆注册句柄(dispose = Effect 回卷) */
|
|
54
|
+
export interface Disposable {
|
|
55
|
+
dispose(): void
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** 共享工具声明 DTO(O-10;注册时构造,进模型请求 schema) */
|
|
59
|
+
export interface ToolDefinition {
|
|
60
|
+
name: string
|
|
61
|
+
description?: string
|
|
62
|
+
/** JSON Schema 形态参数声明 */
|
|
63
|
+
parameters?: unknown
|
|
64
|
+
/** 副作用声明(必填——M5 网关 T-22 强制点同一口径:缺声明即 write) */
|
|
65
|
+
declaredSideEffect: 'readonly' | 'write'
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** 凭据来源(§3.2.M8.3 字段表:env 需用户显式开启 fallback) */
|
|
69
|
+
export type CredentialSource = 'keychain' | 'env'
|
|
70
|
+
|
|
71
|
+
/** 凭据引用(O-13):只含来源定位与掩码提示,值永不入此结构 */
|
|
72
|
+
export interface CredentialRef {
|
|
73
|
+
provider: string
|
|
74
|
+
source: CredentialSource
|
|
75
|
+
/** 来源标识:keychain 服务名 / env 变量名 */
|
|
76
|
+
origin: string
|
|
77
|
+
/** keychain 通道不可用等降级留痕(降级必须显式,禁静默) */
|
|
78
|
+
degraded?: string
|
|
79
|
+
}
|
|
@@ -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
|
package/test/s18.spec.ts
CHANGED
|
@@ -50,6 +50,15 @@ test('S18: 采集窗口——会话结束 stderr 输出零内容快照(登记
|
|
|
50
50
|
assert.ok('car_registry_decision|source=registry' in snap)
|
|
51
51
|
})
|
|
52
52
|
|
|
53
|
+
test('S18+: 1.1-S4 边界——未指定 --plugin 时装载计数不出现(装载接线不改变缺省协议面与快照键集)', async () => {
|
|
54
|
+
const { stderr } = await talk('claude-code', [
|
|
55
|
+
JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name: 'session_start', arguments: { hostSessionId: 's' } } }),
|
|
56
|
+
])
|
|
57
|
+
const snap = JSON.parse(/snapshot=(\{.*?\}) zeroContent/.exec(stderr)![1])
|
|
58
|
+
assert.ok(!('car_load_total|result=ok' in snap), '无 --plugin = 无装载计数(1.1 装载接线为显式 opt-in)')
|
|
59
|
+
assert.ok(!('car_unsigned_confirmed|confirmed=no' in snap))
|
|
60
|
+
})
|
|
61
|
+
|
|
53
62
|
test('S18: codex 宿主接入(同一入口不同 hostId)', async () => {
|
|
54
63
|
const { outs } = await talk('codex', [
|
|
55
64
|
JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name: 'session_start', arguments: { hostSessionId: 'cx-e2e' } } }),
|