mocode-ai 1.1.7 → 1.1.8

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.
Files changed (51) hide show
  1. package/README.md +23 -37
  2. package/README.zh-CN.md +46 -38
  3. package/dist/agent/core.js +100 -446
  4. package/dist/agent/index.js +2 -21
  5. package/dist/agent/spawn.js +3 -5
  6. package/dist/agent/work-discipline.js +16 -70
  7. package/dist/config/index.js +6 -7
  8. package/dist/context/age-aware.js +18 -48
  9. package/dist/context/artifacts.js +19 -17
  10. package/dist/context/budget.js +27 -28
  11. package/dist/context/classifier.js +0 -1
  12. package/dist/context/encoders/index.js +4 -11
  13. package/dist/context/index.js +4 -7
  14. package/dist/context/lifecycle.js +115 -483
  15. package/dist/context/pipeline.js +8 -15
  16. package/dist/context/relevance.js +77 -55
  17. package/dist/host/stdio.js +0 -6
  18. package/dist/i18n/index.js +0 -6
  19. package/dist/index.js +11 -1
  20. package/dist/llm/index.js +72 -3
  21. package/dist/mcp/index.js +0 -1
  22. package/dist/repl/index.js +21 -15
  23. package/dist/runtime/browser-manager.js +299 -0
  24. package/dist/runtime/dev-server-manager.js +354 -0
  25. package/dist/runtime/shutdown.js +26 -0
  26. package/dist/session/compact.js +86 -102
  27. package/dist/session/index.js +0 -1
  28. package/dist/session/scheduler.js +88 -92
  29. package/dist/session/trace-metrics.js +5 -92
  30. package/dist/session/trace.js +1 -10
  31. package/dist/tools/builtins/browser.js +199 -0
  32. package/dist/tools/builtins/dev-server.js +99 -0
  33. package/dist/tools/builtins/index.js +28 -19
  34. package/dist/tools/builtins/screenshot.js +173 -0
  35. package/dist/tools/builtins/view-image.js +49 -0
  36. package/dist/tools/constants.js +3 -0
  37. package/dist/tools/registry.js +5 -29
  38. package/dist/ui/layout.js +28 -5
  39. package/dist/ui/render.js +11 -0
  40. package/package.json +2 -2
  41. package/dist/agent/middleware/checklist.js +0 -59
  42. package/dist/session/drop.d.ts +0 -19
  43. package/dist/session/drop.js +0 -93
  44. package/dist/tools/builtins/drop-context.d.ts +0 -18
  45. package/dist/tools/builtins/drop-context.js +0 -68
  46. package/dist/verification/diagnostics.js +0 -108
  47. package/dist/verification/fingerprint.js +0 -54
  48. package/dist/verification/index.js +0 -333
  49. package/dist/verification/postconditions.js +0 -98
  50. package/dist/verification/targeted-tests.js +0 -96
  51. package/dist/verification/types.js +0 -1
