dsh-workspace-mover 2.3.0 → 2.4.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.
@@ -0,0 +1,303 @@
1
+ // Host-service lifecycle tests: which services the plugin requires, and how the optional
2
+ // ones (dsh-tools, dsh-user-approval) are picked up on a cordis-style host.
3
+ //
4
+ // The fake host below models three cordis 4.x rules the plugin depends on. The real
5
+ // framework is not importable in CI, so the rules are reproduced, not the framework:
6
+ // 1. every name in a plugin's `inject` is REQUIRED: apply runs only once all are present;
7
+ // 2. ctx.get(name) returns the service or undefined and never throws;
8
+ // 3. ctx.inject(names, cb) runs cb in a child context once all names exist, exposes them
9
+ // as properties, and disposes the child's effects (reverse order) when one is removed;
10
+ // the callback runs again with a fresh child when the service comes back.
11
+ import { test, afterEach } from 'node:test';
12
+ import assert from 'node:assert/strict';
13
+ import { mkdtempSync, mkdirSync, rmSync, realpathSync } from 'node:fs';
14
+ import { tmpdir } from 'node:os';
15
+ import { join, basename } from 'node:path';
16
+ import * as plugin from '../lib/index.js';
17
+
18
+ const CHANNEL = '/workspace-mover';
19
+ const REQUIRED = ['workspaceRegistry', 'sessionPersistence'];
20
+ const AGENT_TOOLS = ['mover_list_sessions', 'mover_move_session', 'mover_repair_sessions', 'mover_doctor', 'mover_create_workspace', 'mover_rename_workspace'];
21
+ const TOOL = Object.freeze({ RENAME: 'mover_rename_workspace', MOVE: 'mover_move_session' });
22
+ const RM_TREE = { recursive: true, force: true, maxRetries: 3 };
23
+
24
+ const hosts = [];
25
+ afterEach(() => { for (const host of hosts.splice(0)) host.dispose(); });
26
+
27
+ /** A minimal fiber: effects registered while active, cleaned up in reverse order. */
28
+ function makeFiber() {
29
+ const cleanups = [];
30
+ return {
31
+ effect(register) {
32
+ const cleanup = register();
33
+ if (typeof cleanup === 'function') cleanups.push(cleanup);
34
+ },
35
+ dispose() { while (cleanups.length) { try { cleanups.pop()(); } catch { /* best effort */ } } }
36
+ };
37
+ }
38
+
39
+ /** Cordis-like host: required inject gate (rule 1), get (rule 2), nested inject (rule 3). */
40
+ function makeCordisHost(initial) {
41
+ const services = new Map(Object.entries(initial));
42
+ const watchers = []; // { names, callback, fiber? }
43
+ const rootFiber = makeFiber();
44
+
45
+ const childCtx = (names, fiber) => {
46
+ const ctx = { effect: fiber.effect, get: (key) => services.get(key) };
47
+ for (const key of names) {
48
+ const value = services.get(key);
49
+ // A registration made through a child context is that child's effect (dsh-tools
50
+ // `register` returns a disposer tied to the calling fiber).
51
+ ctx[key] = key === 'tools' ? { ...value, register: (def) => { const off = value.register(def); fiber.effect(() => off); return off; } } : value;
52
+ }
53
+ return ctx;
54
+ };
55
+
56
+ const refresh = () => {
57
+ for (const w of watchers) {
58
+ const ready = w.names.every((n) => services.has(n));
59
+ if (ready && !w.fiber) {
60
+ w.fiber = makeFiber();
61
+ w.callback(childCtx(w.names, w.fiber));
62
+ } else if (!ready && w.fiber) {
63
+ w.fiber.dispose();
64
+ w.fiber = undefined;
65
+ }
66
+ }
67
+ };
68
+
69
+ const ctx = {
70
+ logger: { info() {}, warn() {}, error() {}, debug() {} },
71
+ get: (key) => services.get(key),
72
+ effect: rootFiber.effect,
73
+ inject(names, callback) { watchers.push({ names, callback }); refresh(); }
74
+ };
75
+
76
+ return {
77
+ ctx,
78
+ provide(name, value) { services.set(name, value); refresh(); },
79
+ remove(name) { services.delete(name); refresh(); },
80
+ dispose() { for (const w of watchers) w.fiber?.dispose(); rootFiber.dispose(); }
81
+ };
82
+ }
83
+
84
+ function makeEntity(id, path, title, log) {
85
+ const record = { path, title, sessionIds: [] };
86
+ return {
87
+ id,
88
+ get path() { return record.path; },
89
+ get title() { return record.title; },
90
+ get sessionIds() { return [...record.sessionIds]; },
91
+ get record() { return record; },
92
+ async setTitle(next) { log.push(next); record.title = next; },
93
+ async status() { return 'ok'; },
94
+ async attachSession() {},
95
+ async detachSession() {}
96
+ };
97
+ }
98
+
99
+ function makeTools() {
100
+ const registered = new Map();
101
+ return {
102
+ registered,
103
+ register(def) {
104
+ assert.equal(registered.has(def.name), false, `${def.name} registered twice without being disposed`);
105
+ registered.set(def.name, def);
106
+ return () => registered.delete(def.name);
107
+ }
108
+ };
109
+ }
110
+
111
+ function makeApproval(outcome) {
112
+ const calls = [];
113
+ return { calls, outcome, async request(req) { calls.push(req); return this.outcome; } };
114
+ }
115
+
116
+ /**
117
+ * Mount the plugin on a cordis-like host providing only the required services plus the
118
+ * web transport. Applies the plugin only when its declared `inject` is satisfied (rule 1),
119
+ * exactly as cordis would.
120
+ */
121
+ function mountCordis() {
122
+ const root = realpathSync.native(mkdtempSync(join(tmpdir(), 'wsm-hostsvc-')));
123
+ const dirs = { home: join(root, 'home'), store: join(root, 'store'), a: join(root, 'alpha'), b: join(root, 'beta') };
124
+ for (const dir of Object.values(dirs)) mkdirSync(dir, { recursive: true });
125
+ const previousHome = process.env.DSH_HOME;
126
+ process.env.DSH_HOME = dirs.home;
127
+
128
+ const titles = [];
129
+ const entities = [makeEntity('wid-a', dirs.a, basename(dirs.a), titles), makeEntity('wid-b', dirs.b, basename(dirs.b), titles)];
130
+ const registry = { list: () => [...entities], get: (id) => entities.find((e) => e.id === id), archivedSessionIds: [] };
131
+ let dispatch;
132
+ const host = makeCordisHost({
133
+ workspaceRegistry: registry,
134
+ sessionPersistence: { root: dirs.store, compression: 'zstd', async list() { return []; } },
135
+ connection: { rpc: { handle(channel, fn) { if (channel === CHANNEL) dispatch = fn; return () => {}; } } },
136
+ webServer: {}
137
+ });
138
+ const ctx = { ...host.ctx, workspaceRegistry: registry, sessionPersistence: host.ctx.get('sessionPersistence') };
139
+
140
+ const satisfied = plugin.inject.every((name) => host.ctx.get(name) !== undefined);
141
+ if (satisfied) plugin.apply(ctx);
142
+
143
+ const handle = {
144
+ ...host,
145
+ satisfied,
146
+ registry,
147
+ titles,
148
+ rpc: (endpoint, payload) => { assert.equal(typeof dispatch, 'function', 'RPC channel not mounted'); return dispatch(endpoint, payload ?? {}); },
149
+ async doctorState(id) { return (await handle.rpc('mover.doctor')).value.checks.find((c) => c.id === id)?.state; },
150
+ async agentTools() { return (await handle.rpc('mover.status')).value.agentTools; },
151
+ dispose() {
152
+ host.dispose();
153
+ if (previousHome === undefined) delete process.env.DSH_HOME; else process.env.DSH_HOME = previousHome;
154
+ rmSync(root, RM_TREE);
155
+ }
156
+ };
157
+ hosts.push(handle);
158
+ return handle;
159
+ }
160
+
161
+ function makeExec(callId = 'call-1') {
162
+ return { agent: { session: {} }, callId, signal: new AbortController().signal };
163
+ }
164
+
165
+ //#region 1. required services
166
+ test('inject names only the services the plugin cannot run without', () => {
167
+ assert.deepEqual([...plugin.inject].sort(), [...REQUIRED].sort());
168
+ assert.equal(plugin.inject.includes('tools'), false, 'tools must be optional: cordis deactivates the whole plugin when an injected service is missing');
169
+ assert.equal(plugin.inject.includes('approval'), false, 'approval must be optional for the same reason');
170
+ });
171
+ //#endregion
172
+
173
+ //#region 2. no optional services
174
+ test('without dsh-tools and dsh-user-approval the plugin still mounts and the rescue RPC answers', async () => {
175
+ const host = mountCordis();
176
+ assert.equal(host.satisfied, true, 'required services alone satisfy inject');
177
+ const status = await host.rpc('mover.status');
178
+ assert.equal(status.ok, true, JSON.stringify(status.error));
179
+ assert.equal(status.value.agentTools, false);
180
+ assert.equal(await host.doctorState('host-approval'), 'warn');
181
+ assert.equal(await host.doctorState('agent-tools'), 'warn');
182
+ });
183
+ //#endregion
184
+
185
+ //#region 3. tools follow the tools service (state transitions)
186
+ test('agent tools register with dsh-tools, unregister when it goes, and re-register once when it returns', async () => {
187
+ const host = mountCordis();
188
+ const tools = makeTools();
189
+
190
+ assert.equal(await host.agentTools(), false, 'S0: no tools service');
191
+
192
+ host.provide('tools', tools);
193
+ assert.deepEqual([...tools.registered.keys()].sort(), [...AGENT_TOOLS].sort(), 'S1: all six registered');
194
+ assert.equal(await host.agentTools(), true);
195
+ assert.equal(await host.doctorState('agent-tools'), 'pass');
196
+
197
+ host.remove('tools');
198
+ assert.equal(tools.registered.size, 0, 'S2: every registration disposed with the service');
199
+ assert.equal(await host.agentTools(), false);
200
+ assert.equal((await host.rpc('mover.status')).ok, true, 'plugin stays active without tools');
201
+
202
+ host.provide('tools', tools); // register() asserts no duplicate
203
+ assert.deepEqual([...tools.registered.keys()].sort(), [...AGENT_TOOLS].sort(), 'S3: registered again exactly once each');
204
+ assert.equal(await host.agentTools(), true);
205
+ });
206
+ //#endregion
207
+
208
+ //#region 4. approval is read at call time
209
+ test('rename reads approval per call: absent → refused; added → asks; removed → refused again', async () => {
210
+ const host = mountCordis();
211
+ const tools = makeTools();
212
+ host.provide('tools', tools);
213
+ const rename = tools.registered.get(TOOL.RENAME);
214
+
215
+ const originalTitle = host.registry.get('wid-a').title;
216
+ await assert.rejects(rename.execute({ workspaceId: 'wid-a', name: 'First' }, makeExec('call-1')), /approval could not be requested/);
217
+ assert.equal(host.registry.get('wid-a').title, originalTitle);
218
+ assert.equal(await host.doctorState('host-approval'), 'warn');
219
+
220
+ // Approval appears later, without re-mounting the plugin.
221
+ const approval = makeApproval('rejected');
222
+ host.provide('approval', approval);
223
+ assert.equal(await host.doctorState('host-approval'), 'pass');
224
+ await assert.rejects(rename.execute({ workspaceId: 'wid-a', name: 'Second' }, makeExec('call-2')));
225
+ assert.equal(approval.calls.length, 1, 'asked exactly once');
226
+ assert.equal(host.registry.get('wid-a').title, originalTitle, 'rejected → unchanged');
227
+
228
+ approval.outcome = 'allowed-once';
229
+ const third = await rename.execute({ workspaceId: 'wid-a', name: 'Third' }, makeExec('call-3'));
230
+ assert.equal(approval.calls.length, 2);
231
+ assert.equal(third.title, 'Third');
232
+
233
+ host.remove('approval');
234
+ await assert.rejects(rename.execute({ workspaceId: 'wid-a', name: 'Fourth' }, makeExec('call-4')), /approval could not be requested/);
235
+ assert.equal(approval.calls.length, 2, 'no prompt once the service is gone');
236
+ assert.equal(host.registry.get('wid-a').title, 'Third');
237
+ assert.deepEqual(host.titles, ['Third'], 'setTitle only after approval');
238
+ });
239
+ //#endregion
240
+
241
+ //#region 5. callId is forwarded
242
+ for (const [toolName, args] of [
243
+ [TOOL.RENAME, { workspaceId: 'wid-a', name: 'Renamed' }],
244
+ [TOOL.MOVE, { sessionId: 'session-unknown', targetWorkspaceId: 'wid-b' }]
245
+ ]) {
246
+ test(`${toolName} approval request carries the tool call's callId (ACP answerer ignores requests without one)`, async () => {
247
+ const host = mountCordis();
248
+ const tools = makeTools();
249
+ host.provide('tools', tools);
250
+ const approval = makeApproval('rejected'); // rejected: nothing on disk is touched
251
+ host.provide('approval', approval);
252
+ const exec = makeExec(`call-${toolName}`);
253
+
254
+ await assert.rejects(tools.registered.get(toolName).execute(args, exec));
255
+
256
+ assert.equal(approval.calls.length, 1);
257
+ const req = approval.calls[0];
258
+ assert.equal(req.callId, exec.callId);
259
+ assert.equal(req.agent, exec.agent);
260
+ assert.equal(req.toolName, toolName);
261
+ assert.equal(typeof req.reason, 'string');
262
+ assert.equal(req.signal, exec.signal);
263
+ assert.equal(host.registry.get('wid-a').title, basename(host.registry.get('wid-a').path), 'nothing renamed');
264
+ });
265
+ }
266
+ //#endregion
267
+
268
+ //#region 6. non-cordis hosts
269
+ test('a host without inject still gets tools from ctx.tools and approval from ctx.approval', async () => {
270
+ const root = realpathSync.native(mkdtempSync(join(tmpdir(), 'wsm-hostsvc-plain-')));
271
+ const a = join(root, 'alpha');
272
+ mkdirSync(a);
273
+ const previousHome = process.env.DSH_HOME;
274
+ process.env.DSH_HOME = join(root, 'home');
275
+ const fiber = makeFiber();
276
+ try {
277
+ const titles = [];
278
+ const entity = makeEntity('wid-a', a, 'alpha', titles);
279
+ const tools = makeTools();
280
+ const approval = makeApproval('allowed-once');
281
+ const ctx = {
282
+ workspaceRegistry: { list: () => [entity], get: (id) => (id === 'wid-a' ? entity : undefined), archivedSessionIds: [] },
283
+ sessionPersistence: { root: join(root, 'store'), compression: 'zstd', async list() { return []; } },
284
+ logger: { info() {}, warn() {} },
285
+ get: () => undefined,
286
+ effect: fiber.effect,
287
+ connection: { rpc: { handle() { return () => {}; } } },
288
+ tools,
289
+ approval
290
+ };
291
+ plugin.apply(ctx);
292
+ assert.deepEqual([...tools.registered.keys()].sort(), [...AGENT_TOOLS].sort());
293
+ const value = await tools.registered.get(TOOL.RENAME).execute({ workspaceId: 'wid-a', name: 'Renamed' }, makeExec('call-plain'));
294
+ assert.equal(approval.calls.length, 1);
295
+ assert.equal(approval.calls[0].callId, 'call-plain');
296
+ assert.equal(value.title, 'Renamed');
297
+ } finally {
298
+ fiber.dispose();
299
+ if (previousHome === undefined) delete process.env.DSH_HOME; else process.env.DSH_HOME = previousHome;
300
+ rmSync(root, RM_TREE);
301
+ }
302
+ });
303
+ //#endregion
@@ -0,0 +1,119 @@
1
+ // Opt-in check against an extracted, already installed DSH runtime. No downloads.
2
+ // DSH_TEST_NODE_MODULES must point to that runtime's node_modules directory.
3
+ import { test } from 'node:test';
4
+ import assert from 'node:assert/strict';
5
+ import { mkdtempSync, mkdirSync, rmSync, readFileSync } from 'node:fs';
6
+ import { tmpdir } from 'node:os';
7
+ import { join, resolve } from 'node:path';
8
+ import { pathToFileURL } from 'node:url';
9
+ import * as mover from '../lib/index.js';
10
+
11
+ const modules = process.env.DSH_TEST_NODE_MODULES;
12
+ const load = (name) => import(pathToFileURL(join(resolve(modules), '@deepseek-ai', name, 'lib/index.js')).href);
13
+
14
+ test('installed DSH: real Cordis/tools lifecycle and JSONL write → queue → close → move → resume',
15
+ { skip: !modules, timeout: 60000 }, async () => {
16
+ const { Context } = await load('cordis');
17
+ const persistencePlugin = await load('dsh-session-persistence-jsonl');
18
+ const { ToolRuntime } = await load('dsh-tools');
19
+ const home = mkdtempSync(join(tmpdir(), 'wsm-installed-'));
20
+ const previousHome = process.env.DSH_HOME;
21
+ process.env.DSH_HOME = home;
22
+ const root = new Context();
23
+ const A = join(home, 'A'), B = join(home, 'B');
24
+ mkdirSync(A); mkdirSync(B);
25
+ const entities = ['a', 'b'].map((id, i) => {
26
+ const record = { path: [A, B][i], title: id, sessionIds: [] };
27
+ return { id, record, get path() { return record.path; }, get title() { return record.title; },
28
+ get sessionIds() { return [...record.sessionIds]; }, async status() { return 'ok'; },
29
+ async setTitle(title) { record.title = title; },
30
+ async attachSession(id) { if (!record.sessionIds.includes(id)) record.sessionIds.push(id); },
31
+ async detachSession(id) { record.sessionIds = record.sessionIds.filter((x) => x !== id); } };
32
+ });
33
+ let dispatch;
34
+ let backend, plugin, tools;
35
+ try {
36
+ root.provide('workspaceRegistry', { get: (id) => entities.find((e) => e.id === id), list: () => entities });
37
+ root.provide('connection', { rpc: { handle(_channel, handler) { dispatch = handler; return () => { dispatch = undefined; }; } } });
38
+ root.provide('webServer', {});
39
+ root.provide('systemPrompt', { tools() {} });
40
+ backend = root.plugin(persistencePlugin, { root: join(home, 'sessions'), compression: 'zstd' });
41
+ await backend.await();
42
+ plugin = root.plugin(mover);
43
+ await plugin.await();
44
+ await new Promise(setImmediate);
45
+ assert.equal(typeof dispatch, 'function', 'RPC mounts without tools or approval');
46
+ tools = root.plugin({ name: 'test-tools-provider', apply(ctx) { new ToolRuntime(ctx); } });
47
+ await tools.await();
48
+ await root.fiber.flush?.();
49
+ // Give nested inject fibers a turn to settle.
50
+ await new Promise(setImmediate);
51
+ assert.equal((await dispatch('mover.status', {})).value.agentTools, true);
52
+ await tools.dispose();
53
+ await new Promise(setImmediate);
54
+ assert.equal((await dispatch('mover.status', {})).value.agentTools, false);
55
+
56
+ const store = root.get('sessionPersistence');
57
+ const id = 'session-installed-test';
58
+ entities[0].record.sessionIds.push(id);
59
+ const writer = await store.create({ id, cwd: A, version: 4, createdAt: Date.now(), isSeeded: false, delegationDepth: 0 });
60
+ const initial = { type: 'working-directory/change', seq: 0, time: Date.now(), data: { cwd: A } };
61
+ await writer.append([initial]);
62
+ const source = mover.artifactPath(store.root, A, id);
63
+ const bytes = readFileSync(source);
64
+ const queued = await dispatch('mover.move', { sessionId: id, targetWorkspaceId: 'b' });
65
+ assert.equal(queued.error.code, 'busy');
66
+ assert.deepEqual(readFileSync(source), bytes);
67
+ await writer.append([{ ...initial, seq: 1, time: Date.now() }]);
68
+ root.emit('session/event', { id }, { ...initial, seq: 2, time: Date.now() });
69
+ // Dispose the consumer while its task is waiting. The task must survive
70
+ // a fresh plugin and backend instance without relying on an in-memory queue.
71
+ await plugin.dispose(); plugin = undefined;
72
+ const closing = writer.close();
73
+ await closing;
74
+ await backend.dispose(); backend = undefined;
75
+ backend = root.plugin(persistencePlugin, { root: join(home, 'sessions'), compression: 'zstd' });
76
+ await backend.await();
77
+ plugin = root.plugin(mover);
78
+ await plugin.await();
79
+ await new Promise(setImmediate);
80
+ let task;
81
+ for (let attempt = 0; attempt < 80; attempt++) {
82
+ task = (await dispatch('mover.migrations.list', {})).value.items.find((item) => item.id === queued.error.details.queueTaskId);
83
+ if (task?.state === 'done') break;
84
+ await new Promise((r) => setTimeout(r, 100));
85
+ }
86
+ assert.equal(task?.state, 'done', JSON.stringify(task));
87
+ const reloadedStore = root.get('sessionPersistence');
88
+ assert.equal((await reloadedStore.stat(id)).header.cwd, B);
89
+ const resumed = await reloadedStore.open(id, 'write');
90
+ const before = await resumed.read();
91
+ assert.equal(before.events.length, 4, 'two direct events, drained live event and migration cwd event');
92
+ await resumed.append([{ ...initial, seq: before.events.length, time: Date.now(), data: { cwd: B } }]);
93
+ await resumed.close();
94
+ const reader = await reloadedStore.open(id, 'read');
95
+ const after = await reader.read();
96
+ assert.equal(after.events.length, 5);
97
+ assert.equal(after.events.at(-1).data.cwd, B);
98
+ await reader.close();
99
+ const bytesAfter = readFileSync(mover.artifactPath(reloadedStore.root, B, id));
100
+ await dispatch('mover.migrations.retry', { taskId: queued.error.details.queueTaskId });
101
+ assert.deepEqual(readFileSync(mover.artifactPath(reloadedStore.root, B, id)), bytesAfter, 'completed task cannot duplicate cwd events');
102
+ // Reload both plugins to discard process caches, then read again.
103
+ await plugin.dispose(); plugin = undefined;
104
+ await backend.dispose(); backend = undefined;
105
+ backend = root.plugin(persistencePlugin, { root: join(home, 'sessions'), compression: 'zstd' });
106
+ await backend.await();
107
+ const coldReader = await root.get('sessionPersistence').open(id, 'read');
108
+ assert.equal((await coldReader.read()).events.length, 5);
109
+ assert.equal(coldReader.header.cwd, B);
110
+ await coldReader.close();
111
+ } finally {
112
+ await plugin?.dispose();
113
+ await tools?.dispose();
114
+ await backend?.dispose();
115
+ if (previousHome === undefined) delete process.env.DSH_HOME;
116
+ else process.env.DSH_HOME = previousHome;
117
+ rmSync(home, { recursive: true, force: true, maxRetries: 3 });
118
+ }
119
+ });