@xcanwin/manyoyo 8.7.3 → 8.7.4

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
@@ -34,6 +34,8 @@ const { domainRuleAllows } = require('../network-policy');
34
34
  const { createPortForwarder } = require('../port-forward');
35
35
  const { createContainerManageRoutes, pendingRisks, STALE_PAGE_MESSAGE } = require('./container-manage');
36
36
  const { createModelServiceRoutes } = require('./model-services');
37
+ const { createJobRegistry, gateRequest } = require('./container-jobs');
38
+ const { createTransferRoutes, createImportContainerFactory } = require('./transfer-routes');
37
39
  const { thirdPartyCodexModels } = require('../provider-presets');
38
40
  const { resolveManageOptions } = require('../container-manage-options');
39
41
  const { defaultPolicy, usesProxy } = require('../network-policy');
@@ -392,6 +394,7 @@ function normalizeWebHistoryRecord(containerName, rawData) {
392
394
  applied
393
395
  ),
394
396
  remark: normalizeRemarkText(data.remark),
397
+ pinned: data.pinned === true,
395
398
  applied,
396
399
  // 服务端内部字段,绝不能被 buildSessionSummary/buildSessionDetail/buildSessionAudit 引用或透传给前端。
397
400
  runtimeSnapshot,
@@ -3755,6 +3758,68 @@ function buildFirstEnvLines(firstResolved) {
3755
3758
  return lines;
3756
3759
  }
3757
3760
 
