dsh-workspace-mover 2.4.1 → 2.4.3
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/CHANGELOG.md +345 -317
- package/README.md +389 -373
- package/README_EN.md +376 -360
- package/client/client.js +216 -23
- package/docs/ARCHITECTURE.md +224 -224
- package/docs/compatibility/dsh-0.2.0-rc.2-2026-10-13.md +45 -45
- package/docs/compatibility/immediate-migration-2026-10-26.md +68 -0
- package/docs/compatibility/issue-11-pr-12-review-2026-11-02.md +103 -0
- package/docs/compatibility/issue-5-desktop-acceptance-2026-10-22.md +106 -106
- package/docs/compatibility/pr-review-2026-10-22.md +56 -56
- package/lib/index.js +90 -29
- package/lib/lifecycle.js +104 -0
- package/package.json +104 -103
- package/test/client-dom.test.mjs +194 -4
- package/test/core.test.mjs +73 -73
- package/test/e2e-sandbox.test.mjs +82 -2
- package/test/live-migration.test.mjs +253 -0
- package/test/release-downloads.test.mjs +102 -102
- package/test/traffic-badge.test.mjs +80 -80
package/lib/index.js
CHANGED
|
@@ -36,6 +36,7 @@ import { join, dirname, basename, relative, resolve, isAbsolute } from 'node:pat
|
|
|
36
36
|
import { homedir } from 'node:os';
|
|
37
37
|
import { spawn } from 'node:child_process';
|
|
38
38
|
import { Config, POLICY_FIELDS, buildConfig, attachConfig } from './config.js';
|
|
39
|
+
import { installLifecycle, lifecycleFor, withReleasedSession } from './lifecycle.js';
|
|
39
40
|
|
|
40
41
|
const name = 'workspace-mover';
|
|
41
42
|
// 只声明真正必需的两个服务。cordis 把 inject 里的**每一项**都当必需:任一缺失,
|
|
@@ -913,7 +914,9 @@ async function moveSessionEntry(ctx, payload = {}, hooks = {}) {
|
|
|
913
914
|
`workspace:${targetWorkspaceId}`
|
|
914
915
|
);
|
|
915
916
|
try {
|
|
916
|
-
|
|
917
|
+
if (!ctx.workspaceRegistry?.get(targetWorkspaceId)) throw new Error(`unknown workspace '${targetWorkspaceId}'`);
|
|
918
|
+
return await withReleasedSession(ctx, sessionId, payload.stopRunning === true,
|
|
919
|
+
() => moveSession(ctx, { ...payload, sessionId, targetWorkspaceId }, hooks));
|
|
917
920
|
} finally {
|
|
918
921
|
release();
|
|
919
922
|
}
|
|
@@ -955,7 +958,7 @@ async function moveSession(ctx, { sessionId, targetWorkspaceId, sessionTitle, re
|
|
|
955
958
|
const sourceCwd = meta.cwd;
|
|
956
959
|
const title = typeof sessionTitle === 'string' && sessionTitle.trim()
|
|
957
960
|
? sessionTitle.trim()
|
|
958
|
-
: (
|
|
961
|
+
: readSessionTitle(ctx, sessionId, readProjectionTitles().get(String(sessionId)) ?? meta.title ?? '未命名会话');
|
|
959
962
|
if (sourceCwd === undefined) throw new Error('session header carries no cwd');
|
|
960
963
|
if (samePath(sourceCwd, targetPath)) {
|
|
961
964
|
// 磁盘头已指向目标:若目标工作区还没记账它(历史遗留的未归组会话),
|
|
@@ -1129,7 +1132,11 @@ async function moveSession(ctx, { sessionId, targetWorkspaceId, sessionTitle, re
|
|
|
1129
1132
|
// 才会读取 header 并经 mutate 发出 domain/changed。不能直接预置
|
|
1130
1133
|
// target.record.sessionIds,否则 attach 变成 no-op,WorkspaceFeed 不会推送目标
|
|
1131
1134
|
// 工作区的更新帧,浏览器就会暂时把会话显示在“未分组”。
|
|
1135
|
+
const originalProjection = captureProjection(ctx, sessionId);
|
|
1132
1136
|
try {
|
|
1137
|
+
// Listings can read the title during attach: align BEFORE publishing the
|
|
1138
|
+
// new header, otherwise the host rejects the old cwd-bound checkpoint.
|
|
1139
|
+
await realignProjectionIdentity(ctx, sessionId, targetPath);
|
|
1133
1140
|
await target.attachSession(sessionId);
|
|
1134
1141
|
} catch (err) {
|
|
1135
1142
|
// Roll back original bytes and reattach the source workspace.
|
|
@@ -1142,6 +1149,7 @@ async function moveSession(ctx, { sessionId, targetWorkspaceId, sessionTitle, re
|
|
|
1142
1149
|
throw new Error(`physical rollback could not move the target directory: ${relocateErr?.message ?? relocateErr}`);
|
|
1143
1150
|
}
|
|
1144
1151
|
await storage.replace(sourceLocation, original);
|
|
1152
|
+
await writeProjection(ctx, String(sessionId), originalProjection, sourceCwd, true);
|
|
1145
1153
|
for (const e of allOwners) {
|
|
1146
1154
|
try { if (!rawRecordSessionIds(e).includes(String(sessionId))) await e.attachSession?.(sessionId); } catch { /* ignore */ }
|
|
1147
1155
|
}
|
|
@@ -1400,6 +1408,26 @@ function readProjectionTitles() {
|
|
|
1400
1408
|
return map;
|
|
1401
1409
|
}
|
|
1402
1410
|
|
|
1411
|
+
/** Current hosts use per-record checkpoints; legacy aggregate files can remain
|
|
1412
|
+
* on disk after an upgrade and must never override a current title. */
|
|
1413
|
+
function readSessionTitle(ctx, sessionId, fallback = null) {
|
|
1414
|
+
const titleOf = (record) => {
|
|
1415
|
+
const title = record?.rows?.title?.val;
|
|
1416
|
+
return typeof title === 'string' && title.trim() ? title.trim() : null;
|
|
1417
|
+
};
|
|
1418
|
+
const live = titleOf(captureProjection(ctx, sessionId));
|
|
1419
|
+
if (live) return live;
|
|
1420
|
+
try {
|
|
1421
|
+
const id = validId(String(sessionId), 'sessionId');
|
|
1422
|
+
if (/[\\/:]/.test(id) || id === '.' || id === '..') return fallback;
|
|
1423
|
+
const base = process.env.DSH_HOME ?? join(homedir(), '.dsh');
|
|
1424
|
+
const document = JSON.parse(readFileSync(join(base, 'storages', 'session_projcache', 'sessions', `${id}.json`), 'utf8'));
|
|
1425
|
+
const current = titleOf(document?.record);
|
|
1426
|
+
if (current) return current;
|
|
1427
|
+
} catch { /* Cache absence/corruption must not prevent session rescue. */ }
|
|
1428
|
+
return fallback;
|
|
1429
|
+
}
|
|
1430
|
+
|
|
1403
1431
|
/**
|
|
1404
1432
|
* 扫描存储根下的全部会话档案并分类:
|
|
1405
1433
|
* - orphaned 头部 cwd 指向的目录已不存在(项目文件夹被移动/改名/删除,官方讨论 #3012)
|
|
@@ -1504,8 +1532,8 @@ async function scanSessions(ctx) {
|
|
|
1504
1532
|
: 'misfiled'; // 记账方存在但不是 cwd 匹配的分组
|
|
1505
1533
|
items.push({
|
|
1506
1534
|
sessionId,
|
|
1507
|
-
title: projectionTitles.get(sessionId)
|
|
1508
|
-
?? (typeof header?.title === 'string' && header.title.trim() ? header.title.trim() : '未命名会话'),
|
|
1535
|
+
title: readSessionTitle(ctx, sessionId, projectionTitles.get(sessionId)
|
|
1536
|
+
?? (typeof header?.title === 'string' && header.title.trim() ? header.title.trim() : '未命名会话')),
|
|
1509
1537
|
cwd,
|
|
1510
1538
|
status,
|
|
1511
1539
|
targetWorkspaceId: matching?.id ?? null,
|
|
@@ -1522,6 +1550,13 @@ async function scanSessions(ctx) {
|
|
|
1522
1550
|
}
|
|
1523
1551
|
|
|
1524
1552
|
const ghosts = [];
|
|
1553
|
+
// Include all discovered archives, even those outside the parsed scan limit.
|
|
1554
|
+
const archiveLocations = new Map();
|
|
1555
|
+
for (const { idDir, artifact } of metas) {
|
|
1556
|
+
const paths = archiveLocations.get(String(idDir)) ?? [];
|
|
1557
|
+
paths.push(artifact);
|
|
1558
|
+
archiveLocations.set(String(idDir), paths);
|
|
1559
|
+
}
|
|
1525
1560
|
for (const entity of entities) {
|
|
1526
1561
|
let ids = [];
|
|
1527
1562
|
// 幽灵正是「有账无档」的 id,而官方公开的 sessionIds getter 会把它们
|
|
@@ -1529,7 +1564,11 @@ async function scanSessions(ctx) {
|
|
|
1529
1564
|
try { ids = rawRecordSessionIds(entity); } catch { continue; }
|
|
1530
1565
|
for (const id of ids) {
|
|
1531
1566
|
try {
|
|
1532
|
-
if (!existsSync(artifactPath(root, entity.path, id, compression)))
|
|
1567
|
+
if (!existsSync(artifactPath(root, entity.path, id, compression))) {
|
|
1568
|
+
const locations = archiveLocations.get(String(id)) ?? [];
|
|
1569
|
+
ghosts.push({ workspaceId: entity.id, sessionId: String(id), locations,
|
|
1570
|
+
status: locations.length ? 'found-elsewhere' : 'location-unknown', archived: archived.has(String(id)) });
|
|
1571
|
+
}
|
|
1533
1572
|
} catch { /* skip */ }
|
|
1534
1573
|
}
|
|
1535
1574
|
}
|
|
@@ -1615,12 +1654,8 @@ function invalidateFileReferenceSearches(ctx, stalePath) {
|
|
|
1615
1654
|
*/
|
|
1616
1655
|
async function realignProjectionIdentity(ctx, sessionId, nextCwd) {
|
|
1617
1656
|
try {
|
|
1618
|
-
const
|
|
1619
|
-
|
|
1620
|
-
await table.update(String(sessionId), (rec) =>
|
|
1621
|
-
rec?.identity ? { ...rec, identity: { ...rec.identity, cwd: nextCwd },
|
|
1622
|
-
rows: Object.fromEntries(Object.entries(rec.rows ?? {}).filter(([key]) => key !== 'workingDirectory')) } : rec
|
|
1623
|
-
);
|
|
1657
|
+
const record = captureProjection(ctx, sessionId);
|
|
1658
|
+
await writeProjection(ctx, String(sessionId), record, nextCwd);
|
|
1624
1659
|
} catch (err) {
|
|
1625
1660
|
ctx.logger?.warn?.(`workspace-mover: projection identity alignment failed for ${sessionId}: ${err?.message ?? err}`);
|
|
1626
1661
|
}
|
|
@@ -1999,13 +2034,16 @@ async function inspectMovePlan(ctx, { sessionId, targetWorkspaceId, mode = 'curr
|
|
|
1999
2034
|
const repairMembership = sameWorkspace && !rawRecordSessionIds(target).includes(id);
|
|
2000
2035
|
let backupPlan = { reused: false, estimatedBytes: 0 };
|
|
2001
2036
|
let status = 'safe';
|
|
2037
|
+
const liveMigration = Boolean(lifecycleFor(ctx, id));
|
|
2002
2038
|
if (running) {
|
|
2003
|
-
status = '
|
|
2004
|
-
warnings.push('session is
|
|
2039
|
+
status = 'warning';
|
|
2040
|
+
warnings.push('session is running; moving requires explicit stop confirmation');
|
|
2005
2041
|
}
|
|
2006
2042
|
try { assertNoResidentWriter(ctx, id); } catch (err) {
|
|
2007
|
-
|
|
2008
|
-
|
|
2043
|
+
if (!liveMigration) {
|
|
2044
|
+
status = 'queued';
|
|
2045
|
+
warnings.push(err.message);
|
|
2046
|
+
}
|
|
2009
2047
|
}
|
|
2010
2048
|
if (adapter.mode === 'filesystem-fallback') warnings.push('physical move uses the JSONL path fallback; official raw-byte path API is unavailable');
|
|
2011
2049
|
if (!adapter.capabilities.physicalPath) {
|
|
@@ -2054,10 +2092,11 @@ async function inspectMovePlan(ctx, { sessionId, targetWorkspaceId, mode = 'curr
|
|
|
2054
2092
|
status,
|
|
2055
2093
|
canMove: status === 'safe' || status === 'warning' || status === 'queued',
|
|
2056
2094
|
sessionId: id,
|
|
2057
|
-
title: readProjectionTitles().get(id) ?? meta.title ?? null,
|
|
2095
|
+
title: readSessionTitle(ctx, id, readProjectionTitles().get(id) ?? meta.title ?? null),
|
|
2058
2096
|
source,
|
|
2059
2097
|
target: targetInfo,
|
|
2060
2098
|
running,
|
|
2099
|
+
liveMigration,
|
|
2061
2100
|
branches: { available: true, count: branches.relations.length, mode, sessionIds: branches.ids, relations: branches.relations },
|
|
2062
2101
|
estimatedBackupBytes: sameWorkspace ? 0 : backupPlan.estimatedBytes,
|
|
2063
2102
|
backup: sameWorkspace ? { reused: false, estimatedBytes: 0 } : backupPlan,
|
|
@@ -2067,7 +2106,7 @@ async function inspectMovePlan(ctx, { sessionId, targetWorkspaceId, mode = 'curr
|
|
|
2067
2106
|
}
|
|
2068
2107
|
|
|
2069
2108
|
/** 批量迁移入口:逐会话复用 moveSession(各自独立备份/回滚,单条失败不牵连其余);历史聚合成一条。 */
|
|
2070
|
-
async function moveManySessions(ctx, { sessions, targetWorkspaceId, mode = 'current-only' } = {}) {
|
|
2109
|
+
async function moveManySessions(ctx, { sessions, targetWorkspaceId, mode = 'current-only', stopRunning = false, allowQueue = true } = {}) {
|
|
2071
2110
|
if (!Array.isArray(sessions) || sessions.length === 0) throw new Error('sessions must be a non-empty array');
|
|
2072
2111
|
if (sessions.length > batchLimit()) throw new Error(`too many sessions in one batch (max ${batchLimit()}, got ${sessions.length})`);
|
|
2073
2112
|
if (typeof targetWorkspaceId !== 'string' || targetWorkspaceId.length === 0) throw new Error('targetWorkspaceId required');
|
|
@@ -2113,7 +2152,8 @@ async function moveManySessions(ctx, { sessions, targetWorkspaceId, mode = 'curr
|
|
|
2113
2152
|
sessionId,
|
|
2114
2153
|
targetWorkspaceId,
|
|
2115
2154
|
sessionTitle: typeof item.sessionTitle === 'string' ? item.sessionTitle : undefined,
|
|
2116
|
-
recordHistory: false
|
|
2155
|
+
recordHistory: false,
|
|
2156
|
+
stopRunning
|
|
2117
2157
|
});
|
|
2118
2158
|
if (result.attached) attachedCount += 1; else movedCount += 1;
|
|
2119
2159
|
let sourceWorkspaceId = null;
|
|
@@ -2123,7 +2163,7 @@ async function moveManySessions(ctx, { sessions, targetWorkspaceId, mode = 'curr
|
|
|
2123
2163
|
moved.push({ sessionId, title: result.title, fromCwd: result.from.cwd, sourceWorkspaceId, backup: result.backup ?? null });
|
|
2124
2164
|
results.push({ sessionId, ok: true, moved: result.moved, to: result.to.cwd, title: result.title });
|
|
2125
2165
|
} catch (err) {
|
|
2126
|
-
if (isBusyError(err)) {
|
|
2166
|
+
if (allowQueue && isBusyError(err)) {
|
|
2127
2167
|
const queued = createOrReuseMigration(ctx, {
|
|
2128
2168
|
sessionId,
|
|
2129
2169
|
targetWorkspaceId,
|
|
@@ -3196,14 +3236,14 @@ async function dropProjection(ctx, sessionId) {
|
|
|
3196
3236
|
}
|
|
3197
3237
|
}
|
|
3198
3238
|
|
|
3199
|
-
async function writeProjection(ctx, sessionId, record, cwd) {
|
|
3239
|
+
async function writeProjection(ctx, sessionId, record, cwd, preserveWorkingDirectory = false) {
|
|
3200
3240
|
try {
|
|
3201
3241
|
const table = projectionTable(ctx);
|
|
3202
3242
|
if (!table || typeof table.put !== 'function' || !record?.identity || !record?.rows) return;
|
|
3203
3243
|
// 注意:table 是底层 KvTable,put(key, value) 两参——value 必须是完整 {identity, rows};
|
|
3204
3244
|
// 服务层的 put(id, identity, rows) 三参签名在这里不可用(会整条写坏,域重开时崩)。
|
|
3205
3245
|
await table.put(String(sessionId), { identity: { ...record.identity, cwd },
|
|
3206
|
-
rows: Object.fromEntries(Object.entries(record.rows).filter(([key]) => key !== 'workingDirectory')) });
|
|
3246
|
+
rows: preserveWorkingDirectory ? record.rows : Object.fromEntries(Object.entries(record.rows).filter(([key]) => key !== 'workingDirectory')) });
|
|
3207
3247
|
} catch (err) {
|
|
3208
3248
|
ctx.logger?.warn?.(`workspace-mover: projection restore failed for ${sessionId} (title will rebuild lazily): ${err?.message ?? err}`);
|
|
3209
3249
|
}
|
|
@@ -3308,7 +3348,26 @@ async function deleteSessionToTrash(ctx, payload = {}, hooks = {}) {
|
|
|
3308
3348
|
const sessionId = validId(payload?.sessionId, 'sessionId');
|
|
3309
3349
|
const release = acquireLocks(`session:${sessionId}`);
|
|
3310
3350
|
try {
|
|
3311
|
-
|
|
3351
|
+
assertNotRunning(ctx, sessionId);
|
|
3352
|
+
if (peekService(ctx, 'sessions')?.get?.(sessionId) !== undefined && !lifecycleFor(ctx, sessionId)) {
|
|
3353
|
+
throw new Error('session is resident in memory without a tracked lifecycle handle; restart the harness before deleting');
|
|
3354
|
+
}
|
|
3355
|
+
// Disposing the Agent may remove its projection; preserve display metadata first.
|
|
3356
|
+
const projection = captureProjection(ctx, sessionId);
|
|
3357
|
+
const title = readSessionTitle(ctx, sessionId, null);
|
|
3358
|
+
return await withReleasedSession(ctx, sessionId, false,
|
|
3359
|
+
() => deleteSessionToTrashLocked(ctx, sessionId, { ...hooks, projection, title, purgeBackups: payload?.purgeBackups === true }), {
|
|
3360
|
+
reloadOnSuccess: false,
|
|
3361
|
+
// A failed rollback must never resume a missing archive as a blank session.
|
|
3362
|
+
canReload: async () => {
|
|
3363
|
+
try {
|
|
3364
|
+
const location = await locateArtifact(ctx, sessionId);
|
|
3365
|
+
const storage = createPersistenceAdapter(ctx);
|
|
3366
|
+
const header = readHeader(await storage.read(location), storage.compression);
|
|
3367
|
+
return String(header?.id) === String(sessionId);
|
|
3368
|
+
} catch { return false; }
|
|
3369
|
+
}
|
|
3370
|
+
});
|
|
3312
3371
|
} finally {
|
|
3313
3372
|
release();
|
|
3314
3373
|
}
|
|
@@ -3328,7 +3387,7 @@ async function deleteSessionToTrashLocked(ctx, sessionId, hooks = {}) {
|
|
|
3328
3387
|
const { artifact, cwd: artifactCwd } = location;
|
|
3329
3388
|
const header = readHeader(await storage.read(location), storage.compression);
|
|
3330
3389
|
const archived = readArchivedIds(registry).includes(sessionId);
|
|
3331
|
-
const projection = captureProjection(ctx, sessionId);
|
|
3390
|
+
const projection = hooks.projection ?? captureProjection(ctx, sessionId);
|
|
3332
3391
|
const owners = listOwners(registry, sessionId);
|
|
3333
3392
|
const entryDir = join(recycleDir(), `${Date.now()}-${String(sessionId).replace(/[^A-Za-z0-9._-]/g, '_')}`);
|
|
3334
3393
|
mkdirSync(entryDir, { recursive: true });
|
|
@@ -3343,7 +3402,7 @@ async function deleteSessionToTrashLocked(ctx, sessionId, hooks = {}) {
|
|
|
3343
3402
|
const manifest = {
|
|
3344
3403
|
version: 1,
|
|
3345
3404
|
sessionId: String(sessionId),
|
|
3346
|
-
title: typeof header?.title === 'string' && header.title.trim() ? header.title.trim() : null,
|
|
3405
|
+
title: hooks.title ?? (typeof header?.title === 'string' && header.title.trim() ? header.title.trim() : null),
|
|
3347
3406
|
cwd: artifactCwd ?? header?.cwd ?? null,
|
|
3348
3407
|
ownerWorkspaceId: owners[0]?.id ?? null,
|
|
3349
3408
|
ownerTitle: owners[0]?.title ?? null,
|
|
@@ -3364,6 +3423,7 @@ async function deleteSessionToTrashLocked(ctx, sessionId, hooks = {}) {
|
|
|
3364
3423
|
lastError: `manifest write failed (${err?.message ?? err}) AND move-back failed (${backErr?.message ?? backErr}) — files remain under ${entryDir}`
|
|
3365
3424
|
});
|
|
3366
3425
|
ctx.logger?.warn?.(`workspace-mover: manifest write failed AND move-back failed for ${sessionId}: ${backErr?.message ?? backErr} — files remain under ${entryDir}`);
|
|
3426
|
+
throw new Error(`manifest write failed AND rollback failed; session files preserved under ${entryDir}: ${backErr?.message ?? backErr}`);
|
|
3367
3427
|
}
|
|
3368
3428
|
try { rmSync(entryDir, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
3369
3429
|
throw new Error(`manifest write failed, session moved back: ${err?.message ?? err}`);
|
|
@@ -3477,7 +3537,7 @@ async function restoreFromTrashLocked(ctx, sessionId, targetWorkspaceId) {
|
|
|
3477
3537
|
}
|
|
3478
3538
|
// 先移回原位(头 cwd 与原位一致,attach 校验天然通过)
|
|
3479
3539
|
await storage.relocate(trashLocation, dirname(originalLocation.artifact));
|
|
3480
|
-
let restored = { restored: true, sessionId: String(sessionId), cwd: originalCwd, workspaceId: targetEntity?.id ?? null, moved: null };
|
|
3540
|
+
let restored = { restored: true, sessionId: String(sessionId), cwd: originalCwd, workspaceId: targetEntity?.id ?? null, archived: Boolean(manifest.archived), moved: null };
|
|
3481
3541
|
try {
|
|
3482
3542
|
if (targetEntity && !samePath(originalCwd, destPath)) {
|
|
3483
3543
|
// 原位只是中转:直接走现有 moveSession 全管线(备份/改写/热修/记账全套)
|
|
@@ -3537,7 +3597,7 @@ async function listBackups(ctx) {
|
|
|
3537
3597
|
const { id, ts } = parsed;
|
|
3538
3598
|
let sizeBytes = 0;
|
|
3539
3599
|
try { sizeBytes = statSync(join(dir, name)).size; } catch { continue; }
|
|
3540
|
-
const g = groups.get(id) ?? { sessionId: id, title: titles.get(id) ?? null, count: 0, totalBytes: 0, oldest: Number(ts), newest: Number(ts), files: [] };
|
|
3600
|
+
const g = groups.get(id) ?? { sessionId: id, title: readSessionTitle(ctx, id, titles.get(id) ?? null), count: 0, totalBytes: 0, oldest: Number(ts), newest: Number(ts), files: [] };
|
|
3541
3601
|
g.count++;
|
|
3542
3602
|
g.totalBytes += sizeBytes;
|
|
3543
3603
|
g.oldest = Math.min(g.oldest, Number(ts));
|
|
@@ -4177,6 +4237,7 @@ async function runDoctor(ctx, agentToolsState) {
|
|
|
4177
4237
|
|
|
4178
4238
|
export function apply(ctx) {
|
|
4179
4239
|
const logger = ctx.logger ?? console;
|
|
4240
|
+
ctx.inject?.(['agents'], (owner) => installLifecycle(ctx, owner));
|
|
4180
4241
|
const dispatchRef = { fn: null }; // agent 工具在调用期才用到 dispatch,挂载时序无关
|
|
4181
4242
|
const agentToolsState = { registered: false };
|
|
4182
4243
|
const mount = (owner) => {
|
|
@@ -4271,15 +4332,15 @@ export function apply(ctx) {
|
|
|
4271
4332
|
return ok(await moveManySessions(ctx, {
|
|
4272
4333
|
sessions: [{ sessionId, sessionTitle: payload?.sessionTitle }],
|
|
4273
4334
|
targetWorkspaceId,
|
|
4274
|
-
mode: 'include-descendants'
|
|
4335
|
+
mode: 'include-descendants', stopRunning: payload?.stopRunning === true, allowQueue: payload?.allowQueue !== false
|
|
4275
4336
|
}));
|
|
4276
4337
|
}
|
|
4277
|
-
const result = await moveSessionEntry(ctx, { sessionId, targetWorkspaceId, sessionTitle: payload?.sessionTitle });
|
|
4338
|
+
const result = await moveSessionEntry(ctx, { sessionId, targetWorkspaceId, sessionTitle: payload?.sessionTitle, stopRunning: payload?.stopRunning === true });
|
|
4278
4339
|
logger.info?.(`workspace-mover: moved ${sessionId} -> ${result.to.cwd}`);
|
|
4279
4340
|
return ok(result);
|
|
4280
4341
|
} catch (err) {
|
|
4281
4342
|
logger.warn?.(`workspace-mover: MOVE FAILED sessionId=${payload?.sessionId} target=${payload?.targetWorkspaceId}: ${err?.message ?? err}`);
|
|
4282
|
-
if (isBusyError(err) && typeof payload?.sessionId === 'string' && typeof payload?.targetWorkspaceId === 'string') {
|
|
4343
|
+
if (payload?.allowQueue !== false && isBusyError(err) && typeof payload?.sessionId === 'string' && typeof payload?.targetWorkspaceId === 'string') {
|
|
4283
4344
|
const queued = createOrReuseMigration(ctx, {
|
|
4284
4345
|
sessionId: payload.sessionId,
|
|
4285
4346
|
targetWorkspaceId: payload.targetWorkspaceId,
|
package/lib/lifecycle.js
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// Retain the public create/resume disposer. Never close a borrowed persistence
|
|
2
|
+
// writer or rewrite an immutable live Session header.
|
|
3
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
4
|
+
const migrationContext = new AsyncLocalStorage();
|
|
5
|
+
const original = Symbol.for('cordis.original');
|
|
6
|
+
const runtimes = new WeakMap();
|
|
7
|
+
const raw = (value) => value?.[original] ?? value;
|
|
8
|
+
|
|
9
|
+
function blocked() {
|
|
10
|
+
const error = new Error('This session is being migrated; retry after migration completes.');
|
|
11
|
+
error.code = 'migration-in-progress';
|
|
12
|
+
return error;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function installLifecycle(ctx, owner) {
|
|
16
|
+
const agents = raw(owner.agents);
|
|
17
|
+
if (!agents || runtimes.has(agents)) return;
|
|
18
|
+
const runtime = { handles: new Map(), moving: new Set() };
|
|
19
|
+
runtimes.set(agents, runtime);
|
|
20
|
+
const restore = [];
|
|
21
|
+
for (const method of ['create', 'resume']) {
|
|
22
|
+
const fn = agents[method];
|
|
23
|
+
if (typeof fn !== 'function') continue;
|
|
24
|
+
const wrapped = async function (options, ...args) {
|
|
25
|
+
const id = options?.resumeSessionId ?? options?.sessionId;
|
|
26
|
+
if (runtime.moving.has(id) && migrationContext.getStore() !== runtime) throw blocked();
|
|
27
|
+
const handle = await Reflect.apply(fn, this, [options, ...args]);
|
|
28
|
+
const agent = raw(handle?.agent);
|
|
29
|
+
if (agent && typeof handle.dispose === 'function') {
|
|
30
|
+
const guards = [];
|
|
31
|
+
runtime.handles.set(agent.id, { handle, agent, guards });
|
|
32
|
+
// Requests that already resolved the old Agent must also be refused
|
|
33
|
+
// while its lifecycle is draining or after it has been replaced.
|
|
34
|
+
for (const entry of ['send', 'runMaintenance']) {
|
|
35
|
+
const action = agent[entry];
|
|
36
|
+
if (typeof action !== 'function') continue;
|
|
37
|
+
const guarded = function (...params) {
|
|
38
|
+
if (runtime.moving.has(agent.id) || raw(agents.get(agent.id)) !== agent) throw blocked();
|
|
39
|
+
return Reflect.apply(action, this, params);
|
|
40
|
+
};
|
|
41
|
+
agent[entry] = guarded;
|
|
42
|
+
guards.push(() => { if (agent[entry] === guarded) agent[entry] = action; });
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return handle;
|
|
46
|
+
};
|
|
47
|
+
agents[method] = wrapped;
|
|
48
|
+
restore.push(() => { if (agents[method] === wrapped) agents[method] = fn; });
|
|
49
|
+
}
|
|
50
|
+
owner.on?.('agent/disposed', ({ agent }) => {
|
|
51
|
+
if (runtime.handles.get(agent.id)?.agent === raw(agent)) runtime.handles.delete(agent.id);
|
|
52
|
+
});
|
|
53
|
+
owner.effect(() => () => {
|
|
54
|
+
for (const entry of runtime.handles.values()) for (const undo of entry.guards) undo();
|
|
55
|
+
runtime.handles.clear();
|
|
56
|
+
for (const undo of restore.reverse()) undo();
|
|
57
|
+
runtimes.delete(agents);
|
|
58
|
+
}, 'workspace-mover: restore lifecycle observers');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function lifecycleFor(ctx, id) {
|
|
62
|
+
const agents = raw(typeof ctx.get === 'function' ? ctx.get('agents') : ctx.agents);
|
|
63
|
+
const runtime = agents && runtimes.get(agents);
|
|
64
|
+
const entry = runtime?.handles.get(id);
|
|
65
|
+
return entry && raw(agents.get(id)) === entry.agent ? { ...entry, runtime, agents } : null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export async function withReleasedSession(ctx, id, stopRunning, operation, { reloadOnSuccess = true, canReload = () => true } = {}) {
|
|
69
|
+
const owned = lifecycleFor(ctx, id);
|
|
70
|
+
if (!owned) return operation();
|
|
71
|
+
const { handle, agent, runtime } = owned;
|
|
72
|
+
if (agent.status === 'running' && !stopRunning) {
|
|
73
|
+
const error = new Error('Session is running. Confirm stop and move, or cancel migration.');
|
|
74
|
+
error.code = 'stop-required';
|
|
75
|
+
throw error;
|
|
76
|
+
}
|
|
77
|
+
if (agent.session.header?.parentSession || agent.session.header?.origin === 'subagent') {
|
|
78
|
+
throw new Error('Live subagent migration is unsupported; stop its owning task first.');
|
|
79
|
+
}
|
|
80
|
+
runtime.moving.add(id);
|
|
81
|
+
let result;
|
|
82
|
+
let failure;
|
|
83
|
+
try {
|
|
84
|
+
await handle.dispose(); // owns cancel → drain → close → unregister
|
|
85
|
+
result = await operation();
|
|
86
|
+
} catch (error) {
|
|
87
|
+
failure = error;
|
|
88
|
+
} finally {
|
|
89
|
+
runtime.handles.delete(id);
|
|
90
|
+
try {
|
|
91
|
+
const controller = ctx.get?.('sessionController');
|
|
92
|
+
if ((failure || reloadOnSuccess) && await canReload() && typeof controller?.resolveAgent === 'function') {
|
|
93
|
+
const restored = await migrationContext.run(runtime, () => controller.resolveAgent(id));
|
|
94
|
+
if (restored?.error) throw restored.error;
|
|
95
|
+
}
|
|
96
|
+
} catch (error) {
|
|
97
|
+
failure = new Error(`${failure ? failure.message : 'Files migrated successfully'}; session reload failed: ${error.message}`, { cause: error });
|
|
98
|
+
} finally {
|
|
99
|
+
runtime.moving.delete(id);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
if (failure) throw failure;
|
|
103
|
+
return result;
|
|
104
|
+
}
|
package/package.json
CHANGED
|
@@ -1,103 +1,104 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "dsh-workspace-mover",
|
|
3
|
-
"version": "2.4.
|
|
4
|
-
"description": "在 DSH 侧边栏安全迁移会话:真迁移原始档案,支持失联会话恢复、移动历史、一键撤回和 agent 工具。",
|
|
5
|
-
"type": "module",
|
|
6
|
-
"main": "lib/index.js",
|
|
7
|
-
"exports": {
|
|
8
|
-
".": "./lib/index.js",
|
|
9
|
-
"./client": "./client/client.js",
|
|
10
|
-
"./package.json": "./package.json"
|
|
11
|
-
},
|
|
12
|
-
"files": [
|
|
13
|
-
"lib",
|
|
14
|
-
"client",
|
|
15
|
-
"test",
|
|
16
|
-
"cordis.patch.yml",
|
|
17
|
-
"README.md",
|
|
18
|
-
"README_EN.md",
|
|
19
|
-
"CHANGELOG.md",
|
|
20
|
-
"docs/ARCHITECTURE.md",
|
|
21
|
-
"docs/compatibility",
|
|
22
|
-
"docs/media",
|
|
23
|
-
"LICENSE"
|
|
24
|
-
],
|
|
25
|
-
|
|
26
|
-
"test
|
|
27
|
-
"
|
|
28
|
-
"check:
|
|
29
|
-
"check": "npm
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
"
|
|
34
|
-
"dsh
|
|
35
|
-
"
|
|
36
|
-
"
|
|
37
|
-
"session
|
|
38
|
-
"
|
|
39
|
-
"
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
"
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
"peerDependencies":
|
|
47
|
-
|
|
48
|
-
"@deepseek-ai/
|
|
49
|
-
"@deepseek-ai/dsh-
|
|
50
|
-
"@deepseek-ai/dsh-
|
|
51
|
-
"@deepseek-ai/
|
|
52
|
-
"@deepseek-ai/
|
|
53
|
-
"@deepseek-ai/dsh-client-
|
|
54
|
-
"@deepseek-ai/dsh-client-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
"
|
|
84
|
-
|
|
85
|
-
"
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
"
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
"@deepseek-ai/dsh-client-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "dsh-workspace-mover",
|
|
3
|
+
"version": "2.4.3",
|
|
4
|
+
"description": "在 DSH 侧边栏安全迁移会话:真迁移原始档案,支持失联会话恢复、移动历史、一键撤回和 agent 工具。",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "lib/index.js",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./lib/index.js",
|
|
9
|
+
"./client": "./client/client.js",
|
|
10
|
+
"./package.json": "./package.json"
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"lib",
|
|
14
|
+
"client",
|
|
15
|
+
"test",
|
|
16
|
+
"cordis.patch.yml",
|
|
17
|
+
"README.md",
|
|
18
|
+
"README_EN.md",
|
|
19
|
+
"CHANGELOG.md",
|
|
20
|
+
"docs/ARCHITECTURE.md",
|
|
21
|
+
"docs/compatibility",
|
|
22
|
+
"docs/media",
|
|
23
|
+
"LICENSE"
|
|
24
|
+
],
|
|
25
|
+
"scripts": {
|
|
26
|
+
"test": "node --test test/core.test.mjs test/e2e-sandbox.test.mjs test/policy.test.mjs test/client-dom.test.mjs test/traffic-badge.test.mjs test/release-downloads.test.mjs test/workspace-tools.test.mjs test/host-services.test.mjs test/live-migration.test.mjs",
|
|
27
|
+
"test:installed-host": "node --test test/installed-host.test.mjs test/live-migration.test.mjs",
|
|
28
|
+
"check:syntax": "node --check lib/index.js && node --check client/client.js && node --check lib/lifecycle.js",
|
|
29
|
+
"check:pack": "npm pack --dry-run",
|
|
30
|
+
"check": "npm run check:syntax && npm test && npm run check:pack"
|
|
31
|
+
},
|
|
32
|
+
"keywords": [
|
|
33
|
+
"deepseek-harness",
|
|
34
|
+
"dsh",
|
|
35
|
+
"dsh-plugin",
|
|
36
|
+
"workspace",
|
|
37
|
+
"session",
|
|
38
|
+
"session-migration",
|
|
39
|
+
"drag-and-drop",
|
|
40
|
+
"agent-tools"
|
|
41
|
+
],
|
|
42
|
+
"engines": {
|
|
43
|
+
"node": ">=22",
|
|
44
|
+
"dsh": ">=0.1.5-rc.1"
|
|
45
|
+
},
|
|
46
|
+
"//peerDependencies": "Deliberately `*`. These are OPTIONAL peers: the plugin declares them so storefronts can see the host packages it integrates with, but it fail-softs at runtime (mover.status capabilities + mover_doctor). npm's prerelease rule evaluates each `||` alternative as a set and only admits a prerelease when a comparator in that set shares the version's exact major.minor.patch tuple AND carries a prerelease tag -- so an upper bound like `<0.2.0-0` excludes EVERY 0.2.0 prerelease, which made the host report 2.2.0 as incompatible with 0.2.0-rc.2. Enumerating release lines would keep producing false incompatibilities on each new line; the real floor is engines.dsh, and capability flags report any actual runtime mismatch. Do NOT narrow these to version ranges.",
|
|
47
|
+
"peerDependencies": {
|
|
48
|
+
"@deepseek-ai/cordis": "*",
|
|
49
|
+
"@deepseek-ai/dsh-session": "*",
|
|
50
|
+
"@deepseek-ai/dsh-tools": "*",
|
|
51
|
+
"@deepseek-ai/dsh-workspace": "*",
|
|
52
|
+
"@deepseek-ai/schemastery": "*",
|
|
53
|
+
"@deepseek-ai/dsh-client-connection": "*",
|
|
54
|
+
"@deepseek-ai/dsh-client-locale": "*",
|
|
55
|
+
"@deepseek-ai/dsh-client-ui-slots": "*"
|
|
56
|
+
},
|
|
57
|
+
"peerDependenciesMeta": {
|
|
58
|
+
"@deepseek-ai/cordis": {
|
|
59
|
+
"optional": true
|
|
60
|
+
},
|
|
61
|
+
"@deepseek-ai/dsh-session": {
|
|
62
|
+
"optional": true
|
|
63
|
+
},
|
|
64
|
+
"@deepseek-ai/dsh-tools": {
|
|
65
|
+
"optional": true
|
|
66
|
+
},
|
|
67
|
+
"@deepseek-ai/dsh-workspace": {
|
|
68
|
+
"optional": true
|
|
69
|
+
},
|
|
70
|
+
"@deepseek-ai/schemastery": {
|
|
71
|
+
"optional": true
|
|
72
|
+
},
|
|
73
|
+
"@deepseek-ai/dsh-client-connection": {
|
|
74
|
+
"optional": true
|
|
75
|
+
},
|
|
76
|
+
"@deepseek-ai/dsh-client-locale": {
|
|
77
|
+
"optional": true
|
|
78
|
+
},
|
|
79
|
+
"@deepseek-ai/dsh-client-ui-slots": {
|
|
80
|
+
"optional": true
|
|
81
|
+
}
|
|
82
|
+
},
|
|
83
|
+
"license": "MIT",
|
|
84
|
+
"repository": {
|
|
85
|
+
"type": "git",
|
|
86
|
+
"url": "git+https://github.com/PianoPrince/dsh-workspace-mover.git"
|
|
87
|
+
},
|
|
88
|
+
"bugs": {
|
|
89
|
+
"url": "https://github.com/PianoPrince/dsh-workspace-mover/issues"
|
|
90
|
+
},
|
|
91
|
+
"homepage": "https://github.com/PianoPrince/dsh-workspace-mover#readme",
|
|
92
|
+
"dsh": {
|
|
93
|
+
"bundle": {
|
|
94
|
+
"patch": "./cordis.patch.yml"
|
|
95
|
+
},
|
|
96
|
+
"client": {
|
|
97
|
+
"inject": [
|
|
98
|
+
"@deepseek-ai/dsh-client-connection",
|
|
99
|
+
"@deepseek-ai/dsh-client-ui-slots"
|
|
100
|
+
],
|
|
101
|
+
"platform": "web"
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|