dsh-workspace-mover 2.1.1 → 2.2.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.
@@ -32,36 +32,71 @@ Single logical channel `/workspace-mover` via `ctx.connection.rpc.handle('/works
32
32
 
33
33
  | Endpoint | Purpose |
34
34
  | --- | --- |
35
- | `mover.status` | Capability report (detected services / degraded items) |
35
+ | `mover.status` | Capability report (detected services / degraded items) plus `storageLayout`, `degradedFeatures`, and the `unarchiveApi` / `setTitleApi` flags |
36
36
  | `mover.workspaces` | List workspaces |
37
37
  | `mover.move` | Single-session true move |
38
- | `mover.moveMany` | Bulk move (≤50 / batch) |
39
- | `mover.scan` | Classified rescue scan |
40
- | `mover.repair` | Attach / relink one item |
38
+ | `mover.moveMany` | Bulk move (≤ `batchLimit`, default 50) |
39
+ | `mover.scan` | Classified rescue scan (parses the newest `scanLimit`, default 400; reports `scanned` vs `scannedParsed`) |
40
+ | `mover.repair` | Attach / relink / home one item |
41
41
  | `mover.repairAll` | Batch repair (per-item isolation) |
42
- | `mover.history` | Recent moves (≤100) |
42
+ | `mover.history` | Recent moves (≤ `historyLimit`, default 100) |
43
43
  | `mover.undo` | Undo one history entry |
44
44
  | `mover.ws.audit` | Workspace path health |
45
45
  | `mover.repoint` | Move-home wizard |
46
- | `mover.archived` / `mover.unarchive` | List / restore archived sessions |
46
+ | `mover.archived` / `mover.unarchive` | List / restore archived sessions (`mover.archived` also reports `truncated` / `scannedParsed` / `scannedTotal` / `archivedTotal`) |
47
47
  | `mover.openFolder` | Open group directory in OS file manager |
48
- | `mover.session.delete` | Session → recycle bin |
49
- | `mover.trash.list` / `mover.trash.restore` / `mover.trash.purge` | Recycle bin |
50
- | `mover.backups.list` / `mover.backups.restore` / `mover.backups.deleteOne` | Backup management |
48
+ | `mover.session.delete` | Session → recycle bin; optional `purgeBackups: true` also deletes that session's migration backups |
49
+ | `mover.trash.list` / `mover.trash.restore` / `mover.trash.purge` | Recycle bin; `purge` accepts optional `purgeBackups: true` |
50
+ | `mover.backups.list` / `mover.backups.restore` / `mover.backups.deleteOne` | Backup management; `list` reports `orphan` per group plus `orphanGroups` / `orphanBytes` |
51
51
  | `mover.tasks.list` / `mover.tasks.retry` / `mover.tasks.forget` | Migration task center |
52
- | `mover.data.cleanup` | Dry-run and clean aged plugin data |
52
+ | `mover.data.cleanup` | Dry-run and clean aged plugin data (default age = `cleanupDays`) |
53
+
54
+ ### Tunable policies
55
+
56
+ `mover.data.cleanup`'s age and the caps above come from `resolvePolicy()` in `lib/index.js`, read **at the point of use** (never frozen at module load):
57
+
58
+ | Policy | Default | Environment override |
59
+ | --- | --- | --- |
60
+ | backup retention per session | 20 | `DSH_WORKSPACE_MOVER_BACKUP_KEEP` |
61
+ | move history length | 100 | `DSH_WORKSPACE_MOVER_HISTORY_LIMIT` |
62
+ | batch cap (move / repair) | 50 | `DSH_WORKSPACE_MOVER_BATCH_LIMIT` |
63
+ | repoint cap | 200 | `DSH_WORKSPACE_MOVER_REPOINT_LIMIT` |
64
+ | rescue scan cap | 400 | `DSH_WORKSPACE_MOVER_SCAN_LIMIT` |
65
+ | cleanup age (days) | 30 | `DSH_WORKSPACE_MOVER_CLEANUP_DAYS` |
66
+
67
+ An unparseable or out-of-range value falls back to the default, so a bad configuration can never disable a feature.
68
+
69
+ ### Dependency declaration, and why every peer is `*`
70
+
71
+ `package.json` declares the eight host packages this plugin integrates with as `peerDependencies`, all `*` and all `optional` in `peerDependenciesMeta`. They are **declarative metadata for storefronts**, not a compatibility gate: the plugin fail-softs at runtime and reports any real degradation through `mover.status` capabilities and `mover_doctor`. The real floor is `engines.dsh` (`>=0.1.5-rc.1`) plus `engines.node`.
72
+
73
+ This was not the first attempt. The ranges originally used the ecosystem's "explicit prerelease branch" idiom, e.g. `>=0.0.1-rc.1 <0.1.0 || >=0.1.0-rc.1 <0.2.0-0`, and the host then reported the plugin as **incompatible with `0.2.0-rc.2`** — its own tested host line. The mechanism, confirmed by running dsh-market's own range evaluator:
74
+
75
+ - npm's prerelease rule is evaluated at the **comparator-set level**: one `||` alternative is one set, and a prerelease version satisfies that set only when at least one comparator in it shares the version's exact `[major, minor, patch]` tuple *and* carries a prerelease tag; only then are all comparators checked normally.
76
+ - `<0.2.0-0` does share the `0.2.0` tuple and does carry a prerelease, so the set is admitted — but `0.2.0-rc.2 > 0.2.0-0` (the `-0` sentinel sorts below every real prerelease), so the bound then excludes **every** 0.2.0 prerelease.
77
+
78
+ Enumerating release lines reproduces the fault on each new line: a `<0.3.0-0` bound breaks the moment a 0.3 prerelease ships. Hence `*`. dsh-market filters peer declarations to `/^@deepseek-ai\/dsh(?:-|$)/`, which is also why `@deepseek-ai/cordis` and `@deepseek-ai/schemastery` were never named in the warning — they are only evaluated when `engines.dsh` mentions them, and it does not.
79
+
80
+ `test/policy.test.mjs` asserts that the declared ranges plus `engines.dsh` derive `compatible` — never `incompatible` — for DSH versions from `0.1.5-rc.1` through `1.0.0`.
81
+
82
+ **Official Config is deliberately not registered.** The schema would need `@deepseek-ai/schemastery`, which the host loader provides but which is not resolvable from this plugin's directory (verified by `createRequire` from `lib/index.js`); the npm `schemastery` has no `.volatile()` either. Because a bundle-layer static import failure breaks **boot** (a documented ecosystem failure class), `lib/config.js` never imports it — it exposes `buildConfig(z)` / `attachConfig(moduleOrBuilder)` so a host that hands over a schema builder can attach one, and exports `Config === undefined` otherwise. `buildConfig` contains all its own failures and returns `undefined` rather than throwing.
83
+
84
+ ### Official-API preference is observable
85
+
86
+ v2.2 prefers two documented host APIs and keeps an equivalent fallback for each: `registry.unarchiveSession()` (fallback: `enqueueOperation` + `setState`) and `entity.setTitle()` (formerly folded into `entity.mutate`). Both fallbacks are silent by design — the feature still works — so `mover.status.capabilities` reports `unarchiveApi` / `setTitleApi` and lists `unarchive-official-api` / `set-title-official-api` in `degradedFeatures` when the host lacks them. That is what makes "which path did it take?" answerable on a real machine instead of a guess.
53
87
 
54
88
  Errors carry stable codes (`busy`, `conflict`, `not-found`, `rollback-failed`, …). Host logs write failures under `MOVE FAILED`.
55
89
 
56
90
  ## Agent tools (DSH ≥ 0.1.5 with `dsh-tools`)
57
91
 
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:
92
+ When the host provides the `tools` service, `apply()` registers four 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
93
 
60
94
  | Tool | Gate | Purpose |
61
95
  | --- | --- | --- |
62
96
  | `mover_list_sessions` | none (read-only) | Compose scan + workspace list (sessions capped at 40, counts, ghosts, recoveryCount) for id resolution |
63
97
  | `mover_move_session` | user approval | Move one session (`sessionId`, `targetWorkspaceId`, optional `sessionTitle`) |
64
98
  | `mover_repair_sessions` | user approval | One `mover.repairAll` pass (accounting-only) |
99
+ | `mover_doctor` | none (read-only) | One `mover.doctor` self-check pass (host services, data dirs, recovery records, workspace paths) |
65
100
 
66
101
  - 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
102
  - `exec.signal` propagates into the RPC layer; cancellation lets the in-flight step finish rolling back to a consistent state.
@@ -73,13 +108,19 @@ When the host provides the `tools` service, `apply()` registers three plain (zer
73
108
  - Mixed compression in one persistence root is rejected explicitly.
74
109
  - True move keeps the session id and all frames; only the header `cwd` (first frame) is rewritten; other frames stay byte-identical. Publish via temp file + atomic rename.
75
110
 
111
+ ### The storage-layout assumption, and its self-check
112
+
113
+ The whole pipeline rests on two **undocumented** assumptions: that `persistence.root` exists (`SessionPersistence`'s documented surface is `create`/`open`/`stat`/`list`), and that the on-disk directory layout equals this plugin's local port of `projectKey` / `encodeSegment`. If a host changes either, `sessionDir()` silently points at a path that does not exist — presenting as "every session is unfindable" with no diagnostic.
114
+
115
+ `verifyStorageLayout(ctx)` closes that gap: it takes up to five real headers from `persistence.list()`, reverse-derives each session's directory with this plugin's own encoding, and confirms an archive is actually there. Result states are `ok` / `degraded` / `empty` (no sessions to verify) / `unavailable`. It is exposed as `mover.status.capabilities.storageLayout` plus a `data-layout` check in `mover_doctor`, is strictly read-only, and never throws (any error degrades to a warning).
116
+
76
117
  ## Move pipeline
77
118
 
78
119
  Order of operations for a cross-workspace move:
79
120
 
80
121
  1. **Running check** — reject only mid-turn sessions (`agents.get(id)?.status === 'running'`); idle resident sessions may move.
81
122
  2. **Read authority header** from disk; verify target ≠ source.
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.
123
+ 3. **Byte backup** to `$DSH_HOME/workspace-mover/backups/` (rolling `backupKeep` per session, default 20) **before any side effect** — a backup failure aborts with `backup-failed` and nothing has been touched.
83
124
  4. **Preflight** — dual-accounting detect, target writability probe, disk-space hint.
84
125
  5. **Rewrite first frame** (header cwd) only.
85
126
  6. **Move directory** (Windows: exponential-backoff retry on EPERM; fallback copy+delete).
@@ -118,12 +159,13 @@ Official session files and workspace registry are only mutated via host APIs: re
118
159
 
119
160
  ## Compatibility boundary
120
161
 
121
- Verified:
162
+ Verified (each entry dated; the newest verification supersedes the badge claims in the READMEs):
122
163
 
123
- - DeepSeek Harness `0.1.5-rc.1`
164
+ - DeepSeek Harness `0.2.0-rc.2` (desktop, 2026-10-04) — see `docs/compatibility/dsh-0.2.0-rc.2-2026-10-04.md`
165
+ - DeepSeek Harness `0.1.5-rc.1` / `0.1.5-rc.2` (earlier release line)
124
166
  - dsh-market `1.45.1`
125
167
  - Node.js `≥ 22`
126
- - `package.json` → `engines.dsh`: `>= 0.1.5-rc.1`
168
+ - `package.json` → `engines.dsh`: `>= 0.1.5-rc.1` (a lower bound only; it is deliberately not an upper bound, and dsh-market reads it for its host-requirement card)
127
169
 
128
170
  GitHub-only packages may show “host requirement unknown” in dsh-market when no npm manifest is published; that is metadata, not runtime incompatibility.
129
171
 
@@ -140,10 +182,19 @@ Version-sensitive host behaviors degrade with explicit UI messages (not silent f
140
182
  - Sidebar redraw plugins that replace workspace DOM can break ARIA row lookup — features stop triggering; data is not damaged.
141
183
  - Do not install a second document-level drag interceptor for cross-workspace moves.
142
184
 
185
+ ## Client-half invariants
186
+
187
+ - **Every document-level listener and the menu `MutationObserver` is registered through the `listen()` helper and released in one `ctx.effect` teardown**, and `apply` is guarded by a module-level `applied` flag. The desktop client disposes and recomposes the plugin fiber on hot reload; without both, a second copy of every listener stacks up (duplicate confirm dialogs, duplicate RPC, two closures fighting over the same selection highlight).
188
+ - **A row is only ever mapped to a session id by a verifiable credential.** The authoritative path walks the React fiber to `memoizedProps.node.id`; the fallback aligns DOM rows to `workspace.sessionIds` by exact/longest title match. A row that cannot be confidently mapped is reported, never substituted — the previous "take the next id in order" behavior could move a different, valid session that the host cannot detect.
189
+ - **A workspace header is only mapped to a registry entity when the header text contains that entity's title or path** (or when no entity carries a display identity at all). `registry.list()` includes workspaces whose folder is gone while the sidebar hides them, so positional mapping is unsafe.
190
+ - `rowSessionId`'s fiber guard is intentionally permissive; if the official node shape changes, the fallback engages and the user is told, rather than the feature silently doing nothing.
191
+ - Dialogs are built by `injectOverlay()` and handed to `openDialog()`, which appends **before** focusing (an element not in the document cannot take focus in a browser), focuses the primary button, and traps Tab within the card.
192
+
143
193
  ## Tests & release
144
194
 
145
- - `npm test` runs `test/core.test.mjs`, `test/e2e-sandbox.test.mjs`, `test/traffic-badge.test.mjs`, `test/release-downloads.test.mjs` (95 cases at v2.0.1).
146
- - Coverage includes rollback, rescue scan/repair, history undo, workspace repoint, post-move verification, recycle bin / backup restore, task center, data-protection cleanup, concurrency locks, error codes.
195
+ - `npm test` runs `test/core.test.mjs`, `test/e2e-sandbox.test.mjs`, `test/policy.test.mjs`, `test/client-dom.test.mjs`, `test/traffic-badge.test.mjs` and `test/release-downloads.test.mjs`. `client-dom.test.mjs` drives the **client half** against a hand-rolled DOM stub (no devDependencies, so CI keeps running plain `node --test`); `policy.test.mjs` covers the tunable-policy layer and the guarded Config builder. Exact case count is reported by the test run — do not hard-code it here beyond the release it was written for.
196
+ - Coverage includes rollback, rescue scan/repair, history undo, workspace repoint, post-move verification, recycle bin / backup restore, task center, data-protection cleanup, concurrency locks, error codes, storage-layout self-check, backup orphan GC, tunable policies, and the client half's row/workspace resolution, drag payload, listener teardown and dialog dismissal.
197
+ - Adding a test file requires listing it in `package.json` → `scripts.test`; the script enumerates files explicitly, so a new file that is not listed never runs in CI.
147
198
  - CI: `.github/workflows/test.yml`.
148
199
  - Release assets may ship a `.tgz` install package (`dsh-workspace-mover-*.tgz`).
149
200
  - Traffic/clones and release-download badges are refreshed by `.github/workflows/traffic-badge.yml` via Gist JSON endpoints (not part of the runtime plugin).
package/lib/config.js ADDED
@@ -0,0 +1,74 @@
1
+ // dsh-workspace-mover — 官方 Config 接入(守卫式,同步、无 boot 风险)
2
+ //
3
+ // 官方做法:导出 schemastery schema 作为插件 Config,声明 .volatile() 的字段成为设置页
4
+ // 上的实时字段。但 @deepseek-ai/schemastery 由**宿主的 loader** 提供(它不是本插件的
5
+ // 依赖,也不是本插件目录下可解析的模块)。这里实测:从 lib/index.js 与 lib/config.js
6
+ // 出发,`@deepseek-ai/schemastery` 与 npm 上的 `schemastery` **都解析失败**。
7
+ //
8
+ // 而官方明确记录过一类事故:bundle 层 import 失败会打断 **boot**(有插件 pin 住
9
+ // @deepseek-ai/dsh-settings,宿主删除该模块后整个 DSH 起不来)。所以:
10
+ //
11
+ // 1. 本模块**不做任何静态 import**,也不使用顶层 await(避免模块求值挂起);
12
+ // 2. schema 由注入的构造器同步构造,拿不到就导出 undefined —— 不导出 Config,
13
+ // 插件完全照常工作,只是没有设置页;
14
+ // 3. 策略的实际生效值永远有环境变量 / 代码默认值兜底(见 lib/index.js 的
15
+ // resolvePolicy),所以"没有设置页"不等于"策略不可调"。
16
+ //
17
+ // 宿主若通过 Cordis 的 config 机制注入 schema 构造器,apply(ctx, config) 里的
18
+ // attachConfig() 会把它接上;未注入时一切照旧。
19
+
20
+ /** 策略字段定义(与 lib/index.js 的 POLICY_DEFAULTS 一一对应,勿单独改动)。 */
21
+ const POLICY_FIELDS = [
22
+ { key: 'backupKeep', def: 20, max: 1000, desc: '每个会话保留的迁移备份份数' },
23
+ { key: 'historyLimit', def: 100, max: 10000, desc: '移动历史的保留条数' },
24
+ { key: 'batchLimit', def: 50, max: 500, desc: '单批移动/修复的会话数上限' },
25
+ { key: 'repointLimit', def: 200, max: 5000, desc: '单个工作区搬家的会话数上限' },
26
+ { key: 'scanLimit', def: 400, max: 100000, desc: '救援扫描解析的档案数上限' },
27
+ { key: 'cleanupDays', def: 30, max: 3650, desc: '「清理 N 天前数据」的默认天数' }
28
+ ];
29
+
30
+ /** schemastery 的 .volatile() 只在部分版本存在;缺失时退化为普通字段(仍可配置)。 */
31
+ function live(node) {
32
+ return typeof node?.volatile === 'function' ? node.volatile() : node;
33
+ }
34
+
35
+ /**
36
+ * 用给定的 schema 构造器同步构造 Config。构造过程整体包在 try 内:
37
+ * 不同版本的 schemastery 方法集不同(例如 .volatile 常缺),任何一步失败都
38
+ * 必须降级为 undefined,绝不把异常抛到插件启动路径上。
39
+ * @param {any} z schemastery 构造器(需有 z.object / z.number)
40
+ * @returns {any|undefined} schema,或构造失败时 undefined
41
+ */
42
+ function buildConfig(z) {
43
+ if (!z || typeof z.object !== 'function' || typeof z.number !== 'function') return undefined;
44
+ try {
45
+ const shape = {};
46
+ for (const { key, def, max } of POLICY_FIELDS) {
47
+ let node = z.number();
48
+ if (typeof node.step === 'function') node = node.step(1);
49
+ if (typeof node.min === 'function') node = node.min(1);
50
+ if (typeof node.max === 'function') node = node.max(max);
51
+ if (typeof node.default === 'function') node = node.default(def);
52
+ shape[key] = live(node);
53
+ }
54
+ return z.object(shape);
55
+ } catch {
56
+ return undefined;
57
+ }
58
+ }
59
+
60
+ /**
61
+ * 从宿主注入的对象上取 schema 构造器并构造 Config。
62
+ * 只接受已经在手上的构造器——不做动态 import,因此不会影响启动时序。
63
+ * @param {any} schemaBuilder 可能的 schemastery 构造器(或带 default 的模块对象)
64
+ */
65
+ function attachConfig(schemaBuilder) {
66
+ const z = schemaBuilder?.default ?? schemaBuilder;
67
+ return buildConfig(z);
68
+ }
69
+
70
+ // 本模块导出的 Config 恒为 undefined:插件不主动 import schemastery。
71
+ // 宿主若注入构造器,apply 内会用它构造并注册(见 lib/index.js)。
72
+ const Config = undefined;
73
+
74
+ export { Config, POLICY_FIELDS, buildConfig, attachConfig, live };