dsh-api-runner 0.0.0-stage → 0.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/LICENSE +21 -0
- package/README.md +435 -2
- package/client/client.js +1824 -0
- package/cordis.patch.yml +9 -0
- package/docs/screenshot-service.png +0 -0
- package/docs/screenshot-settings.png +0 -0
- package/docs/verified-dsh-version.png +0 -0
- package/dsh-api-runner.example.yml +92 -0
- package/package.json +71 -6
- package/scripts/stage.mjs +78 -0
- package/src/api.js +194 -0
- package/src/config.js +265 -0
- package/src/java-runner.js +623 -0
- package/src/java.js +658 -0
- package/src/log.js +209 -0
- package/src/main.js +615 -0
- package/src/ops.js +1257 -0
- package/src/project-store.js +334 -0
- package/src/restart.js +549 -0
- package/src/service.js +329 -0
- package/src/settings.js +109 -0
- package/src/util.js +182 -0
- package/test/client.mjs +162 -0
- package/test/java.mjs +298 -0
- package/test/managed.mjs +137 -0
- package/test/plugin.mjs +467 -0
- package/test/smoke.mjs +528 -0
package/src/log.js
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 日志增量读取:按字节游标读文件尾部新增内容,支持正则过滤与"等到某行出现"。
|
|
3
|
+
*
|
|
4
|
+
* 设计要点:
|
|
5
|
+
* - 游标持久化在 ${DSH_HOME}/api-runner-state.json,按 logId 记录 {offset, inode, size};
|
|
6
|
+
* - 检测截断/轮转:记录 inode 与上次 size,当前 size 小于上次 offset(或 inode 变化)就从头读;
|
|
7
|
+
* - 只读新增字节上限,避免把几十 MB 日志读进上下文;返回给模型前再做 maxLines 截断;
|
|
8
|
+
* - waitFor:轮询直到匹配行出现或超时,用于"重启服务→等启动完成日志"。
|
|
9
|
+
*/
|
|
10
|
+
import fs from 'node:fs';
|
|
11
|
+
import { isPlainObject, readStateNamespace, sleep, truncateText, writeStateNamespace } from './util.js';
|
|
12
|
+
|
|
13
|
+
const MAX_DEFAULT_BYTES = 64 * 1024;
|
|
14
|
+
const MAX_DEFAULT_LINES = 400;
|
|
15
|
+
|
|
16
|
+
export class LogReader {
|
|
17
|
+
constructor(options = {}) {
|
|
18
|
+
this.statePath = options.statePath || '';
|
|
19
|
+
this._state = null;
|
|
20
|
+
this._stateDirty = false;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** 读取状态文件(惰性加载一次) */
|
|
24
|
+
_loadState() {
|
|
25
|
+
if (this._state) return this._state;
|
|
26
|
+
this._state = readStateNamespace(this.statePath, 'logs');
|
|
27
|
+
return this._state;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** 写回状态文件(只更新 logs 命名空间,保留 services 记录) */
|
|
31
|
+
save() {
|
|
32
|
+
if (!this.statePath || !this._state) return;
|
|
33
|
+
writeStateNamespace(this.statePath, 'logs', this._state);
|
|
34
|
+
this._stateDirty = false;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** 当前记录的文件大小与 inode,用于轮转检测 */
|
|
38
|
+
stat(logPath) {
|
|
39
|
+
const stat = fs.statSync(logPath);
|
|
40
|
+
return { size: stat.size, ino: String(stat.ino ?? ''), mtimeMs: stat.mtimeMs };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* 增量读取。
|
|
45
|
+
* @param {{id:string,path:string}} log
|
|
46
|
+
* @param {{reset?:boolean, fromOffset?:number, maxBytes?:number, maxLines?:number,
|
|
47
|
+
* pattern?:string, ignoreCase?:boolean, invert?:boolean}} options
|
|
48
|
+
*/
|
|
49
|
+
read(log, options = {}) {
|
|
50
|
+
const filePath = log.path;
|
|
51
|
+
if (!filePath) throw new Error(`日志 ${log.id} 未配置 path`);
|
|
52
|
+
let stat;
|
|
53
|
+
try {
|
|
54
|
+
stat = this.stat(filePath);
|
|
55
|
+
} catch (error) {
|
|
56
|
+
throw new Error(`读取日志文件失败 ${filePath}: ${error.message}`);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const state = this._loadState();
|
|
60
|
+
const record = isPlainObject(state[log.id]) ? state[log.id] : {};
|
|
61
|
+
let start = Number.isInteger(options.fromOffset) ? Number(options.fromOffset) : Number(record.offset || 0);
|
|
62
|
+
const rotated = record.ino !== undefined && record.ino !== '' && String(record.ino) !== stat.ino;
|
|
63
|
+
const truncated = stat.size < start;
|
|
64
|
+
const firstRead = record.offset === undefined;
|
|
65
|
+
let note = '';
|
|
66
|
+
|
|
67
|
+
if (options.reset) {
|
|
68
|
+
start = 0;
|
|
69
|
+
note = 'reset:从头读取';
|
|
70
|
+
} else if (rotated || truncated) {
|
|
71
|
+
start = 0;
|
|
72
|
+
note = rotated ? '检测到日志轮转(inode 变化),从头读取' : '检测到日志截断(文件变小),从头读取';
|
|
73
|
+
} else if (firstRead && options.fromTail !== false) {
|
|
74
|
+
// 首次读取默认只取尾部一段,避免把历史全量灌进上下文
|
|
75
|
+
const tailBytes = Number(options.maxBytes || MAX_DEFAULT_BYTES);
|
|
76
|
+
start = Math.max(0, stat.size - tailBytes);
|
|
77
|
+
note = `首次读取,从末尾 ${tailBytes} 字节开始(如需全量请传 reset:true)`;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const maxBytes = Number(options.maxBytes || MAX_DEFAULT_BYTES);
|
|
81
|
+
const available = Math.max(0, stat.size - start);
|
|
82
|
+
const take = Math.min(available, maxBytes);
|
|
83
|
+
let text = '';
|
|
84
|
+
let end = start + take;
|
|
85
|
+
if (take > 0) {
|
|
86
|
+
const buffer = Buffer.allocUnsafe(take);
|
|
87
|
+
const fd = fs.openSync(filePath, 'r');
|
|
88
|
+
try {
|
|
89
|
+
const read = fs.readSync(fd, buffer, 0, take, start);
|
|
90
|
+
text = buffer.subarray(0, read).toString(log.encoding || 'utf8');
|
|
91
|
+
end = start + read;
|
|
92
|
+
} finally {
|
|
93
|
+
fs.closeSync(fd);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
// 截断到最后一个完整行,剩余留到下次
|
|
97
|
+
if (end < stat.size && !text.endsWith('\n')) {
|
|
98
|
+
const cut = text.lastIndexOf('\n');
|
|
99
|
+
if (cut >= 0) {
|
|
100
|
+
end = start + Buffer.byteLength(text.slice(0, cut + 1), 'utf8');
|
|
101
|
+
text = text.slice(0, cut + 1);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// 按行切分:只丢掉结尾因换行产生的空元素,正文里的空行保留
|
|
106
|
+
const rawLines = text === '' ? [] : text.split(/\r?\n/);
|
|
107
|
+
if (rawLines.length > 0 && rawLines[rawLines.length - 1] === '') rawLines.pop();
|
|
108
|
+
let lines = rawLines;
|
|
109
|
+
if (options.pattern) {
|
|
110
|
+
let regex;
|
|
111
|
+
try {
|
|
112
|
+
regex = new RegExp(options.pattern, options.ignoreCase ? 'i' : '');
|
|
113
|
+
} catch (error) {
|
|
114
|
+
throw new Error(`pattern 不是合法正则:${error.message}`);
|
|
115
|
+
}
|
|
116
|
+
lines = lines.filter((line) => (options.invert ? !regex.test(line) : regex.test(line)));
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const maxLines = Number(options.maxLines || MAX_DEFAULT_LINES);
|
|
120
|
+
const totalLines = lines.length;
|
|
121
|
+
const limited = lines.length > maxLines ? lines.slice(-maxLines) : lines;
|
|
122
|
+
const bodyText = truncateText(limited.join('\n'), Math.max(maxBytes * 2, 4096));
|
|
123
|
+
|
|
124
|
+
state[log.id] = { offset: end, ino: stat.ino, size: end, updatedAt: new Date().toISOString() };
|
|
125
|
+
this._stateDirty = true;
|
|
126
|
+
// 每次读完立即落盘:DSH 重启后游标才不丢(状态文件很小,开销可忽略)
|
|
127
|
+
this.save();
|
|
128
|
+
|
|
129
|
+
return {
|
|
130
|
+
ok: true,
|
|
131
|
+
logId: log.id,
|
|
132
|
+
name: log.name,
|
|
133
|
+
path: filePath,
|
|
134
|
+
fileSize: stat.size,
|
|
135
|
+
fromOffset: start,
|
|
136
|
+
nextOffset: end,
|
|
137
|
+
remainingBytes: Math.max(0, stat.size - end),
|
|
138
|
+
totalLines,
|
|
139
|
+
returnedLines: limited.length,
|
|
140
|
+
droppedLines: Math.max(0, totalLines - limited.length),
|
|
141
|
+
text: bodyText,
|
|
142
|
+
note,
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* 轮询等待匹配行出现。
|
|
148
|
+
* @param {{id:string,path:string}} log
|
|
149
|
+
* @param {{pattern?:string, timeoutMs?:number, intervalMs?:number, maxBytes?:number,
|
|
150
|
+
* maxLines?:number, reset?:boolean, ignoreCase?:boolean}} options
|
|
151
|
+
*/
|
|
152
|
+
async waitFor(log, options = {}) {
|
|
153
|
+
const timeoutMs = Number(options.timeoutMs || 60_000);
|
|
154
|
+
const intervalMs = Math.max(100, Number(options.intervalMs || 500));
|
|
155
|
+
const started = Date.now();
|
|
156
|
+
let cursor = Number.isInteger(options.fromOffset) ? Number(options.fromOffset) : undefined;
|
|
157
|
+
const chunks = [];
|
|
158
|
+
let matched = false;
|
|
159
|
+
|
|
160
|
+
while (Date.now() - started <= timeoutMs) {
|
|
161
|
+
const result = this.read(log, {
|
|
162
|
+
fromOffset: cursor,
|
|
163
|
+
maxBytes: Number(options.maxBytes || 16 * 1024),
|
|
164
|
+
maxLines: Number(options.maxLines || 200),
|
|
165
|
+
pattern: options.pattern,
|
|
166
|
+
ignoreCase: options.ignoreCase,
|
|
167
|
+
reset: chunks.length === 0 ? options.reset : false,
|
|
168
|
+
});
|
|
169
|
+
cursor = result.nextOffset;
|
|
170
|
+
if (result.text !== '') chunks.push(result.text);
|
|
171
|
+
if (options.pattern && result.totalLines > 0) {
|
|
172
|
+
matched = true;
|
|
173
|
+
break;
|
|
174
|
+
}
|
|
175
|
+
if (!options.pattern && result.text !== '') {
|
|
176
|
+
matched = true;
|
|
177
|
+
break;
|
|
178
|
+
}
|
|
179
|
+
await sleep(intervalMs);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return {
|
|
183
|
+
// 超时未命中时 ok 为 false,让调用方(工具/verify)能直接判定失败
|
|
184
|
+
ok: matched,
|
|
185
|
+
logId: log.id,
|
|
186
|
+
path: log.path,
|
|
187
|
+
pattern: options.pattern || '',
|
|
188
|
+
matched,
|
|
189
|
+
waitedMs: Date.now() - started,
|
|
190
|
+
text: truncateText(chunks.join('\n'), Number(options.maxBytes || 16 * 1024) * 2),
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** 暴露给上层的游标快照 */
|
|
195
|
+
snapshot(logId) {
|
|
196
|
+
const state = this._loadState();
|
|
197
|
+
return isPlainObject(state[logId]) ? { ...state[logId] } : undefined;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
resetCursor(logId) {
|
|
201
|
+
const state = this._loadState();
|
|
202
|
+
delete state[logId];
|
|
203
|
+
this._stateDirty = true;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
dispose() {
|
|
207
|
+
if (this._stateDirty) this.save();
|
|
208
|
+
}
|
|
209
|
+
}
|