dsh-workspace-mover 2.0.3 → 2.1.0

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.
@@ -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. **Preflight** — dual-accounting detect, target writability probe, disk-space hint.
69
- 4. **Byte backup** to `$DSH_HOME/workspace-mover/backups/` (rolling 20 per session) **before any side effect**.
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
- const inject = ['workspaceRegistry', 'sessionPersistence'];
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,141 @@ 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
+ return tools;
2365
+ }
2366
+
2367
+ /** 宿主带 dsh-tools 时注册;逐工具隔离失败(一个失败不影响其余),缺服务时静默跳过。 */
2368
+ function registerAgentTools(ctx, dispatchRef, logger, agentToolsState = { registered: false }) {
2369
+ if (typeof ctx?.tools?.register !== 'function') return;
2370
+ for (const tool of createAgentTools(ctx, dispatchRef)) {
2371
+ try {
2372
+ ctx.tools.register(tool);
2373
+ agentToolsState.registered = true;
2374
+ } catch (err) {
2375
+ logger?.warn?.(`workspace-mover: agent tool '${tool?.name}' registration failed: ${err?.message ?? err}`);
2376
+ }
2377
+ }
2378
+ }
2379
+ //#endregion
2380
+
2248
2381
  export function apply(ctx) {
2249
2382
  const logger = ctx.logger ?? console;
2383
+ const dispatchRef = { fn: null }; // agent 工具在调用期才用到 dispatch,挂载时序无关
2384
+ const agentToolsState = { registered: false };
2250
2385
  const mount = (owner) => {
2251
2386
  const connection = owner?.connection;
2252
2387
  if (!connection?.rpc?.handle) return;
@@ -2254,7 +2389,7 @@ export function apply(ctx) {
2254
2389
  // to this injected web context so rpc.handle() can register on webServer.
2255
2390
  const rawConnection = connection[Symbol.for('cordis.original')] ?? connection;
2256
2391
  const scopedConnection = rawConnection?.[Symbol.for('cordis.extend')]?.({ ctx: owner }) ?? connection;
2257
- const dispose = scopedConnection.rpc.handle(CHANNEL, async (endpoint, payload = {}, signal) => {
2392
+ const dispatch = async (endpoint, payload = {}, signal) => {
2258
2393
  if (signal?.aborted) return { ok: false, error: { code: 'cancelled', message: 'cancelled', details: {} } };
2259
2394
  try {
2260
2395
  switch (endpoint) {
@@ -2280,6 +2415,7 @@ export function apply(ctx) {
2280
2415
  channel: CHANNEL,
2281
2416
  capabilities,
2282
2417
  degradedFeatures,
2418
+ agentTools: agentToolsState.registered,
2283
2419
  recoveryCount: recoveryCount()
2284
2420
  });
2285
2421
  }
@@ -2432,7 +2568,9 @@ export function apply(ctx) {
2432
2568
  } catch (err) {
2433
2569
  return mapError(err);
2434
2570
  }
2435
- }, { authority: 'loopback' });
2571
+ };
2572
+ dispatchRef.fn = dispatch;
2573
+ const dispose = scopedConnection.rpc.handle(CHANNEL, dispatch, { authority: 'loopback' });
2436
2574
  owner.effect?.(() => () => { try { dispose?.(); } catch { /* ignore */ } }, 'workspace-mover: rpc dispose');
2437
2575
  };
2438
2576
 
@@ -2441,6 +2579,8 @@ export function apply(ctx) {
2441
2579
  } else {
2442
2580
  mount(ctx);
2443
2581
  }
2582
+
2583
+ registerAgentTools(ctx, dispatchRef, logger, agentToolsState);
2444
2584
  }
2445
2585
 
2446
2586
  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,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-workspace-mover",
3
- "version": "2.0.3",
4
- "description": "在 DSH 侧边栏安全迁移会话:真迁移原始档案,支持失联会话恢复、移动历史和一键撤回。",
3
+ "version": "2.1.0",
4
+ "description": "在 DSH 侧边栏安全迁移会话:真迁移原始档案,支持失联会话恢复、移动历史、一键撤回和 agent 工具。",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "exports": {
@@ -32,7 +32,8 @@
32
32
  "workspace",
33
33
  "session",
34
34
  "session-migration",
35
- "drag-and-drop"
35
+ "drag-and-drop",
36
+ "agent-tools"
36
37
  ],
37
38
  "engines": {
38
39
  "node": ">=22",
@@ -1802,3 +1802,120 @@ 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']);
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
+ });