3761
+ // 容器正在被导出 / 导入时,摘要里带上任务进度(侧栏卡片转圈、刷新页面也看得到)
3762
+ function transferJobOf(state, containerName) {
3763
+ const job = state.jobs ? state.jobs.busyFor(containerName) : null;
3764
+ if (!job) return {};
3765
+ const view = job.view();
3766
+ return { job: { id: view.id, type: view.type, stage: view.stage, bytes: view.bytes, estimate: view.estimate } };
3767
+ }
3768
+
3769
+ // serve 被强杀后重启:恢复被导出任务暂停的容器、撤销没做完的导入(只动记录在案且 manyoyo.id 对得上的)
3770
+ function recoverTransferJobs(ctx, state) {
3771
+ try {
3772
+ // 运行时没起来时读不到容器 id,会把“暂停着的容器”误判成已不存在:保留任务记录,下次启动再试
3773
+ try {
3774
+ ctx.dockerExecArgs(['ps', '-a', '-q'], { stdio: 'pipe' });
3775
+ } catch (e) {
3776
+ ctx.logger.warn(`容器运行时暂不可用,跳过导出 / 导入任务的恢复(记录保留,下次启动再试): ${e.message}`);
3777
+ return;
3778
+ }
3779
+ const reports = state.jobs.recover({
3780
+ containerIdOf: name => resolveContainerId(ctx.dockerExecArgs, name),
3781
+ isPaused: name => {
3782
+ try {
3783
+ return /paused/i.test(String(ctx.dockerExecArgs(['inspect', '-f', '{{.State.Status}}', name], { stdio: 'pipe' }) || ''));
3784
+ } catch (e) {
3785
+ return false;
3786
+ }
3787
+ },
3788
+ unpause: name => ctx.dockerExecArgs(['unpause', name], { stdio: 'pipe' }),
3789
+ isStopped: name => {
3790
+ try {
3791
+ return /exited|stopped|created/i.test(String(ctx.dockerExecArgs(['inspect', '-f', '{{.State.Status}}', name], { stdio: 'pipe' }) || ''));
3792
+ } catch (e) {
3793
+ return false;
3794
+ }
3795
+ },
3796
+ start: name => ctx.dockerExecArgs(['start', name], { stdio: 'pipe' }),
3797
+ removeContainer: name => removeContainerIdempotent(ctx, name),
3798
+ removePath: target => fs.rmSync(target, { recursive: true, force: true })
3799
+ });
3800
+ reports.forEach(report => ctx.logger.warn(`上次的${report.type === 'export' ? '导出' : '导入'}任务被中断,已恢复:${JSON.stringify({ restored: report.restored, removed: report.removed.length })}`));
3801
+ } catch (e) {
3802
+ ctx.logger.warn(`恢复导出 / 导入任务失败: ${e.message}`);
3803
+ }
3804
+ }
3805
+
3806
+ function pickPluginRunConfig(runConfig) {
3807
+ const picked = {};
3808
+ ['plugins', 'imageName', 'imageVersion'].forEach(key => {
3809
+ if (runConfig && hasOwn(runConfig, key)) picked[key] = runConfig[key];
3810
+ });
3811
+ return picked;
3812
+ }
3813
+
3814
+ // 按快照重建 / 复制容器时没有 run 参数:按快照里记的运行配置名现读当前配置,配置已删除就当没有
3815
+ function pluginRunConfigOfSnapshot(state, snapshot) {
3816
+ const runName = snapshot && typeof snapshot.run === 'string' ? snapshot.run : '';
3817
+ if (!runName) return {};
3818
+ const parsed = readWebConfigSnapshot(state.webConfigPath);
3819
+ const runs = toPlainObject(parsed.parseError ? {} : (parsed.parsed || {}).runs);
3820
+ return hasOwn(runs, runName) ? pickPluginRunConfig(toPlainObject(runs[runName])) : {};
3821
+ }
3822
+
3758
3823
  function buildCreateRuntime(ctx, state, payload) {
3759
3824
  const body = toPlainObject(payload);
3760
3825
  const requestOptions = toPlainObject(body.createOptions);
@@ -3762,7 +3827,11 @@ function buildCreateRuntime(ctx, state, payload) {
3762
3827
  const config = snapshot.parseError ? {} : snapshot.parsed;
3763
3828
  const runs = toPlainObject(config.runs);
3764
3829
  const runName = pickFirstString(body.run);
3765
- const runConfig = runName && hasOwn(runs, runName) ? toPlainObject(runs[runName]) : {};
3830
+ // 与 CLI 一致:指定了不存在的运行配置就报错,不悄悄退回全局默认配置
3831
+ if (runName && !hasOwn(runs, runName)) {
3832
+ throw new Error(`未找到运行配置: runs.${runName}`);
3833
+ }
3834
+ const runConfig = runName ? toPlainObject(runs[runName]) : {};
3766
3835
 
3767
3836
  const hasRequestEnv = hasOwn(requestOptions, 'env');
3768
3837
  const hasRequestEnvFile = hasOwn(requestOptions, 'envFile');
@@ -3847,6 +3916,13 @@ function buildCreateRuntime(ctx, state, payload) {
3847
3916
 
3848
3917
  const containerName = resolvedBase.containerName;
3849
3918
  validateContainerNameStrict(containerName);
3919
+ // 名字被正在进行的导入占着(预留):别处不能同名新建,只有导入自己建容器时放行
3920
+ if (payload.allowReservedName !== true && state.jobs && state.jobs.busyFor(containerName)) {
3921
+ const busy = state.jobs.busyFor(containerName);
3922
+ const err = new Error(busy.type === 'import' ? `容器名 ${containerName} 正被导入使用,请换一个名字` : `容器 ${containerName} 正在导出,完成后可用`);
3923
+ err.statusCode = 409;
3924
+ throw err;
3925
+ }
3850
3926
 
3851
3927
  const hostPath = resolvedBase.hostPath;
3852
3928
  // 按模板({now} / {quickChatPath})生成的目录:此刻才建
@@ -3973,6 +4049,8 @@ function buildCreateRuntime(ctx, state, payload) {
3973
4049
  imageVersion,
3974
4050
  contModeArgs,
3975
4051
  containerExtraArgs: Array.isArray(ctx.containerExtraArgs) ? ctx.containerExtraArgs.slice() : [],
4052
+ // 只给 Playwright 插件用(runs.<名>.plugins.playwright 与 vnc 模式用的镜像),不进快照和任何响应
4053
+ pluginRunConfig: pickPluginRunConfig(runConfig),
3976
4054
  containerEnvs,
3977
4055
  envFiles,
3978
4056
  first: firstCommand ? { command: firstCommand, envLines: buildFirstEnvLines(firstResolved) } : null,
@@ -4153,6 +4231,7 @@ async function createClonedContainer(ctx, state, sourceContainerName, requestedN
4153
4231
 
4154
4232
  createOptions.containerName = finalName;
4155
4233
  const runtime = buildCreateRuntime(ctx, state, { createOptions });
4234
+ runtime.pluginRunConfig = pluginRunConfigOfSnapshot(state, history.runtimeSnapshot);
4156
4235
  await ensureWebContainer(ctx, state, runtime);
4157
4236
  setWebSessionAgentPromptCommand(state.webHistoryDir, runtime.containerName, runtime.agentPromptCommand);
4158
4237
 
@@ -4233,6 +4312,7 @@ function listWebManyoyoContainers(ctx) {
4233
4312
  // 历史 JSON(见 buildSessionSummary 的 preloadedHistory 注释)。排查这条路径
4234
4313
  // 变慢时别先怀疑 docker/podman。
4235
4314
  const defaultCommandByName = {};
4315
+ const createdByName = {};
4236
4316
  if (candidates.length) {
4237
4317
  let inspectOutput = '';
4238
4318
  try {
@@ -4241,7 +4321,7 @@ function listWebManyoyoContainers(ctx) {
4241
4321
  [
4242
4322
  'inspect',
4243
4323
  '-f',
4244
- '{{.Name}}\t{{index .Config.Labels "manyoyo.default_cmd"}}',
4324
+ '{{.Name}}\t{{index .Config.Labels "manyoyo.default_cmd"}}\t{{.Created}}',
4245
4325
  ...candidates.map(c => c.name)
4246
4326
  ],
4247
4327
  { ignoreError: true }
@@ -4251,11 +4331,14 @@ function listWebManyoyoContainers(ctx) {
4251
4331
  inspectOutput = '';
4252
4332
  }
4253
4333
  inspectOutput.split('\n').forEach(line => {
4254
- const [rawName, defaultCommand] = line.split('\t');
4334
+ const [rawName, defaultCommand, created] = line.split('\t');
4255
4335
  if (!rawName) {
4256
4336
  return;
4257
4337
  }
4258
- defaultCommandByName[rawName.replace(/^\//, '')] = (defaultCommand || '').trim();
4338
+ const key = rawName.replace(/^\//, '');
4339
+ defaultCommandByName[key] = (defaultCommand || '').trim();
4340
+ const time = new Date(String(created || '').trim());
4341
+ if (!Number.isNaN(time.getTime())) createdByName[key] = time.toISOString();
4259
4342
  });
4260
4343
  }
4261
4344
 
@@ -4265,6 +4348,8 @@ function listWebManyoyoContainers(ctx) {
4265
4348
  status: status || 'unknown',
4266
4349
  image: imageName,
4267
4350
  createdAt: estimateStartTimeFromStatus(status),
4351
+ // 容器真正的创建时间(inspect 的 Created),排序用;createdAt 是从“Up 3 hours”估的启动时间
4352
+ containerCreatedAt: createdByName[name] || null,
4268
4353
  defaultCommand: defaultCommandByName[name] || ''
4269
4354
  };
4270
4355
  });
@@ -4543,6 +4628,7 @@ async function buildWebPlaywrightIntegration(ctx, state, runtime) {
4543
4628
  }
4544
4629
  const integration = await buildContainerIntegration({
4545
4630
  globalConfig,
4631
+ runConfig: runtime.pluginRunConfig || {},
4546
4632
  homeDir: ctx.homeDir,
4547
4633
  runtimeCommand: ctx.dockerCmd || 'docker',
4548
4634
  envEntries: (runtime.containerEnvs || []).filter((_, i) => i % 2 === 1)
@@ -4585,7 +4671,9 @@ function resolveRuntimeForExistingName(ctx, state, containerName) {
4585
4671
  }
4586
4672
  const createOptions = buildCloneCreateOptionsFromSnapshot(snapshot, history);
4587
4673
  createOptions.containerName = containerName;
4588
- return buildCreateRuntime(ctx, state, { createOptions });
4674
+ const runtime = buildCreateRuntime(ctx, state, { createOptions });
4675
+ runtime.pluginRunConfig = pluginRunConfigOfSnapshot(state, snapshot);
4676
+ return runtime;
4589
4677
  }
4590
4678
 
4591
4679
  async function ensureWebContainer(ctx, state, containerInput, messageSessionRef = null) {
@@ -6666,6 +6754,9 @@ function buildSessionSummary(ctx, state, containerMap, sessionRef, preloadedHist
6666
6754
  firstPrompt: agentSession.firstPrompt || '',
6667
6755
  pinned: agentSession.pinned === true,
6668
6756
  containerRemark: history.remark || '',
6757
+ ...(transferJobOf(state, containerName)),
6758
+ containerPinned: history.pinned === true,
6759
+ containerCreatedAt: containerInfo.containerCreatedAt || null,
6669
6760
  status: containerInfo.status || 'history',
6670
6761
  image: containerInfo.image || '',
6671
6762
  createdAt,
@@ -7331,6 +7422,17 @@ async function handleWebApi(req, res, pathname, ctx, state) {
7331
7422
  return true;
7332
7423
  }
7333
7424
  }
7425
+ // 导出 / 导入期间,这些容器的聊天、终端、文件、设置、删除一律 409(只放行读消息和详情)
7426
+ // 清理历史 / 工作目录的接口不带容器名:有任务在跑就一律拒绝(可能删掉正在导出的历史或刚建好的导入目录)
7427
+ if (state.jobs && state.jobs.hasRunning() && req.method !== 'GET' && req.method !== 'HEAD' && pathname.startsWith('/api/storage/')) {
7428
+ sendJson(res, 409, { error: '正在导出或导入,完成后再清理', busy: 'transfer' });
7429
+ return true;
7430
+ }
7431
+ const gateVerdict = state.jobs ? gateRequest(state.jobs, req.method, pathname) : null;
7432
+ if (gateVerdict) {
7433
+ sendJson(res, gateVerdict.status, gateVerdict.body);
7434
+ return true;
7435
+ }
7334
7436
  const routes = [
7335
7437
  {
7336
7438
  method: 'GET',
@@ -7883,6 +7985,14 @@ async function handleWebApi(req, res, pathname, ctx, state) {
7883
7985
  sendJson(res, 400, { error: 'run 不能为空' });
7884
7986
  return;
7885
7987
  }
7988
+ {
7989
+ const configSnapshot = readWebConfigSnapshot(state.webConfigPath);
7990
+ const knownRuns = toPlainObject(configSnapshot.parseError ? {} : (configSnapshot.parsed || {}).runs);
7991
+ if (!hasOwn(knownRuns, quickChatRun)) {
7992
+ sendJson(res, 400, { error: `未找到运行配置: runs.${quickChatRun}` });
7993
+ return;
7994
+ }
7995
+ }
7886
7996
  const resolvedPath = expandHomeAliasPath(quickChatPath);
7887
7997
  const homeDir = process.env.HOME || os.homedir() || '/home';
7888
7998
  if (resolvedPath === '/' || resolvedPath === homeDir) {
@@ -7945,7 +8055,7 @@ async function handleWebApi(req, res, pathname, ctx, state) {
7945
8055
  try {
7946
8056
  runtime = buildCreateRuntime(ctx, state, payload);
7947
8057
  } catch (e) {
7948
- sendJson(res, 400, { error: e.message || '创建参数错误' });
8058
+ sendJson(res, e.statusCode || 400, { error: e.message || '创建参数错误' });
7949
8059
  return;
7950
8060
  }
7951
8061
 
@@ -8051,6 +8161,24 @@ async function handleWebApi(req, res, pathname, ctx, state) {
8051
8161
  sendJson(res, 200, { containerName: sessionRef.containerName, remark });
8052
8162
  }
8053
8163
  },
8164
+ {
8165
+ method: 'POST',
8166
+ match: currentPath => currentPath.match(/^\/api\/sessions\/([^/]+)\/container-pin$/),
8167
+ handler: async match => {
8168
+ const sessionRef = getValidSessionRef(ctx, res, match[1]);
8169
+ if (!sessionRef) {
8170
+ return;
8171
+ }
8172
+ if (sessionRef.agentId !== WEB_DEFAULT_AGENT_ID) {
8173
+ sendJson(res, 400, { error: '该操作需在容器层级执行' });
8174
+ return;
8175
+ }
8176
+ const payload = await readJsonBody(req);
8177
+ const pinned = payload.pinned === true;
8178
+ patchWebSessionHistory(state.webHistoryDir, sessionRef.containerName, { pinned });
8179
+ sendJson(res, 200, { containerName: sessionRef.containerName, pinned });
8180
+ }
8181
+ },
8054
8182
  {
8055
8183
  method: 'POST',
8056
8184
  match: currentPath => currentPath.match(/^\/api\/sessions\/([^/]+)\/agent-remark$/),
@@ -9479,6 +9607,63 @@ async function handleWebApi(req, res, pathname, ctx, state) {
9479
9607
  }
9480
9608
  }));
9481
9609
 
9610
+ routes.push(...createTransferRoutes({
9611
+ ctx,
9612
+ state,
9613
+ req,
9614
+ res,
9615
+ deps: {
9616
+ sendJson,
9617
+ readJsonBody,
9618
+ containerState,
9619
+ version: require('../../package.json').version,
9620
+ loadHistory: name => loadWebSessionHistory(state.webHistoryDir, name),
9621
+ historyExists: name => listWebHistorySessionNames(state.webHistoryDir, ctx.isValidContainerName).includes(name),
9622
+ listContainerNames: () => Object.keys(listWebManyoyoContainers(ctx)),
9623
+ listHistoryNames: () => listWebHistorySessionNames(state.webHistoryDir, ctx.isValidContainerName),
9624
+ quickChatPath: () => {
9625
+ const snapshot = readWebConfigSnapshot(state.webConfigPath);
9626
+ return resolveQuickChatPath(snapshot.parseError ? {} : snapshot.parsed, { homeDir: ctx.homeDir || os.homedir(), defaultDir: getSetupWorkpath(state) });
9627
+ },
9628
+ createImportedContainer: createImportContainerFactory({
9629
+ ctx,
9630
+ state,
9631
+ buildCreateRuntime,
9632
+ ensureWebContainer,
9633
+ resolveContainerId,
9634
+ containerState,
9635
+ patchHistory: (name, patch) => patchWebSessionHistory(state.webHistoryDir, name, patch)
9636
+ }),
9637
+ removeCreatedContainer: async (name, id) => {
9638
+ if (resolveContainerId(ctx.dockerExecArgs, name) === id) removeContainerIdempotent(ctx, name);
9639
+ removeWebSessionHistory(state.webHistoryDir, name);
9640
+ },
9641
+ saveImportedHistory: async (name, archived, info) => {
9642
+ const current = loadWebSessionHistory(state.webHistoryDir, name);
9643
+ const source = normalizeWebHistoryRecord(name, archived);
9644
+ const agents = cloneWebAgentSessionsDeep(source);
9645
+ Object.values(agents).forEach(agent => {
9646
+ // 没带 Agent 会话记录时不能留着续接标记:服务端不检查会话文件在不在,第一轮必定失败
9647
+ if (!info.hasSessions) {
9648
+ agent.engineSessionId = '';
9649
+ agent.lastResumeAt = null;
9650
+ agent.lastResumeOk = null;
9651
+ agent.lastResumeError = '';
9652
+ }
9653
+ // 导入前排着的提示词不要在新环境里自己跑起来
9654
+ if (Array.isArray(agent.queue) && agent.queue.length) agent.queuePaused = true;
9655
+ });
9656
+ saveWebSessionHistory(state.webHistoryDir, name, {
9657
+ ...current,
9658
+ // 导入时用户填了备注就用它(空串表示清掉),没填沿用归档里的
9659
+ remark: typeof info.remark === 'string' ? normalizeRemarkText(info.remark) : source.remark,
9660
+ pinned: source.pinned,
9661
+ agents
9662
+ });
9663
+ }
9664
+ }
9665
+ }));
9666
+
9482
9667
  routes.push(...createContainerManageRoutes({
9483
9668
  ctx,
9484
9669
  state,
@@ -9553,6 +9738,9 @@ async function startWebServer(options) {
9553
9738
  containerExtraArgs: options.containerExtraArgs,
9554
9739
  containerEnvs: options.containerEnvs,
9555
9740
  networkManager: options.networkManager,
9741
+ // 导出 / 导入用的容器运行时封装(默认走命令行 docker / podman),测试里可替换
9742
+ transferRuntime: options.transferRuntime,
9743
+ statfsSync: options.statfsSync,
9556
9744
  envFiles: options.envFiles,
9557
9745
  containerVolumes: options.containerVolumes,
9558
9746
  containerPorts: options.containerPorts,
@@ -9613,6 +9801,7 @@ async function startWebServer(options) {
9613
9801
  warn: message => ctx.logger.warn(message)
9614
9802
  }),
9615
9803
  terminalSessions: new Map(),
9804
+ jobs: createJobRegistry({ homeDir: ctx.homeDir || os.homedir() }),
9616
9805
  agentRuns: new Map(),
9617
9806
  // imageRef -> { status: 'pulling'|'failed', message, promise }
9618
9807
  imagePulls: new Map(),
@@ -9620,6 +9809,16 @@ async function startWebServer(options) {
9620
9809
  };
9621
9810
 
9622
9811
  ensureWebHistoryDir(state.webHistoryDir);
9812
+ recoverTransferJobs(ctx, state);
9813
+ // 进程正常退出(SIGTERM / SIGINT 走 process.exit)时同步恢复被导出任务暂停的容器;被强杀的由下次启动的 recoverTransferJobs 兜底
9814
+ const unpauseOnExit = () => {
9815
+ for (const job of state.jobs.running()) {
9816
+ for (const item of job.paused.slice()) {
9817
+ try { ctx.dockerExecArgs([item.how === 'stop' ? 'start' : 'unpause', item.name], { stdio: 'pipe' }); } catch (e) { /* 下次启动恢复 */ }
9818
+ }
9819
+ }
9820
+ };
9821
+ process.on('exit', unpauseOnExit);
9623
9822
 
9624
9823
  const wsServer = new WebSocket.Server({
9625
9824
  noServer: true,
@@ -9639,7 +9838,9 @@ async function startWebServer(options) {
9639
9838
  bindTerminalWebSocket(ctx, state, ws, containerName, cols, rows);
9640
9839
  });
9641
9840
 
9642
- const server = http.createServer(async (req, res) => {
9841
+ // 导入是一次很长的上传请求:Node 默认 300 秒的整请求超时会把慢链路上的大文件截断。
9842
+ // 请求头仍受 headersTimeout 约束;未登录的请求在读完请求头后立刻 401,不会占着连接等请求体
9843
+ const server = http.createServer({ requestTimeout: 0 }, async (req, res) => {
9643
9844
  // 身份标记:无参启动器复用实例前用它确认端口上跑的确实是 manyoyo serve(未登录的 401 也带);不含任何版本或本机信息
9644
9845
  res.setHeader('X-Manyoyo-Serve', '1');
9645
9846
  try {
@@ -9762,6 +9963,11 @@ async function startWebServer(options) {
9762
9963
  return;
9763
9964
  }
9764
9965
 
9966
+ if (state.jobs.busyFor(sessionRef.containerName)) {
9967
+ sendWebSocketUpgradeError(socket, 409, 'BUSY_EXPORT_IMPORT');
9968
+ return;
9969
+ }
9970
+
9765
9971
  if (state.terminalSessions.size >= WEB_TERMINAL_MAX_SESSIONS) {
9766
9972
  sendWebSocketUpgradeError(socket, 429, 'TERMINAL_LIMIT_REACHED');
9767
9973
  return;
@@ -9842,6 +10048,9 @@ async function startWebServer(options) {
9842
10048
  port: listenPort,
9843
10049
  close: () => new Promise(resolve => {
9844
10050
  ctx.logger.info('web server closing');
10051
+ process.removeListener('exit', unpauseOnExit);
10052
+ // 关服务时取消还在跑的导出 / 导入任务(导出会在取消时恢复暂停的容器)
10053
+ state.jobs.running().forEach(job => { job.cancelRequested = true; });
9845
10054
  flushWebSessionHistories();
9846
10055
  state.authStore.close();
9847
10056
  state.orphanTimers.forEach(timer => clearInterval(timer));