@@ -0,0 +1,299 @@
1
+ // Playwright 浏览器会话管理器。
2
+ //
3
+ // 设计要点:
4
+ // - 会话跨工具调用存活(Browser 单例 + 每会话独立 BrowserContext/Page),这样 agent 可以
5
+ // navigate → click → screenshot 连续操作同一个页面,而不是每次重开浏览器丢状态。
6
+ // - playwright 用动态 import:启动 mocode 不该为一个可选能力付加载成本,缺浏览器二进制时
7
+ // 也能给出明确的 `npx playwright install chromium` 指引而不是崩在模块加载期。
8
+ // - 默认只允许回环地址。agent 能驱动真浏览器意味着它能发任意请求,若放开就等于给了
9
+ // SSRF / 内网扫描能力。放开需用户显式设 MOCODE_BROWSER_ALLOW_REMOTE=true。
10
+ // - 采集 console / pageerror / requestfailed 到有界 ring buffer:前端问题往往只在控制台可见,
11
+ // 截图看不出来。
12
+ import { randomBytes } from 'node:crypto';
13
+ export class BrowserManagerError extends Error {
14
+ code;
15
+ constructor(code, message) {
16
+ super(message);
17
+ this.code = code;
18
+ this.name = 'BrowserManagerError';
19
+ }
20
+ }
21
+ const sessions = new Map();
22
+ const DIAGNOSTIC_LIMIT = 200;
23
+ const DEFAULT_TIMEOUT_MS = 15_000;
24
+ const MAX_TIMEOUT_MS = 60_000;
25
+ const DEFAULT_VIEWPORT = { width: 1280, height: 800 };
26
+ let browserPromise = null;
27
+ let launchedHeaded = false;
28
+ function errorMessage(error) {
29
+ return error instanceof Error ? error.message : String(error);
30
+ }
31
+ export function resolveTimeout(input) {
32
+ const value = Number(input ?? DEFAULT_TIMEOUT_MS);
33
+ if (!Number.isFinite(value))
34
+ return DEFAULT_TIMEOUT_MS;
35
+ return Math.min(Math.max(value, 500), MAX_TIMEOUT_MS);
36
+ }
37
+ function remoteAllowed() {
38
+ return process.env.MOCODE_BROWSER_ALLOW_REMOTE === 'true';
39
+ }
40
+ /** 默认只放行 http/https 回环地址;拒绝 file:/data:/javascript: 等本地读取与脚本注入面。 */
41
+ export function validateNavigationUrl(input) {
42
+ let url;
43
+ try {
44
+ url = new URL(input);
45
+ }
46
+ catch {
47
+ throw new BrowserManagerError('INVALID_ARGUMENTS', `Invalid url: ${input}`);
48
+ }
49
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') {
50
+ throw new BrowserManagerError('SANDBOX_DENIED', `Only http and https URLs are allowed (received ${url.protocol}).`);
51
+ }
52
+ if (url.username || url.password) {
53
+ throw new BrowserManagerError('SANDBOX_DENIED', 'URLs with embedded credentials are not allowed.');
54
+ }
55
+ const host = url.hostname.toLowerCase().replace(/^\[|\]$/g, '');
56
+ const loopback = host === 'localhost' || host === '127.0.0.1' || host === '::1';
57
+ if (!loopback && !remoteAllowed()) {
58
+ throw new BrowserManagerError('SANDBOX_DENIED', `Only localhost, 127.0.0.1, and ::1 are allowed by default (received ${url.hostname}). ` +
59
+ 'Set MOCODE_BROWSER_ALLOW_REMOTE=true to permit remote navigation.');
60
+ }
61
+ return url;
62
+ }
63
+ async function launchBrowser(headed) {
64
+ let playwright;
65
+ try {
66
+ playwright = await import('playwright');
67
+ }
68
+ catch (error) {
69
+ throw new BrowserManagerError('EXECUTION_ERROR', `Playwright is not available: ${errorMessage(error)}. Run "npm install" first.`);
70
+ }
71
+ try {
72
+ return await playwright.chromium.launch({ headless: !headed });
73
+ }
74
+ catch (error) {
75
+ throw new BrowserManagerError('EXECUTION_ERROR', `Unable to launch Chromium: ${errorMessage(error)}. If the browser binary is missing, run "npx playwright install chromium".`);
76
+ }
77
+ }
78
+ async function getBrowser(headed) {
79
+ const existing = browserPromise ? await browserPromise.catch(() => null) : null;
80
+ // headless 与 headed 是不同的进程模式;请求切换时重开,避免默默给出与请求不符的模式。
81
+ if (existing?.isConnected() && launchedHeaded === headed)
82
+ return existing;
83
+ if (existing?.isConnected())
84
+ await existing.close().catch(() => { });
85
+ launchedHeaded = headed;
86
+ browserPromise = launchBrowser(headed);
87
+ try {
88
+ return await browserPromise;
89
+ }
90
+ catch (error) {
91
+ browserPromise = null;
92
+ throw error;
93
+ }
94
+ }
95
+ function record(session, bucket, line) {
96
+ const target = session[bucket];
97
+ target.push(line);
98
+ if (target.length > DIAGNOSTIC_LIMIT)
99
+ target.splice(0, target.length - DIAGNOSTIC_LIMIT);
100
+ }
101
+ export async function openSession(opts = {}) {
102
+ const headed = opts.headed === true;
103
+ const browser = await getBrowser(headed);
104
+ const context = await browser.newContext({ viewport: DEFAULT_VIEWPORT });
105
+ const page = await context.newPage();
106
+ page.setDefaultTimeout(DEFAULT_TIMEOUT_MS);
107
+ const session = {
108
+ sessionId: `page-${randomBytes(3).toString('hex')}`,
109
+ context,
110
+ page,
111
+ headed,
112
+ console: [],
113
+ pageErrors: [],
114
+ failedRequests: [],
115
+ };
116
+ page.on('console', (message) => {
117
+ record(session, 'console', `[${message.type()}] ${message.text()}`);
118
+ });
119
+ page.on('pageerror', (error) => record(session, 'pageErrors', errorMessage(error)));
120
+ page.on('requestfailed', (request) => {
121
+ record(session, 'failedRequests', `${request.method()} ${request.url()} — ${request.failure()?.errorText ?? 'failed'}`);
122
+ });
123
+ sessions.set(session.sessionId, session);
124
+ return describe(session);
125
+ }
126
+ function requireSession(sessionId) {
127
+ if (sessionId) {
128
+ const session = sessions.get(sessionId);
129
+ if (!session) {
130
+ const known = [...sessions.keys()];
131
+ throw new BrowserManagerError('INVALID_ARGUMENTS', known.length
132
+ ? `Unknown browser sessionId "${sessionId}". Known sessions: ${known.join(', ')}.`
133
+ : `Unknown browser sessionId "${sessionId}". Call action=open first.`);
134
+ }
135
+ return session;
136
+ }
137
+ // 只有一个会话时允许省略 sessionId,减少模型出错面;多会话必须显式指定。
138
+ const all = [...sessions.values()];
139
+ if (all.length === 1)
140
+ return all[0];
141
+ if (all.length === 0) {
142
+ throw new BrowserManagerError('INVALID_ARGUMENTS', 'No browser session is open. Call action=open first.');
143
+ }
144
+ throw new BrowserManagerError('INVALID_ARGUMENTS', `Multiple browser sessions are open; specify sessionId. Known sessions: ${all.map((s) => s.sessionId).join(', ')}.`);
145
+ }
146
+ function describe(session) {
147
+ const viewport = session.page.viewportSize() ?? DEFAULT_VIEWPORT;
148
+ return {
149
+ sessionId: session.sessionId,
150
+ url: session.page.url(),
151
+ title: '',
152
+ headed: session.headed,
153
+ viewport,
154
+ };
155
+ }
156
+ async function describeAsync(session) {
157
+ const base = describe(session);
158
+ return { ...base, title: await session.page.title().catch(() => '') };
159
+ }
160
+ /** Playwright 的超时错误统一映射成结构化 TIMEOUT,便于 agent 区分「选择器没出现」与真故障。 */
161
+ function wrapActionError(error) {
162
+ if (error instanceof BrowserManagerError)
163
+ throw error;
164
+ const message = errorMessage(error);
165
+ if (/Timeout .* exceeded|waiting for/i.test(message)) {
166
+ throw new BrowserManagerError('TIMEOUT', message);
167
+ }
168
+ throw new BrowserManagerError('EXECUTION_ERROR', message);
169
+ }
170
+ function diagnostics(session) {
171
+ return {
172
+ console: session.console.slice(-20),
173
+ pageErrors: session.pageErrors.slice(-20),
174
+ failedRequests: session.failedRequests.slice(-20),
175
+ };
176
+ }
177
+ async function finish(session, action) {
178
+ return { action, ...(await describeAsync(session)), diagnostics: diagnostics(session) };
179
+ }
180
+ export async function navigate(opts) {
181
+ const session = requireSession(opts.sessionId);
182
+ const url = validateNavigationUrl(opts.url);
183
+ try {
184
+ const response = await session.page.goto(url.toString(), {
185
+ timeout: resolveTimeout(opts.timeoutMs),
186
+ waitUntil: 'domcontentloaded',
187
+ });
188
+ if (response && !response.ok()) {
189
+ record(session, 'failedRequests', `GET ${url.toString()} — HTTP ${response.status()}`);
190
+ }
191
+ }
192
+ catch (error) {
193
+ wrapActionError(error);
194
+ }
195
+ return finish(session, 'navigate');
196
+ }
197
+ export async function click(opts) {
198
+ const session = requireSession(opts.sessionId);
199
+ try {
200
+ await session.page.click(opts.selector, { timeout: resolveTimeout(opts.timeoutMs) });
201
+ }
202
+ catch (error) {
203
+ wrapActionError(error);
204
+ }
205
+ return finish(session, 'click');
206
+ }
207
+ export async function fill(opts) {
208
+ const session = requireSession(opts.sessionId);
209
+ try {
210
+ await session.page.fill(opts.selector, opts.value, { timeout: resolveTimeout(opts.timeoutMs) });
211
+ }
212
+ catch (error) {
213
+ wrapActionError(error);
214
+ }
215
+ return finish(session, 'fill');
216
+ }
217
+ export async function press(opts) {
218
+ const session = requireSession(opts.sessionId);
219
+ const timeout = resolveTimeout(opts.timeoutMs);
220
+ try {
221
+ if (opts.selector)
222
+ await session.page.press(opts.selector, opts.key, { timeout });
223
+ else
224
+ await session.page.keyboard.press(opts.key);
225
+ }
226
+ catch (error) {
227
+ wrapActionError(error);
228
+ }
229
+ return finish(session, 'press');
230
+ }
231
+ export async function waitFor(opts) {
232
+ const session = requireSession(opts.sessionId);
233
+ try {
234
+ await session.page.waitForSelector(opts.selector, { timeout: resolveTimeout(opts.timeoutMs) });
235
+ }
236
+ catch (error) {
237
+ wrapActionError(error);
238
+ }
239
+ return finish(session, 'wait_for');
240
+ }
241
+ export async function readText(opts) {
242
+ const session = requireSession(opts.sessionId);
243
+ const limit = Math.min(Math.max(Number(opts.limit ?? 4_000), 100), 32_000);
244
+ let text;
245
+ try {
246
+ text = opts.selector
247
+ ? (await session.page.textContent(opts.selector, { timeout: DEFAULT_TIMEOUT_MS })) ?? ''
248
+ : await session.page.innerText('body');
249
+ }
250
+ catch (error) {
251
+ wrapActionError(error);
252
+ }
253
+ const normalized = text.replace(/\n{3,}/g, '\n\n').trim();
254
+ return {
255
+ ...(await finish(session, 'text')),
256
+ text: normalized.length > limit ? `${normalized.slice(0, limit)}\n… (truncated)` : normalized,
257
+ };
258
+ }
259
+ export async function screenshot(opts) {
260
+ const session = requireSession(opts.sessionId);
261
+ const timeout = resolveTimeout(opts.timeoutMs);
262
+ let buffer;
263
+ try {
264
+ buffer = opts.selector
265
+ ? await session.page.locator(opts.selector).screenshot({ timeout, type: 'png' })
266
+ : await session.page.screenshot({ timeout, type: 'png', fullPage: opts.fullPage === true });
267
+ }
268
+ catch (error) {
269
+ wrapActionError(error);
270
+ }
271
+ return { ...(await finish(session, 'screenshot')), buffer };
272
+ }
273
+ export function listSessions() {
274
+ return [...sessions.values()].map(describe);
275
+ }
276
+ export async function closeSession(sessionId) {
277
+ const session = requireSession(sessionId);
278
+ sessions.delete(session.sessionId);
279
+ await session.context.close().catch(() => { });
280
+ if (sessions.size === 0 && browserPromise) {
281
+ const browser = await browserPromise.catch(() => null);
282
+ browserPromise = null;
283
+ await browser?.close().catch(() => { });
284
+ }
285
+ return { sessionId: session.sessionId, closed: true };
286
+ }
287
+ /** 全局退出清理:关闭所有页面与浏览器进程。幂等。 */
288
+ export async function closeAllBrowsers() {
289
+ for (const session of [...sessions.values()]) {
290
+ sessions.delete(session.sessionId);
291
+ await session.context.close().catch(() => { });
292
+ }
293
+ const pending = browserPromise;
294
+ browserPromise = null;
295
+ if (!pending)
296
+ return;
297
+ const browser = await pending.catch(() => null);
298
+ await browser?.close().catch(() => { });
299
+ }
@@ -0,0 +1,354 @@
1
+ // 常驻后台进程管理器(dev server 等)。
2
+ //
3
+ // 与 run_command 的关键区别:run_command 是前台命令,超时或本轮 abort 即 killTree;
4
+ // 这里的进程必须**跨工具调用存活**,所以生命周期只由显式 stop 或进程退出 / 全局 shutdown 决定。
5
+ // 传入的 signal 只用于取消「启动 + 就绪等待」,绝不用于杀已就绪的服务 —— 否则用户下一次
6
+ // Ctrl+C 会顺手把 dev server 干掉。
7
+ //
8
+ // 非安全边界:命令仍是任意 shell 命令(与 run_command 同级风险),故工具层声明 dangerous 风险,
9
+ // 由权限系统在执行前向用户确认。
10
+ import { spawn, spawnSync } from 'node:child_process';
11
+ import { closeSync, openSync, writeSync } from 'node:fs';
12
+ import { mkdir, open, stat } from 'node:fs/promises';
13
+ import { dirname, join } from 'node:path';
14
+ import { randomBytes } from 'node:crypto';
15
+ import { filterEnv, getSandboxRoot, isCommandDenied, jailResolve } from '../sandbox/index.js';
16
+ export class DevServerManagerError extends Error {
17
+ code;
18
+ constructor(code, message) {
19
+ super(message);
20
+ this.code = code;
21
+ this.name = 'DevServerManagerError';
22
+ }
23
+ }
24
+ const servers = new Map();
25
+ const DEFAULT_READY_TIMEOUT_MS = 30_000;
26
+ const MAX_READY_TIMEOUT_MS = 180_000;
27
+ const READY_POLL_INTERVAL_MS = 250;
28
+ const PROBE_TIMEOUT_MS = 2_000;
29
+ const STOP_GRACE_MS = 1_500;
30
+ const DEFAULT_LOG_BYTES = 8_000;
31
+ const MAX_LOG_BYTES = 64_000;
32
+ const RECENT_LOG_CHARS = 4_000;
33
+ const IS_WINDOWS = process.platform === 'win32';
34
+ function errorMessage(error) {
35
+ return error instanceof Error ? error.message : String(error);
36
+ }
37
+ /** 进程是否仍存活;EPERM 说明 pid 存在但不属于当前用户。 */
38
+ function isAlive(pid) {
39
+ try {
40
+ process.kill(pid, 0);
41
+ return true;
42
+ }
43
+ catch (error) {
44
+ return error.code === 'EPERM';
45
+ }
46
+ }
47
+ function snapshot(record) {
48
+ return {
49
+ id: record.id,
50
+ pid: record.pid,
51
+ state: record.state,
52
+ exitCode: record.exitCode,
53
+ signal: record.signal,
54
+ command: record.command,
55
+ cwd: record.cwd,
56
+ logPath: record.logPath,
57
+ startedAt: record.startedAt,
58
+ };
59
+ }
60
+ function requireRecord(id) {
61
+ const record = servers.get(id);
62
+ if (!record) {
63
+ const known = [...servers.keys()];
64
+ throw new DevServerManagerError('INVALID_ARGUMENTS', known.length
65
+ ? `Unknown dev server id "${id}". Known ids: ${known.join(', ')}.`
66
+ : `Unknown dev server id "${id}". No dev server has been started in this session.`);
67
+ }
68
+ return record;
69
+ }
70
+ /** 只允许回环地址:防止 agent 借就绪探测去扫内网 / 打外网。 */
71
+ function validateReadyUrl(input) {
72
+ let url;
73
+ try {
74
+ url = new URL(input);
75
+ }
76
+ catch {
77
+ throw new DevServerManagerError('INVALID_ARGUMENTS', `Invalid readyUrl: ${input}`);
78
+ }
79
+ const host = url.hostname.toLowerCase().replace(/^\[|\]$/g, '');
80
+ const loopback = host === 'localhost' || host === '127.0.0.1' || host === '::1';
81
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') {
82
+ throw new DevServerManagerError('INVALID_ARGUMENTS', 'readyUrl must use http or https.');
83
+ }
84
+ if (!loopback || url.username || url.password) {
85
+ throw new DevServerManagerError('INVALID_ARGUMENTS', 'readyUrl must target localhost, 127.0.0.1, or ::1 without credentials.');
86
+ }
87
+ return url;
88
+ }
89
+ function compileReadyPattern(input) {
90
+ try {
91
+ return new RegExp(input, 'i');
92
+ }
93
+ catch (error) {
94
+ throw new DevServerManagerError('INVALID_ARGUMENTS', `Invalid readyPattern: ${errorMessage(error)}`);
95
+ }
96
+ }
97
+ function sleep(ms, signal) {
98
+ return new Promise((resolve, reject) => {
99
+ const abort = () => {
100
+ clearTimeout(timer);
101
+ reject(new DevServerManagerError('ABORTED', 'Dev server startup wait was aborted.'));
102
+ };
103
+ if (signal?.aborted) {
104
+ abort();
105
+ return;
106
+ }
107
+ const timer = setTimeout(() => {
108
+ signal?.removeEventListener('abort', abort);
109
+ resolve();
110
+ }, ms);
111
+ signal?.addEventListener('abort', abort, { once: true });
112
+ });
113
+ }
114
+ /** 任意 HTTP 响应都算就绪:dev server 常在 / 返回 404 但端口已可用。 */
115
+ async function probeReady(url) {
116
+ const controller = new AbortController();
117
+ const timer = setTimeout(() => controller.abort(), PROBE_TIMEOUT_MS);
118
+ try {
119
+ await fetch(url, { signal: controller.signal, redirect: 'manual' });
120
+ return true;
121
+ }
122
+ catch {
123
+ return false;
124
+ }
125
+ finally {
126
+ clearTimeout(timer);
127
+ }
128
+ }
129
+ function killTree(pid) {
130
+ try {
131
+ if (IS_WINDOWS) {
132
+ spawnSync('taskkill', ['/PID', String(pid), '/T', '/F'], { stdio: 'ignore' });
133
+ }
134
+ else {
135
+ process.kill(pid, 'SIGTERM');
136
+ }
137
+ }
138
+ catch {
139
+ // 已退出或无权限:best-effort。
140
+ }
141
+ }
142
+ function finalizeExit(record, code, signalCode) {
143
+ record.state = record.stopRequested ? 'stopped' : 'exited';
144
+ record.exitCode = code;
145
+ record.signal = signalCode;
146
+ if (record.logFd !== null) {
147
+ try {
148
+ closeSync(record.logFd);
149
+ }
150
+ catch {
151
+ // 忽略:进程收尾期文件句柄可能已失效。
152
+ }
153
+ record.logFd = null;
154
+ }
155
+ }
156
+ /** 进程可能在事件回调之外被外部杀掉,读状态时顺带对账。 */
157
+ function reconcile(record) {
158
+ if (record.state === 'running' && !isAlive(record.pid)) {
159
+ finalizeExit(record, record.child.exitCode, record.child.signalCode);
160
+ }
161
+ return record;
162
+ }
163
+ export async function startDevServer(opts) {
164
+ const command = opts.command.trim();
165
+ if (!command) {
166
+ throw new DevServerManagerError('INVALID_ARGUMENTS', 'command is required to start a dev server.');
167
+ }
168
+ const denied = isCommandDenied(command);
169
+ if (denied) {
170
+ throw new DevServerManagerError('SANDBOX_DENIED', denied);
171
+ }
172
+ const root = getSandboxRoot() ?? process.cwd();
173
+ let cwd = root;
174
+ if (opts.cwd) {
175
+ try {
176
+ cwd = jailResolve(opts.cwd);
177
+ }
178
+ catch (error) {
179
+ throw new DevServerManagerError('SANDBOX_DENIED', errorMessage(error));
180
+ }
181
+ }
182
+ const readyUrl = opts.readyUrl ? validateReadyUrl(opts.readyUrl) : null;
183
+ const readyPattern = opts.readyPattern ? compileReadyPattern(opts.readyPattern) : null;
184
+ const timeoutMs = Math.min(Math.max(Number(opts.timeoutMs ?? DEFAULT_READY_TIMEOUT_MS), 1_000), MAX_READY_TIMEOUT_MS);
185
+ const id = `srv-${randomBytes(3).toString('hex')}`;
186
+ const logPath = jailResolve(join('.mocode', 'dev-servers', `${id}.log`));
187
+ await mkdir(dirname(logPath), { recursive: true });
188
+ let logFd;
189
+ try {
190
+ logFd = openSync(logPath, 'a');
191
+ }
192
+ catch (error) {
193
+ throw new DevServerManagerError('EXECUTION_ERROR', `Unable to open log file: ${errorMessage(error)}`);
194
+ }
195
+ let child;
196
+ try {
197
+ child = spawn(IS_WINDOWS ? 'cmd.exe' : 'bash', IS_WINDOWS ? ['/d', '/s', '/c', command] : ['-c', command], {
198
+ cwd,
199
+ env: filterEnv(process.env),
200
+ windowsVerbatimArguments: IS_WINDOWS,
201
+ windowsHide: true,
202
+ stdio: ['ignore', 'pipe', 'pipe'],
203
+ });
204
+ }
205
+ catch (error) {
206
+ closeSync(logFd);
207
+ throw new DevServerManagerError('EXECUTION_ERROR', `Unable to spawn dev server: ${errorMessage(error)}`);
208
+ }
209
+ if (child.pid === undefined) {
210
+ closeSync(logFd);
211
+ throw new DevServerManagerError('EXECUTION_ERROR', 'Dev server process did not report a pid.');
212
+ }
213
+ const record = {
214
+ id,
215
+ pid: child.pid,
216
+ state: 'running',
217
+ exitCode: null,
218
+ signal: null,
219
+ command,
220
+ cwd,
221
+ logPath,
222
+ startedAt: new Date().toISOString(),
223
+ child,
224
+ logFd,
225
+ stopRequested: false,
226
+ recentLog: '',
227
+ };
228
+ servers.set(id, record);
229
+ const onChunk = (chunk) => {
230
+ const text = chunk.toString('utf8');
231
+ record.recentLog = (record.recentLog + text).slice(-RECENT_LOG_CHARS);
232
+ if (record.logFd === null)
233
+ return;
234
+ try {
235
+ writeSync(record.logFd, text);
236
+ }
237
+ catch {
238
+ // 日志写失败不影响进程本身。
239
+ }
240
+ };
241
+ child.stdout?.on('data', onChunk);
242
+ child.stderr?.on('data', onChunk);
243
+ child.on('error', (error) => onChunk(Buffer.from(`\n[spawn error] ${error.message}\n`)));
244
+ child.on('close', (code, signalCode) => finalizeExit(record, code, signalCode));
245
+ const deadline = Date.now() + timeoutMs;
246
+ const wantsReadySignal = Boolean(readyUrl || readyPattern);
247
+ let ready = false;
248
+ try {
249
+ while (wantsReadySignal && Date.now() < deadline) {
250
+ if (record.state !== 'running') {
251
+ throw new DevServerManagerError('PROCESS_FAILED', `Dev server exited before becoming ready (exitCode=${record.exitCode ?? 'null'}). Recent output:\n${record.recentLog.trim() || '(no output)'}`);
252
+ }
253
+ if (readyPattern?.test(record.recentLog)) {
254
+ ready = true;
255
+ break;
256
+ }
257
+ if (readyUrl && await probeReady(readyUrl)) {
258
+ ready = true;
259
+ break;
260
+ }
261
+ await sleep(READY_POLL_INTERVAL_MS, opts.signal);
262
+ }
263
+ }
264
+ catch (error) {
265
+ // 启动失败 / 被中断:不留孤儿进程。
266
+ record.stopRequested = true;
267
+ killTree(record.pid);
268
+ finalizeExit(record, record.child.exitCode, record.child.signalCode);
269
+ throw error;
270
+ }
271
+ if (wantsReadySignal && !ready) {
272
+ record.stopRequested = true;
273
+ killTree(record.pid);
274
+ finalizeExit(record, record.child.exitCode, record.child.signalCode);
275
+ throw new DevServerManagerError('TIMEOUT', `Dev server did not become ready within ${timeoutMs}ms and was stopped. Recent output:\n${record.recentLog.trim() || '(no output)'}`);
276
+ }
277
+ return {
278
+ ...snapshot(reconcile(record)),
279
+ ready,
280
+ ...(opts.readyUrl ? { readyUrl: opts.readyUrl } : {}),
281
+ ...(opts.readyPattern ? { readyPattern: opts.readyPattern } : {}),
282
+ ...(record.recentLog.trim() ? { logTail: record.recentLog.trim().slice(-1_000) } : {}),
283
+ };
284
+ }
285
+ export function listDevServers() {
286
+ return [...servers.values()].map((record) => snapshot(reconcile(record)));
287
+ }
288
+ export function getDevServerStatus(id) {
289
+ if (!id)
290
+ return listDevServers();
291
+ return [snapshot(reconcile(requireRecord(id)))];
292
+ }
293
+ export async function readDevServerLogs(id, opts = {}) {
294
+ const record = reconcile(requireRecord(id));
295
+ const size = (await stat(record.logPath).catch(() => null))?.size ?? 0;
296
+ const limit = Math.min(Math.max(Number(opts.limit ?? DEFAULT_LOG_BYTES), 1), MAX_LOG_BYTES);
297
+ // 缺省读尾部(最新输出);显式 offset 支持增量轮询。
298
+ const requested = opts.offset === undefined ? Math.max(0, size - limit) : Math.max(0, Number(opts.offset));
299
+ const start = Math.min(requested, size);
300
+ const length = Math.min(limit, size - start);
301
+ let content = '';
302
+ if (length > 0) {
303
+ const handle = await open(record.logPath, 'r');
304
+ try {
305
+ const buffer = Buffer.alloc(length);
306
+ await handle.read(buffer, 0, length, start);
307
+ content = buffer.toString('utf8');
308
+ }
309
+ finally {
310
+ await handle.close();
311
+ }
312
+ }
313
+ return {
314
+ id: record.id,
315
+ state: record.state,
316
+ offset: start,
317
+ nextOffset: start + length,
318
+ size,
319
+ hasMore: start + length < size,
320
+ content,
321
+ };
322
+ }
323
+ export async function stopDevServer(id) {
324
+ const record = reconcile(requireRecord(id));
325
+ if (record.state !== 'running') {
326
+ return { ...snapshot(record), alreadyStopped: true };
327
+ }
328
+ record.stopRequested = true;
329
+ killTree(record.pid);
330
+ const deadline = Date.now() + STOP_GRACE_MS;
331
+ while (Date.now() < deadline && isAlive(record.pid)) {
332
+ await sleep(50);
333
+ }
334
+ if (isAlive(record.pid) && !IS_WINDOWS) {
335
+ try {
336
+ process.kill(record.pid, 'SIGKILL');
337
+ }
338
+ catch {
339
+ // 已退出。
340
+ }
341
+ }
342
+ finalizeExit(record, record.child.exitCode, record.child.signalCode);
343
+ return { ...snapshot(record), alreadyStopped: false };
344
+ }
345
+ /** 全局退出清理:同步树杀所有仍存活的后台进程。可重复调用。 */
346
+ export function stopAllDevServersSync() {
347
+ for (const record of servers.values()) {
348
+ if (record.state !== 'running')
349
+ continue;
350
+ record.stopRequested = true;
351
+ killTree(record.pid);
352
+ finalizeExit(record, record.child.exitCode, record.child.signalCode);
353
+ }
354
+ }
@@ -0,0 +1,26 @@
1
+ // 常驻资源的统一退出清理。
2
+ //
3
+ // 后台 dev server 是我们自己 spawn 的子进程,进程退出不会自动带走(Windows 尤其),必须显式树杀;
4
+ // Playwright 浏览器进程在正常路径 await close(),硬退出路径依赖 Playwright 自身的 exit 处理。
5
+ // 两个入口都必须幂等:normal exit / SIGINT / uncaughtException 可能重复触发。
6
+ import { stopAllDevServersSync } from './dev-server-manager.js';
7
+ let asyncDone = false;
8
+ /** 正常退出路径:可 await,尽量优雅关闭。 */
9
+ export async function shutdownRuntime() {
10
+ if (asyncDone)
11
+ return;
12
+ asyncDone = true;
13
+ stopAllDevServersSync();
14
+ try {
15
+ // 动态 import:未用过 browser 工具时不该把 playwright 拉进进程。
16
+ const { closeAllBrowsers } = await import('./browser-manager.js');
17
+ await closeAllBrowsers();
18
+ }
19
+ catch {
20
+ // 浏览器从未启动或已崩溃:忽略。
21
+ }
22
+ }
23
+ /** 同步兜底(process exit / 信号 / 未捕获异常):只做同步树杀,不留孤儿 dev server。 */
24
+ export function shutdownRuntimeSync() {
25
+ stopAllDevServersSync();
26
+ }