@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/README.md +1 -1
- package/bin/manyoyo.js +31 -1
- package/lib/archive/export.js +564 -0
- package/lib/archive/import.js +521 -0
- package/lib/archive/plan.js +197 -0
- package/lib/archive/risks.js +121 -0
- package/lib/archive/runtime.js +72 -0
- package/lib/archive/safe-extract.js +163 -0
- package/lib/archive/tar.js +329 -0
- package/lib/model-services.js +9 -3
- package/lib/setup.js +2 -2
- package/lib/web/AGENTS.md +1 -0
- package/lib/web/container-jobs.js +276 -0
- package/lib/web/index.html +86 -85
- package/lib/web/server.js +233 -7
- package/lib/web/transfer-routes.js +574 -0
- package/package.json +1 -1
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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));
|