@xcanwin/manyoyo 7.0.20 → 7.0.23
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/lib/web/frontend/shadcn.html +59 -59
- package/lib/web/server.js +99 -300
- package/package.json +1 -1
package/lib/web/server.js
CHANGED
|
@@ -30,82 +30,7 @@ const {
|
|
|
30
30
|
projectSessionEvents
|
|
31
31
|
} = require('../core/events');
|
|
32
32
|
const { FileEventStore } = require('../core/event-store');
|
|
33
|
-
const { buildManyoyoLogPath, getLocalDateTag } = require('../log-path'); // DIAG_LOG
|
|
34
33
|
|
|
35
|
-
// DIAG_LOG BEGIN:临时详细诊断日志基础设施,用于排查 serve 接口超时 / agent
|
|
36
|
-
// 流式回复长时间卡在"…"不实时更新的问题。排查结束后可整体删除:全局搜索
|
|
37
|
-
// "DIAG_LOG" 能定位到全部相关代码(这段辅助函数、各处打点调用、下方
|
|
38
|
-
// /api/diag/logs 接口、/logs 页面路由),一并删掉不影响其它功能。
|
|
39
|
-
const DIAG_LOG_LINE_PATTERN = /^\[([^\]]+)\] \[pid:(\d+)\] \[(\w+)\] \[DIAG_LOG\] (\S+)(?: ([\s\S]*))?$/;
|
|
40
|
-
|
|
41
|
-
function diagLog(ctx, category, extra = {}) {
|
|
42
|
-
if (!ctx || !ctx.logger || typeof ctx.logger.info !== 'function') {
|
|
43
|
-
return;
|
|
44
|
-
}
|
|
45
|
-
ctx.logger.info(`[DIAG_LOG] ${String(category || '')}`, extra);
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
function parseDiagLogLine(line) {
|
|
49
|
-
const matched = String(line || '').match(DIAG_LOG_LINE_PATTERN);
|
|
50
|
-
if (!matched) {
|
|
51
|
-
return null;
|
|
52
|
-
}
|
|
53
|
-
let extra = {};
|
|
54
|
-
if (matched[5]) {
|
|
55
|
-
try {
|
|
56
|
-
extra = JSON.parse(matched[5]);
|
|
57
|
-
} catch (e) {
|
|
58
|
-
extra = { raw: matched[5] };
|
|
59
|
-
}
|
|
60
|
-
}
|
|
61
|
-
return {
|
|
62
|
-
ts: matched[1],
|
|
63
|
-
pid: Number(matched[2]),
|
|
64
|
-
level: matched[3],
|
|
65
|
-
category: matched[4],
|
|
66
|
-
extra
|
|
67
|
-
};
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
function diagLogDir(ctx) {
|
|
71
|
-
if (ctx && ctx.logger && typeof ctx.logger.path === 'string' && ctx.logger.path) {
|
|
72
|
-
return path.dirname(ctx.logger.path);
|
|
73
|
-
}
|
|
74
|
-
return buildManyoyoLogPath('serve').dir;
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
function readDiagLogEntries(ctx, options = {}) {
|
|
78
|
-
const dateTag = options.date ? String(options.date) : getLocalDateTag();
|
|
79
|
-
const filePath = path.join(diagLogDir(ctx), `serve-${dateTag}.log`);
|
|
80
|
-
if (!fs.existsSync(filePath)) {
|
|
81
|
-
return [];
|
|
82
|
-
}
|
|
83
|
-
const limit = Number.isInteger(options.limit) && options.limit > 0 ? Math.min(options.limit, 2000) : 500;
|
|
84
|
-
const category = options.category ? String(options.category) : '';
|
|
85
|
-
const session = options.session ? String(options.session) : '';
|
|
86
|
-
const keyword = options.keyword ? String(options.keyword).toLowerCase() : '';
|
|
87
|
-
|
|
88
|
-
const lines = fs.readFileSync(filePath, 'utf-8').split('\n');
|
|
89
|
-
const entries = [];
|
|
90
|
-
for (const line of lines) {
|
|
91
|
-
const entry = parseDiagLogLine(line);
|
|
92
|
-
if (!entry) {
|
|
93
|
-
continue;
|
|
94
|
-
}
|
|
95
|
-
if (category && entry.category !== category) {
|
|
96
|
-
continue;
|
|
97
|
-
}
|
|
98
|
-
if (session && entry.extra.session !== session) {
|
|
99
|
-
continue;
|
|
100
|
-
}
|
|
101
|
-
if (keyword && !line.toLowerCase().includes(keyword)) {
|
|
102
|
-
continue;
|
|
103
|
-
}
|
|
104
|
-
entries.push(entry);
|
|
105
|
-
}
|
|
106
|
-
return entries.slice(-limit);
|
|
107
|
-
}
|
|
108
|
-
// DIAG_LOG END(下方还有零散打点调用与 /api/diag/logs、/logs 路由,同样标记为 DIAG_LOG)
|
|
109
34
|
|
|
110
35
|
const WEB_HISTORY_MAX_MESSAGES = 500;
|
|
111
36
|
const WEB_OUTPUT_MAX_CHARS = 16000;
|
|
@@ -2755,11 +2680,26 @@ function parseAndValidateConfigRaw(raw) {
|
|
|
2755
2680
|
return config;
|
|
2756
2681
|
}
|
|
2757
2682
|
|
|
2683
|
+
// 新建容器对话框会拿 defaults.hostPath 做预填,但 validateHostPath 明确拒绝
|
|
2684
|
+
// 根目录 / /home / $HOME。serve 以 $HOME 启动(root 用户下就是 /root)时,
|
|
2685
|
+
// 预填的值必定创建失败——这里直接留空,逼用户用"选择"选一个真实工作目录
|
|
2686
|
+
function sanitizeDefaultHostPath(hostPath) {
|
|
2687
|
+
const value = String(hostPath || '').trim();
|
|
2688
|
+
if (!value) {
|
|
2689
|
+
return '';
|
|
2690
|
+
}
|
|
2691
|
+
const homeDir = process.env.HOME || os.homedir() || '/home';
|
|
2692
|
+
if (value === '/' || value === '/home' || value === homeDir) {
|
|
2693
|
+
return '';
|
|
2694
|
+
}
|
|
2695
|
+
return value;
|
|
2696
|
+
}
|
|
2697
|
+
|
|
2758
2698
|
function buildConfigDefaults(ctx, config) {
|
|
2759
2699
|
const parsed = toPlainObject(config);
|
|
2760
2700
|
const defaults = {
|
|
2761
2701
|
containerName: hasOwn(parsed, 'containerName') ? String(parsed.containerName || '') : '',
|
|
2762
|
-
hostPath: pickFirstString(parsed.hostPath, ctx.hostPath),
|
|
2702
|
+
hostPath: sanitizeDefaultHostPath(pickFirstString(parsed.hostPath, ctx.hostPath)),
|
|
2763
2703
|
containerPath: pickFirstString(parsed.containerPath, ctx.containerPath),
|
|
2764
2704
|
imageName: pickFirstString(parsed.imageName, ctx.imageName),
|
|
2765
2705
|
imageVersion: pickFirstString(parsed.imageVersion, ctx.imageVersion),
|
|
@@ -3216,8 +3156,11 @@ function listWebManyoyoContainers(ctx) {
|
|
|
3216
3156
|
});
|
|
3217
3157
|
|
|
3218
3158
|
// 逐容器单独 docker inspect 是 N+1 同步阻塞调用(spawnSync 无 timeout,会
|
|
3219
|
-
// 独占 Node
|
|
3220
|
-
//
|
|
3159
|
+
// 独占 Node 单线程事件循环),这里合并成一次批量 inspect。
|
|
3160
|
+
// 注意:31 个容器实测 `ps -a` 约 40ms、批量 `inspect` 约 45ms,容器运行时调用
|
|
3161
|
+
// 并不是 GET /api/sessions 慢的原因——真正的开销在逐容器同步读取并解析整份
|
|
3162
|
+
// 历史 JSON(见 buildSessionSummary 的 preloadedHistory 注释)。排查这条路径
|
|
3163
|
+
// 变慢时别先怀疑 docker/podman。
|
|
3221
3164
|
const defaultCommandByName = {};
|
|
3222
3165
|
if (candidates.length) {
|
|
3223
3166
|
let inspectOutput = '';
|
|
@@ -3712,24 +3655,38 @@ async function execAgentInWebContainerStream(ctx, state, sessionRefOrContainerNa
|
|
|
3712
3655
|
const sessionRef = typeof sessionRefOrContainerName === 'string'
|
|
3713
3656
|
? { containerName: sessionRefOrContainerName, agentId: WEB_DEFAULT_AGENT_ID }
|
|
3714
3657
|
: sessionRefOrContainerName;
|
|
3715
|
-
const sessionKey = buildWebSessionKey(sessionRef.containerName, sessionRef.agentId);
|
|
3716
3658
|
const agentProgram = typeof opts.agentProgram === 'string' ? opts.agentProgram : '';
|
|
3717
3659
|
const onEvent = typeof opts.onEvent === 'function' ? opts.onEvent : () => {};
|
|
3718
|
-
|
|
3719
|
-
|
|
3720
|
-
|
|
3721
|
-
|
|
3722
|
-
|
|
3660
|
+
// 调用方可能已经在路由入口占过锁(见 reserveWebAgentRun):复用那一份 runState,
|
|
3661
|
+
// 不要另起一个对象,否则 stopWebAgentRun 拿到的还是占位、杀不到真正的进程
|
|
3662
|
+
const runState = opts.runState && typeof opts.runState === 'object'
|
|
3663
|
+
? opts.runState
|
|
3664
|
+
: reserveWebAgentRun(state, sessionRef);
|
|
3665
|
+
let process;
|
|
3666
|
+
try {
|
|
3667
|
+
process = spawn(
|
|
3668
|
+
ctx.dockerCmd,
|
|
3669
|
+
['exec', sessionRef.containerName, '/bin/bash', '-lc', command],
|
|
3670
|
+
{ stdio: ['ignore', 'pipe', 'pipe'] }
|
|
3671
|
+
);
|
|
3672
|
+
} catch (e) {
|
|
3673
|
+
// spawn 同步抛错(如 dockerCmd 不存在)时必须释放锁,否则这个容器永远发不出消息
|
|
3674
|
+
releaseWebAgentRun(state, sessionRef.containerName, runState);
|
|
3675
|
+
throw e;
|
|
3676
|
+
}
|
|
3723
3677
|
|
|
3724
|
-
|
|
3725
|
-
|
|
3726
|
-
|
|
3727
|
-
process,
|
|
3728
|
-
command,
|
|
3729
|
-
startedAt: new Date().toISOString(),
|
|
3730
|
-
stopping: false
|
|
3731
|
-
};
|
|
3678
|
+
runState.process = process;
|
|
3679
|
+
runState.command = command;
|
|
3680
|
+
runState.startedAt = new Date().toISOString();
|
|
3732
3681
|
state.agentRuns.set(sessionRef.containerName, runState);
|
|
3682
|
+
// 准备阶段(拉起容器等)用户就点了停止:进程刚 spawn 出来立刻收掉
|
|
3683
|
+
if (runState.stopping === true) {
|
|
3684
|
+
try {
|
|
3685
|
+
process.kill('SIGTERM');
|
|
3686
|
+
} catch (e) {
|
|
3687
|
+
// 进程可能已经退出,忽略
|
|
3688
|
+
}
|
|
3689
|
+
}
|
|
3733
3690
|
|
|
3734
3691
|
return await new Promise((resolve, reject) => {
|
|
3735
3692
|
const MAX_RAW_OUTPUT_CHARS = 32 * 1024 * 1024;
|
|
@@ -3847,11 +3804,11 @@ async function execAgentInWebContainerStream(ctx, state, sessionRefOrContainerNa
|
|
|
3847
3804
|
});
|
|
3848
3805
|
|
|
3849
3806
|
process.on('error', error => {
|
|
3850
|
-
state
|
|
3807
|
+
releaseWebAgentRun(state, sessionRef.containerName, runState);
|
|
3851
3808
|
reject(error);
|
|
3852
3809
|
});
|
|
3853
3810
|
process.on('close', code => {
|
|
3854
|
-
state
|
|
3811
|
+
releaseWebAgentRun(state, sessionRef.containerName, runState);
|
|
3855
3812
|
if (stdoutPending) {
|
|
3856
3813
|
emitStdoutTraceLine(stdoutPending);
|
|
3857
3814
|
stdoutPending = '';
|
|
@@ -3934,9 +3891,43 @@ function resolveWebStreamControlEventType(event) {
|
|
|
3934
3891
|
return 'process.stdout';
|
|
3935
3892
|
}
|
|
3936
3893
|
|
|
3894
|
+
// 容器级运行锁的"占位"。必须在路由里同步调用:从 409 检查到真正 spawn 之间隔着
|
|
3895
|
+
// readJsonBody / prepareWebAgentExecution(含 ensureWebContainer 拉起容器)等多个
|
|
3896
|
+
// await,容器冷启动时这个窗口有好几秒。不先占住的话第二次发送会穿过 409 检查,
|
|
3897
|
+
// 同一容器里真的跑起两个 agent 进程,而且后者会覆盖前者的运行登记,
|
|
3898
|
+
// 让第一个进程再也停不掉
|
|
3899
|
+
function reserveWebAgentRun(state, sessionRef) {
|
|
3900
|
+
const runState = {
|
|
3901
|
+
containerName: sessionRef.containerName,
|
|
3902
|
+
sessionKey: buildWebSessionKey(sessionRef.containerName, sessionRef.agentId),
|
|
3903
|
+
process: null,
|
|
3904
|
+
command: '',
|
|
3905
|
+
startedAt: new Date().toISOString(),
|
|
3906
|
+
stopping: false
|
|
3907
|
+
};
|
|
3908
|
+
state.agentRuns.set(sessionRef.containerName, runState);
|
|
3909
|
+
return runState;
|
|
3910
|
+
}
|
|
3911
|
+
|
|
3912
|
+
// 只有占位仍是自己那一份时才释放,避免误删别人的登记
|
|
3913
|
+
function releaseWebAgentRun(state, containerName, runState) {
|
|
3914
|
+
if (state.agentRuns.get(containerName) === runState) {
|
|
3915
|
+
state.agentRuns.delete(containerName);
|
|
3916
|
+
}
|
|
3917
|
+
}
|
|
3918
|
+
|
|
3937
3919
|
function stopWebAgentRun(state, containerName) {
|
|
3938
3920
|
const runState = state.agentRuns.get(containerName);
|
|
3939
|
-
if (!runState
|
|
3921
|
+
if (!runState) {
|
|
3922
|
+
return false;
|
|
3923
|
+
}
|
|
3924
|
+
if (!runState.process) {
|
|
3925
|
+
// 还在准备阶段(拉起容器 / 探测 resume),进程尚未 spawn:先记下停止意图,
|
|
3926
|
+
// execAgentInWebContainerStream 拿到进程后会立刻收掉它
|
|
3927
|
+
runState.stopping = true;
|
|
3928
|
+
return true;
|
|
3929
|
+
}
|
|
3930
|
+
if (runState.process.killed) {
|
|
3940
3931
|
return false;
|
|
3941
3932
|
}
|
|
3942
3933
|
runState.stopping = true;
|
|
@@ -4041,6 +4032,7 @@ function buildSessionSummary(ctx, state, containerMap, sessionRef, preloadedHist
|
|
|
4041
4032
|
status: containerInfo.status || 'history',
|
|
4042
4033
|
defaultCommand: containerInfo.defaultCommand || ''
|
|
4043
4034
|
});
|
|
4035
|
+
const agentRunState = state.agentRuns ? state.agentRuns.get(containerName) : undefined;
|
|
4044
4036
|
const createdAt = agentSession.createdAt || containerInfo.createdAt || null;
|
|
4045
4037
|
const updatedAt = agentSession.updatedAt
|
|
4046
4038
|
|| (latestMessage && latestMessage.timestamp)
|
|
@@ -4067,6 +4059,11 @@ function buildSessionSummary(ctx, state, containerMap, sessionRef, preloadedHist
|
|
|
4067
4059
|
hostPath: applied.hostPath || '',
|
|
4068
4060
|
containerPath: applied.containerPath || '',
|
|
4069
4061
|
archived: agentSession.archived === true,
|
|
4062
|
+
// 运行锁是容器级的,界面却是 agent 级的:把"容器在忙"和"是不是自己在忙"
|
|
4063
|
+
// 都透出去,同容器的其他 AGENT 才能提前禁用输入,而不是点了发送才吃 409
|
|
4064
|
+
containerBusy: agentRunState !== undefined,
|
|
4065
|
+
agentRunning: agentRunState !== undefined
|
|
4066
|
+
&& agentRunState.sessionKey === buildWebSessionKey(containerName, agentId),
|
|
4070
4067
|
...(synthetic ? { synthetic: true } : {})
|
|
4071
4068
|
};
|
|
4072
4069
|
}
|
|
@@ -4292,123 +4289,6 @@ function renderLoginHtml(ctx) {
|
|
|
4292
4289
|
return applyServeTitle(loadTemplate('login.html'), ctx);
|
|
4293
4290
|
}
|
|
4294
4291
|
|
|
4295
|
-
// DIAG_LOG:临时诊断日志查看页,自成一个内联 HTML 字符串(不接入 frontend-shadcn
|
|
4296
|
-
// 构建流程),配合 /api/diag/logs 接口使用。排查结束后可整体删除本函数及其
|
|
4297
|
-
// 调用点(全局搜索 "DIAG_LOG")。
|
|
4298
|
-
function renderDiagLogsHtml() {
|
|
4299
|
-
return `<!DOCTYPE html>
|
|
4300
|
-
<html lang="zh-CN">
|
|
4301
|
-
<head>
|
|
4302
|
-
<meta charset="UTF-8">
|
|
4303
|
-
<title>MANYOYO 诊断日志</title>
|
|
4304
|
-
<style>
|
|
4305
|
-
body { margin: 0; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background: #0b0d12; color: #d7dae0; }
|
|
4306
|
-
header { padding: 12px 16px; display: flex; gap: 8px; flex-wrap: wrap; align-items: center; border-bottom: 1px solid #262b36; position: sticky; top: 0; background: #0b0d12; }
|
|
4307
|
-
header input, header select { background: #171a21; border: 1px solid #2c313d; color: #d7dae0; padding: 5px 8px; border-radius: 4px; font-size: 13px; }
|
|
4308
|
-
header button { background: #2b6cb0; border: none; color: #fff; padding: 6px 12px; border-radius: 4px; cursor: pointer; font-size: 13px; }
|
|
4309
|
-
header button.secondary { background: #2c313d; }
|
|
4310
|
-
header label { font-size: 12px; display: flex; align-items: center; gap: 4px; }
|
|
4311
|
-
main { padding: 8px 16px 40px; }
|
|
4312
|
-
table { width: 100%; border-collapse: collapse; font-size: 12px; }
|
|
4313
|
-
th, td { text-align: left; padding: 4px 8px; border-bottom: 1px solid #1c202a; vertical-align: top; white-space: pre-wrap; word-break: break-all; }
|
|
4314
|
-
th { position: sticky; top: 53px; background: #0b0d12; color: #8b93a3; font-weight: 600; }
|
|
4315
|
-
tr:hover { background: #12151c; }
|
|
4316
|
-
.cat { display: inline-block; padding: 1px 6px; border-radius: 3px; background: #1e2530; color: #8fd3ff; }
|
|
4317
|
-
.status-ok { color: #7ee787; }
|
|
4318
|
-
.status-err { color: #ff7b72; }
|
|
4319
|
-
.dur-slow { color: #ffa657; font-weight: 600; }
|
|
4320
|
-
#count { color: #8b93a3; font-size: 12px; }
|
|
4321
|
-
</style>
|
|
4322
|
-
</head>
|
|
4323
|
-
<body>
|
|
4324
|
-
<header>
|
|
4325
|
-
<strong>诊断日志</strong>
|
|
4326
|
-
<label>日期 <input id="f-date" type="text" placeholder="YYYY-MM-DD"></label>
|
|
4327
|
-
<label>分类 <input id="f-category" type="text" placeholder="http_request / docker_exec / stream_event / stream_exec_start / stream_exec_end"></label>
|
|
4328
|
-
<label>会话 <input id="f-session" type="text" placeholder="containerName 或 containerName~agentId"></label>
|
|
4329
|
-
<label>关键字 <input id="f-keyword" type="text" placeholder="任意文本"></label>
|
|
4330
|
-
<label>条数 <input id="f-limit" type="text" value="500" style="width:60px"></label>
|
|
4331
|
-
<button id="btn-refresh">刷新</button>
|
|
4332
|
-
<label><input id="f-auto" type="checkbox"> 每 2 秒自动刷新</label>
|
|
4333
|
-
<span id="count"></span>
|
|
4334
|
-
</header>
|
|
4335
|
-
<main>
|
|
4336
|
-
<table>
|
|
4337
|
-
<thead><tr><th>时间</th><th>分类</th><th>详情</th></tr></thead>
|
|
4338
|
-
<tbody id="rows"></tbody>
|
|
4339
|
-
</table>
|
|
4340
|
-
</main>
|
|
4341
|
-
<script>
|
|
4342
|
-
(function () {
|
|
4343
|
-
var timer = null;
|
|
4344
|
-
function fmtExtra(entry) {
|
|
4345
|
-
var e = entry.extra || {};
|
|
4346
|
-
var parts = [];
|
|
4347
|
-
if (e.method) parts.push(e.method + ' ' + (e.path || ''));
|
|
4348
|
-
if (typeof e.status === 'number') {
|
|
4349
|
-
parts.push('<span class="' + (e.status < 400 ? 'status-ok' : 'status-err') + '">status=' + e.status + '</span>');
|
|
4350
|
-
}
|
|
4351
|
-
if (typeof e.durationMs === 'number') {
|
|
4352
|
-
parts.push('<span class="' + (e.durationMs > 1000 ? 'dur-slow' : '') + '">' + e.durationMs + 'ms</span>');
|
|
4353
|
-
}
|
|
4354
|
-
if (e.session) parts.push('session=' + e.session);
|
|
4355
|
-
if (e.args) parts.push('args=' + JSON.stringify(e.args));
|
|
4356
|
-
if (e.type) parts.push('type=' + e.type);
|
|
4357
|
-
if (typeof e.seq === 'number') parts.push('seq=' + e.seq);
|
|
4358
|
-
if (typeof e.sinceStartMs === 'number') parts.push('sinceStart=' + e.sinceStartMs + 'ms');
|
|
4359
|
-
if (typeof e.totalDurationMs === 'number') parts.push('total=' + e.totalDurationMs + 'ms');
|
|
4360
|
-
if (typeof e.eventCount === 'number') parts.push('events=' + e.eventCount);
|
|
4361
|
-
if (e.error) parts.push('<span class="status-err">error=' + e.error + '</span>');
|
|
4362
|
-
var rest = {};
|
|
4363
|
-
Object.keys(e).forEach(function (k) {
|
|
4364
|
-
if (['method','path','status','durationMs','session','args','type','seq','sinceStartMs','totalDurationMs','eventCount','error'].indexOf(k) === -1) {
|
|
4365
|
-
rest[k] = e[k];
|
|
4366
|
-
}
|
|
4367
|
-
});
|
|
4368
|
-
if (Object.keys(rest).length) parts.push(JSON.stringify(rest));
|
|
4369
|
-
return parts.join(' ');
|
|
4370
|
-
}
|
|
4371
|
-
function escapeHtml(s) {
|
|
4372
|
-
return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) {
|
|
4373
|
-
return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c];
|
|
4374
|
-
});
|
|
4375
|
-
}
|
|
4376
|
-
function load() {
|
|
4377
|
-
var params = new URLSearchParams();
|
|
4378
|
-
var date = document.getElementById('f-date').value.trim();
|
|
4379
|
-
var category = document.getElementById('f-category').value.trim();
|
|
4380
|
-
var session = document.getElementById('f-session').value.trim();
|
|
4381
|
-
var keyword = document.getElementById('f-keyword').value.trim();
|
|
4382
|
-
var limit = document.getElementById('f-limit').value.trim();
|
|
4383
|
-
if (date) params.set('date', date);
|
|
4384
|
-
if (category) params.set('category', category);
|
|
4385
|
-
if (session) params.set('session', session);
|
|
4386
|
-
if (keyword) params.set('keyword', keyword);
|
|
4387
|
-
if (limit) params.set('limit', limit);
|
|
4388
|
-
fetch('/api/diag/logs?' + params.toString(), { headers: { 'X-Requested-With': 'XMLHttpRequest' } })
|
|
4389
|
-
.then(function (r) { return r.json(); })
|
|
4390
|
-
.then(function (data) {
|
|
4391
|
-
var entries = data.entries || [];
|
|
4392
|
-
document.getElementById('count').textContent = entries.length + ' 条(' + data.date + ')';
|
|
4393
|
-
var rows = entries.slice().reverse().map(function (entry) {
|
|
4394
|
-
return '<tr><td>' + escapeHtml(entry.ts) + '</td><td><span class="cat">' + escapeHtml(entry.category) + '</span></td><td>' + fmtExtra(entry) + '</td></tr>';
|
|
4395
|
-
});
|
|
4396
|
-
document.getElementById('rows').innerHTML = rows.join('');
|
|
4397
|
-
});
|
|
4398
|
-
}
|
|
4399
|
-
document.getElementById('btn-refresh').addEventListener('click', load);
|
|
4400
|
-
document.getElementById('f-auto').addEventListener('change', function (e) {
|
|
4401
|
-
if (timer) { clearInterval(timer); timer = null; }
|
|
4402
|
-
if (e.target.checked) { timer = setInterval(load, 2000); }
|
|
4403
|
-
});
|
|
4404
|
-
load();
|
|
4405
|
-
})();
|
|
4406
|
-
</script>
|
|
4407
|
-
</body>
|
|
4408
|
-
</html>
|
|
4409
|
-
`;
|
|
4410
|
-
}
|
|
4411
|
-
|
|
4412
4292
|
function toPositiveInt(value, fallback) {
|
|
4413
4293
|
const parsed = Number.parseInt(value, 10);
|
|
4414
4294
|
if (!Number.isFinite(parsed) || parsed <= 0) {
|
|
@@ -4702,23 +4582,6 @@ async function handleWebApi(req, res, pathname, ctx, state) {
|
|
|
4702
4582
|
}
|
|
4703
4583
|
}
|
|
4704
4584
|
const routes = [
|
|
4705
|
-
// DIAG_LOG:临时诊断日志查询接口,配合 /logs 页面使用,排查完成后与
|
|
4706
|
-
// diagLog/readDiagLogEntries 一起整体删除即可(全局搜索 "DIAG_LOG")。
|
|
4707
|
-
{
|
|
4708
|
-
method: 'GET',
|
|
4709
|
-
match: currentPath => currentPath === '/api/diag/logs' ? [] : null,
|
|
4710
|
-
handler: async () => {
|
|
4711
|
-
const requestUrl = new URL(req.url || '/api/diag/logs', 'http://localhost');
|
|
4712
|
-
const entries = readDiagLogEntries(ctx, {
|
|
4713
|
-
date: requestUrl.searchParams.get('date') || undefined,
|
|
4714
|
-
category: requestUrl.searchParams.get('category') || undefined,
|
|
4715
|
-
session: requestUrl.searchParams.get('session') || undefined,
|
|
4716
|
-
keyword: requestUrl.searchParams.get('keyword') || undefined,
|
|
4717
|
-
limit: Number(requestUrl.searchParams.get('limit')) || undefined
|
|
4718
|
-
});
|
|
4719
|
-
sendJson(res, 200, { date: requestUrl.searchParams.get('date') || getLocalDateTag(), entries });
|
|
4720
|
-
}
|
|
4721
|
-
},
|
|
4722
4585
|
{
|
|
4723
4586
|
method: 'GET',
|
|
4724
4587
|
match: currentPath => currentPath === '/api/fs/directories' ? [] : null,
|
|
@@ -5525,6 +5388,8 @@ async function handleWebApi(req, res, pathname, ctx, state) {
|
|
|
5525
5388
|
sendJson(res, 409, { error: '当前容器已有运行中的 agent 任务,请等它结束或先停止' });
|
|
5526
5389
|
return;
|
|
5527
5390
|
}
|
|
5391
|
+
// 紧接着检查同步占位,中间不能有 await,否则并发请求会同时穿过上面这道检查
|
|
5392
|
+
const runState = reserveWebAgentRun(state, sessionRef);
|
|
5528
5393
|
|
|
5529
5394
|
const userMessage = appendWebSessionMessage(state.webHistoryDir, sessionRef, 'user', prompt, {
|
|
5530
5395
|
mode: 'agent',
|
|
@@ -5544,6 +5409,7 @@ async function handleWebApi(req, res, pathname, ctx, state) {
|
|
|
5544
5409
|
try {
|
|
5545
5410
|
prepared = await prepareWebAgentExecution(ctx, state, sessionRef, prompt);
|
|
5546
5411
|
} catch (e) {
|
|
5412
|
+
releaseWebAgentRun(state, sessionRef.containerName, runState);
|
|
5547
5413
|
removeWebSessionMessage(state.webHistoryDir, sessionRef, traceMessage && traceMessage.id);
|
|
5548
5414
|
removeWebSessionMessage(state.webHistoryDir, sessionRef, userMessage && userMessage.id);
|
|
5549
5415
|
sendJson(res, 400, { error: e && e.message ? e.message : 'Agent 执行准备失败' });
|
|
@@ -5617,31 +5483,15 @@ async function handleWebApi(req, res, pathname, ctx, state) {
|
|
|
5617
5483
|
});
|
|
5618
5484
|
|
|
5619
5485
|
let lastPartialPersistAt = 0;
|
|
5620
|
-
const diagStreamStartedAt = Date.now(); // DIAG_LOG
|
|
5621
|
-
let diagEventSeq = 0; // DIAG_LOG
|
|
5622
|
-
diagLog(ctx, 'stream_exec_start', { // DIAG_LOG
|
|
5623
|
-
session: buildWebSessionKey(sessionRef.containerName, sessionRef.agentId),
|
|
5624
|
-
agentProgram: agentMeta.agentProgram,
|
|
5625
|
-
command
|
|
5626
|
-
});
|
|
5627
5486
|
try {
|
|
5628
5487
|
const result = await execAgentInWebContainerStream(ctx, state, sessionRef, command, {
|
|
5629
5488
|
agentProgram: agentMeta.agentProgram,
|
|
5489
|
+
// 复用路由入口占好的那把锁,而不是另起一份运行登记
|
|
5490
|
+
runState,
|
|
5630
5491
|
onEvent: event => {
|
|
5631
|
-
// token
|
|
5632
|
-
//
|
|
5492
|
+
// token 级增量:只走网线,不进事件日志,历史落盘按
|
|
5493
|
+
// AGENT_STREAM_PARTIAL_PERSIST_INTERVAL_MS 节流
|
|
5633
5494
|
const isPartialDelta = event && event.type === 'content_delta' && event.partial === true;
|
|
5634
|
-
if (!isPartialDelta) {
|
|
5635
|
-
diagEventSeq += 1; // DIAG_LOG
|
|
5636
|
-
diagLog(ctx, 'stream_event', { // DIAG_LOG
|
|
5637
|
-
session: buildWebSessionKey(sessionRef.containerName, sessionRef.agentId),
|
|
5638
|
-
seq: diagEventSeq,
|
|
5639
|
-
type: event && event.type,
|
|
5640
|
-
sinceStartMs: Date.now() - diagStreamStartedAt,
|
|
5641
|
-
textLength: event && typeof event.text === 'string' ? event.text.length : undefined,
|
|
5642
|
-
contentLength: event && typeof event.content === 'string' ? event.content.length : undefined
|
|
5643
|
-
});
|
|
5644
|
-
}
|
|
5645
5495
|
if (event && event.type === 'trace' && event.text) {
|
|
5646
5496
|
traceLines.push(String(event.text));
|
|
5647
5497
|
if (event.traceEvent && typeof event.traceEvent === 'object') {
|
|
@@ -5696,13 +5546,6 @@ async function handleWebApi(req, res, pathname, ctx, state) {
|
|
|
5696
5546
|
});
|
|
5697
5547
|
}
|
|
5698
5548
|
});
|
|
5699
|
-
diagLog(ctx, 'stream_exec_end', { // DIAG_LOG
|
|
5700
|
-
session: buildWebSessionKey(sessionRef.containerName, sessionRef.agentId),
|
|
5701
|
-
totalDurationMs: Date.now() - diagStreamStartedAt,
|
|
5702
|
-
eventCount: diagEventSeq,
|
|
5703
|
-
interrupted: result.interrupted === true,
|
|
5704
|
-
exitCode: result.exitCode
|
|
5705
|
-
});
|
|
5706
5549
|
traceLines.push(result.interrupted === true ? '[任务] 已停止' : '[任务] 已完成');
|
|
5707
5550
|
patchWebSessionMessage(state.webHistoryDir, sessionRef, userMessage && userMessage.id, {
|
|
5708
5551
|
pending: false,
|
|
@@ -5921,29 +5764,6 @@ async function startWebServer(options) {
|
|
|
5921
5764
|
throw new Error('Web 认证配置缺失,请设置 serve -U / serve -P');
|
|
5922
5765
|
}
|
|
5923
5766
|
|
|
5924
|
-
// DIAG_LOG:包一层记录每次 docker/podman 子进程调用的参数与耗时,覆盖
|
|
5925
|
-
// listWebManyoyoContainers、ensureWebContainer 等所有经由 ctx.dockerExecArgs
|
|
5926
|
-
// 发起的调用,不需要逐个调用点单独打点。
|
|
5927
|
-
if (typeof ctx.dockerExecArgs === 'function') {
|
|
5928
|
-
const rawDockerExecArgs = ctx.dockerExecArgs;
|
|
5929
|
-
ctx.dockerExecArgs = (args, execOptions) => {
|
|
5930
|
-
const startedAt = Date.now();
|
|
5931
|
-
let errorMessage = '';
|
|
5932
|
-
try {
|
|
5933
|
-
return rawDockerExecArgs(args, execOptions);
|
|
5934
|
-
} catch (e) {
|
|
5935
|
-
errorMessage = e && e.message ? e.message : String(e);
|
|
5936
|
-
throw e;
|
|
5937
|
-
} finally {
|
|
5938
|
-
diagLog(ctx, 'docker_exec', {
|
|
5939
|
-
args: Array.isArray(args) ? args : [],
|
|
5940
|
-
durationMs: Date.now() - startedAt,
|
|
5941
|
-
error: errorMessage || undefined
|
|
5942
|
-
});
|
|
5943
|
-
}
|
|
5944
|
-
};
|
|
5945
|
-
}
|
|
5946
|
-
|
|
5947
5767
|
const state = {
|
|
5948
5768
|
webHistoryDir: options.webHistoryDir || path.join(os.homedir(), '.manyoyo', 'web-history'),
|
|
5949
5769
|
webConfigPath: options.webConfigPath || getDefaultWebConfigPath(),
|
|
@@ -5973,20 +5793,6 @@ async function startWebServer(options) {
|
|
|
5973
5793
|
});
|
|
5974
5794
|
|
|
5975
5795
|
const server = http.createServer(async (req, res) => {
|
|
5976
|
-
// DIAG_LOG:记录每个 HTTP 请求从进入到响应完成(含长连接的 agent/stream,
|
|
5977
|
-
// 'finish' 在 chunked 响应整体结束时才触发)的方法/路径/状态码/耗时。
|
|
5978
|
-
const diagRequestStartedAt = Date.now();
|
|
5979
|
-
res.on('finish', () => {
|
|
5980
|
-
const rawPath = String(req.url || '').split('?')[0];
|
|
5981
|
-
const sessionMatch = rawPath.match(/^\/api\/sessions\/([^/]+)/);
|
|
5982
|
-
diagLog(ctx, 'http_request', {
|
|
5983
|
-
method: req.method,
|
|
5984
|
-
path: rawPath,
|
|
5985
|
-
status: res.statusCode,
|
|
5986
|
-
durationMs: Date.now() - diagRequestStartedAt,
|
|
5987
|
-
session: sessionMatch ? decodeURIComponent(sessionMatch[1]) : undefined
|
|
5988
|
-
});
|
|
5989
|
-
});
|
|
5990
5796
|
try {
|
|
5991
5797
|
const fallbackHost = `${formatUrlHost(ctx.serverHost)}:${ctx.serverPort}`;
|
|
5992
5798
|
const url = new URL(req.url, `http://${req.headers.host || fallbackHost}`);
|
|
@@ -6021,13 +5827,6 @@ async function startWebServer(options) {
|
|
|
6021
5827
|
return;
|
|
6022
5828
|
}
|
|
6023
5829
|
|
|
6024
|
-
// DIAG_LOG:临时诊断日志查看页,排查完成后与 renderDiagLogsHtml、
|
|
6025
|
-
// /api/diag/logs 一起整体删除即可(全局搜索 "DIAG_LOG")。
|
|
6026
|
-
if (req.method === 'GET' && pathname === '/logs') {
|
|
6027
|
-
sendHtml(res, 200, renderDiagLogsHtml());
|
|
6028
|
-
return;
|
|
6029
|
-
}
|
|
6030
|
-
|
|
6031
5830
|
const appFrontendMatch = pathname.match(/^\/app\/frontend\/([A-Za-z0-9._-]+)$/);
|
|
6032
5831
|
if (req.method === 'GET' && appFrontendMatch) {
|
|
6033
5832
|
const assetName = appFrontendMatch[1];
|