@syrkos/filaxis 0.1.0-alpha.10
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/AGENTS.md +46 -0
- package/LICENSE +202 -0
- package/NOTICE +10 -0
- package/README.md +61 -0
- package/bin/filaxis.ts +112 -0
- package/dist/agent-toolkit.d.ts +99 -0
- package/dist/dialect.d.ts +31 -0
- package/dist/driver.d.ts +130 -0
- package/dist/events.d.ts +26 -0
- package/dist/factory.d.ts +16 -0
- package/dist/file-edit.d.ts +43 -0
- package/dist/file-transfer.d.ts +44 -0
- package/dist/index.d.ts +38 -0
- package/dist/index.js +4200 -0
- package/dist/layer-discovery.d.ts +30 -0
- package/dist/layer-env.d.ts +95 -0
- package/dist/local-driver.d.ts +47 -0
- package/dist/local-session-manager.d.ts +98 -0
- package/dist/logger.d.ts +12 -0
- package/dist/remote-session-manager.d.ts +64 -0
- package/dist/runtime-stack.d.ts +52 -0
- package/dist/session-buffer.d.ts +73 -0
- package/dist/session-types.d.ts +74 -0
- package/dist/shell-discovery.d.ts +4 -0
- package/dist/ssh-helpers.d.ts +88 -0
- package/dist/ssh-pty-driver.d.ts +74 -0
- package/dist/stream-session-manager.d.ts +83 -0
- package/dist/stream-window.d.ts +124 -0
- package/dist/terminal/command-executer.d.ts +73 -0
- package/dist/terminal/output-buffer.d.ts +49 -0
- package/dist/terminal/session-launcher.d.ts +28 -0
- package/dist/terminal/session-registry.d.ts +29 -0
- package/dist/terminal/tmux-invoker.d.ts +8 -0
- package/dist/terminal/types.d.ts +56 -0
- package/dist/terminal-manager.d.ts +30 -0
- package/dist/tmux-driver.d.ts +49 -0
- package/dist/vt100-screen.d.ts +46 -0
- package/docs/DEVELOPMENT.md +38 -0
- package/docs/KNOWN-ISSUES.md +35 -0
- package/docs/implementation-notes.md +57 -0
- package/docs/skills/cross-host-diagnosis.md +21 -0
- package/docs/skills/file-sync.md +22 -0
- package/docs/skills/longrun-observe.md +20 -0
- package/docs/skills/stream-window.md +28 -0
- package/docs//345/256/214/346/225/264/345/267/241/346/243/200/346/212/245/345/221/212.md +141 -0
- package/docs//346/265/213/350/257/2252.md +112 -0
- package/docs//346/265/213/350/257/2253.md +73 -0
- package/docs//346/265/213/350/257/2254.md +138 -0
- package/docs//346/265/213/350/257/2255.md +124 -0
- package/docs//347/254/254/344/272/214/344/273/275/345/267/241/346/243/200/346/212/245/345/221/212.md +103 -0
- package/examples/agent-tools.ts +74 -0
- package/examples/cross-host-diagnosis.ts +49 -0
- package/examples/longrun-observe.ts +46 -0
- package/package.json +47 -0
- package/src/agent-toolkit.ts +247 -0
- package/src/dialect.ts +100 -0
- package/src/driver.ts +142 -0
- package/src/events.ts +96 -0
- package/src/factory.ts +51 -0
- package/src/file-edit.ts +195 -0
- package/src/file-transfer.ts +196 -0
- package/src/index.ts +38 -0
- package/src/layer-discovery.ts +79 -0
- package/src/layer-env.ts +331 -0
- package/src/local-driver.ts +325 -0
- package/src/local-session-manager.ts +397 -0
- package/src/logger.ts +19 -0
- package/src/remote-session-manager.ts +286 -0
- package/src/runtime-stack.ts +128 -0
- package/src/session-buffer.ts +183 -0
- package/src/session-types.ts +78 -0
- package/src/shell-discovery.ts +52 -0
- package/src/ssh-helpers.ts +225 -0
- package/src/ssh-pty-driver.ts +655 -0
- package/src/stream-session-manager.ts +296 -0
- package/src/stream-window.ts +309 -0
- package/src/terminal/command-executer.ts +374 -0
- package/src/terminal/output-buffer.ts +126 -0
- package/src/terminal/session-launcher.ts +146 -0
- package/src/terminal/session-registry.ts +98 -0
- package/src/terminal/tmux-invoker.ts +65 -0
- package/src/terminal/types.ts +85 -0
- package/src/terminal-manager.ts +83 -0
- package/src/tmux-driver.ts +167 -0
- package/src/vt100-screen.ts +166 -0
|
@@ -0,0 +1,397 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* local-session-manager.ts — 本地长跑会话引擎(spawn 即返 + 双文件落盘 + 热窗口 + watcher)
|
|
3
|
+
*
|
|
4
|
+
* 净室平移自 Aethelum-Nexus `src/core/tools/runtime/session/local-session-manager.ts`
|
|
5
|
+
* (源锚点:Aethelum-Nexus dev @ 8977ff3 + fix 203a31c),按 REQ-0001 OSS 化改造:
|
|
6
|
+
* - 剥 SubstrateSecurity(执行网/沙箱属消费方策略——内核不越权预设安全边界)
|
|
7
|
+
* - SessionNotifier 队列投递 → 纯 onEvent 回调(内核零队列红线)
|
|
8
|
+
* - 落盘协议改双文件 `<logBase>.out` / `<logBase>.err`(FEAT-04——源版单文件混写)
|
|
9
|
+
* - 热窗口行对象带 stream 标记;聚合序为到达序(REQ §3.2 跨流顺序契约)
|
|
10
|
+
* - logger 可注入(缺省 noop);日志目录/Shell 可配
|
|
11
|
+
*
|
|
12
|
+
* 终端语义:start 立即返回,进程长跑(无自动超时——长跑是特性)。
|
|
13
|
+
* 三级读取:文件全量(完整行落盘,1MB/行防爆 + 64MiB 总量硬顶)+ 热窗口 + watcher 推送。
|
|
14
|
+
* 生命周期:退出后句柄保留 ttlMs(默认 10min)供补读,TTL 后回收(timer unref 不撑进程)。
|
|
15
|
+
*/
|
|
16
|
+
import { spawn, type ChildProcess } from "node:child_process";
|
|
17
|
+
import fs from "node:fs/promises";
|
|
18
|
+
import { createWriteStream } from "node:fs";
|
|
19
|
+
import path from "node:path";
|
|
20
|
+
import { tmpdir } from "node:os";
|
|
21
|
+
import * as crypto from "node:crypto";
|
|
22
|
+
import { LineAssembler, OutputRingBuffer, WatchRegistry } from "./session-buffer.js";
|
|
23
|
+
import type {
|
|
24
|
+
SessionStartOptions,
|
|
25
|
+
SessionHandleInfo,
|
|
26
|
+
SessionEvent,
|
|
27
|
+
WatchOptions,
|
|
28
|
+
ReadLogOptions,
|
|
29
|
+
OutputStream,
|
|
30
|
+
} from "./session-types.js";
|
|
31
|
+
import { noopLogger, type FilaxisLogger } from "./logger.js";
|
|
32
|
+
import { resolveLocalShell } from "./shell-discovery.js";
|
|
33
|
+
/** 子串语义转义(与 WatchRegistry.toRegex 同规则) */
|
|
34
|
+
function escapeRegExp(p: string): string {
|
|
35
|
+
return p.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** 文件侧单行上限(防爆盘;窗口侧 maxLineBytes 截断另算) */
|
|
39
|
+
const MAX_FILE_LINE_BYTES = 1_000_000;
|
|
40
|
+
/** 会话日志总量硬顶(两流合计;失控输出防写爆 tmpfs);超限停写文件,ring/watcher 继续 */
|
|
41
|
+
const MAX_SESSION_LOG_BYTES = 64 * 1024 * 1024;
|
|
42
|
+
/** stop 后 SIGKILL 升级宽限 */
|
|
43
|
+
const KILL_GRACE_MS = 1500;
|
|
44
|
+
|
|
45
|
+
interface SessionEntry {
|
|
46
|
+
sessionId: string;
|
|
47
|
+
command: string;
|
|
48
|
+
startedAt: number;
|
|
49
|
+
ownerSessionId?: string;
|
|
50
|
+
ring: OutputRingBuffer;
|
|
51
|
+
watchers: WatchRegistry;
|
|
52
|
+
watcherMeta: Map<string, WatchOptions>;
|
|
53
|
+
assemblers: Record<OutputStream, LineAssembler>;
|
|
54
|
+
logStreams: Record<OutputStream, ReturnType<typeof createWriteStream>>;
|
|
55
|
+
logBase: string;
|
|
56
|
+
logBytes: number;
|
|
57
|
+
logCapped: boolean;
|
|
58
|
+
proc?: ChildProcess;
|
|
59
|
+
running: boolean;
|
|
60
|
+
exitCode?: number | null;
|
|
61
|
+
ttlTimer?: ReturnType<typeof setTimeout>;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface LocalSessionManagerOptions {
|
|
65
|
+
maxLines?: number;
|
|
66
|
+
maxLineBytes?: number;
|
|
67
|
+
/** 退出后句柄保留时长,默认 10min */
|
|
68
|
+
ttlMs?: number;
|
|
69
|
+
/** 会话日志目录(缺省 os.tmpdir()/filaxis-sessions) */
|
|
70
|
+
logDir?: string;
|
|
71
|
+
/** 执行 shell(缺省 "bash";Windows 需 Git Bash/WSL 提供 bash——平台差异显式声明,不静默切换) */
|
|
72
|
+
shell?: string;
|
|
73
|
+
/** spawn 注入(缺省 node:child_process.spawn)——消费方沙箱包装/测试 mock 注入点 */
|
|
74
|
+
spawnImpl?: (cmd: string, argv: string[], opts: { env: NodeJS.ProcessEnv; cwd?: string; stdio: unknown[] }) => ChildProcess;
|
|
75
|
+
/** 可注入日志(缺省 noop——SDK 不替调用方往 stdout 喷日志) */
|
|
76
|
+
logger?: FilaxisLogger;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export class LocalSessionManager {
|
|
80
|
+
/** 事件回调(FEAT-09:消费方订阅点;缺省 noop)。回调异常被吞并记日志,不杀会话 */
|
|
81
|
+
onEvent: (ev: SessionEvent) => void = () => undefined;
|
|
82
|
+
|
|
83
|
+
private readonly entries = new Map<string, SessionEntry>();
|
|
84
|
+
private readonly maxLines: number;
|
|
85
|
+
private readonly maxLineBytes: number;
|
|
86
|
+
private readonly ttlMs: number;
|
|
87
|
+
private readonly logDir: string;
|
|
88
|
+
private readonly shell: string;
|
|
89
|
+
private readonly log: FilaxisLogger;
|
|
90
|
+
private readonly spawnImpl: LocalSessionManagerOptions["spawnImpl"];
|
|
91
|
+
|
|
92
|
+
constructor(opts: LocalSessionManagerOptions = {}) {
|
|
93
|
+
this.maxLines = opts.maxLines ?? 50;
|
|
94
|
+
this.maxLineBytes = opts.maxLineBytes ?? 512;
|
|
95
|
+
this.ttlMs = opts.ttlMs ?? 10 * 60_000;
|
|
96
|
+
this.logDir = opts.logDir ?? path.join(tmpdir(), "filaxis-sessions");
|
|
97
|
+
this.log = opts.logger ?? noopLogger;
|
|
98
|
+
this.shell = resolveLocalShell(opts.shell, this.log);
|
|
99
|
+
this.spawnImpl = opts.spawnImpl ?? ((cmd, argv, o) => spawn(cmd, argv, o as never) as ChildProcess);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** 启动长跑会话:立即返回句柄 */
|
|
103
|
+
async start(command: string, opts: SessionStartOptions = {}): Promise<SessionHandleInfo> {
|
|
104
|
+
const sessionId =
|
|
105
|
+
`${opts.name ? opts.name.replace(/[^a-zA-Z0-9_-]/g, "-") + "-" : "sess-"}` +
|
|
106
|
+
crypto.randomBytes(4).toString("hex");
|
|
107
|
+
|
|
108
|
+
await fs.mkdir(this.logDir, { recursive: true });
|
|
109
|
+
const logBase = path.join(this.logDir, sessionId);
|
|
110
|
+
|
|
111
|
+
const entry: SessionEntry = {
|
|
112
|
+
sessionId,
|
|
113
|
+
command,
|
|
114
|
+
startedAt: Date.now(),
|
|
115
|
+
ownerSessionId: opts.ownerSessionId,
|
|
116
|
+
ring: new OutputRingBuffer({ maxLines: this.maxLines, maxLineBytes: this.maxLineBytes }),
|
|
117
|
+
watchers: new WatchRegistry(),
|
|
118
|
+
watcherMeta: new Map(),
|
|
119
|
+
assemblers: { stdout: new LineAssembler(), stderr: new LineAssembler() },
|
|
120
|
+
logStreams: {
|
|
121
|
+
stdout: createWriteStream(`${logBase}.out`),
|
|
122
|
+
stderr: createWriteStream(`${logBase}.err`),
|
|
123
|
+
},
|
|
124
|
+
logBase,
|
|
125
|
+
logBytes: 0,
|
|
126
|
+
logCapped: false,
|
|
127
|
+
running: true,
|
|
128
|
+
};
|
|
129
|
+
this.entries.set(sessionId, entry);
|
|
130
|
+
|
|
131
|
+
const env = { ...process.env, ...opts.env };
|
|
132
|
+
const cwd = opts.cwd || process.cwd();
|
|
133
|
+
// 注:不 unref——unref 的子进程在 Bun 下不保活事件循环,close/exit 事件会静默丢失
|
|
134
|
+
const proc = this.spawnImpl!(this.shell, ["-c", command], {
|
|
135
|
+
env,
|
|
136
|
+
cwd,
|
|
137
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
138
|
+
});
|
|
139
|
+
entry.proc = proc;
|
|
140
|
+
|
|
141
|
+
proc.stdout?.on("data", (buf: Buffer) => this.ingestChunk(entry, buf.toString("utf-8"), "stdout"));
|
|
142
|
+
proc.stderr?.on("data", (buf: Buffer) => this.ingestChunk(entry, buf.toString("utf-8"), "stderr"));
|
|
143
|
+
|
|
144
|
+
proc.on("close", (code) => this.finalize(entry, code));
|
|
145
|
+
proc.on("error", (err) => {
|
|
146
|
+
this.ingestLine(entry, `[session-error] ${err.message}`, "stderr");
|
|
147
|
+
this.finalize(entry, null);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
this.log.info(`[Session ${sessionId}] started: ${command.slice(0, 80)} (pid=${proc.pid})`);
|
|
151
|
+
return { sessionId, logBase, pid: proc.pid };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** 热窗口快照(最近 n 行 text 投影,默认 50;不碰盘) */
|
|
155
|
+
tail(sessionId: string, n = 50): string[] {
|
|
156
|
+
return this.require(sessionId).ring.tail(n);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** 热窗口行对象(带 stream 标记——按流过滤消费) */
|
|
160
|
+
tailLines(sessionId: string, n = 50) {
|
|
161
|
+
return this.require(sessionId).ring.tailLines(n);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** 文件全量/大范围读取(窗口滚出的内容在这里;双文件按流分读) */
|
|
165
|
+
async readLog(sessionId: string, opts: ReadLogOptions = {}): Promise<string> {
|
|
166
|
+
const entry = this.require(sessionId);
|
|
167
|
+
const file = opts.stream === "stderr" ? `${entry.logBase}.err` : `${entry.logBase}.out`;
|
|
168
|
+
let content: string;
|
|
169
|
+
try {
|
|
170
|
+
content = await fs.readFile(file, "utf-8");
|
|
171
|
+
} catch {
|
|
172
|
+
return ""; // 流文件尚未创建(该流无输出)→ 空而非异常
|
|
173
|
+
}
|
|
174
|
+
let lines = content.split("\n");
|
|
175
|
+
if (lines.length > 0 && lines[lines.length - 1] === "") lines.pop();
|
|
176
|
+
if (opts.offsetLines) lines = lines.slice(opts.offsetLines);
|
|
177
|
+
if (opts.lines) lines = lines.slice(-opts.lines);
|
|
178
|
+
return lines.join("\n");
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* 注册 watcher:**回溯匹配**热窗口已有行(spawn→watch 注册间隙的输出不漏——
|
|
183
|
+
* 与 ready() 同款语义;真实反馈:bash 冷启热后 GO 常在注册前已进窗口),
|
|
184
|
+
* 未命中再匹配未来行;命中 → onEvent("match")。
|
|
185
|
+
*/
|
|
186
|
+
watch(sessionId: string, opts: WatchOptions): { cancel(): void } {
|
|
187
|
+
const entry = this.require(sessionId);
|
|
188
|
+
const watcherId = `w-${crypto.randomBytes(3).toString("hex")}`;
|
|
189
|
+
entry.watchers.add({ id: watcherId, pattern: opts.pattern, once: opts.once });
|
|
190
|
+
entry.watcherMeta.set(watcherId, opts);
|
|
191
|
+
// 回溯:窗口已有行命中即发(once 语义保持——命中即摘除)
|
|
192
|
+
const regex = opts.pattern instanceof RegExp
|
|
193
|
+
? opts.pattern
|
|
194
|
+
: new RegExp(escapeRegExp(String(opts.pattern)));
|
|
195
|
+
for (const l of entry.ring.tailLines(Infinity)) {
|
|
196
|
+
if (regex.test(l.text)) {
|
|
197
|
+
if (opts.once !== false) {
|
|
198
|
+
entry.watchers.cancel(watcherId);
|
|
199
|
+
entry.watcherMeta.delete(watcherId);
|
|
200
|
+
}
|
|
201
|
+
if (opts.thenStop) void this.stop(sessionId).catch(() => undefined);
|
|
202
|
+
this.emit({
|
|
203
|
+
type: "match", sessionId, ownerSessionId: entry.ownerSessionId, watcherId,
|
|
204
|
+
pattern: String(opts.pattern), matchedLine: l.text, stream: l.stream,
|
|
205
|
+
context: entry.ring.contextAround(entry.ring.size - 1, opts.contextLines ?? 5).map((c) => c.text),
|
|
206
|
+
});
|
|
207
|
+
break; // 回溯命中一次即止(未来行交给在线匹配)
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
return {
|
|
211
|
+
cancel: () => {
|
|
212
|
+
entry.watchers.cancel(watcherId);
|
|
213
|
+
entry.watcherMeta.delete(watcherId);
|
|
214
|
+
},
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* 就绪握手(FEAT-11 / AC-13):先回溯扫描热窗口已有行(覆盖 spawn→ready
|
|
220
|
+
* 调用间隙已输出的标记——只监听增量则快输出进程必漏),未命中再监听增量行。
|
|
221
|
+
* 滚出感知有据:evictedCount===0 → 窗口历史完整,标记确定未出现,安心等待;
|
|
222
|
+
* >0 → 历史可能不完整,报「标记可能已滚出(可回查落盘日志)」而非死等。
|
|
223
|
+
* 超时 → 自动 stop 清理(不留孤儿进程)。
|
|
224
|
+
*/
|
|
225
|
+
async ready(
|
|
226
|
+
sessionId: string,
|
|
227
|
+
opts: { pattern: string | RegExp; timeoutMs: number },
|
|
228
|
+
): Promise<{ matchedLine: string }> {
|
|
229
|
+
const entry = this.require(sessionId);
|
|
230
|
+
const regex = opts.pattern instanceof RegExp
|
|
231
|
+
? opts.pattern
|
|
232
|
+
: new RegExp(opts.pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
|
|
233
|
+
// ① 回溯:窗口已有行命中即立即 resolve
|
|
234
|
+
for (const l of entry.ring.tailLines(Infinity)) {
|
|
235
|
+
if (regex.test(l.text)) return { matchedLine: l.text };
|
|
236
|
+
}
|
|
237
|
+
if (entry.ring.evictedCount > 0) {
|
|
238
|
+
throw new Error(
|
|
239
|
+
`就绪标记可能已滚出热窗口(evicted=${entry.ring.evictedCount},历史不完整)——` +
|
|
240
|
+
`可经 readLog 回查 ${entry.logBase}.out/.err,或加大窗口后重启会话`,
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
// ② 增量监听(一次性内部 watcher)
|
|
244
|
+
return await new Promise<{ matchedLine: string }>((resolve, reject) => {
|
|
245
|
+
const w = this.watch(sessionId, { pattern: opts.pattern, once: true });
|
|
246
|
+
let poll: ReturnType<typeof setInterval> | undefined;
|
|
247
|
+
const timer = setTimeout(() => {
|
|
248
|
+
if (poll) clearInterval(poll);
|
|
249
|
+
w.cancel();
|
|
250
|
+
// 超时自动清理:不留孤儿进程(AC-13)
|
|
251
|
+
void this.stop(sessionId).catch(() => undefined);
|
|
252
|
+
reject(new Error(`ready() 超时(${opts.timeoutMs}ms)未见到就绪标记——会话已自动停止`));
|
|
253
|
+
}, opts.timeoutMs);
|
|
254
|
+
poll = setInterval(() => {
|
|
255
|
+
const e = this.entries.get(sessionId);
|
|
256
|
+
if (!e) {
|
|
257
|
+
clearTimeout(timer); clearInterval(poll!);
|
|
258
|
+
reject(new Error(`会话 ${sessionId} 已消失(终态回收)`));
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
for (const l of e.ring.tailLines(5)) {
|
|
262
|
+
if (regex.test(l.text)) {
|
|
263
|
+
clearTimeout(timer); clearInterval(poll!); w.cancel();
|
|
264
|
+
resolve({ matchedLine: l.text });
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
}, 30);
|
|
269
|
+
poll.unref?.();
|
|
270
|
+
if (!entry.running) {
|
|
271
|
+
clearTimeout(timer); clearInterval(poll);
|
|
272
|
+
reject(new Error(`会话 ${sessionId} 已退出(exitCode=${entry.exitCode ?? "?"}),就绪标记未出现`));
|
|
273
|
+
}
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* 停止会话:SIGTERM → 宽限 → SIGKILL 升级;随后短窗核实终态
|
|
279
|
+
* (FEAT-04:终止即请求、死亡须核实——close 事件到达=exited 带退出码;
|
|
280
|
+
* 核实窗内未死=stopped(SIGKILL 升级已安排),返回三态不静默当成功)
|
|
281
|
+
*/
|
|
282
|
+
async stop(sessionId: string): Promise<{ status: "exited" | "stopped" | "unknown"; exitCode?: number | null }> {
|
|
283
|
+
const entry = this.require(sessionId);
|
|
284
|
+
if (!entry.running) {
|
|
285
|
+
return { status: "exited", exitCode: entry.exitCode };
|
|
286
|
+
}
|
|
287
|
+
if (entry.proc?.pid) {
|
|
288
|
+
try { entry.proc.kill("SIGTERM"); } catch { /* already dead */ }
|
|
289
|
+
const pid = entry.proc.pid;
|
|
290
|
+
setTimeout(() => {
|
|
291
|
+
try { process.kill(pid, "SIGKILL"); } catch { /* gone */ }
|
|
292
|
+
}, KILL_GRACE_MS).unref();
|
|
293
|
+
}
|
|
294
|
+
// 短窗核实(local 子进程 close 可靠;1s 内死亡即带退出码)
|
|
295
|
+
for (let i = 0; i < 20 && entry.running; i++) {
|
|
296
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
297
|
+
}
|
|
298
|
+
if (!entry.running) return { status: "exited", exitCode: entry.exitCode };
|
|
299
|
+
return { status: "stopped" }; // SIGTERM 已发、SIGKILL 升级在场——清理请求已核实路径
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
list(): Array<{ sessionId: string; command: string; running: boolean; startedAt: number }> {
|
|
303
|
+
return Array.from(this.entries.values()).map((e) => ({
|
|
304
|
+
sessionId: e.sessionId,
|
|
305
|
+
command: e.command,
|
|
306
|
+
running: e.running,
|
|
307
|
+
startedAt: e.startedAt,
|
|
308
|
+
}));
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// ── 内部 ─────────────────────────────────────────────
|
|
312
|
+
|
|
313
|
+
private require(sessionId: string): SessionEntry {
|
|
314
|
+
const e = this.entries.get(sessionId);
|
|
315
|
+
if (!e) throw new Error(`session '${sessionId}' 不存在(可能已终态被 TTL 回收)`);
|
|
316
|
+
return e;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
private ingestChunk(entry: SessionEntry, text: string, stream: OutputStream): void {
|
|
320
|
+
for (const line of entry.assemblers[stream].feed(text)) this.ingestLine(entry, line, stream);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
private ingestLine(entry: SessionEntry, fullLine: string, stream: OutputStream): void {
|
|
324
|
+
// 文件侧写完整行(1MB 防爆)+ 总量硬顶(64MiB 两流合计);窗口侧截断
|
|
325
|
+
const fileChunk = fullLine.length > MAX_FILE_LINE_BYTES
|
|
326
|
+
? fullLine.slice(0, MAX_FILE_LINE_BYTES) + "\n[…file-line-truncated]"
|
|
327
|
+
: fullLine;
|
|
328
|
+
if (!entry.logCapped) {
|
|
329
|
+
entry.logBytes += fileChunk.length + 1;
|
|
330
|
+
if (entry.logBytes > MAX_SESSION_LOG_BYTES) {
|
|
331
|
+
entry.logCapped = true;
|
|
332
|
+
for (const s of ["stdout", "stderr"] as const) {
|
|
333
|
+
entry.logStreams[s].end(
|
|
334
|
+
s === stream
|
|
335
|
+
? `[log-capped: session log exceeded ${MAX_SESSION_LOG_BYTES} bytes, file writes suppressed; ring buffer continues]\n`
|
|
336
|
+
: `[log-capped]\n`,
|
|
337
|
+
);
|
|
338
|
+
}
|
|
339
|
+
} else {
|
|
340
|
+
entry.logStreams[stream].write(fileChunk + "\n");
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
entry.ring.push({ stream, text: fullLine });
|
|
344
|
+
|
|
345
|
+
const anchor = entry.ring.size - 1;
|
|
346
|
+
for (const watcherId of entry.watchers.match(fullLine)) {
|
|
347
|
+
const meta = entry.watcherMeta.get(watcherId);
|
|
348
|
+
entry.watcherMeta.delete(watcherId);
|
|
349
|
+
// ★ thenStop:命中即收摊——观察对象结束,观察者退场(防遗忘泄漏)
|
|
350
|
+
if (meta?.thenStop) {
|
|
351
|
+
void this.stop(entry.sessionId).catch(() => undefined);
|
|
352
|
+
}
|
|
353
|
+
this.emit({
|
|
354
|
+
type: "match",
|
|
355
|
+
sessionId: entry.sessionId,
|
|
356
|
+
ownerSessionId: entry.ownerSessionId,
|
|
357
|
+
watcherId,
|
|
358
|
+
pattern: String(meta?.pattern ?? "?"),
|
|
359
|
+
matchedLine: entry.ring.tail(1)[0] ?? fullLine,
|
|
360
|
+
stream,
|
|
361
|
+
context: entry.ring.contextAround(anchor, meta?.contextLines ?? 5).map((l) => l.text),
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
private finalize(entry: SessionEntry, exitCode: number | null): void {
|
|
367
|
+
if (!entry.running) return;
|
|
368
|
+
entry.running = false;
|
|
369
|
+
entry.exitCode = exitCode;
|
|
370
|
+
|
|
371
|
+
for (const s of ["stdout", "stderr"] as const) {
|
|
372
|
+
for (const line of entry.assemblers[s].flush()) this.ingestLine(entry, line, s);
|
|
373
|
+
if (!entry.logCapped) entry.logStreams[s].end();
|
|
374
|
+
}
|
|
375
|
+
entry.watcherMeta.clear();
|
|
376
|
+
|
|
377
|
+
this.emit({
|
|
378
|
+
type: "exit",
|
|
379
|
+
sessionId: entry.sessionId,
|
|
380
|
+
ownerSessionId: entry.ownerSessionId,
|
|
381
|
+
exitCode,
|
|
382
|
+
lastLines: entry.ring.tail(20),
|
|
383
|
+
});
|
|
384
|
+
this.log.info(`[Session ${entry.sessionId}] exited code=${exitCode}`);
|
|
385
|
+
|
|
386
|
+
// TTL 回收(unref:不撑进程)
|
|
387
|
+
entry.ttlTimer = setTimeout(() => this.entries.delete(entry.sessionId), this.ttlMs).unref();
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
private emit(ev: SessionEvent): void {
|
|
391
|
+
try {
|
|
392
|
+
this.onEvent(ev);
|
|
393
|
+
} catch (err) {
|
|
394
|
+
this.log.warn(`session event sink threw: ${err instanceof Error ? err.message : String(err)}`);
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
}
|
package/src/logger.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* logger.ts — 可注入日志接口(REQ-0001 §8.1:内核不绑任何 logger 实现)
|
|
3
|
+
*
|
|
4
|
+
* 消费方注入自己的 logger;缺省 noop(静默内核——SDK 不替调用方往 stdout 喷日志)。
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export interface FilaxisLogger {
|
|
8
|
+
debug(msg: string, ...args: unknown[]): void;
|
|
9
|
+
info(msg: string, ...args: unknown[]): void;
|
|
10
|
+
warn(msg: string, ...args: unknown[]): void;
|
|
11
|
+
error(msg: string, ...args: unknown[]): void;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export const noopLogger: FilaxisLogger = {
|
|
15
|
+
debug: () => undefined,
|
|
16
|
+
info: () => undefined,
|
|
17
|
+
warn: () => undefined,
|
|
18
|
+
error: () => undefined,
|
|
19
|
+
};
|