@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,286 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* remote-session-manager.ts — 远端长跑会话引擎(nohup 双文件落盘 + 终态三态核实)
|
|
3
|
+
*
|
|
4
|
+
* 净室设计自 Aethelum-Nexus RemoteSessionManager(源锚 8977ff3)的 OSS 精简重实现:
|
|
5
|
+
* - 落盘协议直接按 FEAT-04 契约:`<base>.out` / `<base>.err` 双文件 + `<base>.code`
|
|
6
|
+
* 退出码文件(`{ cmd; }; echo $? > base.code`——nohup 后台进程退出码可核实)
|
|
7
|
+
* - 终态三态(FEAT-04):exited(读到 .code)/ stopped(kill 后 kill -0 核实已死)/
|
|
8
|
+
* unknown(核实不可达——exec 抛错/网络断)——发送终止≠确认死亡
|
|
9
|
+
* - watcher 轮询匹配(远端无推送通道,与源版同语义);行到达序即 tail 读取序
|
|
10
|
+
* - exec 经注入回调(通常是 driver 的 exec 通道)——本模块零 I/O 假设、可 mock 全测
|
|
11
|
+
*/
|
|
12
|
+
import * as crypto from "node:crypto";
|
|
13
|
+
import { LineAssembler } from "./session-buffer.js";
|
|
14
|
+
import type {
|
|
15
|
+
SessionStartOptions,
|
|
16
|
+
SessionHandleInfo,
|
|
17
|
+
SessionEvent,
|
|
18
|
+
WatchOptions,
|
|
19
|
+
ReadLogOptions,
|
|
20
|
+
SessionFinalStatus,
|
|
21
|
+
} from "./session-types.js";
|
|
22
|
+
import type { ExecResult } from "./driver.js";
|
|
23
|
+
import { noopLogger, type FilaxisLogger } from "./logger.js";
|
|
24
|
+
|
|
25
|
+
export interface RemoteSessionManagerOptions {
|
|
26
|
+
/** 执行通道回调(远端命令落点) */
|
|
27
|
+
exec: (command: string) => Promise<Pick<ExecResult, "stdout" | "exitCode">>;
|
|
28
|
+
/** 远端会话日志目录(缺省 /tmp/filaxis-sessions) */
|
|
29
|
+
logDir?: string;
|
|
30
|
+
/** watcher 轮询间隔(默认 1500ms) */
|
|
31
|
+
pollMs?: number;
|
|
32
|
+
/** 无人消费且无 armed watcher 的回收 TTL(缺省 30min;时钟可注入测试) */
|
|
33
|
+
idleReapMs?: number;
|
|
34
|
+
logger?: FilaxisLogger;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
interface RemoteEntry {
|
|
38
|
+
sessionId: string;
|
|
39
|
+
command: string;
|
|
40
|
+
base: string;
|
|
41
|
+
pid?: number;
|
|
42
|
+
startedAt: number;
|
|
43
|
+
ownerSessionId?: string;
|
|
44
|
+
running: boolean;
|
|
45
|
+
exitCode?: number | null;
|
|
46
|
+
/** 轮询读到的 .out 行偏移 */
|
|
47
|
+
outOffset: number;
|
|
48
|
+
assembler: LineAssembler;
|
|
49
|
+
watchers: Map<string, WatchOptions & { regex: RegExp }>;
|
|
50
|
+
pollTimer?: ReturnType<typeof setInterval>;
|
|
51
|
+
lastAttentionAt: number;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const q = (s: string) => `'${s.replace(/'/g, "'\\''")}'`;
|
|
55
|
+
|
|
56
|
+
export class RemoteSessionManager {
|
|
57
|
+
onEvent: (ev: SessionEvent) => void = () => undefined;
|
|
58
|
+
|
|
59
|
+
private entries = new Map<string, RemoteEntry>();
|
|
60
|
+
private readonly exec: RemoteSessionManagerOptions["exec"];
|
|
61
|
+
private readonly logDir: string;
|
|
62
|
+
private readonly pollMs: number;
|
|
63
|
+
private readonly idleReapMs?: number;
|
|
64
|
+
private readonly log: FilaxisLogger;
|
|
65
|
+
|
|
66
|
+
constructor(opts: RemoteSessionManagerOptions) {
|
|
67
|
+
this.exec = opts.exec;
|
|
68
|
+
this.logDir = opts.logDir ?? "/tmp/filaxis-sessions";
|
|
69
|
+
this.pollMs = opts.pollMs ?? 1500;
|
|
70
|
+
this.idleReapMs = opts.idleReapMs;
|
|
71
|
+
this.log = opts.logger ?? noopLogger;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** 启动远端长跑会话:nohup 双文件落盘 + 退出码文件;立即返回 */
|
|
75
|
+
async start(command: string, opts: SessionStartOptions = {}): Promise<SessionHandleInfo> {
|
|
76
|
+
const sessionId =
|
|
77
|
+
`${opts.name ? opts.name.replace(/[^a-zA-Z0-9_-]/g, "-") + "-" : "rsess-"}` +
|
|
78
|
+
crypto.randomBytes(4).toString("hex");
|
|
79
|
+
const base = `${this.logDir}/${sessionId}`;
|
|
80
|
+
const wrapped = `{ ${command}; }; echo $? > ${base}.code`;
|
|
81
|
+
const cd = opts.cwd ? `cd ${q(opts.cwd)} && ` : "";
|
|
82
|
+
// 环境经 env 前缀注入(nohup 继承当前 exec 通道环境)
|
|
83
|
+
const envPrefix = opts.env
|
|
84
|
+
? Object.entries(opts.env).map(([k, v]) => `${k}=${q(v)}`).join(" ") + " "
|
|
85
|
+
: "";
|
|
86
|
+
const r = await this.exec(
|
|
87
|
+
`mkdir -p ${q(this.logDir)}; ${cd}${envPrefix}nohup sh -c ${q(wrapped)} > ${base}.out 2> ${base}.err < /dev/null & echo $!`,
|
|
88
|
+
);
|
|
89
|
+
const pid = parseInt((r.stdout || "").trim().split("\n").pop() ?? "", 10);
|
|
90
|
+
|
|
91
|
+
const entry: RemoteEntry = {
|
|
92
|
+
sessionId,
|
|
93
|
+
command,
|
|
94
|
+
base,
|
|
95
|
+
pid: Number.isFinite(pid) ? pid : undefined,
|
|
96
|
+
startedAt: Date.now(),
|
|
97
|
+
ownerSessionId: opts.ownerSessionId,
|
|
98
|
+
running: true,
|
|
99
|
+
outOffset: 0,
|
|
100
|
+
assembler: new LineAssembler(),
|
|
101
|
+
watchers: new Map(),
|
|
102
|
+
lastAttentionAt: Date.now(),
|
|
103
|
+
};
|
|
104
|
+
this.entries.set(sessionId, entry);
|
|
105
|
+
this.startPolling(entry);
|
|
106
|
+
this.log.info(`[RemoteSession ${sessionId}] started (pid=${entry.pid}): ${command.slice(0, 60)}`);
|
|
107
|
+
return { sessionId, logBase: base, pid: entry.pid };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** 热窗口快照(远端 tail .out) */
|
|
111
|
+
async tail(sessionId: string, n = 50): Promise<string[]> {
|
|
112
|
+
const e = this.require(sessionId);
|
|
113
|
+
e.lastAttentionAt = Date.now();
|
|
114
|
+
const r = await this.exec(`tail -n ${n} ${e.base}.out 2>/dev/null || true`);
|
|
115
|
+
return (r.stdout || "").split("\n").filter((l, i, arr) => l !== "" || i < arr.length - 1).filter((l) => l !== "");
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** 落盘日志读取(按流分读——FEAT-04 双文件) */
|
|
119
|
+
async readLog(sessionId: string, opts: ReadLogOptions = {}): Promise<string> {
|
|
120
|
+
const e = this.require(sessionId);
|
|
121
|
+
const file = opts.stream === "stderr" ? `${e.base}.err` : `${e.base}.out`;
|
|
122
|
+
let cmd = `cat ${file} 2>/dev/null || true`;
|
|
123
|
+
if (opts.offsetLines) cmd = `tail -n +${opts.offsetLines + 1} ${file} 2>/dev/null || true`;
|
|
124
|
+
if (opts.lines) cmd += ` | tail -n ${opts.lines}`;
|
|
125
|
+
const r = await this.exec(cmd);
|
|
126
|
+
return (r.stdout || "").replace(/\n$/, "");
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
watch(sessionId: string, opts: WatchOptions): { cancel(): void } {
|
|
130
|
+
const e = this.require(sessionId);
|
|
131
|
+
e.lastAttentionAt = Date.now();
|
|
132
|
+
const id = `rw-${crypto.randomBytes(3).toString("hex")}`;
|
|
133
|
+
const regex = opts.pattern instanceof RegExp ? opts.pattern : new RegExp(opts.pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
|
|
134
|
+
e.watchers.set(id, { ...opts, regex });
|
|
135
|
+
return {
|
|
136
|
+
cancel: () => { e.watchers.delete(id); },
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* 行对象视图(双流:.out + .err 各取最近 n 行,行对象带 stream 标记)——
|
|
142
|
+
* observe 工具族用(第五季反馈 #2:此前仅 tail() 投影 stdout,stderr 漏读)
|
|
143
|
+
*/
|
|
144
|
+
async tailLines(sessionId: string, n = 50): Promise<Array<{ stream: "stdout" | "stderr"; text: string }>> {
|
|
145
|
+
const e = this.require(sessionId);
|
|
146
|
+
e.lastAttentionAt = Date.now();
|
|
147
|
+
const r = await this.exec(`tail -n ${n} ${e.base}.out 2>/dev/null; echo __FX_TL__; tail -n ${n} ${e.base}.err 2>/dev/null`);
|
|
148
|
+
const parts = (r.stdout || "").split("__FX_TL__");
|
|
149
|
+
const out: Array<{ stream: "stdout" | "stderr"; text: string }> = [];
|
|
150
|
+
for (const l of (parts[0] ?? "").split("\n")) {
|
|
151
|
+
if (l.trim() !== "") out.push({ stream: "stdout", text: l });
|
|
152
|
+
}
|
|
153
|
+
for (const l of (parts[1] ?? "").split("\n")) {
|
|
154
|
+
if (l.trim() !== "") out.push({ stream: "stderr", text: l });
|
|
155
|
+
}
|
|
156
|
+
return out;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* 停止会话:发 kill -TERM → kill -0 核实。核实不可达(exec 抛错)→ 显式 unknown
|
|
161
|
+
* (终止即请求、死亡须核实——REQ §3.2 细则 6)。
|
|
162
|
+
*/
|
|
163
|
+
async stop(sessionId: string): Promise<{ status: SessionFinalStatus; exitCode?: number | null }> {
|
|
164
|
+
const e = this.require(sessionId);
|
|
165
|
+
if (!e.running) return { status: e.exitCode != null ? "exited" : "stopped", exitCode: e.exitCode };
|
|
166
|
+
if (e.pid == null) return { status: "unknown" };
|
|
167
|
+
try {
|
|
168
|
+
// 第五季盲测反馈 #3:sh -c 的子进程(脚本/守护)收不到主 kill 的 TERM——
|
|
169
|
+
// pkill -P 杀直接子进程 + kill 主进程;SIGKILL 升级同理
|
|
170
|
+
await this.exec(`pkill -TERM -P ${e.pid} 2>/dev/null; kill -TERM ${e.pid} 2>/dev/null; sleep 1; kill -0 ${e.pid} 2>/dev/null && echo ALIVE || echo DEAD`);
|
|
171
|
+
const probe = await this.exec(`kill -0 ${e.pid} 2>/dev/null && echo ALIVE || echo DEAD`);
|
|
172
|
+
if (probe.stdout.includes("ALIVE")) {
|
|
173
|
+
await this.exec(`pkill -KILL -P ${e.pid} 2>/dev/null; kill -KILL ${e.pid} 2>/dev/null; true`);
|
|
174
|
+
}
|
|
175
|
+
return { status: "stopped" };
|
|
176
|
+
} catch {
|
|
177
|
+
return { status: "unknown" };
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
list(): Array<{ sessionId: string; command: string; running: boolean; startedAt: number }> {
|
|
182
|
+
return Array.from(this.entries.values()).map((e) => ({
|
|
183
|
+
sessionId: e.sessionId, command: e.command, running: e.running, startedAt: e.startedAt,
|
|
184
|
+
}));
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** 注意力衰减回收:无人消费且无 armed watcher 超 TTL → 自动收摊 + 事件广播(时钟可注入) */
|
|
188
|
+
reapIdle(now: number = Date.now(), ttlMs?: number): void {
|
|
189
|
+
const ttl = ttlMs ?? this.idleReapMs ?? 30 * 60 * 1000;
|
|
190
|
+
for (const e of Array.from(this.entries.values())) {
|
|
191
|
+
if (!e.running) continue;
|
|
192
|
+
if (e.watchers.size > 0) continue; // armed watcher = 常驻注意力
|
|
193
|
+
if (now - e.lastAttentionAt < ttl) continue;
|
|
194
|
+
this.emit({
|
|
195
|
+
type: "exit", sessionId: e.sessionId, ownerSessionId: e.ownerSessionId,
|
|
196
|
+
exitCode: null, lastLines: [],
|
|
197
|
+
});
|
|
198
|
+
void this.stop(e.sessionId).catch(() => undefined);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// ── 内部 ──
|
|
203
|
+
|
|
204
|
+
private require(sessionId: string): RemoteEntry {
|
|
205
|
+
const e = this.entries.get(sessionId);
|
|
206
|
+
if (!e) throw new Error(`remote session '${sessionId}' 不存在`);
|
|
207
|
+
return e;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
private startPolling(entry: RemoteEntry): void {
|
|
211
|
+
entry.pollTimer = setInterval(() => void this.poll(entry).catch(() => undefined), this.pollMs);
|
|
212
|
+
entry.pollTimer.unref?.();
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** 轮询:增量读 .out 喂 watcher;读 .code 判定终态 */
|
|
216
|
+
private async poll(entry: RemoteEntry): Promise<void> {
|
|
217
|
+
if (!entry.running) return;
|
|
218
|
+
try {
|
|
219
|
+
// 增量输出(watcher 匹配用)
|
|
220
|
+
const r = await this.exec(
|
|
221
|
+
`tail -n +${entry.outOffset + 1} ${entry.base}.out 2>/dev/null || true`,
|
|
222
|
+
);
|
|
223
|
+
const text = r.stdout || "";
|
|
224
|
+
if (text) {
|
|
225
|
+
entry.outOffset += text.split("\n").filter((l, i, a) => i < a.length - 1 || l !== "").length;
|
|
226
|
+
for (const line of entry.assembler.feed(text)) this.onLine(entry, line);
|
|
227
|
+
}
|
|
228
|
+
// 终态:.code 文件在场 = 进程已退出(nohup 包装的 echo 已落盘)
|
|
229
|
+
const code = await this.exec(`cat ${entry.base}.code 2>/dev/null || true`);
|
|
230
|
+
const codeStr = (code.stdout || "").trim();
|
|
231
|
+
if (codeStr !== "" && /^\d+$/.test(codeStr)) {
|
|
232
|
+
this.finalize(entry, parseInt(codeStr, 10));
|
|
233
|
+
} else if (entry.pid != null) {
|
|
234
|
+
// 无 .code 但进程没了(被外部 kill -9)→ unknown 退出码
|
|
235
|
+
const alive = await this.exec(`kill -0 ${entry.pid} 2>/dev/null && echo Y || echo N`);
|
|
236
|
+
if (alive.stdout.includes("N")) this.finalize(entry, null);
|
|
237
|
+
}
|
|
238
|
+
} catch {
|
|
239
|
+
/* 轮询失败(exec 通道断)——下轮再试;终态核实属 stop()/result() 职责 */
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
private onLine(entry: RemoteEntry, line: string): void {
|
|
244
|
+
for (const [id, w] of entry.watchers) {
|
|
245
|
+
if (w.regex.test(line)) {
|
|
246
|
+
if (w.once !== false) entry.watchers.delete(id);
|
|
247
|
+
if (w.thenStop) void this.stop(entry.sessionId).catch(() => undefined);
|
|
248
|
+
this.emit({
|
|
249
|
+
type: "match",
|
|
250
|
+
sessionId: entry.sessionId,
|
|
251
|
+
ownerSessionId: entry.ownerSessionId,
|
|
252
|
+
watcherId: id,
|
|
253
|
+
pattern: String(w.pattern),
|
|
254
|
+
matchedLine: line,
|
|
255
|
+
stream: "stdout",
|
|
256
|
+
context: [line],
|
|
257
|
+
});
|
|
258
|
+
entry.lastAttentionAt = Date.now();
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
private finalize(entry: RemoteEntry, exitCode: number | null): void {
|
|
264
|
+
if (!entry.running) return;
|
|
265
|
+
entry.running = false;
|
|
266
|
+
entry.exitCode = exitCode;
|
|
267
|
+
if (entry.pollTimer) clearInterval(entry.pollTimer);
|
|
268
|
+
entry.watchers.clear();
|
|
269
|
+
this.emit({
|
|
270
|
+
type: "exit",
|
|
271
|
+
sessionId: entry.sessionId,
|
|
272
|
+
ownerSessionId: entry.ownerSessionId,
|
|
273
|
+
exitCode,
|
|
274
|
+
lastLines: [],
|
|
275
|
+
});
|
|
276
|
+
this.log.info(`[RemoteSession ${entry.sessionId}] exited code=${exitCode}`);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
private emit(ev: SessionEvent): void {
|
|
280
|
+
try {
|
|
281
|
+
this.onEvent(ev);
|
|
282
|
+
} catch (err) {
|
|
283
|
+
this.log.warn(`remote session event sink threw: ${err instanceof Error ? err.message : String(err)}`);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* runtime-stack.ts — 栈门面:连接拓扑指纹缓存 + Transport 注册 + 能力探测
|
|
3
|
+
*
|
|
4
|
+
* 净室平移自 Aethelum-Nexus `src/core/tools/runtime/runtime-stack.ts`
|
|
5
|
+
* (源锚 8977ff3,仅取内核性——list/promptSummary 展示层留消费方),OSS 化:
|
|
6
|
+
* - driverKey 连接拓扑指纹平移(身份白名单投影;enterSteps 待 ssh Transport 批次并入键)
|
|
7
|
+
* - 内核事件总线(FEAT-09,EventBus)
|
|
8
|
+
* - features() 能力探测(AC-02:降级不靠猜——mux/sftp/persistent 按平台与二进制探测)
|
|
9
|
+
*/
|
|
10
|
+
import { spawn } from "node:child_process";
|
|
11
|
+
import type { RuntimeDriver, RuntimeDescriptor } from "./driver.js";
|
|
12
|
+
import { RuntimeFactory } from "./factory.js";
|
|
13
|
+
import { EventBus } from "./events.js";
|
|
14
|
+
import type { SessionEvent } from "./session-types.js";
|
|
15
|
+
import { noopLogger, type FilaxisLogger } from "./logger.js";
|
|
16
|
+
|
|
17
|
+
export type FilaxisEvents = SessionEvent | { type: "layerState"; layer: string; state: string };
|
|
18
|
+
|
|
19
|
+
export interface StackFeatures {
|
|
20
|
+
/** ControlMaster 多路复用可用(非 Windows + ssh 二进制存在) */
|
|
21
|
+
mux: boolean;
|
|
22
|
+
/** sftp 大文件快路径可用 */
|
|
23
|
+
sftp: boolean;
|
|
24
|
+
/** tmux 持久会话后端可用(缺席 → 持久层显式降级非持久) */
|
|
25
|
+
persistent: boolean;
|
|
26
|
+
/** 已注册 Transport 类型 */
|
|
27
|
+
transports: string[];
|
|
28
|
+
/** exec 通道 stderr 物理分离(平台默认;远端层的逐层标注属 driver 属性) */
|
|
29
|
+
stderrSeparated: boolean;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface RuntimeStackOptions {
|
|
33
|
+
logger?: FilaxisLogger;
|
|
34
|
+
factory?: RuntimeFactory;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export class RuntimeStack {
|
|
38
|
+
/** 进程级驱动缓存:同连接拓扑指纹复用既有连接(杜绝孤儿/重复建连) */
|
|
39
|
+
private static driverCache = new Map<string, RuntimeDriver>();
|
|
40
|
+
|
|
41
|
+
readonly events = new EventBus<FilaxisEvents>();
|
|
42
|
+
private readonly factory: RuntimeFactory;
|
|
43
|
+
private readonly log: FilaxisLogger;
|
|
44
|
+
private featuresCache: StackFeatures | null = null;
|
|
45
|
+
|
|
46
|
+
constructor(opts: RuntimeStackOptions = {}) {
|
|
47
|
+
this.factory = opts.factory ?? new RuntimeFactory();
|
|
48
|
+
this.log = opts.logger ?? noopLogger;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* 连接拓扑指纹(净室移植自源版 driverKey,身份白名单投影):
|
|
53
|
+
* 显示标签/运行态缓存等非身份字段不进键——不把既有连接挤出缓存、不误建重复连接。
|
|
54
|
+
* 注:ssh 层的 enterSteps 管线属 ssh Transport 批次,届时并入 cfg 投影。
|
|
55
|
+
*/
|
|
56
|
+
static driverKey(d: RuntimeDescriptor): string {
|
|
57
|
+
const c = (d.config ?? {}) as Record<string, unknown>;
|
|
58
|
+
return JSON.stringify({
|
|
59
|
+
driver: d.driver,
|
|
60
|
+
target: d.target,
|
|
61
|
+
terminalId: d.terminalId,
|
|
62
|
+
cwd: d.cwd,
|
|
63
|
+
capabilities: d.capabilities,
|
|
64
|
+
cfg: {
|
|
65
|
+
launchCmd: c.launchCmd,
|
|
66
|
+
runtime: c.runtime,
|
|
67
|
+
shell: c.shell,
|
|
68
|
+
namespace: c.namespace,
|
|
69
|
+
sshOptions: c.sshOptions,
|
|
70
|
+
connParams: c.connParams,
|
|
71
|
+
},
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** 建立层连接:指纹命中即复用(AC-07 连接计数语义) */
|
|
76
|
+
async connect(descriptor: RuntimeDescriptor, fallbackCwd?: string): Promise<RuntimeDriver> {
|
|
77
|
+
const key = RuntimeStack.driverKey(descriptor);
|
|
78
|
+
const cached = RuntimeStack.driverCache.get(key);
|
|
79
|
+
if (cached) {
|
|
80
|
+
this.log.debug(`[Filaxis] 指纹命中复用连接: ${descriptor.driver}/${descriptor.target ?? "local"}`);
|
|
81
|
+
return cached;
|
|
82
|
+
}
|
|
83
|
+
const drv = this.factory.create(descriptor, fallbackCwd);
|
|
84
|
+
await drv.init();
|
|
85
|
+
RuntimeStack.driverCache.set(key, drv);
|
|
86
|
+
this.log.info(`[Filaxis] 新建连接: ${descriptor.driver}/${descriptor.target ?? "local"}`);
|
|
87
|
+
return drv;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** 逐出缓存(销毁归调用方——先 evict 再 destroy,避免销毁竞态复用) */
|
|
91
|
+
static evictDriver(d: RuntimeDescriptor): RuntimeDriver | undefined {
|
|
92
|
+
const key = RuntimeStack.driverKey(d);
|
|
93
|
+
const drv = RuntimeStack.driverCache.get(key);
|
|
94
|
+
RuntimeStack.driverCache.delete(key);
|
|
95
|
+
return drv;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** 开放注册(透传 factory;AC-09 第三方 Transport 同权) */
|
|
99
|
+
registerDriver(type: string, f: (descriptor: RuntimeDescriptor) => RuntimeDriver): void {
|
|
100
|
+
this.factory.registerDriver(type, f);
|
|
101
|
+
this.featuresCache = null;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* 能力探测(AC-02:降级不靠猜)。结果缓存至注册表变化。
|
|
106
|
+
* Windows 无 ControlMaster → mux=false(自动退化直连为既定行为)。
|
|
107
|
+
*/
|
|
108
|
+
async features(): Promise<StackFeatures> {
|
|
109
|
+
if (this.featuresCache) return this.featuresCache;
|
|
110
|
+
const has = (bin: string) =>
|
|
111
|
+
new Promise<boolean>((resolve) => {
|
|
112
|
+
const probe = process.platform === "win32"
|
|
113
|
+
? spawn("where", [bin], { stdio: "ignore" })
|
|
114
|
+
: spawn("which", [bin], { stdio: "ignore" });
|
|
115
|
+
probe.on("close", (code) => resolve(code === 0));
|
|
116
|
+
probe.on("error", () => resolve(false));
|
|
117
|
+
});
|
|
118
|
+
const [sshOk, sftpOk, tmuxOk] = await Promise.all([has("ssh"), has("sftp"), has("tmux")]);
|
|
119
|
+
this.featuresCache = {
|
|
120
|
+
mux: process.platform !== "win32" && sshOk,
|
|
121
|
+
sftp: sftpOk,
|
|
122
|
+
persistent: tmuxOk,
|
|
123
|
+
transports: this.factory.registeredTypes(),
|
|
124
|
+
stderrSeparated: true,
|
|
125
|
+
};
|
|
126
|
+
return this.featuresCache;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* session-buffer.ts — 会话窗口基础件(纯逻辑,无 I/O)
|
|
3
|
+
*
|
|
4
|
+
* 净室平移自 Aethelum-Nexus `src/core/tools/runtime/session/session-buffer.ts`
|
|
5
|
+
* (源锚点:Aethelum-Nexus dev @ 8977ff3)。
|
|
6
|
+
*
|
|
7
|
+
* LineAssembler 字节流按 \n 组行(CRLF 剥 \r;partial 暂存;flush 收尾)
|
|
8
|
+
* OutputRingBuffer 热窗口:maxLines × maxLineBytes 截断;截断仅发生在窗口副本
|
|
9
|
+
* ——落盘文件侧由调用方写完整行
|
|
10
|
+
* OSS 扩展(REQ-0001 FEAT-11):evictedCount 逐出计数——
|
|
11
|
+
* ready() 滚出感知「有据」的机制件(>0 = 窗口历史不完整)
|
|
12
|
+
* WatchRegistry 行级 watcher:string=子串 / RegExp 命中;once 默认;每会话上限
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** 无换行极端流的暂存硬上限(防 OOM):超限强制切块成行 */
|
|
16
|
+
const MAX_PENDING_BYTES = 2 * 1024 * 1024;
|
|
17
|
+
|
|
18
|
+
export class LineAssembler {
|
|
19
|
+
private pending = '';
|
|
20
|
+
|
|
21
|
+
/** 喂入一段输出,返回其中已成行的部分(不含尾部的 partial) */
|
|
22
|
+
feed(chunk: string): string[] {
|
|
23
|
+
this.pending += chunk;
|
|
24
|
+
const lines: string[] = [];
|
|
25
|
+
let idx: number;
|
|
26
|
+
while ((idx = this.pending.indexOf('\n')) !== -1) {
|
|
27
|
+
let line = this.pending.slice(0, idx);
|
|
28
|
+
if (line.endsWith('\r')) line = line.slice(0, -1);
|
|
29
|
+
lines.push(line);
|
|
30
|
+
this.pending = this.pending.slice(idx + 1);
|
|
31
|
+
}
|
|
32
|
+
// 硬上限:极端无换行字节流强制切块(如失控输出/二进制误入),插入截断标记
|
|
33
|
+
if (this.pending.length > MAX_PENDING_BYTES) {
|
|
34
|
+
lines.push(this.pending.slice(0, MAX_PENDING_BYTES) + '[TRUNCATED_CHUNK]');
|
|
35
|
+
this.pending = '';
|
|
36
|
+
}
|
|
37
|
+
return lines;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** 进程退出时调用:无换行的残余强制成行 */
|
|
41
|
+
flush(): string[] {
|
|
42
|
+
if (this.pending === '') return [];
|
|
43
|
+
const rest = this.pending.endsWith('\r') ? this.pending.slice(0, -1) : this.pending;
|
|
44
|
+
this.pending = '';
|
|
45
|
+
return rest === '' ? [] : [rest];
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface RingBufferOptions {
|
|
50
|
+
maxLines?: number;
|
|
51
|
+
maxLineBytes?: number;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const TRUNC_SUFFIX = '[…truncated, see session log]';
|
|
55
|
+
|
|
56
|
+
/** 输出流标记(定义于 session-types,此处 re-export 便利用户) */
|
|
57
|
+
export type { OutputStream } from "./session-types.js";
|
|
58
|
+
import type { OutputStream } from "./session-types.js";
|
|
59
|
+
|
|
60
|
+
/** 热窗口行对象:跨流聚合序为到达序(不承诺进程真实写入序——REQ §3.2) */
|
|
61
|
+
export interface SessionLine {
|
|
62
|
+
stream: OutputStream;
|
|
63
|
+
text: string;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export class OutputRingBuffer {
|
|
67
|
+
private readonly maxLines: number;
|
|
68
|
+
private readonly maxLineBytes: number;
|
|
69
|
+
private lines: SessionLine[] = [];
|
|
70
|
+
private evictedLines = 0;
|
|
71
|
+
|
|
72
|
+
constructor(opts: RingBufferOptions = {}) {
|
|
73
|
+
this.maxLines = opts.maxLines ?? 50;
|
|
74
|
+
this.maxLineBytes = opts.maxLineBytes ?? 512;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** 完整行入窗(窗口侧按 maxLineBytes 硬截断 text 含标记;文件侧由调用方写原行) */
|
|
78
|
+
push(line: SessionLine): void {
|
|
79
|
+
let text = line.text;
|
|
80
|
+
if (text.length > this.maxLineBytes) {
|
|
81
|
+
// OSS 边界修复(源实现 bug):源版 slice(0, maxLineBytes - suffix.length)
|
|
82
|
+
// 在 maxLineBytes < 后缀长度时为负索引,行为反转(越截越长)。keep 下限 0
|
|
83
|
+
// 保截断标记;此时行长为后缀长度(29)恒定,防 OOM 语义不破。
|
|
84
|
+
const keep = Math.max(0, this.maxLineBytes - TRUNC_SUFFIX.length);
|
|
85
|
+
text = text.slice(0, keep) + TRUNC_SUFFIX;
|
|
86
|
+
}
|
|
87
|
+
this.lines.push({ stream: line.stream, text });
|
|
88
|
+
if (this.lines.length > this.maxLines) {
|
|
89
|
+
const overflow = this.lines.length - this.maxLines;
|
|
90
|
+
this.lines = this.lines.slice(-this.maxLines);
|
|
91
|
+
this.evictedLines += overflow;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** 最近 n 行的 text 投影(简洁消费) */
|
|
96
|
+
tail(n: number): string[] {
|
|
97
|
+
return this.lines.slice(-Math.max(0, n)).map((l) => l.text);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** 最近 n 行对象(带 stream 标记——FEAT-05 按流过滤迭代用) */
|
|
101
|
+
tailLines(n: number): SessionLine[] {
|
|
102
|
+
return this.lines.slice(-Math.max(0, n));
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* 自创建以来被挤出窗口的行数(FEAT-11 滚出感知有据):
|
|
107
|
+
* 0 = 窗口历史完整(标记若出现过必仍在窗内);>0 = 历史可能不完整。
|
|
108
|
+
*/
|
|
109
|
+
get evictedCount(): number {
|
|
110
|
+
return this.evictedLines;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* 命中行 ± contextLines 的上下文切片(索引相对当前窗口,越界收敛)。
|
|
115
|
+
* 供 watcher 命中时组装通知上下文。
|
|
116
|
+
*/
|
|
117
|
+
contextAround(index: number, contextLines: number): SessionLine[] {
|
|
118
|
+
const from = Math.max(0, index - contextLines);
|
|
119
|
+
const to = Math.min(this.lines.length, index + contextLines + 1);
|
|
120
|
+
return this.lines.slice(from, to);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** 命中行的窗口索引(按 text 查找;找不到回退末行)——watch 上下文组装用 */
|
|
124
|
+
indexOfLine(text: string): number {
|
|
125
|
+
const i = this.lines.findIndex((l) => l.text === text);
|
|
126
|
+
return i === -1 ? this.lines.length - 1 : i;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
get size(): number {
|
|
130
|
+
return this.lines.length;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export interface WatcherSpec {
|
|
135
|
+
id: string;
|
|
136
|
+
pattern: string | RegExp;
|
|
137
|
+
/** 命中一次即摘除(默认 true) */
|
|
138
|
+
once?: boolean;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
interface WatcherEntry extends WatcherSpec {
|
|
142
|
+
regex: RegExp;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const MAX_WATCHERS_PER_SESSION = 8;
|
|
146
|
+
|
|
147
|
+
export class WatchRegistry {
|
|
148
|
+
private watchers = new Map<string, WatcherEntry>();
|
|
149
|
+
|
|
150
|
+
add(spec: WatcherSpec): void {
|
|
151
|
+
if (this.watchers.size >= MAX_WATCHERS_PER_SESSION) {
|
|
152
|
+
throw new Error(`watcher 数量已达上限(${MAX_WATCHERS_PER_SESSION}/会话),先 cancel 旧的再注册`);
|
|
153
|
+
}
|
|
154
|
+
this.watchers.set(spec.id, { ...spec, regex: this.toRegex(spec.pattern) });
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** 匹配一行,返回命中的 watcher id(once 的命中即摘除) */
|
|
158
|
+
match(line: string): string[] {
|
|
159
|
+
const hits: string[] = [];
|
|
160
|
+
for (const [id, w] of this.watchers) {
|
|
161
|
+
if (w.regex.test(line)) {
|
|
162
|
+
hits.push(id);
|
|
163
|
+
if (w.once !== false) this.watchers.delete(id);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
return hits;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
cancel(id: string): boolean {
|
|
170
|
+
return this.watchers.delete(id);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
get size(): number {
|
|
174
|
+
return this.watchers.size;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
private toRegex(pattern: string | RegExp): RegExp {
|
|
178
|
+
if (pattern instanceof RegExp) return pattern;
|
|
179
|
+
// string 模式 = 子串语义:全文转义
|
|
180
|
+
const escaped = pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
181
|
+
return new RegExp(escaped);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* session-types.ts — 会话生命周期公共类型
|
|
3
|
+
*
|
|
4
|
+
* 净室平移自 Aethelum-Nexus `src/core/tools/runtime/session/local-session-manager.ts`
|
|
5
|
+
* 类型段(源锚点:Aethelum-Nexus dev @ 8977ff3),按 REQ-0001 OSS 化裁剪:
|
|
6
|
+
* - 剔除 notifyQueue(通知路由目标队列——消息队列路由属消费方桥,内核零队列红线)
|
|
7
|
+
* - ownerSessionId 保留(纯透传的归属上下文 ID,无业务语义负载)
|
|
8
|
+
* - 新增终态三态 SessionFinalStatus(FEAT-04:终止即请求、死亡须核实)
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export interface SessionStartOptions {
|
|
12
|
+
cwd?: string;
|
|
13
|
+
env?: Record<string, string>;
|
|
14
|
+
/** 会话名(sessionId 前缀,便于人读) */
|
|
15
|
+
name?: string;
|
|
16
|
+
/** 归属上下文 ID(事件通知时原样透传,供调用方关联自己的任务/请求) */
|
|
17
|
+
ownerSessionId?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface SessionHandleInfo {
|
|
21
|
+
sessionId: string;
|
|
22
|
+
/** 落盘日志基名——实际双文件 `<logBase>.out` / `<logBase>.err`(FEAT-04 双文件协议) */
|
|
23
|
+
logBase: string;
|
|
24
|
+
pid?: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* 会话终态三态(FEAT-04 / REQ §3.2 细则 6):
|
|
29
|
+
* - exited 收到进程退出码(自然退出或终止后的确认退出)
|
|
30
|
+
* - stopped 终止请求已发出且清理核实通过
|
|
31
|
+
* - unknown 核实不可达(SSH 断线/网络分区/核实超时)——显式暴露,严禁当成功
|
|
32
|
+
*/
|
|
33
|
+
export type SessionFinalStatus = "exited" | "stopped" | "unknown";
|
|
34
|
+
|
|
35
|
+
export interface SessionFinalState {
|
|
36
|
+
status: SessionFinalStatus;
|
|
37
|
+
exitCode?: number;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export type SessionEvent =
|
|
41
|
+
| {
|
|
42
|
+
type: "match";
|
|
43
|
+
sessionId: string;
|
|
44
|
+
watcherId: string;
|
|
45
|
+
pattern: string;
|
|
46
|
+
matchedLine: string;
|
|
47
|
+
/** 命中行所属流(FEAT-04/05 双流标记) */
|
|
48
|
+
stream: "stdout" | "stderr";
|
|
49
|
+
context: string[];
|
|
50
|
+
/** 归属上下文(调用方透传) */
|
|
51
|
+
ownerSessionId?: string;
|
|
52
|
+
}
|
|
53
|
+
| { type: "exit"; sessionId: string; exitCode: number | null; lastLines: string[]; ownerSessionId?: string };
|
|
54
|
+
|
|
55
|
+
export interface WatchOptions {
|
|
56
|
+
pattern: string | RegExp;
|
|
57
|
+
/** 命中携带 命中行 ± N 行,默认 5 */
|
|
58
|
+
contextLines?: number;
|
|
59
|
+
/** 命中一次即摘除(默认 true) */
|
|
60
|
+
once?: boolean;
|
|
61
|
+
/**
|
|
62
|
+
* 命中即收摊:watcher 命中后自动 stop 会话——「观察对象结束 → 观察者退场」
|
|
63
|
+
* 从模型自觉变机制。观察型会话(tail -f 等永不退出的流)尤其需要,防遗忘泄漏。
|
|
64
|
+
*/
|
|
65
|
+
thenStop?: boolean;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** 输出流标记(FEAT-04/05:双流行对象——stdout/stderr 物理分离) */
|
|
69
|
+
export type OutputStream = "stdout" | "stderr";
|
|
70
|
+
|
|
71
|
+
export interface ReadLogOptions {
|
|
72
|
+
/** 取最近 N 行(缺省=全量) */
|
|
73
|
+
lines?: number;
|
|
74
|
+
/** 自文件头跳过 N 行 */
|
|
75
|
+
offsetLines?: number;
|
|
76
|
+
/** 读哪条流的落盘文件(FEAT-04 双文件协议;缺省 "stdout") */
|
|
77
|
+
stream?: OutputStream;
|
|
78
|
+
}
|