dsh-workspace-mover 2.0.3 → 2.1.1
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 +304 -239
- package/client/client.js +2002 -1918
- package/docs/ARCHITECTURE.md +16 -2
- package/lib/index.js +252 -19
- package/package.json +63 -62
- package/test/e2e-sandbox.test.mjs +158 -0
- package/test/traffic-badge.test.mjs +18 -4
package/docs/ARCHITECTURE.md
CHANGED
|
@@ -53,6 +53,20 @@ Single logical channel `/workspace-mover` via `ctx.connection.rpc.handle('/works
|
|
|
53
53
|
|
|
54
54
|
Errors carry stable codes (`busy`, `conflict`, `not-found`, `rollback-failed`, …). Host logs write failures under `MOVE FAILED`.
|
|
55
55
|
|
|
56
|
+
## Agent tools (DSH ≥ 0.1.5 with `dsh-tools`)
|
|
57
|
+
|
|
58
|
+
When the host provides the `tools` service, `apply()` registers three plain (zero-dependency) tool definitions; each tool's execute re-enters the internal RPC dispatch, so agent calls share the panel's locks, error codes, and transaction semantics:
|
|
59
|
+
|
|
60
|
+
| Tool | Gate | Purpose |
|
|
61
|
+
| --- | --- | --- |
|
|
62
|
+
| `mover_list_sessions` | none (read-only) | Compose scan + workspace list (sessions capped at 40, counts, ghosts, recoveryCount) for id resolution |
|
|
63
|
+
| `mover_move_session` | user approval | Move one session (`sessionId`, `targetWorkspaceId`, optional `sessionTitle`) |
|
|
64
|
+
| `mover_repair_sessions` | user approval | One `mover.repairAll` pass (accounting-only) |
|
|
65
|
+
|
|
66
|
+
- Mutating tools call `ctx.approval.request({ agent, toolName, reason, signal })` before acting; `allowed-once` proceeds, anything else (`denied` / `cancelled` / `unavailable`) or a throwing seam fails closed. Hosts without the approval seam (no UI) rely on conversational confirmation and proceed.
|
|
67
|
+
- `exec.signal` propagates into the RPC layer; cancellation lets the in-flight step finish rolling back to a consistent state.
|
|
68
|
+
- Hosts without `dsh-tools` skip registration silently; `mover.status` reports the `agentTools` flag.
|
|
69
|
+
|
|
56
70
|
## Session archives (v3)
|
|
57
71
|
|
|
58
72
|
- Naming: `session.v3.jsonl.zstd` or `session.v3.jsonl` (highest generation wins).
|
|
@@ -65,8 +79,8 @@ Order of operations for a cross-workspace move:
|
|
|
65
79
|
|
|
66
80
|
1. **Running check** — reject only mid-turn sessions (`agents.get(id)?.status === 'running'`); idle resident sessions may move.
|
|
67
81
|
2. **Read authority header** from disk; verify target ≠ source.
|
|
68
|
-
3. **
|
|
69
|
-
4. **
|
|
82
|
+
3. **Byte backup** to `$DSH_HOME/workspace-mover/backups/` (rolling 20 per session) **before any side effect** — a backup failure aborts with `backup-failed` and nothing has been touched.
|
|
83
|
+
4. **Preflight** — dual-accounting detect, target writability probe, disk-space hint.
|
|
70
84
|
5. **Rewrite first frame** (header cwd) only.
|
|
71
85
|
6. **Move directory** (Windows: exponential-backoff retry on EPERM; fallback copy+delete).
|
|
72
86
|
7. **In-memory closeout** — invalidate three registry indexes; for resident sessions clear stale persistence-coordinator write state; retarget live header `cwd` before official `attachSession`.
|
package/lib/index.js
CHANGED
|
@@ -40,7 +40,9 @@ import { homedir } from 'node:os';
|
|
|
40
40
|
import { spawn } from 'node:child_process';
|
|
41
41
|
|
|
42
42
|
const name = 'workspace-mover';
|
|
43
|
-
|
|
43
|
+
// tools/approval 由 dsh-tools / dsh-user-approval 提供(dsh-base 默认挂载);
|
|
44
|
+
// 缺失时 Inject.resolve 填 null,agent 工具与审批门相应降级。
|
|
45
|
+
const inject = ['workspaceRegistry', 'sessionPersistence', 'tools', 'approval'];
|
|
44
46
|
|
|
45
47
|
const CHANNEL = '/workspace-mover';
|
|
46
48
|
const ZSTD_MAGIC = 4247762216;
|
|
@@ -145,22 +147,22 @@ function listGenerationArtifacts(dir) {
|
|
|
145
147
|
return out.sort((a, b) => b.version - a.version || a.name.localeCompare(b.name));
|
|
146
148
|
}
|
|
147
149
|
|
|
148
|
-
function findHighestArtifact(dir, compression = 'zstd') {
|
|
149
|
-
const entries = listGenerationArtifacts(dir);
|
|
150
|
-
const encodings = new Set(entries.map((entry) => entry.compression));
|
|
151
|
-
if (encodings.size > 1) {
|
|
152
|
-
const err = new Error(`mixed session compression encodings under: ${dir}`);
|
|
153
|
-
err.code = 'unsupported';
|
|
154
|
-
throw err;
|
|
155
|
-
}
|
|
156
|
-
if (entries.length > 0 && !encodings.has(compression)) {
|
|
157
|
-
const actual = [...encodings][0];
|
|
158
|
-
const err = new Error(`session compression ${actual} does not match configured ${compression} under: ${dir}`);
|
|
159
|
-
err.code = 'unsupported';
|
|
160
|
-
throw err;
|
|
161
|
-
}
|
|
162
|
-
return entries[0] ?? null;
|
|
163
|
-
}
|
|
150
|
+
function findHighestArtifact(dir, compression = 'zstd') {
|
|
151
|
+
const entries = listGenerationArtifacts(dir);
|
|
152
|
+
const encodings = new Set(entries.map((entry) => entry.compression));
|
|
153
|
+
if (encodings.size > 1) {
|
|
154
|
+
const err = new Error(`mixed session compression encodings under: ${dir}`);
|
|
155
|
+
err.code = 'unsupported';
|
|
156
|
+
throw err;
|
|
157
|
+
}
|
|
158
|
+
if (entries.length > 0 && !encodings.has(compression)) {
|
|
159
|
+
const actual = [...encodings][0];
|
|
160
|
+
const err = new Error(`session compression ${actual} does not match configured ${compression} under: ${dir}`);
|
|
161
|
+
err.code = 'unsupported';
|
|
162
|
+
throw err;
|
|
163
|
+
}
|
|
164
|
+
return entries[0] ?? null;
|
|
165
|
+
}
|
|
164
166
|
|
|
165
167
|
function preferredArtifactName(compression = 'zstd') {
|
|
166
168
|
return generationLogFilename(CURRENT_GENERATION, compression);
|
|
@@ -2245,8 +2247,232 @@ async function repairAllSessions(ctx) {
|
|
|
2245
2247
|
};
|
|
2246
2248
|
}
|
|
2247
2249
|
|
|
2250
|
+
//#region agent tools(v2.1):把救援/迁移能力暴露给模型
|
|
2251
|
+
// 注册的是裸 ToolDefinition 对象(零依赖):dsh-tools 的 register 只要求
|
|
2252
|
+
// { name, description, parameters(JSON Schema), output:{schema, render}, execute };
|
|
2253
|
+
// defineTool 只是参数校验语法糖,execute 内自行做轻校验即可。
|
|
2254
|
+
// 变更类工具统一走 ctx.approval.request(dsh-user-approval,dsh-base 默认挂载)
|
|
2255
|
+
// 弹用户确认——与面板 confirm 同级的人审;接缝缺失时降级为模型对话纪律。
|
|
2256
|
+
|
|
2257
|
+
/** 通过审批接缝请求用户确认;未获准(denied/cancelled/unavailable)一律抛错拒绝。 */
|
|
2258
|
+
async function approveOrThrow(ctx, exec, toolName, reason) {
|
|
2259
|
+
const approval = ctx?.approval;
|
|
2260
|
+
if (!exec?.agent || typeof approval?.request !== 'function') return; // 无审批接缝(如无 UI 宿主):由模型对话纪律兜底
|
|
2261
|
+
let outcome;
|
|
2262
|
+
try {
|
|
2263
|
+
outcome = await approval.request({ agent: exec.agent, toolName, reason, signal: exec.signal });
|
|
2264
|
+
} catch (err) {
|
|
2265
|
+
// request 在回合外等异常情况会抛错:按未批准处理,不静默放行
|
|
2266
|
+
throw new Error(`${toolName} approval could not be requested (${err?.message ?? err}); use the rescue panel in settings instead`);
|
|
2267
|
+
}
|
|
2268
|
+
if (outcome !== 'allowed-once') {
|
|
2269
|
+
throw new Error(`user did not approve this ${toolName} call (outcome: ${outcome ?? 'unavailable'}); ask the user first and only retry after they agree, or use the rescue panel in settings`);
|
|
2270
|
+
}
|
|
2271
|
+
}
|
|
2272
|
+
|
|
2273
|
+
/** 统一把 dispatch 的 {ok:false,error} 信封转成模型可见的异常(错误码随行)。 */
|
|
2274
|
+
async function callDispatch(dispatchRef, endpoint, payload, signal) {
|
|
2275
|
+
if (typeof dispatchRef?.fn !== 'function') throw new Error('workspace-mover RPC is not mounted on this host; the rescue panel is unavailable too');
|
|
2276
|
+
const res = await dispatchRef.fn(endpoint, payload ?? {}, signal);
|
|
2277
|
+
if (!res?.ok) {
|
|
2278
|
+
const err = new Error(`[${res?.error?.code ?? 'internal-error'}] ${res?.error?.message ?? 'request failed'}`);
|
|
2279
|
+
err.code = res?.error?.code;
|
|
2280
|
+
throw err;
|
|
2281
|
+
}
|
|
2282
|
+
return res.value;
|
|
2283
|
+
}
|
|
2284
|
+
|
|
2285
|
+
const JSON_TEXT = (args, value) => [{ type: 'text', text: JSON.stringify(value) }];
|
|
2286
|
+
const LIST_MAX_SESSIONS = 40;
|
|
2287
|
+
|
|
2288
|
+
/**
|
|
2289
|
+
* 构造三个 agent 工具(复用 RPC dispatch:锁、错误码、事务语义与面板完全一致):
|
|
2290
|
+
* - mover_list_sessions 只读:会话/分组清单,供模型把用户口中的名字解析成精确 id
|
|
2291
|
+
* - mover_move_session 迁移单个会话(审批门)
|
|
2292
|
+
* - mover_repair_sessions 一键修复(审批门,只动记账不动文件)
|
|
2293
|
+
*/
|
|
2294
|
+
function createAgentTools(ctx, dispatchRef) {
|
|
2295
|
+
const SIGNAL_NOTE = 'Cancellation is cooperative: the in-flight step finishes rolling back to a consistent state before stopping.';
|
|
2296
|
+
const tools = [
|
|
2297
|
+
{
|
|
2298
|
+
name: 'mover_list_sessions',
|
|
2299
|
+
description: 'List DSH workspace groups and their sessions for the workspace-mover plugin. Read-only. Returns workspace ids/titles/paths, per-session id/title/path/rescue status (ok / orphaned / unregistered / misfiled / unreadable), aggregate counts, and how many records need manual recovery. Use it to resolve a user-named group or conversation into the exact ids required by mover_move_session and mover_repair_sessions.',
|
|
2300
|
+
parameters: { type: 'object', properties: {}, additionalProperties: false },
|
|
2301
|
+
output: { schema: { type: 'object' }, render: JSON_TEXT },
|
|
2302
|
+
isConcurrencySafe: () => true,
|
|
2303
|
+
timeoutMs: 30000,
|
|
2304
|
+
async execute(args, exec) {
|
|
2305
|
+
const scan = await callDispatch(dispatchRef, 'mover.scan', {}, exec?.signal);
|
|
2306
|
+
const ws = await callDispatch(dispatchRef, 'mover.workspaces', {}, exec?.signal);
|
|
2307
|
+
const sessions = (scan.items ?? []).slice(0, LIST_MAX_SESSIONS).map((it) => ({
|
|
2308
|
+
sessionId: it.sessionId,
|
|
2309
|
+
title: it.title,
|
|
2310
|
+
cwd: it.cwd,
|
|
2311
|
+
status: it.status,
|
|
2312
|
+
ownerWorkspaceIds: it.ownerWorkspaceIds ?? []
|
|
2313
|
+
}));
|
|
2314
|
+
return {
|
|
2315
|
+
workspaces: (ws.items ?? []).map((w) => ({
|
|
2316
|
+
workspaceId: w.workspaceId,
|
|
2317
|
+
title: w.title,
|
|
2318
|
+
path: w.path,
|
|
2319
|
+
sessionCount: Array.isArray(w.sessionIds) ? w.sessionIds.length : undefined
|
|
2320
|
+
})),
|
|
2321
|
+
sessions,
|
|
2322
|
+
counts: scan.counts ?? {},
|
|
2323
|
+
scanned: scan.scanned,
|
|
2324
|
+
truncated: Boolean(scan.truncated) || (scan.items?.length ?? 0) > LIST_MAX_SESSIONS,
|
|
2325
|
+
recoveryCount: scan.recoveryCount ?? 0,
|
|
2326
|
+
ghosts: (scan.ghosts ?? []).slice(0, 20)
|
|
2327
|
+
};
|
|
2328
|
+
}
|
|
2329
|
+
},
|
|
2330
|
+
{
|
|
2331
|
+
name: 'mover_move_session',
|
|
2332
|
+
description: `Move one DSH session into another workspace group with full protection: byte-level backup is taken before anything changes, then the session header is rewritten, the archive directory is moved atomically, the result is verified by re-reading, and any failure rolls everything back automatically. The session keeps its id and full history; a post-move record enables one-click undo. ${SIGNAL_NOTE} Ask the user to confirm the target group before calling, then pass the exact ids from mover_list_sessions.`,
|
|
2333
|
+
parameters: {
|
|
2334
|
+
type: 'object',
|
|
2335
|
+
properties: {
|
|
2336
|
+
sessionId: { type: 'string', description: 'Exact session id from mover_list_sessions' },
|
|
2337
|
+
targetWorkspaceId: { type: 'string', description: 'Exact workspace id of the destination group from mover_list_sessions' },
|
|
2338
|
+
sessionTitle: { type: 'string', description: 'Optional display title used in move history; omit to use the session\'s own title' }
|
|
2339
|
+
},
|
|
2340
|
+
required: ['sessionId', 'targetWorkspaceId'],
|
|
2341
|
+
additionalProperties: false
|
|
2342
|
+
},
|
|
2343
|
+
output: { schema: { type: 'object' }, render: JSON_TEXT },
|
|
2344
|
+
timeoutMs: 120000,
|
|
2345
|
+
async execute(args, exec) {
|
|
2346
|
+
const sessionId = validId(args?.sessionId, 'sessionId');
|
|
2347
|
+
const targetWorkspaceId = validId(args?.targetWorkspaceId, 'targetWorkspaceId');
|
|
2348
|
+
await approveOrThrow(ctx, exec, 'mover_move_session', `Move session "${sessionId}" into workspace group "${targetWorkspaceId}" (files are moved on disk with automatic backup and rollback).`);
|
|
2349
|
+
return await callDispatch(dispatchRef, 'mover.move', { sessionId, targetWorkspaceId, sessionTitle: typeof args?.sessionTitle === 'string' ? args.sessionTitle : undefined }, exec?.signal);
|
|
2350
|
+
}
|
|
2351
|
+
},
|
|
2352
|
+
{
|
|
2353
|
+
name: 'mover_repair_sessions',
|
|
2354
|
+
description: `Run one workspace-mover repair pass across all detected session issues: misfiled sessions are re-homed to the group matching their folder, unregistered sessions with a matching group get accounted. Accounting-only — no session files are moved. Anything needing a human decision (orphaned, damaged archives, no matching group) is skipped with a stated reason, and results report fixed / skipped / failed. ${SIGNAL_NOTE} Ask the user to confirm before calling.`,
|
|
2355
|
+
parameters: { type: 'object', properties: {}, additionalProperties: false },
|
|
2356
|
+
output: { schema: { type: 'object' }, render: JSON_TEXT },
|
|
2357
|
+
timeoutMs: 120000,
|
|
2358
|
+
async execute(args, exec) {
|
|
2359
|
+
await approveOrThrow(ctx, exec, 'mover_repair_sessions', 'Run a workspace-mover repair pass (accounting-only fixes for misfiled / unregistered sessions; no files are moved).');
|
|
2360
|
+
return await callDispatch(dispatchRef, 'mover.repairAll', {}, exec?.signal);
|
|
2361
|
+
}
|
|
2362
|
+
},
|
|
2363
|
+
{
|
|
2364
|
+
name: 'mover_doctor',
|
|
2365
|
+
description: `Run the workspace-mover self-check (read-only diagnostics): host services (registry / persistence / projection cache / archive channel / agents / approval seam), agent-tool registration, data directories (recycle bin, backups, move history, tasks), pending manual-recovery records, and workspace folder presence. Returns per-check pass / warn / fail states with details and a summary — use it after a DSH upgrade or when a feature misbehaves, to see what is degraded before attempting anything else. No user approval needed; nothing is modified.`,
|
|
2366
|
+
parameters: { type: 'object', properties: {}, additionalProperties: false },
|
|
2367
|
+
output: { schema: { type: 'object' }, render: JSON_TEXT },
|
|
2368
|
+
isConcurrencySafe: () => true,
|
|
2369
|
+
timeoutMs: 30000,
|
|
2370
|
+
async execute(args, exec) {
|
|
2371
|
+
return await callDispatch(dispatchRef, 'mover.doctor', {}, exec?.signal);
|
|
2372
|
+
}
|
|
2373
|
+
}
|
|
2374
|
+
];
|
|
2375
|
+
return tools;
|
|
2376
|
+
}
|
|
2377
|
+
|
|
2378
|
+
/** 宿主带 dsh-tools 时注册;逐工具隔离失败(一个失败不影响其余),缺服务时静默跳过。 */
|
|
2379
|
+
function registerAgentTools(ctx, dispatchRef, logger, agentToolsState = { registered: false }) {
|
|
2380
|
+
if (typeof ctx?.tools?.register !== 'function') return;
|
|
2381
|
+
for (const tool of createAgentTools(ctx, dispatchRef)) {
|
|
2382
|
+
try {
|
|
2383
|
+
ctx.tools.register(tool);
|
|
2384
|
+
agentToolsState.registered = true;
|
|
2385
|
+
} catch (err) {
|
|
2386
|
+
logger?.warn?.(`workspace-mover: agent tool '${tool?.name}' registration failed: ${err?.message ?? err}`);
|
|
2387
|
+
}
|
|
2388
|
+
}
|
|
2389
|
+
}
|
|
2390
|
+
//#endregion
|
|
2391
|
+
|
|
2392
|
+
//#region doctor(v2.2):只读自检——服务、数据目录、恢复记录、工作区路径
|
|
2393
|
+
// 纯 ok(value),无锁无变更;刻意不跑全量 scan,保证秒级返回。
|
|
2394
|
+
async function runDoctor(ctx, agentToolsState) {
|
|
2395
|
+
const checks = [];
|
|
2396
|
+
const add = (id, state, detail = '') => checks.push({ id, state, detail });
|
|
2397
|
+
const pass = (id, detail) => add(id, 'pass', detail);
|
|
2398
|
+
const warn = (id, detail) => add(id, 'warn', detail);
|
|
2399
|
+
const fail = (id, detail) => add(id, 'fail', detail);
|
|
2400
|
+
|
|
2401
|
+
const registry = ctx?.workspaceRegistry;
|
|
2402
|
+
const persistence = ctx?.sessionPersistence;
|
|
2403
|
+
|
|
2404
|
+
if (!registry) fail('host-registry', 'workspace registry service unavailable');
|
|
2405
|
+
else {
|
|
2406
|
+
let count = 0;
|
|
2407
|
+
try { count = registry.list().length; } catch { /* list 失败按 0 报告 */ }
|
|
2408
|
+
pass('host-registry', `${count} workspaces`);
|
|
2409
|
+
}
|
|
2410
|
+
|
|
2411
|
+
if (!persistence) fail('host-persistence', 'session persistence service unavailable');
|
|
2412
|
+
else if (typeof persistence.root === 'string' && dirExists(persistence.root)) pass('host-persistence', String(persistence.root));
|
|
2413
|
+
else fail('host-persistence', `sessions root missing: ${persistence.root ?? '(none)'}`);
|
|
2414
|
+
|
|
2415
|
+
const projection = projectionTable(ctx);
|
|
2416
|
+
const projectionRead = typeof projection?.get === 'function';
|
|
2417
|
+
const projectionWrite = projectionRead && typeof projection?.put === 'function' && typeof projection?.delete === 'function';
|
|
2418
|
+
if (projectionWrite) pass('host-projection', 'read + write');
|
|
2419
|
+
else if (projectionRead) warn('host-projection', 'read-only — projection title restore degraded');
|
|
2420
|
+
else warn('host-projection', 'absent — projection title restore degraded');
|
|
2421
|
+
|
|
2422
|
+
if (typeof registry?.enqueueOperation === 'function') pass('host-archive', 'archive-set channel ready');
|
|
2423
|
+
else warn('host-archive', 'archive-set changes degraded');
|
|
2424
|
+
|
|
2425
|
+
if (peekService(ctx, 'agents')) pass('host-agents', 'running-state checks active');
|
|
2426
|
+
else warn('host-agents', 'running-state checks degraded');
|
|
2427
|
+
|
|
2428
|
+
if (typeof ctx?.approval?.request === 'function') pass('host-approval', 'approval seam ready');
|
|
2429
|
+
else warn('host-approval', 'agent approval seam absent — conversational moves rely on model discipline');
|
|
2430
|
+
|
|
2431
|
+
if (agentToolsState?.registered) pass('agent-tools', 'registered with dsh-tools');
|
|
2432
|
+
else warn('agent-tools', 'not registered — dsh-tools absent on this host');
|
|
2433
|
+
|
|
2434
|
+
try { const trash = await listTrash(ctx); pass('data-recycle', `${trash.items.length} entries`); }
|
|
2435
|
+
catch (err) { fail('data-recycle', String(err?.message ?? err).slice(0, 140)); }
|
|
2436
|
+
|
|
2437
|
+
try { const backups = await listBackups(ctx); pass('data-backups', `${backups.items.length} session groups`); }
|
|
2438
|
+
catch (err) { fail('data-backups', String(err?.message ?? err).slice(0, 140)); }
|
|
2439
|
+
|
|
2440
|
+
try { const history = await listHistory(); pass('data-history', `${history.items?.length ?? 0} records`); }
|
|
2441
|
+
catch (err) { fail('data-history', String(err?.message ?? err).slice(0, 140)); }
|
|
2442
|
+
|
|
2443
|
+
try { const tasks = await listTasks(); pass('data-tasks', `${tasks.items?.length ?? 0} records`); }
|
|
2444
|
+
catch (err) { fail('data-tasks', String(err?.message ?? err).slice(0, 140)); }
|
|
2445
|
+
|
|
2446
|
+
const recovered = recoveryCount();
|
|
2447
|
+
if (recovered === 0) pass('recovery', 'no manual-recovery records');
|
|
2448
|
+
else warn('recovery', `${recovered} record(s) need manual review — see the rescue panel`);
|
|
2449
|
+
|
|
2450
|
+
if (!registry) warn('workspaces', 'skipped — registry unavailable');
|
|
2451
|
+
else {
|
|
2452
|
+
let total = 0;
|
|
2453
|
+
let missing = [];
|
|
2454
|
+
try {
|
|
2455
|
+
for (const entity of registry.list()) {
|
|
2456
|
+
total++;
|
|
2457
|
+
try { if (!dirExists(entity.path)) missing.push(String(entity.id)); }
|
|
2458
|
+
catch { missing.push(String(entity.id)); }
|
|
2459
|
+
}
|
|
2460
|
+
} catch { /* 同上 */ }
|
|
2461
|
+
if (total === 0) warn('workspaces', 'no registered workspaces');
|
|
2462
|
+
else if (missing.length === 0) pass('workspaces', `${total} paths all present`);
|
|
2463
|
+
else warn('workspaces', `${missing.length}/${total} folders missing: ${missing.slice(0, 5).join(', ')}`);
|
|
2464
|
+
}
|
|
2465
|
+
|
|
2466
|
+
const summary = { pass: 0, warn: 0, fail: 0 };
|
|
2467
|
+
for (const c of checks) summary[c.state]++;
|
|
2468
|
+
return { checks, summary, generatedAt: new Date().toISOString() };
|
|
2469
|
+
}
|
|
2470
|
+
//#endregion
|
|
2471
|
+
|
|
2248
2472
|
export function apply(ctx) {
|
|
2249
2473
|
const logger = ctx.logger ?? console;
|
|
2474
|
+
const dispatchRef = { fn: null }; // agent 工具在调用期才用到 dispatch,挂载时序无关
|
|
2475
|
+
const agentToolsState = { registered: false };
|
|
2250
2476
|
const mount = (owner) => {
|
|
2251
2477
|
const connection = owner?.connection;
|
|
2252
2478
|
if (!connection?.rpc?.handle) return;
|
|
@@ -2254,7 +2480,7 @@ export function apply(ctx) {
|
|
|
2254
2480
|
// to this injected web context so rpc.handle() can register on webServer.
|
|
2255
2481
|
const rawConnection = connection[Symbol.for('cordis.original')] ?? connection;
|
|
2256
2482
|
const scopedConnection = rawConnection?.[Symbol.for('cordis.extend')]?.({ ctx: owner }) ?? connection;
|
|
2257
|
-
const
|
|
2483
|
+
const dispatch = async (endpoint, payload = {}, signal) => {
|
|
2258
2484
|
if (signal?.aborted) return { ok: false, error: { code: 'cancelled', message: 'cancelled', details: {} } };
|
|
2259
2485
|
try {
|
|
2260
2486
|
switch (endpoint) {
|
|
@@ -2280,9 +2506,12 @@ export function apply(ctx) {
|
|
|
2280
2506
|
channel: CHANNEL,
|
|
2281
2507
|
capabilities,
|
|
2282
2508
|
degradedFeatures,
|
|
2509
|
+
agentTools: agentToolsState.registered,
|
|
2283
2510
|
recoveryCount: recoveryCount()
|
|
2284
2511
|
});
|
|
2285
2512
|
}
|
|
2513
|
+
case 'mover.doctor':
|
|
2514
|
+
return ok(await runDoctor(ctx, agentToolsState));
|
|
2286
2515
|
case 'mover.workspaces':
|
|
2287
2516
|
return ok(await listWorkspaces(ctx));
|
|
2288
2517
|
case 'mover.history':
|
|
@@ -2432,7 +2661,9 @@ export function apply(ctx) {
|
|
|
2432
2661
|
} catch (err) {
|
|
2433
2662
|
return mapError(err);
|
|
2434
2663
|
}
|
|
2435
|
-
}
|
|
2664
|
+
};
|
|
2665
|
+
dispatchRef.fn = dispatch;
|
|
2666
|
+
const dispose = scopedConnection.rpc.handle(CHANNEL, dispatch, { authority: 'loopback' });
|
|
2436
2667
|
owner.effect?.(() => () => { try { dispose?.(); } catch { /* ignore */ } }, 'workspace-mover: rpc dispose');
|
|
2437
2668
|
};
|
|
2438
2669
|
|
|
@@ -2441,6 +2672,8 @@ export function apply(ctx) {
|
|
|
2441
2672
|
} else {
|
|
2442
2673
|
mount(ctx);
|
|
2443
2674
|
}
|
|
2675
|
+
|
|
2676
|
+
registerAgentTools(ctx, dispatchRef, logger, agentToolsState);
|
|
2444
2677
|
}
|
|
2445
2678
|
|
|
2446
2679
|
export { name, inject, CHANNEL, projectKey, encodeSegment, sessionDir, artifactPath, generationLogFilename, parseGenerationLogFilename, listGenerationArtifacts, findHighestArtifact, preferredArtifactName, moveDir, stashBackup, verifyRelocatedArtifact, deleteSessionToTrash, moveSession, moveSessionEntry, validId, SCAN_MAX_ITEMS };
|
package/package.json
CHANGED
|
@@ -1,62 +1,63 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "dsh-workspace-mover",
|
|
3
|
-
"version": "2.
|
|
4
|
-
"description": "在 DSH
|
|
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
|
-
"docs/ARCHITECTURE.md",
|
|
19
|
-
"docs/media",
|
|
20
|
-
"LICENSE"
|
|
21
|
-
],
|
|
22
|
-
"scripts": {
|
|
23
|
-
"test": "node --test test/core.test.mjs test/e2e-sandbox.test.mjs test/traffic-badge.test.mjs test/release-downloads.test.mjs",
|
|
24
|
-
"check:syntax": "node --check lib/index.js && node --check client/client.js",
|
|
25
|
-
"check:pack": "npm pack --dry-run",
|
|
26
|
-
"check": "npm run check:syntax && npm test && npm run check:pack"
|
|
27
|
-
},
|
|
28
|
-
"keywords": [
|
|
29
|
-
"deepseek-harness",
|
|
30
|
-
"dsh",
|
|
31
|
-
"dsh-plugin",
|
|
32
|
-
"workspace",
|
|
33
|
-
"session",
|
|
34
|
-
"session-migration",
|
|
35
|
-
"drag-and-drop"
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
"
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
"
|
|
43
|
-
|
|
44
|
-
"
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
"
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
"@deepseek-ai/dsh-client-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "dsh-workspace-mover",
|
|
3
|
+
"version": "2.1.1",
|
|
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
|
+
"docs/ARCHITECTURE.md",
|
|
19
|
+
"docs/media",
|
|
20
|
+
"LICENSE"
|
|
21
|
+
],
|
|
22
|
+
"scripts": {
|
|
23
|
+
"test": "node --test test/core.test.mjs test/e2e-sandbox.test.mjs test/traffic-badge.test.mjs test/release-downloads.test.mjs",
|
|
24
|
+
"check:syntax": "node --check lib/index.js && node --check client/client.js",
|
|
25
|
+
"check:pack": "npm pack --dry-run",
|
|
26
|
+
"check": "npm run check:syntax && npm test && npm run check:pack"
|
|
27
|
+
},
|
|
28
|
+
"keywords": [
|
|
29
|
+
"deepseek-harness",
|
|
30
|
+
"dsh",
|
|
31
|
+
"dsh-plugin",
|
|
32
|
+
"workspace",
|
|
33
|
+
"session",
|
|
34
|
+
"session-migration",
|
|
35
|
+
"drag-and-drop",
|
|
36
|
+
"agent-tools"
|
|
37
|
+
],
|
|
38
|
+
"engines": {
|
|
39
|
+
"node": ">=22",
|
|
40
|
+
"dsh": ">=0.1.5-rc.1"
|
|
41
|
+
},
|
|
42
|
+
"license": "MIT",
|
|
43
|
+
"repository": {
|
|
44
|
+
"type": "git",
|
|
45
|
+
"url": "git+https://github.com/PianoPrince/dsh-workspace-mover.git"
|
|
46
|
+
},
|
|
47
|
+
"bugs": {
|
|
48
|
+
"url": "https://github.com/PianoPrince/dsh-workspace-mover/issues"
|
|
49
|
+
},
|
|
50
|
+
"homepage": "https://github.com/PianoPrince/dsh-workspace-mover#readme",
|
|
51
|
+
"dsh": {
|
|
52
|
+
"bundle": {
|
|
53
|
+
"patch": "./cordis.patch.yml"
|
|
54
|
+
},
|
|
55
|
+
"client": {
|
|
56
|
+
"inject": [
|
|
57
|
+
"@deepseek-ai/dsh-client-connection",
|
|
58
|
+
"@deepseek-ai/dsh-client-ui-slots"
|
|
59
|
+
],
|
|
60
|
+
"platform": "web"
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
@@ -1802,3 +1802,161 @@ test('mover.session.delete: archived resident idle session still refuses delete'
|
|
|
1802
1802
|
assert.ok(existsSync(artifactPath(root, A, 'session-aaa')), 'artifact untouched');
|
|
1803
1803
|
assert.deepEqual(ctx.workspaceRegistry.archivedSessionIds, ['session-aaa']);
|
|
1804
1804
|
});
|
|
1805
|
+
|
|
1806
|
+
// ===== v2.1.0 agent 工具 =====
|
|
1807
|
+
|
|
1808
|
+
/** 给 ctx 挂上 dsh-tools / dsh-user-approval 的测试替身,返回工具定义捕获数组和审批记录。 */
|
|
1809
|
+
function mountToolStubs(approvalOutcome = 'allowed-once') {
|
|
1810
|
+
const registered = [];
|
|
1811
|
+
const approvalCalls = [];
|
|
1812
|
+
ctx.tools = { register: (def) => registered.push(def) };
|
|
1813
|
+
ctx.approval = {
|
|
1814
|
+
async request(req) {
|
|
1815
|
+
approvalCalls.push(req);
|
|
1816
|
+
if (typeof approvalOutcome === 'function') return approvalOutcome(req);
|
|
1817
|
+
return approvalOutcome;
|
|
1818
|
+
}
|
|
1819
|
+
};
|
|
1820
|
+
return { registered, approvalCalls };
|
|
1821
|
+
}
|
|
1822
|
+
|
|
1823
|
+
const FAKE_EXEC = () => ({ agent: { session: {} }, signal: undefined });
|
|
1824
|
+
|
|
1825
|
+
test('v2.1 apply 在带 dsh-tools 的宿主上注册三个 agent 工具,status 汇报能力位', async () => {
|
|
1826
|
+
const { registered } = mountToolStubs();
|
|
1827
|
+
apply(ctx);
|
|
1828
|
+
assert.deepEqual(registered.map((t) => t.name), ['mover_list_sessions', 'mover_move_session', 'mover_repair_sessions', 'mover_doctor']);
|
|
1829
|
+
for (const def of registered) {
|
|
1830
|
+
assert.equal(typeof def.execute, 'function', `${def.name}.execute`);
|
|
1831
|
+
assert.equal(typeof def.output?.render, 'function', `${def.name}.output.render`);
|
|
1832
|
+
assert.equal(def.output?.schema?.type, 'object', `${def.name}.output.schema`);
|
|
1833
|
+
}
|
|
1834
|
+
assert.equal(registered[1].parameters.required.join(','), 'sessionId,targetWorkspaceId');
|
|
1835
|
+
const status = await call('mover.status');
|
|
1836
|
+
assert.equal(status.value.agentTools, true);
|
|
1837
|
+
});
|
|
1838
|
+
|
|
1839
|
+
test('v2.1 无 dsh-tools 宿主上 apply 不受影响(静默跳过)', async () => {
|
|
1840
|
+
apply(ctx);
|
|
1841
|
+
const status = await call('mover.status');
|
|
1842
|
+
assert.equal(status.ok, true);
|
|
1843
|
+
assert.equal(status.value.agentTools, false);
|
|
1844
|
+
});
|
|
1845
|
+
|
|
1846
|
+
test('v2.1 mover_list_sessions 合成 scan + workspaces(含状态与计数)', async () => {
|
|
1847
|
+
const { registered } = mountToolStubs();
|
|
1848
|
+
apply(ctx);
|
|
1849
|
+
const list = registered.find((t) => t.name === 'mover_list_sessions');
|
|
1850
|
+
entityA.record.sessionIds.push('session-aaa'); // 夹具本就未记账:记账后断言 ok 路径
|
|
1851
|
+
const value = await list.execute({}, FAKE_EXEC());
|
|
1852
|
+
assert.ok(value.workspaces.some((w) => w.workspaceId === 'wid-a'));
|
|
1853
|
+
assert.ok(value.workspaces.some((w) => w.workspaceId === 'wid-b'));
|
|
1854
|
+
const aaa = value.sessions.find((s) => s.sessionId === 'session-aaa');
|
|
1855
|
+
assert.ok(aaa, 'fixture session listed');
|
|
1856
|
+
assert.equal(aaa.status, 'ok');
|
|
1857
|
+
assert.deepEqual(aaa.ownerWorkspaceIds, ['wid-a']);
|
|
1858
|
+
assert.equal(typeof value.counts, 'object');
|
|
1859
|
+
assert.equal(value.recoveryCount, 0);
|
|
1860
|
+
});
|
|
1861
|
+
|
|
1862
|
+
test('v2.1 mover_move_session 审批通过后经 dispatch 迁移成功', async () => {
|
|
1863
|
+
const { registered, approvalCalls } = mountToolStubs();
|
|
1864
|
+
apply(ctx);
|
|
1865
|
+
const move = registered.find((t) => t.name === 'mover_move_session');
|
|
1866
|
+
const exec = FAKE_EXEC();
|
|
1867
|
+
const value = await move.execute({ sessionId: 'session-aaa', targetWorkspaceId: 'wid-b' }, exec);
|
|
1868
|
+
assert.equal(value.verified, true);
|
|
1869
|
+
assert.equal(readHeader(readFileSync(findArtifact('session-aaa'))).cwd, B);
|
|
1870
|
+
assert.equal(approvalCalls.length, 1);
|
|
1871
|
+
assert.equal(approvalCalls[0].toolName, 'mover_move_session');
|
|
1872
|
+
assert.equal(approvalCalls[0].agent, exec.agent);
|
|
1873
|
+
assert.match(String(approvalCalls[0].reason), /session-aaa/);
|
|
1874
|
+
});
|
|
1875
|
+
|
|
1876
|
+
test('v2.1 审批拒绝 / 接缝异常时 move 拒绝执行(fail-closed)', async () => {
|
|
1877
|
+
for (const outcome of ['denied', 'cancelled', 'unavailable']) {
|
|
1878
|
+
const { registered } = mountToolStubs(outcome);
|
|
1879
|
+
apply(ctx);
|
|
1880
|
+
const move = registered.find((t) => t.name === 'mover_move_session');
|
|
1881
|
+
await assert.rejects(
|
|
1882
|
+
move.execute({ sessionId: 'session-aaa', targetWorkspaceId: 'wid-b' }, FAKE_EXEC()),
|
|
1883
|
+
new RegExp(outcome)
|
|
1884
|
+
);
|
|
1885
|
+
assert.equal(readHeader(readFileSync(findArtifact('session-aaa'))).cwd, A, `${outcome} 后文件未动`);
|
|
1886
|
+
}
|
|
1887
|
+
// 接缝抛错:同样不放行
|
|
1888
|
+
const { registered: reg2 } = mountToolStubs(() => { throw new Error('no open turn'); });
|
|
1889
|
+
apply(ctx);
|
|
1890
|
+
const move2 = reg2.find((t) => t.name === 'mover_move_session');
|
|
1891
|
+
await assert.rejects(move2.execute({ sessionId: 'session-aaa', targetWorkspaceId: 'wid-b' }, FAKE_EXEC()), /could not be requested/);
|
|
1892
|
+
assert.equal(readHeader(readFileSync(findArtifact('session-aaa'))).cwd, A);
|
|
1893
|
+
});
|
|
1894
|
+
|
|
1895
|
+
test('v2.1 mover_move_session 错误码透传;无效输入不惊动审批', async () => {
|
|
1896
|
+
const { registered, approvalCalls } = mountToolStubs();
|
|
1897
|
+
apply(ctx);
|
|
1898
|
+
const move = registered.find((t) => t.name === 'mover_move_session');
|
|
1899
|
+
await assert.rejects(
|
|
1900
|
+
move.execute({ sessionId: 'session-aaa', targetWorkspaceId: 'wid-nope' }, FAKE_EXEC()),
|
|
1901
|
+
/\[not-found\]/
|
|
1902
|
+
);
|
|
1903
|
+
await assert.rejects(
|
|
1904
|
+
move.execute({ sessionId: 'x'.repeat(301), targetWorkspaceId: 'wid-b' }, FAKE_EXEC()),
|
|
1905
|
+
/\[invalid-input\]|must be a non-empty string/
|
|
1906
|
+
);
|
|
1907
|
+
assert.equal(approvalCalls.length, 1, 'invalid-input 在审批前就被拦截');
|
|
1908
|
+
});
|
|
1909
|
+
|
|
1910
|
+
test('v2.1 mover_repair_sessions 走审批并修复未记账夹具会话', async () => {
|
|
1911
|
+
const { registered, approvalCalls } = mountToolStubs();
|
|
1912
|
+
apply(ctx);
|
|
1913
|
+
const repair = registered.find((t) => t.name === 'mover_repair_sessions');
|
|
1914
|
+
// 夹具 session-aaa 有匹配分组(wid-a)但未记账:repairAll 应把它补账
|
|
1915
|
+
const value = await repair.execute({}, FAKE_EXEC());
|
|
1916
|
+
assert.equal(value.fixedCount, 1);
|
|
1917
|
+
assert.equal(value.failedCount, 0);
|
|
1918
|
+
assert.ok(entityA.record.sessionIds.includes('session-aaa'), 'repair 后已补账');
|
|
1919
|
+
assert.equal(approvalCalls.length, 1);
|
|
1920
|
+
assert.equal(approvalCalls[0].toolName, 'mover_repair_sessions');
|
|
1921
|
+
});
|
|
1922
|
+
|
|
1923
|
+
// ===== v2.2.0 mover.doctor 自检 =====
|
|
1924
|
+
|
|
1925
|
+
test('v2.2 mover.doctor 沙箱全绿:服务/数据/工作区全部 pass', async () => {
|
|
1926
|
+
apply(ctx);
|
|
1927
|
+
const res = await call('mover.doctor');
|
|
1928
|
+
assert.equal(res.ok, true, JSON.stringify(res));
|
|
1929
|
+
const { checks, summary } = res.value;
|
|
1930
|
+
assert.ok(checks.length >= 12, `至少 12 项检查,实际 ${checks.length}`);
|
|
1931
|
+
for (const c of checks) assert.ok(['pass', 'warn', 'fail'].includes(c.state), `state 合法: ${c.id}`);
|
|
1932
|
+
assert.equal(summary.fail, 0, `不应有 fail: ${JSON.stringify(checks.filter((c) => c.state === 'fail'))}`);
|
|
1933
|
+
const ids = new Set(checks.map((c) => c.id));
|
|
1934
|
+
for (const expected of ['host-registry', 'host-persistence', 'host-projection', 'data-recycle', 'data-backups', 'recovery', 'workspaces']) {
|
|
1935
|
+
assert.ok(ids.has(expected), `缺少检查项 ${expected}`);
|
|
1936
|
+
}
|
|
1937
|
+
assert.equal(typeof res.value.generatedAt, 'string');
|
|
1938
|
+
});
|
|
1939
|
+
|
|
1940
|
+
test('v2.2 mover.doctor 降级:摘除注册表后 fail + warn,但不抛错', async () => {
|
|
1941
|
+
apply(ctx);
|
|
1942
|
+
ctx.workspaceRegistry = undefined;
|
|
1943
|
+
const res = await call('mover.doctor');
|
|
1944
|
+
assert.equal(res.ok, true, 'doctor 本身永远 ok');
|
|
1945
|
+
const registryCheck = res.value.checks.find((c) => c.id === 'host-registry');
|
|
1946
|
+
assert.equal(registryCheck.state, 'fail');
|
|
1947
|
+
const workspacesCheck = res.value.checks.find((c) => c.id === 'workspaces');
|
|
1948
|
+
assert.equal(workspacesCheck.state, 'warn');
|
|
1949
|
+
assert.equal(res.value.summary.fail >= 1, true);
|
|
1950
|
+
});
|
|
1951
|
+
|
|
1952
|
+
test('v2.2 mover.doctor 恢复记录告警:recovery.json 存在时 warn', async () => {
|
|
1953
|
+
apply(ctx);
|
|
1954
|
+
const dir = join(root, 'workspace-mover');
|
|
1955
|
+
mkdirSync(dir, { recursive: true });
|
|
1956
|
+
writeFileSync(join(dir, 'recovery.json'), JSON.stringify({ version: 1, records: [{ id: 'rec-x', kind: 'delete', phase: 'test', createdAt: new Date().toISOString() }] }));
|
|
1957
|
+
const res = await call('mover.doctor');
|
|
1958
|
+
assert.equal(res.ok, true);
|
|
1959
|
+
const recovery = res.value.checks.find((c) => c.id === 'recovery');
|
|
1960
|
+
assert.equal(recovery.state, 'warn');
|
|
1961
|
+
assert.match(recovery.detail, /1 record/);
|
|
1962
|
+
});
|