@huaqiu/dsh-log 0.0.0-stage → 1.1.2
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 +532 -2
- package/audit-core.ts +1149 -0
- package/dsh-log.yml +71 -0
- package/index.ts +848 -0
- package/package.json +36 -4
- package/tools/audit-log.mjs +391 -0
package/package.json
CHANGED
|
@@ -1,6 +1,38 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@huaqiu/dsh-log",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "1.1.2",
|
|
4
|
+
"description": "DSH plugin: external API calls + whitelisted script execution with structured audit logging (v1.1.2)",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "index.ts",
|
|
7
|
+
"files": [
|
|
8
|
+
"index.ts",
|
|
9
|
+
"audit-core.ts",
|
|
10
|
+
"dsh-log.yml",
|
|
11
|
+
"tools/audit-log.mjs"
|
|
12
|
+
],
|
|
13
|
+
"publishConfig": {
|
|
14
|
+
"access": "public",
|
|
15
|
+
"registry": "https://registry.npmjs.org"
|
|
16
|
+
},
|
|
17
|
+
"scripts": {
|
|
18
|
+
"test": "node --test test/audit-core.test.ts && node --import ./test/register-stubs.mjs --test test/plugin.test.ts && node --test test/audit-log.test.ts",
|
|
19
|
+
"demo": "node --import ./test/register-stubs.mjs demo.ts",
|
|
20
|
+
"log": "node tools/audit-log.mjs"
|
|
21
|
+
},
|
|
22
|
+
"peerDependencies": {
|
|
23
|
+
"@deepseek-ai/cordis": "*",
|
|
24
|
+
"@deepseek-ai/dsh-tools": "*",
|
|
25
|
+
"@deepseek-ai/schemastery": "*"
|
|
26
|
+
},
|
|
27
|
+
"peerDependenciesMeta": {
|
|
28
|
+
"@deepseek-ai/cordis": {
|
|
29
|
+
"optional": true
|
|
30
|
+
},
|
|
31
|
+
"@deepseek-ai/dsh-tools": {
|
|
32
|
+
"optional": true
|
|
33
|
+
},
|
|
34
|
+
"@deepseek-ai/schemastery": {
|
|
35
|
+
"optional": true
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* audit-log.mjs — read the dsh-log host audit log (channel 1).
|
|
4
|
+
*
|
|
5
|
+
* The host log is the only place the FULL lifecycle of a tool call is observable:
|
|
6
|
+
* the trajectory channel receives just the terminal record, so a call that begins
|
|
7
|
+
* and never returns is invisible there. This reader makes the richer channel
|
|
8
|
+
* actually usable — filter by call/session/tool, dump statistics, and detect
|
|
9
|
+
* invocations that LOST a record (`--incomplete`).
|
|
10
|
+
*
|
|
11
|
+
* Usage:
|
|
12
|
+
* node tools/audit-log.mjs # newest day: header + last 20 records
|
|
13
|
+
* node tools/audit-log.mjs --stats # aggregate statistics
|
|
14
|
+
* node tools/audit-log.mjs --errors # ERROR records only
|
|
15
|
+
* node tools/audit-log.mjs --call 8b47827d # one invocation (callId prefix ok)
|
|
16
|
+
* node tools/audit-log.mjs --session sess-abc # one session
|
|
17
|
+
* node tools/audit-log.mjs --tool run_external_script
|
|
18
|
+
* node tools/audit-log.mjs --incomplete # calls missing start or terminal
|
|
19
|
+
* node tools/audit-log.mjs --tail 100 --json # raw JSON lines of the last 100
|
|
20
|
+
* node tools/audit-log.mjs --file <path.jsonl> # explicit file
|
|
21
|
+
* node tools/audit-log.mjs --all # every rotated file, not just newest day
|
|
22
|
+
*
|
|
23
|
+
* Default directory: $DSH_HOME/logs, falling back to ~/.dsh/logs.
|
|
24
|
+
* Zero dependencies; plain Node ESM (works on the same Node the plugin needs).
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { readdirSync, readFileSync, statSync } from 'node:fs'
|
|
28
|
+
import os from 'node:os'
|
|
29
|
+
import path from 'node:path'
|
|
30
|
+
|
|
31
|
+
const PLUGIN = 'dsh-log'
|
|
32
|
+
const DEFAULT_TAIL = 20
|
|
33
|
+
|
|
34
|
+
const HELP = `audit-log.mjs — 读取 dsh-log 主机审计日志
|
|
35
|
+
|
|
36
|
+
用法: node tools/audit-log.mjs [选项]
|
|
37
|
+
|
|
38
|
+
选项:
|
|
39
|
+
--file <path> 指定日志文件(默认读 $DSH_HOME/logs 下最新一天的文件)
|
|
40
|
+
--dir <path> 指定日志目录(默认 $DSH_HOME/logs 或 ~/.dsh/logs)
|
|
41
|
+
--all 读取所有轮转文件,而不只是最新一天
|
|
42
|
+
--call <id> 只看某个调用(callId 前缀即可)
|
|
43
|
+
--session <id> 只看某个会话
|
|
44
|
+
--tool <name> call_external_api | run_external_script
|
|
45
|
+
--errors 只看 ERROR 级记录
|
|
46
|
+
--incomplete 只看记录不完整的调用(缺 start 或缺终态)
|
|
47
|
+
--tail <n> 只显示最后 n 条(默认 ${DEFAULT_TAIL};带过滤条件时默认全部)
|
|
48
|
+
--stats 打印统计汇总
|
|
49
|
+
--json 输出原始 JSON 行
|
|
50
|
+
--help 显示本帮助
|
|
51
|
+
|
|
52
|
+
示例:
|
|
53
|
+
node tools/audit-log.mjs --errors --tail 50
|
|
54
|
+
node tools/audit-log.mjs --call 8b47827d
|
|
55
|
+
node tools/audit-log.mjs --incomplete
|
|
56
|
+
`
|
|
57
|
+
|
|
58
|
+
// ---------------------------------------------------------------- arg parsing
|
|
59
|
+
|
|
60
|
+
function parseArgs(argv) {
|
|
61
|
+
const o = {
|
|
62
|
+
file: null, dir: null, all: false, call: null, session: null, tool: null,
|
|
63
|
+
errors: false, incomplete: false, tail: null, stats: false, json: false, help: false,
|
|
64
|
+
}
|
|
65
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
66
|
+
const a = argv[i]
|
|
67
|
+
const need = () => {
|
|
68
|
+
const v = argv[i + 1]
|
|
69
|
+
if (v === undefined || v.startsWith('--')) throw new Error(`${a} 需要一个参数值`)
|
|
70
|
+
i += 1
|
|
71
|
+
return v
|
|
72
|
+
}
|
|
73
|
+
switch (a) {
|
|
74
|
+
case '--file': o.file = need(); break
|
|
75
|
+
case '--dir': o.dir = need(); break
|
|
76
|
+
case '--all': o.all = true; break
|
|
77
|
+
case '--call': o.call = need(); break
|
|
78
|
+
case '--session': o.session = need(); break
|
|
79
|
+
case '--tool': o.tool = need(); break
|
|
80
|
+
case '--errors': o.errors = true; break
|
|
81
|
+
case '--incomplete': o.incomplete = true; break
|
|
82
|
+
case '--tail': o.tail = Number(need()); break
|
|
83
|
+
case '--stats': o.stats = true; break
|
|
84
|
+
case '--json': o.json = true; break
|
|
85
|
+
case '--help': case '-h': o.help = true; break
|
|
86
|
+
default: throw new Error(`未知选项: ${a}(用 --help 查看用法)`)
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
if (o.tail !== null && (!Number.isInteger(o.tail) || o.tail < 0)) throw new Error('--tail 需要非负整数')
|
|
90
|
+
return o
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// ------------------------------------------------------------ file discovery
|
|
94
|
+
|
|
95
|
+
/** Default audit-log directory: $DSH_HOME/logs, else ~/.dsh/logs. */
|
|
96
|
+
function defaultLogDir() {
|
|
97
|
+
const home = (process.env.DSH_HOME || '').trim() || path.join(os.homedir(), '.dsh')
|
|
98
|
+
return path.join(home, 'logs')
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const FILE_RE = new RegExp(`^${PLUGIN}-(\\d{4})-(\\d{2})-(\\d{2})(-part\\d+)?\\.jsonl$`)
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Resolve which files to read. Without --all, returns every rotated file of the
|
|
105
|
+
* NEWEST day (rotation splits one day across -partN siblings, so one day is the
|
|
106
|
+
* smallest complete unit).
|
|
107
|
+
*/
|
|
108
|
+
function resolveFiles(o) {
|
|
109
|
+
if (o.file) {
|
|
110
|
+
statSync(o.file) // throws with a clear message when missing
|
|
111
|
+
return [path.resolve(o.file)]
|
|
112
|
+
}
|
|
113
|
+
const dir = o.dir ? path.resolve(o.dir) : defaultLogDir()
|
|
114
|
+
let entries
|
|
115
|
+
try {
|
|
116
|
+
entries = readdirSync(dir)
|
|
117
|
+
} catch {
|
|
118
|
+
throw new Error(`日志目录不存在或不可读: ${dir}\n(用 --file 直接指定文件,或用 --dir 指定目录)`)
|
|
119
|
+
}
|
|
120
|
+
const matched = entries.filter(e => FILE_RE.test(e))
|
|
121
|
+
if (matched.length === 0) throw new Error(`目录中没有 ${PLUGIN}-*.jsonl 文件: ${dir}`)
|
|
122
|
+
let picked = matched
|
|
123
|
+
if (!o.all) {
|
|
124
|
+
const newest = matched.map(e => e.match(FILE_RE)).map(m => `${m[1]}-${m[2]}-${m[3]}`).sort().at(-1)
|
|
125
|
+
picked = matched.filter(e => e.startsWith(`${PLUGIN}-${newest}`))
|
|
126
|
+
}
|
|
127
|
+
return picked.sort().map(e => path.join(dir, e))
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// ---------------------------------------------------------------- reading
|
|
131
|
+
|
|
132
|
+
/** Read one file into { file, line, record } entries; unparsable lines are kept with record=null. */
|
|
133
|
+
function readFile(file) {
|
|
134
|
+
const text = readFileSync(file, 'utf8')
|
|
135
|
+
const out = []
|
|
136
|
+
const lines = text.split('\n')
|
|
137
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
138
|
+
const line = lines[i]
|
|
139
|
+
if (line.trim() === '') continue
|
|
140
|
+
let record = null
|
|
141
|
+
try { record = JSON.parse(line) } catch { /* surfaced by --json / stats as corrupt */ }
|
|
142
|
+
out.push({ file, line, lineNo: i + 1, record })
|
|
143
|
+
}
|
|
144
|
+
return out
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Phase of one record. Prefers the explicit `phase` field (plugin >= 1.0.1) and
|
|
149
|
+
* falls back to field-presence inference so logs written by older builds — like
|
|
150
|
+
* the ones already on disk — still analyse correctly.
|
|
151
|
+
*/
|
|
152
|
+
function phaseOf(r) {
|
|
153
|
+
if (typeof r.phase === 'string') return r.phase
|
|
154
|
+
if (r.level === 'ERROR') return 'error'
|
|
155
|
+
if (r.response !== undefined || r.code !== undefined) return 'finished'
|
|
156
|
+
return 'start'
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const isAudit = r => r !== null && typeof r === 'object' && typeof r.callId === 'string'
|
|
160
|
+
const isEvent = r => r !== null && typeof r === 'object' && typeof r.event === 'string'
|
|
161
|
+
|
|
162
|
+
// ---------------------------------------------------------------- filtering
|
|
163
|
+
|
|
164
|
+
function applyFilters(entries, o) {
|
|
165
|
+
return entries.filter(({ record: r }) => {
|
|
166
|
+
if (o.errors && !(isAudit(r) && r.level === 'ERROR')) return false
|
|
167
|
+
if (o.call && !(isAudit(r) && String(r.callId).startsWith(o.call))) return false
|
|
168
|
+
if (o.session && !(isAudit(r) && String(r.sessionId).includes(o.session))) return false
|
|
169
|
+
if (o.tool && !(isAudit(r) && r.tool === o.tool)) return false
|
|
170
|
+
return true
|
|
171
|
+
})
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Group audit records by callId and report every invocation whose record set is
|
|
176
|
+
* incomplete. Two failure modes, both invisible on the trajectory channel:
|
|
177
|
+
* - `缺终态`: the call started and never reported back — process killed
|
|
178
|
+
* mid-flight, a broken sink, or simply a call still in progress.
|
|
179
|
+
* - `首条记录丢失` / `seq 不连续`: the log's own `seq` proves a record should
|
|
180
|
+
* exist and does not (start lost, gap in the chain).
|
|
181
|
+
* Lost-record detection deliberately requires `seq` evidence: without it a missing
|
|
182
|
+
* `start` cannot be told apart from a call that failed before it ever started
|
|
183
|
+
* (which is a complete story, not a lost record). Legacy records without `seq`
|
|
184
|
+
* therefore only yield the `缺终态` check.
|
|
185
|
+
*/
|
|
186
|
+
function analyzeCalls(entries) {
|
|
187
|
+
const calls = new Map()
|
|
188
|
+
for (const { record: r } of entries) {
|
|
189
|
+
if (!isAudit(r)) continue
|
|
190
|
+
let c = calls.get(r.callId)
|
|
191
|
+
if (!c) {
|
|
192
|
+
c = { callId: r.callId, sessionId: r.sessionId, tool: r.tool, records: [], seqs: [] }
|
|
193
|
+
calls.set(r.callId, c)
|
|
194
|
+
}
|
|
195
|
+
c.records.push(r)
|
|
196
|
+
if (typeof r.seq === 'number') c.seqs.push(r.seq)
|
|
197
|
+
}
|
|
198
|
+
const list = [...calls.values()].map(c => {
|
|
199
|
+
const phases = c.records.map(phaseOf)
|
|
200
|
+
const hasTerminal = phases.some(p => p === 'finished' || p === 'error')
|
|
201
|
+
const problems = []
|
|
202
|
+
if (!hasTerminal) problems.push('缺终态')
|
|
203
|
+
const seqs = c.seqs.length === c.records.length ? c.seqs.slice().sort((a, b) => a - b) : null
|
|
204
|
+
if (seqs !== null && seqs.length > 0) {
|
|
205
|
+
if (seqs[0] !== 1) problems.push('首条记录丢失(缺 seq1)')
|
|
206
|
+
else if (!seqs.every((s, i) => s === i + 1)) problems.push(`seq 不连续 (${seqs.join(',')})`)
|
|
207
|
+
}
|
|
208
|
+
const last = c.records.at(-1)
|
|
209
|
+
return {
|
|
210
|
+
...c,
|
|
211
|
+
phases,
|
|
212
|
+
complete: problems.length === 0,
|
|
213
|
+
problems,
|
|
214
|
+
costMs: typeof last?.costMs === 'number' ? last.costMs : undefined,
|
|
215
|
+
errorType: c.records.map(r => r.errorType).find(Boolean),
|
|
216
|
+
ts: c.records.at(-1)?.ts,
|
|
217
|
+
}
|
|
218
|
+
})
|
|
219
|
+
return list.sort((a, b) => (a.ts ?? 0) - (b.ts ?? 0))
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// ---------------------------------------------------------------- rendering
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Display width of a string in terminal cells: CJK and fullwidth code points
|
|
226
|
+
* occupy two cells, everything else one. Plain `String.length` padding produces
|
|
227
|
+
* ragged columns the moment a header contains Chinese.
|
|
228
|
+
*/
|
|
229
|
+
function width(s) {
|
|
230
|
+
let n = 0
|
|
231
|
+
for (const ch of String(s)) {
|
|
232
|
+
const cp = ch.codePointAt(0)
|
|
233
|
+
const wide = (cp >= 0x1100 && cp <= 0x115f) || (cp >= 0x2e80 && cp <= 0xa4cf)
|
|
234
|
+
|| (cp >= 0xac00 && cp <= 0xd7a3) || (cp >= 0xf900 && cp <= 0xfaff)
|
|
235
|
+
|| (cp >= 0xfe30 && cp <= 0xfe4f) || (cp >= 0xff00 && cp <= 0xff60) || (cp >= 0xffe0 && cp <= 0xffe6)
|
|
236
|
+
n += wide ? 2 : 1
|
|
237
|
+
}
|
|
238
|
+
return n
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const pad = (s, w) => String(s) + ' '.repeat(Math.max(0, w - width(s)))
|
|
242
|
+
const padL = (s, w) => ' '.repeat(Math.max(0, w - width(s))) + String(s)
|
|
243
|
+
|
|
244
|
+
/** Local wall-clock time of a record, `HH:MM:SS.mmm`. */
|
|
245
|
+
function clockOf(ts) {
|
|
246
|
+
if (typeof ts !== 'number') return '--:--:--.---'
|
|
247
|
+
const d = new Date(ts)
|
|
248
|
+
const p = (n, w = 2) => String(n).padStart(w, '0')
|
|
249
|
+
return `${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}.${p(d.getMilliseconds(), 3)}`
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const short = (id, n = 8) => (typeof id === 'string' ? id.slice(0, n) : '-')
|
|
253
|
+
|
|
254
|
+
function renderRecord({ record: r }) {
|
|
255
|
+
if (isEvent(r)) {
|
|
256
|
+
// Lifecycle events (plugin-started, exporter-unavailable) share the audit
|
|
257
|
+
// column layout so a mixed dump stays readable; the event name takes the
|
|
258
|
+
// errorType column and a false `persistentLog` is called out explicitly.
|
|
259
|
+
return [
|
|
260
|
+
pad(clockOf(r.ts), 12),
|
|
261
|
+
pad(r.level ?? '-', 6),
|
|
262
|
+
pad('(event)', 19),
|
|
263
|
+
pad('-', 9),
|
|
264
|
+
padL('-', 3),
|
|
265
|
+
padL('-', 9),
|
|
266
|
+
pad('-', 5),
|
|
267
|
+
pad(r.event, 15),
|
|
268
|
+
r.persistentLog === false ? '未持久化!' : '',
|
|
269
|
+
].join(' ')
|
|
270
|
+
}
|
|
271
|
+
if (!isAudit(r)) {
|
|
272
|
+
return [
|
|
273
|
+
pad(clockOf(r?.ts), 12),
|
|
274
|
+
pad('?', 6),
|
|
275
|
+
pad('(无法解析)', 19),
|
|
276
|
+
pad('-', 9),
|
|
277
|
+
padL('-', 3),
|
|
278
|
+
padL('-', 9),
|
|
279
|
+
pad('-', 5),
|
|
280
|
+
pad('-', 15),
|
|
281
|
+
r === null ? 'JSON 解析失败' : JSON.stringify(r).slice(0, 60),
|
|
282
|
+
].join(' ')
|
|
283
|
+
}
|
|
284
|
+
return [
|
|
285
|
+
pad(clockOf(r.ts), 12),
|
|
286
|
+
pad(r.level, 6),
|
|
287
|
+
pad(r.tool, 19),
|
|
288
|
+
pad(phaseOf(r), 9),
|
|
289
|
+
padL(r.seq ?? '-', 3),
|
|
290
|
+
padL(r.costMs ?? '-', 7) + 'ms',
|
|
291
|
+
pad(r.code ?? '-', 5),
|
|
292
|
+
pad(r.errorType ?? '-', 15),
|
|
293
|
+
short(r.callId),
|
|
294
|
+
].join(' ')
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function renderTable(entries) {
|
|
298
|
+
const head = [
|
|
299
|
+
pad('时间', 12), pad('级别', 6), pad('工具', 19), pad('阶段', 9),
|
|
300
|
+
padL('seq', 3), padL('耗时', 9), pad('code', 5), pad('errorType', 15), 'callId',
|
|
301
|
+
].join(' ')
|
|
302
|
+
return [head, '-'.repeat(width(head)), ...entries.map(renderRecord)].join('\n')
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function renderStats(entries) {
|
|
306
|
+
const audits = entries.filter(e => isAudit(e.record)).map(e => e.record)
|
|
307
|
+
const events = entries.filter(e => isEvent(e.record)).map(e => e.record)
|
|
308
|
+
const corrupt = entries.filter(e => e.record === null).length
|
|
309
|
+
const calls = analyzeCalls(entries)
|
|
310
|
+
const byTool = {}, byLevel = {}, byError = {}, byPhase = {}
|
|
311
|
+
for (const r of audits) {
|
|
312
|
+
byTool[r.tool] = (byTool[r.tool] ?? 0) + 1
|
|
313
|
+
byLevel[r.level] = (byLevel[r.level] ?? 0) + 1
|
|
314
|
+
byPhase[phaseOf(r)] = (byPhase[phaseOf(r)] ?? 0) + 1
|
|
315
|
+
if (r.errorType) byError[r.errorType] = (byError[r.errorType] ?? 0) + 1
|
|
316
|
+
}
|
|
317
|
+
const costs = audits.map(r => r.costMs).filter(n => typeof n === 'number').sort((a, b) => a - b)
|
|
318
|
+
const pct = p => (costs.length === 0 ? '-' : `${costs[Math.min(costs.length - 1, Math.floor(costs.length * p))]}ms`)
|
|
319
|
+
const ts = audits.map(r => r.ts).filter(n => typeof n === 'number')
|
|
320
|
+
const incomplete = calls.filter(c => !c.complete)
|
|
321
|
+
const kv = obj => Object.entries(obj).sort((a, b) => b[1] - a[1]).map(([k, v]) => `${k}=${v}`).join(' ') || '-'
|
|
322
|
+
const lines = [
|
|
323
|
+
`文件 ${[...new Set(entries.map(e => path.basename(e.file)))].join(', ') || '-'}`,
|
|
324
|
+
`记录 ${audits.length} 条审计 + ${events.length} 条事件${corrupt ? ` + ${corrupt} 条无法解析` : ''}`,
|
|
325
|
+
`调用 ${calls.length} 个(完整 ${calls.length - incomplete.length},不完整 ${incomplete.length})`,
|
|
326
|
+
`时间范围 ${ts.length ? `${clockOf(Math.min(...ts))} → ${clockOf(Math.max(...ts))}` : '-'}`,
|
|
327
|
+
`按工具 ${kv(byTool)}`,
|
|
328
|
+
`按阶段 ${kv(byPhase)}`,
|
|
329
|
+
`按级别 ${kv(byLevel)}`,
|
|
330
|
+
`按错误类型 ${kv(byError)}`,
|
|
331
|
+
`耗时 p50=${pct(0.5)} p95=${pct(0.95)} max=${costs.at(-1) ?? '-'}ms`,
|
|
332
|
+
]
|
|
333
|
+
if (incomplete.length > 0) {
|
|
334
|
+
lines.push('', '不完整的调用:')
|
|
335
|
+
for (const c of incomplete) lines.push(` ! ${short(c.callId)} ${c.tool} 记录 ${c.records.length} 条 ${c.problems.join(' + ')}`)
|
|
336
|
+
}
|
|
337
|
+
return lines.join('\n')
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function renderIncomplete(entries) {
|
|
341
|
+
const calls = analyzeCalls(entries).filter(c => !c.complete)
|
|
342
|
+
const head = [pad('callId', 10), pad('工具', 19), padL('记录', 5), pad('问题', 22), 'sessionId'].join(' ')
|
|
343
|
+
const body = calls.map(c => [pad(short(c.callId), 10), pad(c.tool, 19), padL(c.records.length, 5), pad(c.problems.join(' + '), 22), short(c.sessionId, 18)].join(' '))
|
|
344
|
+
const all = analyzeCalls(entries)
|
|
345
|
+
return [
|
|
346
|
+
`共 ${all.length} 个调用,其中 ${calls.length} 个记录不完整`,
|
|
347
|
+
'',
|
|
348
|
+
...(calls.length === 0 ? ['(没有发现缺失记录的情况)'] : [head, '-'.repeat(width(head)), ...body]),
|
|
349
|
+
].join('\n')
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// ---------------------------------------------------------------- main
|
|
353
|
+
|
|
354
|
+
function main() {
|
|
355
|
+
const o = parseArgs(process.argv.slice(2))
|
|
356
|
+
if (o.help) { process.stdout.write(HELP); return 0 }
|
|
357
|
+
|
|
358
|
+
const files = resolveFiles(o)
|
|
359
|
+
const entries = files.flatMap(readFile)
|
|
360
|
+
if (o.json) {
|
|
361
|
+
const filtered = applyFilters(entries, o)
|
|
362
|
+
const tail = o.tail ?? (o.call || o.session || o.tool || o.errors ? filtered.length : DEFAULT_TAIL)
|
|
363
|
+
for (const e of tail > 0 ? filtered.slice(-tail) : filtered) process.stdout.write(`${e.line}\n`)
|
|
364
|
+
return 0
|
|
365
|
+
}
|
|
366
|
+
if (o.incomplete) { process.stdout.write(`${renderIncomplete(entries)}\n`); return 0 }
|
|
367
|
+
if (o.stats) { process.stdout.write(`${renderStats(entries)}\n`); return 0 }
|
|
368
|
+
|
|
369
|
+
const filtered = applyFilters(entries, o).filter(e => !o.errors || !isEvent(e.record))
|
|
370
|
+
const tail = o.tail ?? (o.call || o.session || o.tool || o.errors ? filtered.length : DEFAULT_TAIL)
|
|
371
|
+
const shown = tail > 0 ? filtered.slice(-tail) : filtered
|
|
372
|
+
const all = analyzeCalls(entries)
|
|
373
|
+
const bad = all.filter(c => !c.complete).length
|
|
374
|
+
const audits = filtered.filter(e => isAudit(e.record)).length
|
|
375
|
+
process.stdout.write([
|
|
376
|
+
`文件 ${files.map(f => path.basename(f)).join(', ')}`,
|
|
377
|
+
`筛选 ${shown.length}/${filtered.length} 条${o.call || o.session || o.tool || o.errors ? '(已应用过滤条件)' : `(默认最近 ${DEFAULT_TAIL} 条)`} · 命中审计记录 ${audits} 条 · 全文件不完整调用 ${bad} 个`,
|
|
378
|
+
'',
|
|
379
|
+
renderTable(shown),
|
|
380
|
+
'',
|
|
381
|
+
bad > 0 ? `提示: 有 ${bad} 个调用记录不完整,用 --incomplete 查看详情。` : '提示: 未发现记录不完整的调用。',
|
|
382
|
+
].join('\n') + '\n')
|
|
383
|
+
return 0
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
try {
|
|
387
|
+
process.exitCode = main()
|
|
388
|
+
} catch (err) {
|
|
389
|
+
process.stderr.write(`audit-log: ${err instanceof Error ? err.message : String(err)}\n`)
|
|
390
|
+
process.exitCode = 1
|
|
391
|
+
}
|