@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,296 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* stream-session-manager.ts — 观察流会话引擎(手眼分离:观察走独立真流式 channel)
|
|
3
|
+
*
|
|
4
|
+
* 净室平移自 Aethelum-Nexus `src/core/tools/runtime/session/stream-session-manager.ts`
|
|
5
|
+
* (源锚 8977ff3),按 REQ-0001 OSS 化改造:
|
|
6
|
+
* - SessionNotifier 队列投递 → onEvent 纯回调(内核零队列红线)
|
|
7
|
+
* - stderr 双流(FEAT-05):源版刻意并入同一窗口——OSS 版行对象带 stream 标记,
|
|
8
|
+
* onLine 桥与热窗口均按流可辨
|
|
9
|
+
* - spawn 注入化(spawnImpl)——零 I/O 假设,mock 全测(AC-06 逻辑面)
|
|
10
|
+
*
|
|
11
|
+
* 设计语义(源生产锤炼):
|
|
12
|
+
* - 每个观察流 = 一条独立 SSH channel 子进程(stdout 逐行直推本地,
|
|
13
|
+
* 无轮询、无漏行);ControlMaster 多路复用共享主连接(连接是稀缺资源——
|
|
14
|
+
* 观察是廉价的);Windows 无 mux 支持时自动退化直连
|
|
15
|
+
* - 注意力衰减回收:无人问津且无 armed watcher 超 TTL → 自动收摊 + 事件广播
|
|
16
|
+
* - 桥接消费方(onLine/onExit)异常不杀流
|
|
17
|
+
*/
|
|
18
|
+
import * as crypto from "node:crypto";
|
|
19
|
+
import { existsSync, mkdirSync, rmSync } from "node:fs";
|
|
20
|
+
import { join } from "node:path";
|
|
21
|
+
import { tmpdir } from "node:os";
|
|
22
|
+
import { spawn, type ChildProcess } from "node:child_process";
|
|
23
|
+
import { OutputRingBuffer, WatchRegistry, type SessionLine } from "./session-buffer.js";
|
|
24
|
+
import { Vt100Screen } from "./vt100-screen.js";
|
|
25
|
+
import type { SessionEvent } from "./session-types.js";
|
|
26
|
+
import { noopLogger, type FilaxisLogger } from "./logger.js";
|
|
27
|
+
|
|
28
|
+
export interface StreamStartOptions {
|
|
29
|
+
name?: string;
|
|
30
|
+
ownerSessionId?: string;
|
|
31
|
+
/** 建立即挂 watcher(真流式命中——无需等轮询) */
|
|
32
|
+
watchPattern?: string;
|
|
33
|
+
watchOnce?: boolean;
|
|
34
|
+
contextLines?: number;
|
|
35
|
+
/** 观察窗流桥:每行对象原样回调(截断/入窗由窗侧自理) */
|
|
36
|
+
onLine?: (line: SessionLine) => void;
|
|
37
|
+
/** 流进程退出回调(观察窗 stale 语义) */
|
|
38
|
+
onExit?: (exitCode: number | null) => void;
|
|
39
|
+
/**
|
|
40
|
+
* TUI 屏桥(第五季灵感):原始 chunk(含 ANSI 转义)喂 Vt100Screen 后回调
|
|
41
|
+
* snapshot(每 chunk 一帧——ncurses 增量包一包一帧)。观察 watch/htop/nvtop
|
|
42
|
+
* 这类无换行的全屏重绘流用 onScreen 截屏,行流用 onLine——两桥可并存。
|
|
43
|
+
*/
|
|
44
|
+
onScreen?: (snap: string, frames: number) => void;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export type SpawnImpl = (cmd: string, argv: string[], opts: { stdio: unknown[] }) => ChildProcess;
|
|
48
|
+
|
|
49
|
+
export interface StreamSessionManagerOptions {
|
|
50
|
+
/** 禁用 ControlMaster(测试/平台受限) */
|
|
51
|
+
disableMux?: boolean;
|
|
52
|
+
/** 无人消费且无 armed watcher 的回收 TTL(默认 30min;测试可注入短值) */
|
|
53
|
+
idleReapMs?: number;
|
|
54
|
+
/** spawn 注入(缺省 node:child_process.spawn)——mock 全测关键 */
|
|
55
|
+
spawnImpl?: SpawnImpl;
|
|
56
|
+
/** mux socket 目录(缺省 os.tmpdir()/filaxis-stream-mux) */
|
|
57
|
+
muxDir?: string;
|
|
58
|
+
logger?: FilaxisLogger;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** 主连接注册表:destKey → ControlPath socket(进程级共享) */
|
|
62
|
+
const masters = new Map<string, { path: string; refCount: number }>();
|
|
63
|
+
|
|
64
|
+
export class StreamSessionManager {
|
|
65
|
+
onEvent: (ev: SessionEvent) => void = () => undefined;
|
|
66
|
+
|
|
67
|
+
private entries = new Map<string, StreamEntry>();
|
|
68
|
+
private readonly destKey: string;
|
|
69
|
+
private readonly destArgs: string[];
|
|
70
|
+
private readonly muxEnabled: boolean;
|
|
71
|
+
private readonly idleReapMs?: number;
|
|
72
|
+
private readonly spawnImpl: SpawnImpl;
|
|
73
|
+
private readonly muxDir: string;
|
|
74
|
+
private readonly log: FilaxisLogger;
|
|
75
|
+
|
|
76
|
+
private static readonly DEFAULT_IDLE_REAP_MS = 30 * 60 * 1000;
|
|
77
|
+
|
|
78
|
+
constructor(destArgs: string[], opts: StreamSessionManagerOptions = {}) {
|
|
79
|
+
this.destArgs = destArgs;
|
|
80
|
+
this.destKey = crypto.createHash("sha256").update(destArgs.join("\x00")).digest("hex").slice(0, 16);
|
|
81
|
+
// Windows OpenSSH 无 ControlMaster 支持(known limitation)——退化直连
|
|
82
|
+
this.muxEnabled = !opts.disableMux && process.platform !== "win32";
|
|
83
|
+
this.idleReapMs = opts.idleReapMs;
|
|
84
|
+
this.spawnImpl = opts.spawnImpl ?? ((cmd, argv, o) => spawn(cmd, argv, o as never) as ChildProcess);
|
|
85
|
+
this.muxDir = opts.muxDir ?? join(tmpdir(), "filaxis-stream-mux");
|
|
86
|
+
this.log = opts.logger ?? noopLogger;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** ControlMaster flags:auto + Persist(首条流顺带建立主连接,无独立 -MNf 步骤) */
|
|
90
|
+
private muxFlags(): string[] {
|
|
91
|
+
if (!this.muxEnabled) return [];
|
|
92
|
+
let m = masters.get(this.destKey);
|
|
93
|
+
if (!m) {
|
|
94
|
+
m = { path: join(this.ensureMuxDir(), `cm-${this.destKey}`), refCount: 0 };
|
|
95
|
+
masters.set(this.destKey, m);
|
|
96
|
+
}
|
|
97
|
+
return [
|
|
98
|
+
"-o", `ControlPath=${m.path}`,
|
|
99
|
+
"-o", "ControlMaster=auto",
|
|
100
|
+
"-o", "ControlPersist=600", // 最后一条流关闭后主连接保留 10min(复用窗口)
|
|
101
|
+
];
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
private ensureMuxDir(): string {
|
|
105
|
+
if (!existsSync(this.muxDir)) mkdirSync(this.muxDir, { recursive: true });
|
|
106
|
+
return this.muxDir;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** 建立观察流:真流式子进程,stdout/stderr 双流逐行进 ring/watcher */
|
|
110
|
+
async start(command: string, opts: StreamStartOptions = {}): Promise<{ sessionId: string; name?: string }> {
|
|
111
|
+
const sessionId = `${opts.name ?? "stream"}-${crypto.randomBytes(3).toString("hex")}`;
|
|
112
|
+
const argv = ["-o", "StrictHostKeyChecking=accept-new", "-o", "ConnectTimeout=10",
|
|
113
|
+
...this.muxFlags(), ...this.destArgs, "--", command];
|
|
114
|
+
const child = this.spawnImpl("ssh", argv, { stdio: ["ignore", "pipe", "pipe"] });
|
|
115
|
+
|
|
116
|
+
const entry: StreamEntry = {
|
|
117
|
+
sessionId, name: opts.name, command, destKey: this.destKey, child,
|
|
118
|
+
ring: new OutputRingBuffer({ maxLines: 50, maxLineBytes: 512 }),
|
|
119
|
+
watchers: new WatchRegistry(),
|
|
120
|
+
watcherMeta: new Map(),
|
|
121
|
+
startedAt: Date.now(),
|
|
122
|
+
running: true,
|
|
123
|
+
lastAttentionAt: Date.now(),
|
|
124
|
+
ownerSessionId: opts.ownerSessionId,
|
|
125
|
+
opts,
|
|
126
|
+
};
|
|
127
|
+
this.entries.set(sessionId, entry);
|
|
128
|
+
const m = masters.get(this.destKey);
|
|
129
|
+
if (m) m.refCount++;
|
|
130
|
+
|
|
131
|
+
child.stdout?.on("data", (d: Buffer) => {
|
|
132
|
+
const text = d.toString("utf-8");
|
|
133
|
+
if (entry.opts?.onScreen) {
|
|
134
|
+
if (!entry.screen) entry.screen = new Vt100Screen();
|
|
135
|
+
entry.screen.feed(text);
|
|
136
|
+
try { entry.opts.onScreen(entry.screen.snapshot(), entry.screen.frames); } catch { /* 桥异常不杀流 */ }
|
|
137
|
+
}
|
|
138
|
+
this.onChunk(entry, text, "stdout");
|
|
139
|
+
});
|
|
140
|
+
child.stderr?.on("data", (d: Buffer) => this.onChunk(entry, d.toString("utf-8"), "stderr"));
|
|
141
|
+
child.on("exit", (code) => {
|
|
142
|
+
if (!entry.running) return;
|
|
143
|
+
entry.running = false;
|
|
144
|
+
entry.exitCode = code;
|
|
145
|
+
try { opts.onExit?.(code); } catch { /* 桥接消费方异常不杀流 */ }
|
|
146
|
+
this.emit({
|
|
147
|
+
type: "exit", sessionId, ownerSessionId: opts.ownerSessionId,
|
|
148
|
+
exitCode: code, lastLines: entry.ring.tail(10),
|
|
149
|
+
});
|
|
150
|
+
this.releaseMaster();
|
|
151
|
+
});
|
|
152
|
+
child.on("error", (err) => {
|
|
153
|
+
entry.running = false;
|
|
154
|
+
this.emit({
|
|
155
|
+
type: "exit", sessionId, ownerSessionId: opts.ownerSessionId,
|
|
156
|
+
exitCode: null, lastLines: [err.message],
|
|
157
|
+
});
|
|
158
|
+
this.releaseMaster();
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
if (opts.watchPattern) {
|
|
162
|
+
this.watch(sessionId, {
|
|
163
|
+
pattern: opts.watchPattern, once: opts.watchOnce ?? true, contextLines: opts.contextLines,
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
this.log.info(`[StreamSession ${sessionId}] 流建立(mux=${this.muxEnabled})`);
|
|
167
|
+
return { sessionId, name: opts.name };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** 流式 watcher:行到达即匹配(真流,无轮询间隔) */
|
|
171
|
+
watch(sessionId: string, opts: { pattern: string | RegExp; once?: boolean; contextLines?: number }): { cancel(): void } {
|
|
172
|
+
const e = this.require(sessionId);
|
|
173
|
+
e.lastAttentionAt = Date.now();
|
|
174
|
+
const watcherId = `sw-${crypto.randomBytes(3).toString("hex")}`;
|
|
175
|
+
e.watchers.add({ id: watcherId, pattern: opts.pattern, once: opts.once });
|
|
176
|
+
e.watcherMeta.set(watcherId, { pattern: opts.pattern, contextLines: opts.contextLines, once: opts.once });
|
|
177
|
+
return {
|
|
178
|
+
cancel: () => {
|
|
179
|
+
e.watchers.cancel(watcherId);
|
|
180
|
+
e.watcherMeta.delete(watcherId);
|
|
181
|
+
},
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
tail(sessionId: string, n = 50): string[] {
|
|
186
|
+
const e = this.require(sessionId);
|
|
187
|
+
e.lastAttentionAt = Date.now();
|
|
188
|
+
return e.ring.tail(n);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** 行对象视图(带 stream 标记——FEAT-05 按流过滤) */
|
|
192
|
+
tailLines(sessionId: string, n = 50): SessionLine[] {
|
|
193
|
+
const e = this.require(sessionId);
|
|
194
|
+
e.lastAttentionAt = Date.now();
|
|
195
|
+
return e.ring.tailLines(n);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
stop(sessionId: string): void {
|
|
199
|
+
const e = this.require(sessionId);
|
|
200
|
+
e.running = false;
|
|
201
|
+
e.child.kill("SIGTERM");
|
|
202
|
+
this.entries.delete(sessionId);
|
|
203
|
+
this.log.info(`[StreamSession ${sessionId}] 已收摊`);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
list(): Array<{ sessionId: string; name?: string; command: string; running: boolean; idleMs: number; armedWatchers: number }> {
|
|
207
|
+
const now = Date.now();
|
|
208
|
+
return Array.from(this.entries.values()).map((e) => ({
|
|
209
|
+
sessionId: e.sessionId, name: e.name, command: e.command,
|
|
210
|
+
running: e.running, idleMs: now - e.lastAttentionAt, armedWatchers: e.watcherMeta.size,
|
|
211
|
+
}));
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** 注意力衰减回收(时钟可注入——测试用);armed watcher = 常驻注意力不回收 */
|
|
215
|
+
reapIdle(now: number = Date.now(), ttlMs?: number): void {
|
|
216
|
+
const ttl = ttlMs ?? this.idleReapMs ?? StreamSessionManager.DEFAULT_IDLE_REAP_MS;
|
|
217
|
+
for (const e of Array.from(this.entries.values())) {
|
|
218
|
+
if (!e.running) continue;
|
|
219
|
+
if (e.watcherMeta.size > 0) continue;
|
|
220
|
+
if (now - e.lastAttentionAt < ttl) continue;
|
|
221
|
+
this.log.warn(`[StreamSession ${e.sessionId}] 注意力衰减:自动回收`);
|
|
222
|
+
this.emit({
|
|
223
|
+
type: "exit", sessionId: e.sessionId, ownerSessionId: e.ownerSessionId,
|
|
224
|
+
exitCode: null, lastLines: e.ring.tail(5),
|
|
225
|
+
});
|
|
226
|
+
this.stop(e.sessionId);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
private onChunk(entry: StreamEntry, text: string, stream: "stdout" | "stderr"): void {
|
|
231
|
+
for (const raw of text.split("\n")) {
|
|
232
|
+
if (raw === "") continue;
|
|
233
|
+
const clean = raw.replace(/\r$/, "");
|
|
234
|
+
entry.ring.push({ stream, text: clean });
|
|
235
|
+
try { entry.opts?.onLine?.({ stream, text: clean }); } catch { /* 桥接异常不杀流 */ }
|
|
236
|
+
const anchor = entry.ring.size - 1;
|
|
237
|
+
for (const watcherId of entry.watchers.match(clean)) {
|
|
238
|
+
entry.lastAttentionAt = Date.now(); // 命中=被消费的注意力
|
|
239
|
+
const meta = entry.watcherMeta.get(watcherId);
|
|
240
|
+
entry.watcherMeta.delete(watcherId);
|
|
241
|
+
this.emit({
|
|
242
|
+
type: "match",
|
|
243
|
+
sessionId: entry.sessionId,
|
|
244
|
+
ownerSessionId: entry.ownerSessionId,
|
|
245
|
+
watcherId,
|
|
246
|
+
pattern: String(meta?.pattern ?? "?"),
|
|
247
|
+
matchedLine: clean,
|
|
248
|
+
stream,
|
|
249
|
+
context: entry.ring.contextAround(anchor, meta?.contextLines ?? 5).map((l) => l.text),
|
|
250
|
+
} as SessionEvent);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
private releaseMaster(): void {
|
|
256
|
+
const m = masters.get(this.destKey);
|
|
257
|
+
if (!m) return;
|
|
258
|
+
m.refCount = Math.max(0, m.refCount - 1);
|
|
259
|
+
if (m.refCount === 0 && this.entries.size === 0 && existsSync(m.path)) {
|
|
260
|
+
try { rmSync(m.path, { force: true }); } catch { /* 并发流重建 socket,容忍 */ }
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
private require(sessionId: string): StreamEntry {
|
|
265
|
+
const e = this.entries.get(sessionId);
|
|
266
|
+
if (!e) throw new Error(`stream 会话 '${sessionId}' 不存在`);
|
|
267
|
+
return e;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
private emit(ev: SessionEvent): void {
|
|
271
|
+
try {
|
|
272
|
+
this.onEvent(ev);
|
|
273
|
+
} catch (err) {
|
|
274
|
+
this.log.warn(`stream event sink threw: ${err instanceof Error ? err.message : String(err)}`);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
interface StreamEntry {
|
|
280
|
+
sessionId: string;
|
|
281
|
+
name?: string;
|
|
282
|
+
command: string;
|
|
283
|
+
destKey: string;
|
|
284
|
+
child: ChildProcess;
|
|
285
|
+
ring: OutputRingBuffer;
|
|
286
|
+
watchers: WatchRegistry;
|
|
287
|
+
watcherMeta: Map<string, { pattern: string | RegExp; contextLines?: number; once?: boolean }>;
|
|
288
|
+
startedAt: number;
|
|
289
|
+
running: boolean;
|
|
290
|
+
lastAttentionAt: number;
|
|
291
|
+
ownerSessionId?: string;
|
|
292
|
+
exitCode?: number | null;
|
|
293
|
+
opts?: StreamStartOptions;
|
|
294
|
+
/** TUI 虚拟屏(onScreen 存在时惰性建) */
|
|
295
|
+
screen?: Vt100Screen;
|
|
296
|
+
}
|
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* stream-window.ts — 流式观察窗(远端拉模式流反转为本地推模式滚动窗)
|
|
3
|
+
*
|
|
4
|
+
* 净室平移自 Aethelum-Nexus `src/core/stream/stream-window.ts`
|
|
5
|
+
* (源锚 8977ff3)。OSS 化:logger 注入;引擎接 filaxis StreamSessionManager
|
|
6
|
+
* (onLine 行对象→text 投影);local 窗 spawn 弃 shell:true(Windows cmd 会挂
|
|
7
|
+
* unix 命令)改 shell -c 与 exec 一致。
|
|
8
|
+
*
|
|
9
|
+
* 关键语义(源施工图定案,全保):
|
|
10
|
+
* - 帧编号 per-window 单调递增,断连续编不归零(断点续读正确性依赖)
|
|
11
|
+
* - 双限缓冲:帧数环形覆盖(滚动=设计行为)+ 单帧字节截断
|
|
12
|
+
* - peek 三模式(last / window 时间窗 / since 增量),全部非破坏读
|
|
13
|
+
* - 多消费者游标:consumer_id → last_seq,互不干扰
|
|
14
|
+
* - 断流 stale 窗保留全部帧;重连续编;关窗收割流进程
|
|
15
|
+
* - 窗纯内存缓冲(不走消息路由——消费方自行桥接)
|
|
16
|
+
*/
|
|
17
|
+
import { spawn, type ChildProcess } from "node:child_process";
|
|
18
|
+
import { StreamSessionManager } from "./stream-session-manager.js";
|
|
19
|
+
import { resolveLocalShell } from "./shell-discovery.js";
|
|
20
|
+
import { noopLogger, type FilaxisLogger } from "./logger.js";
|
|
21
|
+
|
|
22
|
+
/** 单帧截断后缀(与 session-buffer.ts TRUNC_SUFFIX 同语义) */
|
|
23
|
+
const TRUNC_SUFFIX = "…[truncated]";
|
|
24
|
+
|
|
25
|
+
export interface StreamFrame {
|
|
26
|
+
seq: number;
|
|
27
|
+
/** 帧到达本地时刻(时钟域统一本地) */
|
|
28
|
+
ts: number;
|
|
29
|
+
text: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface PeekResult {
|
|
33
|
+
name: string;
|
|
34
|
+
oldestSeq: number;
|
|
35
|
+
newestSeq: number;
|
|
36
|
+
stale: boolean;
|
|
37
|
+
frames: StreamFrame[];
|
|
38
|
+
/** since 模式且中间发生环形覆盖时置 true(空洞显式可见,不静默) */
|
|
39
|
+
gap?: boolean;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
type Transport =
|
|
43
|
+
| { kind: "local"; child: ChildProcess }
|
|
44
|
+
| { kind: "remote"; manager: StreamSessionManager; sessionId: string };
|
|
45
|
+
|
|
46
|
+
export class StreamWindow {
|
|
47
|
+
private frames: StreamFrame[] = [];
|
|
48
|
+
private nextSeq = 1;
|
|
49
|
+
private _stale = false;
|
|
50
|
+
private _closed = false;
|
|
51
|
+
private readonly consumers = new Map<string, number>();
|
|
52
|
+
private transport: Transport | null = null;
|
|
53
|
+
private lastActivityAt = Date.now();
|
|
54
|
+
|
|
55
|
+
constructor(
|
|
56
|
+
readonly name: string,
|
|
57
|
+
readonly layerAlias: string,
|
|
58
|
+
readonly source: string,
|
|
59
|
+
readonly command: string,
|
|
60
|
+
private readonly maxFrames = 200,
|
|
61
|
+
private readonly maxLineBytes = 1024,
|
|
62
|
+
) {}
|
|
63
|
+
|
|
64
|
+
get stale(): boolean { return this._stale; }
|
|
65
|
+
get closed(): boolean { return this._closed; }
|
|
66
|
+
get frameCount(): number { return this.frames.length; }
|
|
67
|
+
get oldestSeq(): number { return this.frames.length ? this.frames[0]!.seq : 0; }
|
|
68
|
+
get newestSeq(): number { return this.frames.length ? this.frames[this.frames.length - 1]!.seq : 0; }
|
|
69
|
+
get lastActiveAt(): number { return this.lastActivityAt; }
|
|
70
|
+
|
|
71
|
+
/** 行入窗(截断+环形覆盖;不更新消费者游标——游标只在 peek since 时推进) */
|
|
72
|
+
push(text: string): void {
|
|
73
|
+
if (this._closed) return;
|
|
74
|
+
let line = text;
|
|
75
|
+
if (line.length > this.maxLineBytes) {
|
|
76
|
+
line = line.slice(0, this.maxLineBytes - TRUNC_SUFFIX.length) + TRUNC_SUFFIX;
|
|
77
|
+
}
|
|
78
|
+
this.frames.push({ seq: this.nextSeq++, ts: Date.now(), text: line });
|
|
79
|
+
if (this.frames.length > this.maxFrames) {
|
|
80
|
+
this.frames = this.frames.slice(-this.maxFrames);
|
|
81
|
+
}
|
|
82
|
+
this._stale = false; // 新帧到达 = 流复活
|
|
83
|
+
this.lastActivityAt = Date.now();
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** 最近 n 帧 */
|
|
87
|
+
peekLast(n = 20): PeekResult {
|
|
88
|
+
return this.snapshot(this.frames.slice(-Math.max(0, n)));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** 最近 ms 毫秒内的帧 */
|
|
92
|
+
peekWindow(ms: number): PeekResult {
|
|
93
|
+
const cutoff = Date.now() - ms;
|
|
94
|
+
const frames = this.frames.filter((f) => f.ts >= cutoff);
|
|
95
|
+
return this.snapshot(frames);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* 增量断点续读:返回 > sinceSeq 的帧(上限 n 兜底)。
|
|
100
|
+
* 带 consumerId 时推进该消费者游标;中间覆盖(oldestSeq > sinceSeq+1)置 gap。
|
|
101
|
+
*/
|
|
102
|
+
peekSince(sinceSeq: number, n = 50, consumerId?: string): PeekResult {
|
|
103
|
+
const frames = this.frames.filter((f) => f.seq > sinceSeq).slice(0, Math.max(0, n));
|
|
104
|
+
const result = this.snapshot(frames);
|
|
105
|
+
if (this.oldestSeq > sinceSeq + 1 && sinceSeq > 0) result.gap = true;
|
|
106
|
+
if (consumerId) {
|
|
107
|
+
const lastSeq = frames.length ? frames[frames.length - 1]!.seq : sinceSeq;
|
|
108
|
+
this.consumers.set(consumerId, Math.max(lastSeq, sinceSeq));
|
|
109
|
+
this.lastActivityAt = Date.now();
|
|
110
|
+
}
|
|
111
|
+
return result;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** 无状态读取消费者已知游标(未注册返回 0) */
|
|
115
|
+
consumerSeq(consumerId: string): number {
|
|
116
|
+
return this.consumers.get(consumerId) ?? 0;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** 流进程退出/断连 → stale(保留全部帧,seq 冻结在 nextSeq 上,重连续编) */
|
|
120
|
+
markStale(): void {
|
|
121
|
+
if (this._closed) return;
|
|
122
|
+
this._stale = true;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
attachTransport(t: Transport): void { this.transport = t; }
|
|
126
|
+
|
|
127
|
+
/** 关窗:收割流进程 + 内容清空(由 Registry 除名) */
|
|
128
|
+
close(): void {
|
|
129
|
+
if (this._closed) return;
|
|
130
|
+
this._closed = true;
|
|
131
|
+
try {
|
|
132
|
+
if (this.transport?.kind === "local") {
|
|
133
|
+
this.transport.child.kill("SIGTERM");
|
|
134
|
+
} else if (this.transport?.kind === "remote") {
|
|
135
|
+
void Promise.resolve(this.transport.manager.stop(this.transport.sessionId)).catch(() => {});
|
|
136
|
+
}
|
|
137
|
+
} catch { /* 收割尽力而为 */ }
|
|
138
|
+
this.frames = [];
|
|
139
|
+
this.consumers.clear();
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
private snapshot(frames: StreamFrame[]): PeekResult {
|
|
143
|
+
this.lastActivityAt = Date.now();
|
|
144
|
+
return {
|
|
145
|
+
name: this.name,
|
|
146
|
+
oldestSeq: this.oldestSeq,
|
|
147
|
+
newestSeq: this.newestSeq,
|
|
148
|
+
stale: this._stale,
|
|
149
|
+
frames,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** 行切分器(跨 chunk 行缓冲,CRLF 容忍) */
|
|
155
|
+
class LineSplitter {
|
|
156
|
+
private buf = "";
|
|
157
|
+
push(chunk: string, onLine: (line: string) => void): void {
|
|
158
|
+
this.buf += chunk;
|
|
159
|
+
const lines = this.buf.split("\n");
|
|
160
|
+
this.buf = lines.pop() ?? "";
|
|
161
|
+
for (const line of lines) {
|
|
162
|
+
const clean = line.replace(/\r$/, "");
|
|
163
|
+
if (clean !== "") onLine(clean);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
flush(onLine: (line: string) => void): void {
|
|
167
|
+
const rest = this.buf.replace(/\r$/, "");
|
|
168
|
+
this.buf = "";
|
|
169
|
+
if (rest !== "") onLine(rest);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** 远程流桥:StreamSessionManager 行回调 → 窗入帧 */
|
|
174
|
+
class RemoteBridge {
|
|
175
|
+
constructor(
|
|
176
|
+
private readonly window: StreamWindow,
|
|
177
|
+
private readonly manager: StreamSessionManager,
|
|
178
|
+
) {}
|
|
179
|
+
|
|
180
|
+
async start(command: string): Promise<void> {
|
|
181
|
+
const { sessionId } = await this.manager.start(command, {
|
|
182
|
+
name: this.window.name.replace(/[^A-Za-z0-9_-]/g, "_"),
|
|
183
|
+
onLine: (line) => this.window.push(line.text), // filaxis 行对象 → text 投影
|
|
184
|
+
onExit: () => this.window.markStale(),
|
|
185
|
+
});
|
|
186
|
+
this.window.attachTransport({ kind: "remote", manager: this.manager, sessionId });
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export interface StreamWindowRegistryOptions {
|
|
191
|
+
/** 空引用窗延迟销毁 TTL(对齐 stream 注意力衰减语义) */
|
|
192
|
+
idleTtlMs?: number;
|
|
193
|
+
/** local 窗 shell(缺省 resolveLocalShell——与 exec 同源) */
|
|
194
|
+
shell?: string;
|
|
195
|
+
logger?: FilaxisLogger;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export class StreamWindowRegistry {
|
|
199
|
+
private readonly windows = new Map<string, StreamWindow>();
|
|
200
|
+
private readonly sweeper: ReturnType<typeof setInterval>;
|
|
201
|
+
private readonly idleTtlMs: number;
|
|
202
|
+
private readonly shell: string;
|
|
203
|
+
private readonly log: FilaxisLogger;
|
|
204
|
+
|
|
205
|
+
constructor(opts: StreamWindowRegistryOptions = {}) {
|
|
206
|
+
this.idleTtlMs = opts.idleTtlMs ?? 30 * 60_000;
|
|
207
|
+
this.shell = opts.shell ?? resolveLocalShell(undefined, opts.logger);
|
|
208
|
+
this.log = opts.logger ?? noopLogger;
|
|
209
|
+
this.sweeper = setInterval(() => this.sweep(), 60_000);
|
|
210
|
+
this.sweeper.unref();
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** 开窗(local 经 spawn shell -c;remote 经 StreamSessionManager 行桥) */
|
|
214
|
+
async open(opts: {
|
|
215
|
+
name: string;
|
|
216
|
+
layerAlias?: string;
|
|
217
|
+
command: string;
|
|
218
|
+
maxFrames?: number;
|
|
219
|
+
maxLineBytes?: number;
|
|
220
|
+
manager?: StreamSessionManager;
|
|
221
|
+
}): Promise<StreamWindow> {
|
|
222
|
+
const existing = this.windows.get(opts.name);
|
|
223
|
+
if (existing && !existing.closed) {
|
|
224
|
+
throw new Error(`观察窗 '${opts.name}' 已存在(stop 后可重建)`);
|
|
225
|
+
}
|
|
226
|
+
const layerAlias = opts.layerAlias ?? "local";
|
|
227
|
+
const source = opts.name.split(":").slice(-1)[0] ?? opts.name;
|
|
228
|
+
const win = new StreamWindow(
|
|
229
|
+
opts.name, layerAlias, source, opts.command, opts.maxFrames, opts.maxLineBytes,
|
|
230
|
+
);
|
|
231
|
+
|
|
232
|
+
if (layerAlias === "local") {
|
|
233
|
+
// shell -c(与 exec 同源——弃 shell:true:Windows 下 cmd 解释器会挂 unix 命令)
|
|
234
|
+
const child = spawn(this.shell, ["-c", opts.command], { stdio: ["ignore", "pipe", "pipe"] });
|
|
235
|
+
const splitter = new LineSplitter();
|
|
236
|
+
child.stdout?.on("data", (d: Buffer) => splitter.push(d.toString("utf-8"), (l) => win.push(l)));
|
|
237
|
+
child.stderr?.on("data", (d: Buffer) => splitter.push(d.toString("utf-8"), (l) => win.push(l)));
|
|
238
|
+
child.on("exit", () => { splitter.flush((l) => win.push(l)); win.markStale(); });
|
|
239
|
+
win.attachTransport({ kind: "local", child });
|
|
240
|
+
} else {
|
|
241
|
+
if (!opts.manager) throw new Error(`remote 窗需要 StreamSessionManager(layer='${layerAlias}')`);
|
|
242
|
+
await new RemoteBridge(win, opts.manager).start(opts.command);
|
|
243
|
+
}
|
|
244
|
+
this.windows.set(opts.name, win);
|
|
245
|
+
this.log.info(`[StreamWindow] open '${opts.name}' (layer=${layerAlias})`);
|
|
246
|
+
return win;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
stop(name: string): void {
|
|
250
|
+
const win = this.require(name);
|
|
251
|
+
win.close();
|
|
252
|
+
this.windows.delete(name);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** 通配聚合 peek:glob 如 "vm-ai:*"(* 与 ?;无通配=精确名) */
|
|
256
|
+
peek(pattern: string): StreamWindow[] {
|
|
257
|
+
return this.matches(pattern);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
get(name: string): StreamWindow | undefined {
|
|
261
|
+
return this.windows.get(name);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
list(): Array<{ name: string; layer: string; frames: number; stale: boolean; command: string }> {
|
|
265
|
+
return [...this.windows.values()].map((w) => ({
|
|
266
|
+
name: w.name, layer: w.layerAlias, frames: w.frameCount, stale: w.stale, command: w.command,
|
|
267
|
+
}));
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
private require(name: string): StreamWindow {
|
|
271
|
+
const w = this.windows.get(name);
|
|
272
|
+
if (!w) throw new Error(`观察窗 '${name}' 不存在`);
|
|
273
|
+
return w;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
private matches(pattern: string): StreamWindow[] {
|
|
277
|
+
const hasWildcard = pattern.includes("*") || pattern.includes("?");
|
|
278
|
+
if (!hasWildcard) {
|
|
279
|
+
const w = this.windows.get(pattern);
|
|
280
|
+
return w ? [w] : [];
|
|
281
|
+
}
|
|
282
|
+
const re = new RegExp(
|
|
283
|
+
"^" + pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*").replace(/\?/g, ".") + "$",
|
|
284
|
+
);
|
|
285
|
+
return [...this.windows.values()].filter((w) => re.test(w.name));
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
private sweep(): void {
|
|
289
|
+
const now = Date.now();
|
|
290
|
+
for (const [name, w] of this.windows) {
|
|
291
|
+
if (now - w.lastActiveAt > this.idleTtlMs) {
|
|
292
|
+
this.log.info(`[StreamWindow] '${name}' 空引用超时 → 销毁`);
|
|
293
|
+
this.stop(name);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
dispose(): void {
|
|
299
|
+
clearInterval(this.sweeper);
|
|
300
|
+
for (const name of [...this.windows.keys()]) this.stop(name);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/** 模块级单例(消费方共享;多实例场景自行 new Registry) */
|
|
305
|
+
let _registry: StreamWindowRegistry | null = null;
|
|
306
|
+
export function getStreamWindowRegistry(): StreamWindowRegistry {
|
|
307
|
+
if (!_registry) _registry = new StreamWindowRegistry();
|
|
308
|
+
return _registry;
|
|
309
|
+
}
|