@xcanwin/manyoyo 7.0.15 → 7.0.16

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/server.js CHANGED
@@ -30,6 +30,82 @@ 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
+
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)
33
109
 
34
110
  const WEB_HISTORY_MAX_MESSAGES = 500;
35
111
  const WEB_OUTPUT_MAX_CHARS = 16000;
@@ -3089,6 +3165,7 @@ function listWebManyoyoContainers(ctx) {
3089
3165
  return map;
3090
3166
  }
3091
3167
 
3168
+ const candidates = [];
3092
3169
  output.trim().split('\n').forEach(line => {
3093
3170
  const [name, status, image] = line.split('\t');
3094
3171
  if (!ctx.isValidContainerName(name)) {
@@ -3098,23 +3175,46 @@ function listWebManyoyoContainers(ctx) {
3098
3175
  if (!imageName.includes('manyoyo') && !name.startsWith('manyoyo-') && !name.startsWith('my-')) {
3099
3176
  return;
3100
3177
  }
3101
- let defaultCommand = '';
3178
+ candidates.push({ name, status, imageName });
3179
+ });
3180
+
3181
+ // 逐容器单独 docker inspect 是 N+1 同步阻塞调用(spawnSync 无 timeout,会
3182
+ // 独占 Node 单线程事件循环);容器一多,serve 所有接口(包括正在流式输出
3183
+ // 的 agent/stream)都会被拖慢甚至冻结。这里合并成一次批量 inspect。
3184
+ const defaultCommandByName = {};
3185
+ if (candidates.length) {
3186
+ let inspectOutput = '';
3102
3187
  try {
3103
- defaultCommand = String(
3188
+ inspectOutput = String(
3104
3189
  ctx.dockerExecArgs(
3105
- ['inspect', '-f', '{{index .Config.Labels "manyoyo.default_cmd"}}', name],
3190
+ [
3191
+ 'inspect',
3192
+ '-f',
3193
+ '{{.Name}}\t{{index .Config.Labels "manyoyo.default_cmd"}}',
3194
+ ...candidates.map(c => c.name)
3195
+ ],
3106
3196
  { ignoreError: true }
3107
3197
  ) || ''
3108
- ).trim();
3198
+ );
3109
3199
  } catch (e) {
3110
- defaultCommand = '';
3200
+ inspectOutput = '';
3111
3201
  }
3202
+ inspectOutput.split('\n').forEach(line => {
3203
+ const [rawName, defaultCommand] = line.split('\t');
3204
+ if (!rawName) {
3205
+ return;
3206
+ }
3207
+ defaultCommandByName[rawName.replace(/^\//, '')] = (defaultCommand || '').trim();
3208
+ });
3209
+ }
3210
+
3211
+ candidates.forEach(({ name, status, imageName }) => {
3112
3212
  map[name] = {
3113
3213
  name,
3114
3214
  status: status || 'unknown',
3115
3215
  image: imageName,
3116
3216
  createdAt: estimateStartTimeFromStatus(status),
3117
- defaultCommand
3217
+ defaultCommand: defaultCommandByName[name] || ''
3118
3218
  };
3119
3219
  });
3120
3220
 
@@ -4144,6 +4244,123 @@ function renderLoginHtml(ctx) {
4144
4244
  return applyServeTitle(loadTemplate('login.html'), ctx);
4145
4245
  }
4146
4246
 
4247
+ // DIAG_LOG:临时诊断日志查看页,自成一个内联 HTML 字符串(不接入 frontend-shadcn
4248
+ // 构建流程),配合 /api/diag/logs 接口使用。排查结束后可整体删除本函数及其
4249
+ // 调用点(全局搜索 "DIAG_LOG")。
4250
+ function renderDiagLogsHtml() {
4251
+ return `<!DOCTYPE html>
4252
+ <html lang="zh-CN">
4253
+ <head>
4254
+ <meta charset="UTF-8">
4255
+ <title>MANYOYO 诊断日志</title>
4256
+ <style>
4257
+ body { margin: 0; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background: #0b0d12; color: #d7dae0; }
4258
+ 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; }
4259
+ header input, header select { background: #171a21; border: 1px solid #2c313d; color: #d7dae0; padding: 5px 8px; border-radius: 4px; font-size: 13px; }
4260
+ header button { background: #2b6cb0; border: none; color: #fff; padding: 6px 12px; border-radius: 4px; cursor: pointer; font-size: 13px; }
4261
+ header button.secondary { background: #2c313d; }
4262
+ header label { font-size: 12px; display: flex; align-items: center; gap: 4px; }
4263
+ main { padding: 8px 16px 40px; }
4264
+ table { width: 100%; border-collapse: collapse; font-size: 12px; }
4265
+ th, td { text-align: left; padding: 4px 8px; border-bottom: 1px solid #1c202a; vertical-align: top; white-space: pre-wrap; word-break: break-all; }
4266
+ th { position: sticky; top: 53px; background: #0b0d12; color: #8b93a3; font-weight: 600; }
4267
+ tr:hover { background: #12151c; }
4268
+ .cat { display: inline-block; padding: 1px 6px; border-radius: 3px; background: #1e2530; color: #8fd3ff; }
4269
+ .status-ok { color: #7ee787; }
4270
+ .status-err { color: #ff7b72; }
4271
+ .dur-slow { color: #ffa657; font-weight: 600; }
4272
+ #count { color: #8b93a3; font-size: 12px; }
4273
+ </style>
4274
+ </head>
4275
+ <body>
4276
+ <header>
4277
+ <strong>诊断日志</strong>
4278
+ <label>日期 <input id="f-date" type="text" placeholder="YYYY-MM-DD"></label>
4279
+ <label>分类 <input id="f-category" type="text" placeholder="http_request / docker_exec / stream_event / stream_exec_start / stream_exec_end"></label>
4280
+ <label>会话 <input id="f-session" type="text" placeholder="containerName 或 containerName~agentId"></label>
4281
+ <label>关键字 <input id="f-keyword" type="text" placeholder="任意文本"></label>
4282
+ <label>条数 <input id="f-limit" type="text" value="500" style="width:60px"></label>
4283
+ <button id="btn-refresh">刷新</button>
4284
+ <label><input id="f-auto" type="checkbox"> 每 2 秒自动刷新</label>
4285
+ <span id="count"></span>
4286
+ </header>
4287
+ <main>
4288
+ <table>
4289
+ <thead><tr><th>时间</th><th>分类</th><th>详情</th></tr></thead>
4290
+ <tbody id="rows"></tbody>
4291
+ </table>
4292
+ </main>
4293
+ <script>
4294
+ (function () {
4295
+ var timer = null;
4296
+ function fmtExtra(entry) {
4297
+ var e = entry.extra || {};
4298
+ var parts = [];
4299
+ if (e.method) parts.push(e.method + ' ' + (e.path || ''));
4300
+ if (typeof e.status === 'number') {
4301
+ parts.push('<span class="' + (e.status < 400 ? 'status-ok' : 'status-err') + '">status=' + e.status + '</span>');
4302
+ }
4303
+ if (typeof e.durationMs === 'number') {
4304
+ parts.push('<span class="' + (e.durationMs > 1000 ? 'dur-slow' : '') + '">' + e.durationMs + 'ms</span>');
4305
+ }
4306
+ if (e.session) parts.push('session=' + e.session);
4307
+ if (e.args) parts.push('args=' + JSON.stringify(e.args));
4308
+ if (e.type) parts.push('type=' + e.type);
4309
+ if (typeof e.seq === 'number') parts.push('seq=' + e.seq);
4310
+ if (typeof e.sinceStartMs === 'number') parts.push('sinceStart=' + e.sinceStartMs + 'ms');
4311
+ if (typeof e.totalDurationMs === 'number') parts.push('total=' + e.totalDurationMs + 'ms');
4312
+ if (typeof e.eventCount === 'number') parts.push('events=' + e.eventCount);
4313
+ if (e.error) parts.push('<span class="status-err">error=' + e.error + '</span>');
4314
+ var rest = {};
4315
+ Object.keys(e).forEach(function (k) {
4316
+ if (['method','path','status','durationMs','session','args','type','seq','sinceStartMs','totalDurationMs','eventCount','error'].indexOf(k) === -1) {
4317
+ rest[k] = e[k];
4318
+ }
4319
+ });
4320
+ if (Object.keys(rest).length) parts.push(JSON.stringify(rest));
4321
+ return parts.join(' ');
4322
+ }
4323
+ function escapeHtml(s) {
4324
+ return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) {
4325
+ return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c];
4326
+ });
4327
+ }
4328
+ function load() {
4329
+ var params = new URLSearchParams();
4330
+ var date = document.getElementById('f-date').value.trim();
4331
+ var category = document.getElementById('f-category').value.trim();
4332
+ var session = document.getElementById('f-session').value.trim();
4333
+ var keyword = document.getElementById('f-keyword').value.trim();
4334
+ var limit = document.getElementById('f-limit').value.trim();
4335
+ if (date) params.set('date', date);
4336
+ if (category) params.set('category', category);
4337
+ if (session) params.set('session', session);
4338
+ if (keyword) params.set('keyword', keyword);
4339
+ if (limit) params.set('limit', limit);
4340
+ fetch('/api/diag/logs?' + params.toString(), { headers: { 'X-Requested-With': 'XMLHttpRequest' } })
4341
+ .then(function (r) { return r.json(); })
4342
+ .then(function (data) {
4343
+ var entries = data.entries || [];
4344
+ document.getElementById('count').textContent = entries.length + ' 条(' + data.date + ')';
4345
+ var rows = entries.slice().reverse().map(function (entry) {
4346
+ return '<tr><td>' + escapeHtml(entry.ts) + '</td><td><span class="cat">' + escapeHtml(entry.category) + '</span></td><td>' + fmtExtra(entry) + '</td></tr>';
4347
+ });
4348
+ document.getElementById('rows').innerHTML = rows.join('');
4349
+ });
4350
+ }
4351
+ document.getElementById('btn-refresh').addEventListener('click', load);
4352
+ document.getElementById('f-auto').addEventListener('change', function (e) {
4353
+ if (timer) { clearInterval(timer); timer = null; }
4354
+ if (e.target.checked) { timer = setInterval(load, 2000); }
4355
+ });
4356
+ load();
4357
+ })();
4358
+ </script>
4359
+ </body>
4360
+ </html>
4361
+ `;
4362
+ }
4363
+
4147
4364
  function toPositiveInt(value, fallback) {
4148
4365
  const parsed = Number.parseInt(value, 10);
4149
4366
  if (!Number.isFinite(parsed) || parsed <= 0) {
@@ -4437,6 +4654,23 @@ async function handleWebApi(req, res, pathname, ctx, state) {
4437
4654
  }
4438
4655
  }
4439
4656
  const routes = [
4657
+ // DIAG_LOG:临时诊断日志查询接口,配合 /logs 页面使用,排查完成后与
4658
+ // diagLog/readDiagLogEntries 一起整体删除即可(全局搜索 "DIAG_LOG")。
4659
+ {
4660
+ method: 'GET',
4661
+ match: currentPath => currentPath === '/api/diag/logs' ? [] : null,
4662
+ handler: async () => {
4663
+ const requestUrl = new URL(req.url || '/api/diag/logs', 'http://localhost');
4664
+ const entries = readDiagLogEntries(ctx, {
4665
+ date: requestUrl.searchParams.get('date') || undefined,
4666
+ category: requestUrl.searchParams.get('category') || undefined,
4667
+ session: requestUrl.searchParams.get('session') || undefined,
4668
+ keyword: requestUrl.searchParams.get('keyword') || undefined,
4669
+ limit: Number(requestUrl.searchParams.get('limit')) || undefined
4670
+ });
4671
+ sendJson(res, 200, { date: requestUrl.searchParams.get('date') || getLocalDateTag(), entries });
4672
+ }
4673
+ },
4440
4674
  {
4441
4675
  method: 'GET',
4442
4676
  match: currentPath => currentPath === '/api/fs/directories' ? [] : null,
@@ -5311,10 +5545,26 @@ async function handleWebApi(req, res, pathname, ctx, state) {
5311
5545
  pending: true
5312
5546
  });
5313
5547
 
5548
+ const diagStreamStartedAt = Date.now(); // DIAG_LOG
5549
+ let diagEventSeq = 0; // DIAG_LOG
5550
+ diagLog(ctx, 'stream_exec_start', { // DIAG_LOG
5551
+ session: buildWebSessionKey(sessionRef.containerName, sessionRef.agentId),
5552
+ agentProgram: agentMeta.agentProgram,
5553
+ command
5554
+ });
5314
5555
  try {
5315
5556
  const result = await execAgentInWebContainerStream(ctx, state, sessionRef, command, {
5316
5557
  agentProgram: agentMeta.agentProgram,
5317
5558
  onEvent: event => {
5559
+ diagEventSeq += 1; // DIAG_LOG
5560
+ diagLog(ctx, 'stream_event', { // DIAG_LOG
5561
+ session: buildWebSessionKey(sessionRef.containerName, sessionRef.agentId),
5562
+ seq: diagEventSeq,
5563
+ type: event && event.type,
5564
+ sinceStartMs: Date.now() - diagStreamStartedAt,
5565
+ textLength: event && typeof event.text === 'string' ? event.text.length : undefined,
5566
+ contentLength: event && typeof event.content === 'string' ? event.content.length : undefined
5567
+ });
5318
5568
  if (event && event.type === 'trace' && event.text) {
5319
5569
  traceLines.push(String(event.text));
5320
5570
  if (event.traceEvent && typeof event.traceEvent === 'object') {
@@ -5355,6 +5605,13 @@ async function handleWebApi(req, res, pathname, ctx, state) {
5355
5605
  });
5356
5606
  }
5357
5607
  });
5608
+ diagLog(ctx, 'stream_exec_end', { // DIAG_LOG
5609
+ session: buildWebSessionKey(sessionRef.containerName, sessionRef.agentId),
5610
+ totalDurationMs: Date.now() - diagStreamStartedAt,
5611
+ eventCount: diagEventSeq,
5612
+ interrupted: result.interrupted === true,
5613
+ exitCode: result.exitCode
5614
+ });
5358
5615
  traceLines.push(result.interrupted === true ? '[任务] 已停止' : '[任务] 已完成');
5359
5616
  patchWebSessionMessage(state.webHistoryDir, sessionRef, userMessage && userMessage.id, {
5360
5617
  pending: false,
@@ -5568,6 +5825,29 @@ async function startWebServer(options) {
5568
5825
  throw new Error('Web 认证配置缺失,请设置 serve -U / serve -P');
5569
5826
  }
5570
5827
 
5828
+ // DIAG_LOG:包一层记录每次 docker/podman 子进程调用的参数与耗时,覆盖
5829
+ // listWebManyoyoContainers、ensureWebContainer 等所有经由 ctx.dockerExecArgs
5830
+ // 发起的调用,不需要逐个调用点单独打点。
5831
+ if (typeof ctx.dockerExecArgs === 'function') {
5832
+ const rawDockerExecArgs = ctx.dockerExecArgs;
5833
+ ctx.dockerExecArgs = (args, execOptions) => {
5834
+ const startedAt = Date.now();
5835
+ let errorMessage = '';
5836
+ try {
5837
+ return rawDockerExecArgs(args, execOptions);
5838
+ } catch (e) {
5839
+ errorMessage = e && e.message ? e.message : String(e);
5840
+ throw e;
5841
+ } finally {
5842
+ diagLog(ctx, 'docker_exec', {
5843
+ args: Array.isArray(args) ? args : [],
5844
+ durationMs: Date.now() - startedAt,
5845
+ error: errorMessage || undefined
5846
+ });
5847
+ }
5848
+ };
5849
+ }
5850
+
5571
5851
  const state = {
5572
5852
  webHistoryDir: options.webHistoryDir || path.join(os.homedir(), '.manyoyo', 'web-history'),
5573
5853
  webConfigPath: options.webConfigPath || getDefaultWebConfigPath(),
@@ -5597,6 +5877,20 @@ async function startWebServer(options) {
5597
5877
  });
5598
5878
 
5599
5879
  const server = http.createServer(async (req, res) => {
5880
+ // DIAG_LOG:记录每个 HTTP 请求从进入到响应完成(含长连接的 agent/stream,
5881
+ // 'finish' 在 chunked 响应整体结束时才触发)的方法/路径/状态码/耗时。
5882
+ const diagRequestStartedAt = Date.now();
5883
+ res.on('finish', () => {
5884
+ const rawPath = String(req.url || '').split('?')[0];
5885
+ const sessionMatch = rawPath.match(/^\/api\/sessions\/([^/]+)/);
5886
+ diagLog(ctx, 'http_request', {
5887
+ method: req.method,
5888
+ path: rawPath,
5889
+ status: res.statusCode,
5890
+ durationMs: Date.now() - diagRequestStartedAt,
5891
+ session: sessionMatch ? decodeURIComponent(sessionMatch[1]) : undefined
5892
+ });
5893
+ });
5600
5894
  try {
5601
5895
  const fallbackHost = `${formatUrlHost(ctx.serverHost)}:${ctx.serverPort}`;
5602
5896
  const url = new URL(req.url, `http://${req.headers.host || fallbackHost}`);
@@ -5631,6 +5925,13 @@ async function startWebServer(options) {
5631
5925
  return;
5632
5926
  }
5633
5927
 
5928
+ // DIAG_LOG:临时诊断日志查看页,排查完成后与 renderDiagLogsHtml、
5929
+ // /api/diag/logs 一起整体删除即可(全局搜索 "DIAG_LOG")。
5930
+ if (req.method === 'GET' && pathname === '/logs') {
5931
+ sendHtml(res, 200, renderDiagLogsHtml());
5932
+ return;
5933
+ }
5934
+
5634
5935
  const appFrontendMatch = pathname.match(/^\/app\/frontend\/([A-Za-z0-9._-]+)$/);
5635
5936
  if (req.method === 'GET' && appFrontendMatch) {
5636
5937
  const assetName = appFrontendMatch[1];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xcanwin/manyoyo",
3
- "version": "7.0.15",
3
+ "version": "7.0.16",
4
4
  "imageVersion": "1.9.1-common",
5
5
  "playwrightCliVersion": "0.1.18",
6
6
  "description": "AI Agent CLI Security Sandbox for Docker and Podman",