dsh-workspace-mover 2.4.1 → 2.4.2

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,183 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { mkdtempSync, mkdirSync, rmSync, readFileSync } from 'node:fs';
4
+ import { runInNewContext } from 'node:vm';
5
+ import { tmpdir } from 'node:os';
6
+ import { join, resolve } from 'node:path';
7
+ import { pathToFileURL } from 'node:url';
8
+ import * as mover from '../lib/index.js';
9
+ import { installLifecycle, lifecycleFor, withReleasedSession } from '../lib/lifecycle.js';
10
+
11
+ test('live lifecycle: explicit stop, request exclusion, failure recovery and unload', async () => {
12
+ const entries = new Map();
13
+ let teardown;
14
+ const agents = {
15
+ get: (id) => entries.get(id),
16
+ async resume({ resumeSessionId: id }) {
17
+ const agent = { id, status: 'running', session: { header: {} }, send() { return 'sent'; } };
18
+ entries.set(id, agent);
19
+ return { agent, async dispose() { await teardown?.(); entries.delete(id); } };
20
+ }
21
+ };
22
+ let off;
23
+ const ctx = { agents, get: (key) => key === 'agents' ? agents : null };
24
+ installLifecycle(ctx, { agents, effect(fn) { off = fn(); } });
25
+ const { agent } = await agents.resume({ resumeSessionId: 'a' });
26
+ await assert.rejects(withReleasedSession(ctx, 'a', false, () => assert.fail()), { code: 'stop-required' });
27
+ assert.equal(agent.send(), 'sent');
28
+ teardown = async () => {
29
+ assert.throws(() => agent.send(), { code: 'migration-in-progress' });
30
+ await assert.rejects(agents.resume({ resumeSessionId: 'a' }), { code: 'migration-in-progress' });
31
+ };
32
+ await assert.rejects(withReleasedSession(ctx, 'a', true, () => { throw new Error('disk failure'); }), /disk failure/);
33
+ assert.equal(lifecycleFor(ctx, 'a'), null);
34
+ assert.throws(() => agent.send(), { code: 'migration-in-progress' });
35
+ const next = await agents.resume({ resumeSessionId: 'a' });
36
+ assert.equal(next.agent.send(), 'sent');
37
+ off();
38
+ assert.equal(lifecycleFor(ctx, 'a'), null);
39
+ });
40
+
41
+ const modules = process.env.DSH_TEST_NODE_MODULES;
42
+ const load = (name) => import(pathToFileURL(join(resolve(modules), '@deepseek-ai', name, 'lib/index.js')).href);
43
+
44
+ test('installed client Controller: a Provider reference keeps the removed generation alive until released',
45
+ { skip: !modules, timeout: 10000 }, async () => {
46
+ const cordis = await load('cordis');
47
+ // Execute installed client logic, exposing the otherwise bundle-local class
48
+ // only inside this test. Renderer stores/transport are inert fixtures; actual
49
+ // Controller retention, scope retirement and Session flags are unmodified.
50
+ const file = join(resolve(modules), '@deepseek-ai/dsh-api-session-controller/lib/client.js');
51
+ const source = readFileSync(file, 'utf8').replace('exports.apply = apply;', 'exports.__ClientSessions = ClientSessions; exports.apply = apply;');
52
+ let exports;
53
+ const store = {
54
+ notifySubscribers(listeners) { for (const listener of [...listeners]) listener(); },
55
+ createSnapshotStore(initial) {
56
+ let value = initial;
57
+ const listeners = new Set();
58
+ return { getSnapshot: () => value,
59
+ subscribe(fn) { listeners.add(fn); return () => listeners.delete(fn); },
60
+ set(next) { value = next; store.notifySubscribers(listeners); } };
61
+ }
62
+ };
63
+ runInNewContext(source, { window: { __ModuleLoader__: { load({ factory }) {
64
+ exports = factory((name) => {
65
+ if (name === '@deepseek-ai/cordis') return cordis;
66
+ if (name === '@deepseek-ai/dsh-client-store') return store;
67
+ if (name === '@deepseek-ai/dsh-api-gateway/client') return { RemoteJournalStream: class {} };
68
+ throw new Error(`Unexpected client dependency: ${name}`);
69
+ });
70
+ } } }, console, AbortController, AbortSignal, queueMicrotask, setTimeout, clearTimeout });
71
+ const root = new cordis.Context();
72
+ try {
73
+ const client = new exports.__ClientSessions(root, {});
74
+ const id = 'session-retained-view';
75
+ const summary = { sessionId: id, blank: false, running: false, agentAvailable: true, updatedAt: 1 };
76
+ client.handleSessionAdded(summary);
77
+ const main = client.retainAgentScope(id);
78
+ const provider = client.retainAgentScope(id);
79
+ const previous = main.binding;
80
+ client.handleSessionRemoved(id);
81
+ client.handleSessionAdded(summary);
82
+ assert.equal(previous.session.getSnapshot().removed, true, 'host resume does not clear the flag');
83
+ main.release();
84
+ const premature = client.retainAgentScope(id);
85
+ assert.equal(premature.binding, previous, 'immediate reopen reuses the removed instance');
86
+ premature.release();
87
+ await new Promise((resolve) => {
88
+ const stop = client.retainInfo(id).subscribe(() => {
89
+ if (client.binding(id) !== previous) { stop(); resolve(); }
90
+ });
91
+ setTimeout(() => provider.release(), 0);
92
+ });
93
+ const fresh = client.retainAgentScope(id);
94
+ assert.notEqual(fresh.binding, previous);
95
+ assert.equal(fresh.binding.session.getSnapshot().removed, false);
96
+ fresh.release();
97
+ } finally {
98
+ await root.fiber.dispose();
99
+ }
100
+ });
101
+
102
+ test('installed DSH AgentLoop: dispose writer, physical move, resume same session and append',
103
+ { skip: !modules, timeout: 30000 }, async () => {
104
+ const { Context } = await load('cordis');
105
+ const { default: Agents } = await load('dsh-agent');
106
+ const { default: Sessions } = await load('dsh-session');
107
+ const { default: Projections } = await load('dsh-session-projection');
108
+ const { default: Loop } = await load('dsh-agent-loop');
109
+ const persistence = await load('dsh-session-persistence-jsonl');
110
+ const home = mkdtempSync(join(tmpdir(), 'wsm-live-'));
111
+ const prior = process.env.DSH_HOME;
112
+ process.env.DSH_HOME = home;
113
+ const root = new Context();
114
+ const a = join(home, 'a'), b = join(home, 'b');
115
+ mkdirSync(a); mkdirSync(b);
116
+ const groups = ['a', 'b'].map((id, i) => ({ id, path: [a,b][i], record: { sessionIds: [] },
117
+ async attachSession(id) { this.record.sessionIds.push(id); },
118
+ async detachSession(id) { this.record.sessionIds = this.record.sessionIds.filter((x) => x !== id); } }));
119
+ try {
120
+ root.provide('workspaceRegistry', { list: () => groups, get: (id) => groups.find((x) => x.id === id) });
121
+ root.provide('llm', {});
122
+ root.provide('tools', {});
123
+ root.provide('systemPrompt', { variable() {}, assemble() { return []; } });
124
+ for (const plugin of [Sessions, Agents, Projections]) await root.plugin(plugin).await();
125
+ await root.plugin(persistence, { root: join(home, 'sessions'), compression: 'zstd' }).await();
126
+ await root.plugin({ name: 'lifecycle-observer', inject: ['agents'], apply(ctx) { installLifecycle(ctx, ctx); } }).await();
127
+ await root.plugin(Loop, { agents: [], maxParallelToolCalls: 1 }).await();
128
+ const agents = root.get('agents');
129
+ root.provide('sessionController', { async resolveAgent(id) {
130
+ return { agent: agents.get(id) ?? (await agents.resume({ resumeSessionId: id, agentOptions: {} })).agent };
131
+ } });
132
+ const id = 'session-live-test';
133
+ const first = await agents.create({ sessionId: id, meta: { cwd: a }, agentOptions: {} });
134
+ first.agent.session.append('working-directory/change', { cwd: a });
135
+ groups[0].record.sessionIds.push(id);
136
+ assert.ok(lifecycleFor(root, id), 'real public lifecycle handle was captured');
137
+ const moved = await mover.moveSessionEntry(root, { sessionId: id, targetWorkspaceId: 'b' });
138
+ assert.equal(moved.verified, true);
139
+ assert.equal(root.get('sessions').get(id).header.cwd, b, 'controller restores from the actual target');
140
+ const resumed = lifecycleFor(root, id).handle;
141
+ assert.equal(resumed.agent.session.header.cwd, b);
142
+ resumed.agent.session.append('working-directory/change', { cwd: b });
143
+ const attach = groups[0].attachSession;
144
+ groups[0].attachSession = async () => { throw new Error('injected attach failure'); };
145
+ await assert.rejects(mover.moveSessionEntry(root, { sessionId: id, targetWorkspaceId: 'a' }), /rolled back cleanly/);
146
+ assert.equal(agents.get(id).session.header.cwd, b, 'failed move reloads the original workspace');
147
+ groups[0].attachSession = attach;
148
+ const recovered = lifecycleFor(root, id).handle;
149
+ recovered.agent.session.append('working-directory/change', { cwd: b });
150
+ await recovered.dispose();
151
+ const read = await root.get('sessionPersistence').open(id, 'read');
152
+ assert.equal((await read.read()).events.at(-1).data.cwd, b);
153
+ await read.close();
154
+ // Exercise cancellation of a real running driver without a model call.
155
+ const active = await agents.resume({ resumeSessionId: id, agentOptions: {} });
156
+ let entered;
157
+ const started = new Promise((resolve) => { entered = resolve; });
158
+ const off = root.on('agent/pre-step', async ({ signal }) => {
159
+ entered();
160
+ await new Promise((resolve) => {
161
+ if (signal.aborted) resolve();
162
+ else signal.addEventListener('abort', resolve, { once: true });
163
+ });
164
+ return { kind: 'reject' };
165
+ });
166
+ active.agent.followup({ role: 'user', source: { kind: 'user' }, content: [{ type: 'text', text: 'test' }] });
167
+ await started;
168
+ assert.equal(active.agent.status, 'running');
169
+ await assert.rejects(mover.moveSessionEntry(root, { sessionId: id, targetWorkspaceId: 'a' }), { code: 'stop-required' });
170
+ assert.equal(active.agent.status, 'running', 'refusal does not stop the task');
171
+ const back = await mover.moveSessionEntry(root, { sessionId: id, targetWorkspaceId: 'a', stopRunning: true });
172
+ assert.equal(back.verified, true);
173
+ assert.equal(agents.get(id).session.header.cwd, a);
174
+ off();
175
+ const again = lifecycleFor(root, id).handle;
176
+ assert.equal(again.agent.session.header.cwd, a);
177
+ await again.dispose();
178
+ } finally {
179
+ await root.fiber.dispose();
180
+ if (prior === undefined) delete process.env.DSH_HOME; else process.env.DSH_HOME = prior;
181
+ rmSync(home, { recursive: true, force: true, maxRetries: 3 });
182
+ }
183
+ });