@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/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 +87 -86
- package/lib/web/server.js +216 -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) {
|
|
@@ -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
|
-
|
|
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));
|