@inobit/pi-instabox 0.0.0-stage → 0.1.1
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/CHANGELOG.md +20 -0
- package/LICENSE +21 -0
- package/README.md +214 -2
- package/README.zh-CN.md +215 -0
- package/package.json +64 -3
- package/src/boxes.ts +158 -0
- package/src/cli.ts +440 -0
- package/src/config.ts +310 -0
- package/src/devtools.ts +257 -0
- package/src/entry-render.ts +42 -0
- package/src/index.ts +1172 -0
- package/src/log.ts +203 -0
- package/src/pool.ts +651 -0
- package/src/remote.ts +678 -0
- package/src/resolve.ts +216 -0
- package/src/schema.ts +370 -0
- package/src/session-target.ts +100 -0
- package/src/template.ts +473 -0
- package/src/tools.ts +362 -0
package/src/log.ts
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @inobit/pi-instabox — 运行期事件日志(JSONL,参考 `pi-permission/src/audit.ts` 的写入纪律)。
|
|
3
|
+
*
|
|
4
|
+
* 契约:
|
|
5
|
+
* - 文件:`<dir>/pi-instabox.jsonl`,一行一条记录;目录 0700、文件 0600(`setMode` 失败忽略,
|
|
6
|
+
* 权限不是写入的前提)。`dir` 由装配层给(`<agentDir>/logs/pi-instabox`),本模块不推导路径。
|
|
7
|
+
* - 级别:`off` 全关;`info`(默认,记生命周期)不记 `debug` 事件;`debug` 全记。
|
|
8
|
+
* 单条记录的级别由 `event(event, fields, level)` 指定,默认 `info`。
|
|
9
|
+
* - 字段:丢掉 `undefined`;字符串截断到 2000 字符;键名命中 `token|secret|password|api[-_]?key`
|
|
10
|
+
* 的字段值整体替换为 `"[redacted]"`(字段在记录里是 `fields` 子对象,不摊平到顶层)。
|
|
11
|
+
* - 轮转:默认 1 MiB / 3 份备份,命名 `pi-instabox.jsonl.1..3`(`.` 为最旧)。
|
|
12
|
+
* **写入后**才判定大小并滚动,因此滚动后的主文件为空、下次写入时重新创建。
|
|
13
|
+
* - IO 失败一律吞掉,同一错误只回调一次 `onError`,绝不 throw 到会话层(与 `cli.ts` 同一口径)。
|
|
14
|
+
* - `logEvent` 读模块级单例;未 `setLogger` 时是 no-op,故 `VITEST` 下默认不写盘。
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import fs from "node:fs";
|
|
18
|
+
import path from "node:path";
|
|
19
|
+
|
|
20
|
+
/** 日志级别(`off` 全关 / `info` 记生命周期 / `debug` 记细节)。 */
|
|
21
|
+
export type LogLevel = "off" | "info" | "debug";
|
|
22
|
+
|
|
23
|
+
/** 单条记录的级别(`warn` / `error` 在 `info` 级别下同样落盘)。 */
|
|
24
|
+
type LogEventLevel = "info" | "debug" | "warn" | "error";
|
|
25
|
+
|
|
26
|
+
/** 一条日志记录:写盘时序列化成一行 JSON。 */
|
|
27
|
+
export interface LogRecord {
|
|
28
|
+
ts: string;
|
|
29
|
+
level: LogEventLevel;
|
|
30
|
+
event: string;
|
|
31
|
+
fields?: Record<string, unknown>;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** 日志写入器(文件实现与内存实现共用)。 */
|
|
35
|
+
export interface Logger {
|
|
36
|
+
event(event: string, fields?: Record<string, unknown>, level?: LogEventLevel): void;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** 写入面(注入替身用:单测不碰真实磁盘)。 */
|
|
40
|
+
export interface LogFileSystem {
|
|
41
|
+
mkdir(dir: string): void;
|
|
42
|
+
append(file: string, line: string): void;
|
|
43
|
+
rename(from: string, to: string): void;
|
|
44
|
+
exists(file: string): boolean;
|
|
45
|
+
size(file: string): number;
|
|
46
|
+
setMode(path: string, mode: number): void;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const LOG_FILE_NAME = "pi-instabox.jsonl";
|
|
50
|
+
const DEFAULT_MAX_BYTES = 1024 * 1024;
|
|
51
|
+
const DEFAULT_MAX_BACKUPS = 3;
|
|
52
|
+
/** 单个字符串字段的字符上限(防日志膨胀,与 `pi-permission` 的 2000 对齐)。 */
|
|
53
|
+
const MAX_FIELD_CHARS = 2000;
|
|
54
|
+
const REDACTED = "[redacted]";
|
|
55
|
+
const SENSITIVE_KEY_RE = /token|secret|password|api[-_]?key/i;
|
|
56
|
+
|
|
57
|
+
/** 默认真实磁盘实现:尺寸读取失败视为 0(文件不存在时与「空文件」同义)。 */
|
|
58
|
+
const nodeLogFileSystem: LogFileSystem = {
|
|
59
|
+
mkdir(dir) {
|
|
60
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
61
|
+
},
|
|
62
|
+
append(file, line) {
|
|
63
|
+
fs.appendFileSync(file, line);
|
|
64
|
+
},
|
|
65
|
+
rename(from, to) {
|
|
66
|
+
fs.renameSync(from, to);
|
|
67
|
+
},
|
|
68
|
+
exists(file) {
|
|
69
|
+
return fs.existsSync(file);
|
|
70
|
+
},
|
|
71
|
+
size(file) {
|
|
72
|
+
try {
|
|
73
|
+
return fs.statSync(file).size;
|
|
74
|
+
} catch {
|
|
75
|
+
return 0;
|
|
76
|
+
}
|
|
77
|
+
},
|
|
78
|
+
setMode(target, mode) {
|
|
79
|
+
fs.chmodSync(target, mode);
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
function trySetMode(io: LogFileSystem, target: string, mode: number): void {
|
|
84
|
+
try {
|
|
85
|
+
io.setMode(target, mode);
|
|
86
|
+
} catch {
|
|
87
|
+
// 权限设置失败不影响写入
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** 字段预处理:丢 `undefined`、敏感键脱敏、字符串截断;全丢光时字段整体缺席。 */
|
|
92
|
+
function prepareFields(fields: Record<string, unknown> | undefined): Record<string, unknown> | undefined {
|
|
93
|
+
if (fields === undefined) return undefined;
|
|
94
|
+
const out: Record<string, unknown> = {};
|
|
95
|
+
for (const [key, value] of Object.entries(fields)) {
|
|
96
|
+
if (value === undefined) continue;
|
|
97
|
+
if (SENSITIVE_KEY_RE.test(key)) {
|
|
98
|
+
out[key] = REDACTED;
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
out[key] = typeof value === "string" && value.length > MAX_FIELD_CHARS ? value.slice(0, MAX_FIELD_CHARS) : value;
|
|
102
|
+
}
|
|
103
|
+
return Object.keys(out).length > 0 ? out : undefined;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function buildRecord(
|
|
107
|
+
clock: () => number,
|
|
108
|
+
event: string,
|
|
109
|
+
fields: Record<string, unknown> | undefined,
|
|
110
|
+
level: LogEventLevel,
|
|
111
|
+
): LogRecord {
|
|
112
|
+
const record: LogRecord = { ts: new Date(clock()).toISOString(), level, event };
|
|
113
|
+
const prepared = prepareFields(fields);
|
|
114
|
+
if (prepared !== undefined) record.fields = prepared;
|
|
115
|
+
return record;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** 级别过滤:`off` 全关,`info` 不记 `debug` 事件,`debug` 全记。 */
|
|
119
|
+
function shouldLog(configured: LogLevel, level: LogEventLevel): boolean {
|
|
120
|
+
if (configured === "off") return false;
|
|
121
|
+
if (configured === "debug") return true;
|
|
122
|
+
return level !== "debug";
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** 文件日志:JSONL 追加 + 大小轮转;IO 失败吞掉并去重回调 `onError`。 */
|
|
126
|
+
export function createFileLogger(options: {
|
|
127
|
+
dir: string;
|
|
128
|
+
level?: LogLevel;
|
|
129
|
+
clock?: () => number;
|
|
130
|
+
fs?: LogFileSystem;
|
|
131
|
+
maxBytes?: number;
|
|
132
|
+
maxBackups?: number;
|
|
133
|
+
onError?: (message: string) => void;
|
|
134
|
+
}): Logger {
|
|
135
|
+
const file = path.join(options.dir, LOG_FILE_NAME);
|
|
136
|
+
const level = options.level ?? "info";
|
|
137
|
+
const clock = options.clock ?? Date.now;
|
|
138
|
+
const io = options.fs ?? nodeLogFileSystem;
|
|
139
|
+
const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES;
|
|
140
|
+
const maxBackups = Math.max(1, Math.floor(options.maxBackups ?? DEFAULT_MAX_BACKUPS));
|
|
141
|
+
const reported = new Set<string>();
|
|
142
|
+
|
|
143
|
+
const report = (err: unknown): void => {
|
|
144
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
145
|
+
if (reported.has(message)) return;
|
|
146
|
+
reported.add(message);
|
|
147
|
+
try {
|
|
148
|
+
options.onError?.(`[pi-instabox] failed to write log: ${message}`);
|
|
149
|
+
} catch {
|
|
150
|
+
// onError 自身抛错也不能冒泡
|
|
151
|
+
}
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
const roll = (): void => {
|
|
155
|
+
for (let i = maxBackups - 1; i >= 1; i--) {
|
|
156
|
+
const from = `${file}.${i}`;
|
|
157
|
+
if (io.exists(from)) io.rename(from, `${file}.${i + 1}`);
|
|
158
|
+
}
|
|
159
|
+
io.rename(file, `${file}.1`);
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
const write = (record: LogRecord): void => {
|
|
163
|
+
try {
|
|
164
|
+
io.mkdir(options.dir);
|
|
165
|
+
trySetMode(io, options.dir, 0o700);
|
|
166
|
+
io.append(file, `${JSON.stringify(record)}\n`);
|
|
167
|
+
trySetMode(io, file, 0o600);
|
|
168
|
+
if (io.size(file) >= maxBytes) roll();
|
|
169
|
+
} catch (err) {
|
|
170
|
+
report(err);
|
|
171
|
+
}
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
return {
|
|
175
|
+
event(event, fields, recordLevel = "info") {
|
|
176
|
+
if (!shouldLog(level, recordLevel)) return;
|
|
177
|
+
write(buildRecord(clock, event, fields, recordLevel));
|
|
178
|
+
},
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** 内存日志:只收集记录(字段预处理与文件实现一致),供单测与装配层断言。 */
|
|
183
|
+
export function createMemoryLogger(): Logger & { records: LogRecord[] } {
|
|
184
|
+
const records: LogRecord[] = [];
|
|
185
|
+
return {
|
|
186
|
+
records,
|
|
187
|
+
event(event, fields, level = "info") {
|
|
188
|
+
records.push(buildRecord(Date.now, event, fields, level));
|
|
189
|
+
},
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
let currentLogger: Logger | undefined;
|
|
194
|
+
|
|
195
|
+
/** 配置模块级单例;传 `undefined` 复位成 no-op。 */
|
|
196
|
+
export function setLogger(logger: Logger | undefined): void {
|
|
197
|
+
currentLogger = logger;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** 记录一条事件;未配置单例时什么都不做。 */
|
|
201
|
+
export function logEvent(event: string, fields?: Record<string, unknown>, level: LogEventLevel = "info"): void {
|
|
202
|
+
currentLogger?.event(event, fields, level);
|
|
203
|
+
}
|