@xcanwin/manyoyo 8.7.2 → 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) {
@@ -6372,6 +6460,23 @@ async function loadStorageListing(ctx, state) {
6372
6460
  ...getStorageRootsConfig(ctx, state),
6373
6461
  historyDir: state.webHistoryDir
6374
6462
  });
6463
+ // 关联容器带上容器备注(备注存在各容器的历史文件里)
6464
+ for (const row of listing.rows) {
6465
+ for (const session of row.sessions) {
6466
+ try {
6467
+ session.remark = loadWebSessionHistory(state.webHistoryDir, session.name).remark || '';
6468
+ } catch (e) {
6469
+ session.remark = '';
6470
+ }
6471
+ }
6472
+ }
6473
+ for (const item of listing.deletedHistories) {
6474
+ try {
6475
+ item.remark = loadWebSessionHistory(state.webHistoryDir, item.name).remark || '';
6476
+ } catch (e) {
6477
+ item.remark = '';
6478
+ }
6479
+ }
6375
6480
  state.storageKnownPaths = new Set(listing.rows.map(row => row.path));
6376
6481
  return listing;
6377
6482
  }
@@ -6649,6 +6754,9 @@ function buildSessionSummary(ctx, state, containerMap, sessionRef, preloadedHist
6649
6754
  firstPrompt: agentSession.firstPrompt || '',
6650
6755
  pinned: agentSession.pinned === true,
6651
6756
  containerRemark: history.remark || '',
6757
+ ...(transferJobOf(state, containerName)),
6758
+ containerPinned: history.pinned === true,
6759
+ containerCreatedAt: containerInfo.containerCreatedAt || null,
6652
6760
  status: containerInfo.status || 'history',
6653
6761
  image: containerInfo.image || '',
6654
6762
  createdAt,
@@ -7314,6 +7422,17 @@ async function handleWebApi(req, res, pathname, ctx, state) {
7314
7422
  return true;
7315
7423
  }
7316
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
+ }
7317
7436
  const routes = [
7318
7437
  {
7319
7438
  method: 'GET',
@@ -7866,6 +7985,14 @@ async function handleWebApi(req, res, pathname, ctx, state) {
7866
7985
  sendJson(res, 400, { error: 'run 不能为空' });
7867
7986
  return;
7868
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
+ }
7869
7996
  const resolvedPath = expandHomeAliasPath(quickChatPath);
7870
7997
  const homeDir = process.env.HOME || os.homedir() || '/home';
7871
7998
  if (resolvedPath === '/' || resolvedPath === homeDir) {
@@ -7928,7 +8055,7 @@ async function handleWebApi(req, res, pathname, ctx, state) {
7928
8055
  try {
7929
8056
  runtime = buildCreateRuntime(ctx, state, payload);
7930
8057
  } catch (e) {
7931
- sendJson(res, 400, { error: e.message || '创建参数错误' });
8058
+ sendJson(res, e.statusCode || 400, { error: e.message || '创建参数错误' });
7932
8059
  return;
7933
8060
  }
7934
8061
 
@@ -8034,6 +8161,24 @@ async function handleWebApi(req, res, pathname, ctx, state) {
8034
8161
  sendJson(res, 200, { containerName: sessionRef.containerName, remark });
8035
8162
  }
8036
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
+ },
8037
8182
  {
8038
8183
  method: 'POST',
8039
8184
  match: currentPath => currentPath.match(/^\/api\/sessions\/([^/]+)\/agent-remark$/),
@@ -9462,6 +9607,63 @@ async function handleWebApi(req, res, pathname, ctx, state) {
9462
9607
  }
9463
9608
  }));
9464
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
+
9465
9667
  routes.push(...createContainerManageRoutes({
9466
9668
  ctx,
9467
9669
  state,
@@ -9536,6 +9738,9 @@ async function startWebServer(options) {
9536
9738
  containerExtraArgs: options.containerExtraArgs,
9537
9739
  containerEnvs: options.containerEnvs,
9538
9740
  networkManager: options.networkManager,
9741
+ // 导出 / 导入用的容器运行时封装(默认走命令行 docker / podman),测试里可替换
9742
+ transferRuntime: options.transferRuntime,
9743
+ statfsSync: options.statfsSync,
9539
9744
  envFiles: options.envFiles,
9540
9745
  containerVolumes: options.containerVolumes,
9541
9746
  containerPorts: options.containerPorts,
@@ -9596,6 +9801,7 @@ async function startWebServer(options) {
9596
9801
  warn: message => ctx.logger.warn(message)
9597
9802
  }),
9598
9803
  terminalSessions: new Map(),
9804
+ jobs: createJobRegistry({ homeDir: ctx.homeDir || os.homedir() }),
9599
9805
  agentRuns: new Map(),
9600
9806
  // imageRef -> { status: 'pulling'|'failed', message, promise }
9601
9807
  imagePulls: new Map(),
@@ -9603,6 +9809,16 @@ async function startWebServer(options) {
9603
9809
  };
9604
9810
 
9605
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);
9606
9822
 
9607
9823
  const wsServer = new WebSocket.Server({
9608
9824
  noServer: true,
@@ -9622,7 +9838,9 @@ async function startWebServer(options) {
9622
9838
  bindTerminalWebSocket(ctx, state, ws, containerName, cols, rows);
9623
9839
  });
9624
9840
 
9625
- const server = http.createServer(async (req, res) => {
9841
+ // 导入是一次很长的上传请求:Node 默认 300 秒的整请求超时会把慢链路上的大文件截断。
9842
+ // 请求头仍受 headersTimeout 约束;未登录的请求在读完请求头后立刻 401,不会占着连接等请求体
9843
+ const server = http.createServer({ requestTimeout: 0 }, async (req, res) => {
9626
9844
  // 身份标记:无参启动器复用实例前用它确认端口上跑的确实是 manyoyo serve(未登录的 401 也带);不含任何版本或本机信息
9627
9845
  res.setHeader('X-Manyoyo-Serve', '1');
9628
9846
  try {
@@ -9745,6 +9963,11 @@ async function startWebServer(options) {
9745
9963
  return;
9746
9964
  }
9747
9965
 
9966
+ if (state.jobs.busyFor(sessionRef.containerName)) {
9967
+ sendWebSocketUpgradeError(socket, 409, 'BUSY_EXPORT_IMPORT');
9968
+ return;
9969
+ }
9970
+
9748
9971
  if (state.terminalSessions.size >= WEB_TERMINAL_MAX_SESSIONS) {
9749
9972
  sendWebSocketUpgradeError(socket, 429, 'TERMINAL_LIMIT_REACHED');
9750
9973
  return;
@@ -9825,6 +10048,9 @@ async function startWebServer(options) {
9825
10048
  port: listenPort,
9826
10049
  close: () => new Promise(resolve => {
9827
10050
  ctx.logger.info('web server closing');
10051
+ process.removeListener('exit', unpauseOnExit);
10052
+ // 关服务时取消还在跑的导出 / 导入任务(导出会在取消时恢复暂停的容器)
10053
+ state.jobs.running().forEach(job => { job.cancelRequested = true; });
9828
10054
  flushWebSessionHistories();
9829
10055
  state.authStore.close();
9830
10056
  state.orphanTimers.forEach(timer => clearInterval(timer));