feat: support per-bot workspace switching

This commit is contained in:
xmanrui 2026-08-17 23:56:27 +08:00
parent adcace3512
commit 44dd50216a
64 changed files with 3878 additions and 712 deletions

826
test/workspace.test.mjs Normal file
View file

@ -0,0 +1,826 @@
import assert from 'node:assert/strict';
import { mkdtemp, mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import test from 'node:test';
import {
BotWorkspaceStore,
createBotScopedHarness,
createBotWorkspaceScope,
createWorkspaceAwareController,
observeBotWorkspaceRemovals,
validateWorkspacePath,
} from '../src/channels/shared/bot-workspace-store.mjs';
import { runWorkspaceCommand } from '../src/channels/shared/workspace-command.mjs';
import { askInWorkspaceSession } from '../src/channels/shared/workspace-session.mjs';
import { HarnessClient as WeixinHarnessClient } from '../src/channels/weixin/harness-client.mjs';
import { HarnessClient as FeishuHarnessClient } from '../src/channels/feishu/harness-client.mjs';
import { HarnessClient as DingtalkHarnessClient } from '../src/channels/dingtalk/harness-client.mjs';
import { ConversationStateStore } from '../src/channels/shared/conversation-state-store.mjs';
import { WeixinStateStore } from '../src/channels/weixin/state-store.mjs';
import { StateStore as FeishuStateStore } from '../src/channels/feishu/state-store.mjs';
import { DingtalkStateStore } from '../src/channels/dingtalk/state-store.mjs';
import { WecomStateStore } from '../src/channels/wecom/state-store.mjs';
import { QqStateStore } from '../src/channels/qq/state-store.mjs';
import {
TOKEN_BOT_ENDPOINTS,
createTokenBotRpcHandler,
} from '../plugin-src/host/channels/shared/rpc.mjs';
async function fixture(t) {
const root = await mkdtemp(join(tmpdir(), 'dsh-im-workspace-'));
t.after(() => rm(root, { recursive: true, force: true }));
const defaultWorkspace = join(root, 'default');
const alternateWorkspace = join(root, 'alternate workspace');
await Promise.all([
mkdir(defaultWorkspace),
mkdir(alternateWorkspace),
]);
return { root, defaultWorkspace, alternateWorkspace, path: join(root, 'workspaces.json') };
}
test('BotWorkspaceStore persists the creation default and keeps bots isolated', async (t) => {
const { path, defaultWorkspace, alternateWorkspace } = await fixture(t);
const store = await new BotWorkspaceStore(path, { defaultWorkspace }).load();
assert.equal(await store.ensure('bot_one'), defaultWorkspace);
assert.equal(await store.ensure('bot_two'), defaultWorkspace);
await store.setWorkspace('bot_one', alternateWorkspace);
assert.equal(store.workspaceFor('bot_one'), alternateWorkspace);
assert.equal(store.workspaceFor('bot_two'), defaultWorkspace);
assert.deepEqual(JSON.parse(await readFile(path, 'utf8')), {
version: 1,
workspaces: { bot_one: alternateWorkspace, bot_two: defaultWorkspace },
});
const reloaded = await new BotWorkspaceStore(path, { defaultWorkspace: tmpdir() }).load();
assert.equal(reloaded.workspaceFor('bot_one'), alternateWorkspace);
assert.equal(reloaded.workspaceFor('bot_two'), defaultWorkspace);
});
test('BotWorkspaceStore uses process.cwd() when a bot has no configured workspace', async (t) => {
const { root } = await fixture(t);
const store = await new BotWorkspaceStore(join(root, 'cwd-workspaces.json')).load();
assert.equal(await store.ensure('bot_cwd'), process.cwd());
});
test('workspace writes roll back updates while committed removals stay retired in memory', async (t) => {
const { root, defaultWorkspace, alternateWorkspace } = await fixture(t);
const storeDirectory = join(root, 'workspace-store');
const storePath = join(storeDirectory, 'workspaces.json');
await mkdir(storeDirectory);
const store = await new BotWorkspaceStore(storePath, { defaultWorkspace }).load();
await store.ensure('bot_io');
await store.setWorkspace('bot_io', alternateWorkspace);
await rename(storeDirectory, `${storeDirectory}-saved`);
await writeFile(storeDirectory, 'blocks workspace persistence');
let clears = 0;
await assert.rejects(store.setWorkspace('bot_io', defaultWorkspace, {
clearSessions: async () => { clears += 1; },
}));
assert.equal(clears, 1);
assert.equal(store.workspaceFor('bot_io'), alternateWorkspace);
await assert.rejects(store.remove('bot_io'));
assert.equal(store.has('bot_io'), false);
assert.equal(store.workspaceFor('bot_io'), defaultWorkspace);
await rm(storeDirectory, { force: true });
await rename(`${storeDirectory}-saved`, storeDirectory);
const staleDisk = await new BotWorkspaceStore(storePath, { defaultWorkspace }).load();
assert.equal(staleDisk.workspaceFor('bot_io'), alternateWorkspace);
await staleDisk.reconcile([]);
assert.equal(staleDisk.has('bot_io'), false);
const blockedParent = join(root, 'blocked-parent');
await writeFile(blockedParent, 'not a directory');
const broken = new BotWorkspaceStore(join(blockedParent, 'workspaces.json'), { defaultWorkspace });
await assert.rejects(broken.ensure('bot_new'));
assert.equal(broken.workspaceFor('bot_new'), defaultWorkspace);
});
test('workspace validation rejects relative, missing, and file paths', async (t) => {
const { root } = await fixture(t);
const file = join(root, 'file.txt');
await writeFile(file, 'not a directory');
await assert.rejects(validateWorkspacePath('relative/path'), { code: 'workspace-not-absolute' });
await assert.rejects(validateWorkspacePath(join(root, 'missing')), { code: 'workspace-not-found' });
await assert.rejects(validateWorkspacePath(file), { code: 'workspace-not-directory' });
});
test('bot-scoped Harness creates sessions in each bot workspace and switching clears sessions', async (t) => {
const { path, defaultWorkspace, alternateWorkspace } = await fixture(t);
const workspaces = await new BotWorkspaceStore(path, { defaultWorkspace }).load();
await Promise.all([workspaces.ensure('bot_one'), workspaces.ensure('bot_two')]);
const calls = [];
const harness = {
async createSession(options) { calls.push(options); return `session-${calls.length}`; },
async ensureRunning() { return true; },
};
let cleared = 0;
const state = { async clearSessions() { cleared += 1; } };
const one = createBotScopedHarness(harness, { botId: 'bot_one', workspaces, state });
const two = createBotScopedHarness(harness, { botId: 'bot_two', workspaces, state });
await one.createSession();
await one.switchWorkspace(alternateWorkspace);
await Promise.all([one.createSession(), two.createSession()]);
assert.equal(cleared, 1);
assert.deepEqual(calls.map((call) => call.workspace), [
defaultWorkspace,
alternateWorkspace,
defaultWorkspace,
]);
});
test('an old session cannot be written back while RPC switches the bot workspace', async (t) => {
const { path, defaultWorkspace, alternateWorkspace } = await fixture(t);
const workspaces = await new BotWorkspaceStore(path, { defaultWorkspace }).load();
await workspaces.ensure('bot_race');
let finishCreation;
let existenceChecks = 0;
const harness = {
createSession() {
return new Promise((resolveCreation) => { finishCreation = resolveCreation; });
},
async sessionExists() {
existenceChecks += 1;
return true;
},
};
let persistedSession = null;
const state = {
async clearSessions() { persistedSession = null; },
async setSession(_key, sessionId) { persistedSession = sessionId; },
};
const scope = createBotWorkspaceScope(harness, { botId: 'bot_race', workspaces, state });
const controller = createWorkspaceAwareController({
status() { return { bots: [{ botId: 'bot_race' }] }; },
}, {
workspaces,
stateFor: async () => state,
});
const oldSession = scope.harness.createSession();
await controller.updateWorkspace('bot_race', alternateWorkspace);
finishCreation('session-from-old-workspace');
const sessionId = await oldSession;
assert.equal(await scope.state.setSession('conversation', sessionId), false);
assert.equal(persistedSession, null);
const oldSessionForLookup = scope.harness.createSession();
await controller.updateWorkspace('bot_race', defaultWorkspace);
finishCreation('second-session-from-old-workspace');
assert.equal(await scope.harness.sessionExists(await oldSessionForLookup), false);
assert.equal(existenceChecks, 0, 'stale sessions are rejected before asking Harness');
});
test('a prompt retries in the new workspace when switching after session creation', async (t) => {
const { path, defaultWorkspace, alternateWorkspace } = await fixture(t);
const workspaces = await new BotWorkspaceStore(path, { defaultWorkspace }).load();
await workspaces.ensure('bot_prompt');
const createdIn = [];
const asks = [];
let sessionNumber = 0;
let markFirstSet;
let releaseFirstSet;
const firstSet = new Promise((resolveSet) => { markFirstSet = resolveSet; });
const firstSetGate = new Promise((resolveSet) => { releaseFirstSet = resolveSet; });
const harness = {
async createSession({ workspace }) {
createdIn.push(workspace);
sessionNumber += 1;
return `session-${sessionNumber}`;
},
async sessionExists() { return true; },
async ask(sessionId) { asks.push(sessionId); return `answer-${sessionId}`; },
};
let persistedSession = null;
const state = {
sessionFor() { return persistedSession; },
async setSession(_key, sessionId) {
persistedSession = sessionId;
if (sessionId === 'session-1') {
markFirstSet();
await firstSetGate;
}
},
async clearSessions() { persistedSession = null; },
};
const scope = createBotWorkspaceScope(harness, { botId: 'bot_prompt', workspaces, state });
const controller = createWorkspaceAwareController({
status() { return { bots: [{ botId: 'bot_prompt' }] }; },
}, { workspaces, stateFor: async () => state });
const prompting = askInWorkspaceSession({
harness: scope.harness,
state: scope.state,
key: 'conversation',
text: 'hello',
});
await firstSet;
await controller.updateWorkspace('bot_prompt', alternateWorkspace);
releaseFirstSet();
const result = await prompting;
assert.equal(result.answer, 'answer-session-2');
assert.deepEqual(createdIn, [defaultWorkspace, alternateWorkspace]);
assert.deepEqual(asks, ['session-2']);
assert.equal(persistedSession, 'session-2');
});
test('a workspace switch lets an already-started reply finish and moves the next message', async (t) => {
const { path, defaultWorkspace, alternateWorkspace } = await fixture(t);
const workspaces = await new BotWorkspaceStore(path, { defaultWorkspace }).load();
await workspaces.ensure('bot_started_prompt');
const createdIn = [];
const asks = [];
let sessionNumber = 0;
let markFirstAskStarted;
let releaseFirstAsk;
const firstAskStarted = new Promise((resolveStarted) => { markFirstAskStarted = resolveStarted; });
const firstAskGate = new Promise((resolveAsk) => { releaseFirstAsk = resolveAsk; });
const harness = {
async createSession({ workspace }) {
createdIn.push(workspace);
sessionNumber += 1;
return `session-${sessionNumber}`;
},
async sessionExists() { return true; },
async ask(sessionId, text) {
asks.push({ sessionId, text });
if (asks.length === 1) {
markFirstAskStarted();
await firstAskGate;
}
return `answer-${sessionId}`;
},
};
let persistedSession = null;
const state = {
sessionFor() { return persistedSession; },
async setSession(_key, sessionId) { persistedSession = sessionId; },
async clearSessions() { persistedSession = null; },
};
const scope = createBotWorkspaceScope(harness, {
botId: 'bot_started_prompt', workspaces, state,
});
const controller = createWorkspaceAwareController({
status() { return { bots: [{ botId: 'bot_started_prompt' }] }; },
}, { workspaces, stateFor: async () => state });
const first = askInWorkspaceSession({
harness: scope.harness,
state: scope.state,
key: 'conversation',
text: 'first',
});
await firstAskStarted;
await controller.updateWorkspace('bot_started_prompt', alternateWorkspace);
assert.equal(workspaces.workspaceFor('bot_started_prompt'), alternateWorkspace);
releaseFirstAsk();
assert.deepEqual(await first, {
sessionId: 'session-1',
answer: 'answer-session-1',
});
assert.deepEqual(await askInWorkspaceSession({
harness: scope.harness,
state: scope.state,
key: 'conversation',
text: 'second',
}), {
sessionId: 'session-2',
answer: 'answer-session-2',
});
assert.deepEqual(createdIn, [defaultWorkspace, alternateWorkspace]);
assert.deepEqual(asks, [
{ sessionId: 'session-1', text: 'first' },
{ sessionId: 'session-2', text: 'second' },
]);
});
test('deleting and rebinding a bot cannot accept an old in-flight session', async (t) => {
const { path, defaultWorkspace, alternateWorkspace } = await fixture(t);
const workspaces = await new BotWorkspaceStore(path, { defaultWorkspace }).load();
await workspaces.ensure('bot_rebind');
let finishCreation;
const harness = {
createSession() {
return new Promise((resolveCreation) => { finishCreation = resolveCreation; });
},
};
let persistedSession = null;
const state = {
async clearSessions() { persistedSession = null; },
async setSession(_key, sessionId) { persistedSession = sessionId; },
};
const oldScope = createBotWorkspaceScope(harness, { botId: 'bot_rebind', workspaces, state });
let bots = [{ botId: 'bot_rebind' }];
const controller = createWorkspaceAwareController({
status() { return { bots }; },
async deleteBot() { bots = []; return { bots }; },
}, { workspaces, stateFor: async () => state });
const oldSession = oldScope.harness.createSession();
await controller.deleteBot('bot_rebind');
await workspaces.ensure('bot_rebind');
finishCreation('session-before-delete');
assert.equal(await oldScope.state.setSession('conversation', await oldSession), false);
assert.equal(persistedSession, null);
await assert.rejects(oldScope.harness.switchWorkspace(alternateWorkspace), {
code: 'workspace-bot-not-found',
});
const reboundScope = createBotWorkspaceScope(harness, {
botId: 'bot_rebind', workspaces, state,
});
assert.equal(await reboundScope.harness.switchWorkspace(alternateWorkspace), alternateWorkspace);
});
test('a successful public delete clears sessions before a same-id rebind', async (t) => {
const { path, defaultWorkspace } = await fixture(t);
const workspaces = await new BotWorkspaceStore(path, { defaultWorkspace }).load();
await workspaces.ensure('bot_reused_id');
let persistedSession = 'old-session';
const state = {
sessionFor() { return persistedSession; },
async setSession(_key, sessionId) { persistedSession = sessionId; },
async clearSessions() { persistedSession = null; },
};
let bots = [{ botId: 'bot_reused_id' }];
const controller = createWorkspaceAwareController({
status() { return { bots }; },
async deleteBot() {
assert.equal(persistedSession, null, 'session cleanup precedes the config deletion');
bots = [];
return { bots };
},
}, { workspaces, stateFor: async () => state });
await controller.deleteBot('bot_reused_id');
await workspaces.ensure('bot_reused_id');
const asks = [];
const scope = createBotWorkspaceScope({
async createSession() { return 'new-session'; },
async sessionExists() { return true; },
async ask(sessionId) { asks.push(sessionId); return 'new-answer'; },
}, { botId: 'bot_reused_id', workspaces, state });
assert.deepEqual(await askInWorkspaceSession({
harness: scope.harness,
state: scope.state,
key: 'conversation',
text: 'after rebind',
}), { sessionId: 'new-session', answer: 'new-answer' });
assert.deepEqual(asks, ['new-session']);
assert.equal(persistedSession, 'new-session');
});
test('session cleanup load or clear failures do not block public deletion', async (t) => {
const { path, defaultWorkspace } = await fixture(t);
const workspaces = await new BotWorkspaceStore(path, { defaultWorkspace }).load();
const warnings = [];
t.mock.method(console, 'warn', (...args) => { warnings.push(args); });
for (const failure of ['load', 'clear']) {
const botId = `bot_cleanup_${failure}`;
await workspaces.ensure(botId);
let bots = [{ botId }];
let deletions = 0;
const controller = createWorkspaceAwareController({
status() { return { bots }; },
async deleteBot() {
deletions += 1;
bots = [];
return { bots };
},
}, {
workspaces,
stateFor: async () => {
if (failure === 'load') throw new Error('state load failed');
return { async clearSessions() { throw new Error('session clear failed'); } };
},
});
await controller.deleteBot(botId);
assert.equal(deletions, 1);
assert.equal(workspaces.has(botId), false);
}
assert.equal(warnings.length, 2);
assert.ok(warnings.every(([message]) => message.includes('ignored session cleanup failure')));
});
test('an old deletion transaction cannot retire a same-id rebound bot', async (t) => {
const { path, defaultWorkspace, alternateWorkspace } = await fixture(t);
const workspaces = await new BotWorkspaceStore(path, { defaultWorkspace }).load();
await workspaces.ensure('bot_lifecycle');
await workspaces.setWorkspace('bot_lifecycle', alternateWorkspace);
const firstIncarnation = workspaces.incarnationFor('bot_lifecycle');
let bots = [{ botId: 'bot_lifecycle', lifecycle: 'old' }];
const observedStore = observeBotWorkspaceRemovals({
async remove(botId) { return { botId }; },
}, { workspaces });
const controller = createWorkspaceAwareController({
status() { return { bots }; },
async deleteBot(botId) {
// The config commit retires the old lifecycle before the outer adapter
// resumes. Simulate a queued same-account provisioning completing in
// that gap and creating a new incarnation with the deterministic id.
await observedStore.remove(botId);
await workspaces.ensure(botId, { workspace: defaultWorkspace });
bots = [{ botId, lifecycle: 'rebound' }];
return { bots };
},
}, { workspaces, stateFor: async () => ({ async clearSessions() {} }) });
const result = await controller.deleteBot('bot_lifecycle');
assert.equal(workspaces.has('bot_lifecycle'), true);
assert.equal(workspaces.workspaceFor('bot_lifecycle'), defaultWorkspace);
assert.notEqual(workspaces.incarnationFor('bot_lifecycle'), firstIncarnation);
assert.equal(result.bots[0].workspace, defaultWorkspace);
const staleRemoval = await workspaces.beginRemoval('bot_lifecycle');
await workspaces.retireAfterConfigCommit('bot_lifecycle');
await workspaces.ensure('bot_lifecycle', { workspace: alternateWorkspace });
const latestIncarnation = workspaces.incarnationFor('bot_lifecycle');
assert.equal(await workspaces.abortRemoval(staleRemoval), false);
assert.equal((await workspaces.finishRemoval(staleRemoval)).stale, true);
assert.equal(workspaces.has('bot_lifecycle'), true);
assert.equal(workspaces.workspaceFor('bot_lifecycle'), alternateWorkspace);
assert.equal(workspaces.incarnationFor('bot_lifecycle'), latestIncarnation);
});
test('a workspace update for an old incarnation cannot mutate a same-id rebound bot', async (t) => {
const { path, defaultWorkspace, alternateWorkspace } = await fixture(t);
const workspaces = await new BotWorkspaceStore(path, { defaultWorkspace }).load();
await workspaces.ensure('bot_update_aba');
let markStateRequested;
let releaseState;
const stateRequested = new Promise((resolveRequested) => { markStateRequested = resolveRequested; });
const stateGate = new Promise((resolveState) => { releaseState = resolveState; });
let clears = 0;
const controller = createWorkspaceAwareController({
status() { return { bots: [{ botId: 'bot_update_aba' }] }; },
}, {
workspaces,
stateFor: async () => {
markStateRequested();
await stateGate;
return { async clearSessions() { clears += 1; } };
},
});
const updating = controller.updateWorkspace('bot_update_aba', alternateWorkspace);
await stateRequested;
await workspaces.retireAfterConfigCommit('bot_update_aba');
await workspaces.ensure('bot_update_aba', { workspace: defaultWorkspace });
releaseState();
await assert.rejects(updating, { code: 'workspace-bot-not-found' });
assert.equal(clears, 0);
assert.equal(workspaces.has('bot_update_aba'), true);
assert.equal(workspaces.workspaceFor('bot_update_aba'), defaultWorkspace);
});
test('a blocked workspace switch for one bot does not block another bot session', async (t) => {
const { path, defaultWorkspace, alternateWorkspace } = await fixture(t);
const workspaces = await new BotWorkspaceStore(path, { defaultWorkspace }).load();
await Promise.all([workspaces.ensure('bot_slow'), workspaces.ensure('bot_ready')]);
let markClearStarted;
let releaseClear;
const clearStarted = new Promise((resolveClear) => { markClearStarted = resolveClear; });
const clearGate = new Promise((resolveClear) => { releaseClear = resolveClear; });
const stateSlow = {
async clearSessions() { markClearStarted(); await clearGate; },
};
const stateReady = { async clearSessions() {} };
const created = [];
const harness = {
async createSession({ workspace }) { created.push(workspace); return 'ready-session'; },
};
const slow = createBotScopedHarness(harness, {
botId: 'bot_slow', workspaces, state: stateSlow,
});
const ready = createBotScopedHarness(harness, {
botId: 'bot_ready', workspaces, state: stateReady,
});
const switching = slow.switchWorkspace(alternateWorkspace);
await clearStarted;
assert.equal(await ready.createSession(), 'ready-session');
assert.deepEqual(created, [defaultWorkspace]);
releaseClear();
await switching;
});
test('clearing workspace sessions preserves message deduplication and channel cursors', async (t) => {
const { root } = await fixture(t);
const stores = [
['shared', await new ConversationStateStore(join(root, 'shared-state.json')).load()],
['weixin', await new WeixinStateStore(join(root, 'weixin-state.json')).load()],
['feishu', await new FeishuStateStore(join(root, 'feishu-state.json')).load()],
['dingtalk', await new DingtalkStateStore(join(root, 'dingtalk-state.json'), {
idFactory: () => 'request',
now: () => '2026-08-17T00:00:00.000Z',
}).load()],
['wecom', await new WecomStateStore(join(root, 'wecom-state.json')).load()],
['qq', await new QqStateStore(join(root, 'qq-state.json')).load()],
];
for (const [name, store] of stores) {
await store.setSession('conversation', `session-${name}`);
await store.markSeen(`message-${name}`);
}
await stores[0][1].setCursor(42);
await stores[1][1].setGetUpdatesBuf('next-weixin-cursor');
await stores[3][1].recordPendingSender('staff-one', 'User One');
for (const [name, store] of stores) {
await store.clearSessions();
assert.equal(store.sessionFor('conversation'), null, `${name} clears its Harness session`);
assert.equal(store.hasSeen(`message-${name}`), true, `${name} keeps message deduplication`);
}
assert.equal(stores[0][1].cursor(), 42);
assert.equal(stores[1][1].getUpdatesBuf(), 'next-weixin-cursor');
assert.equal(stores[3][1].pendingSenders().length, 1);
});
test('workspace-aware controller decorates status and updates one bot', async (t) => {
const { path, defaultWorkspace, alternateWorkspace } = await fixture(t);
const workspaces = await new BotWorkspaceStore(path, { defaultWorkspace }).load();
await Promise.all([workspaces.ensure('bot_one'), workspaces.ensure('bot_two')]);
const cleared = [];
const controller = createWorkspaceAwareController({
status() { return { bots: [{ botId: 'bot_one' }, { botId: 'bot_two' }] }; },
async deleteBot(botId) { return { bots: [{ botId: botId === 'bot_one' ? 'bot_two' : 'bot_one' }] }; },
}, {
workspaces,
stateFor: async (botId) => ({ async clearSessions() { cleared.push(botId); } }),
});
const updated = await controller.updateWorkspace('bot_one', alternateWorkspace);
assert.equal(updated.bots[0].workspace, alternateWorkspace);
assert.equal(updated.bots[1].workspace, defaultWorkspace);
assert.deepEqual(cleared, ['bot_one']);
await assert.rejects(controller.updateWorkspace('missing_bot', alternateWorkspace), {
code: 'workspace-bot-not-found',
});
});
test('workspace updates serialize with deletion and cannot recreate a removed bot mapping', async (t) => {
const { path, defaultWorkspace, alternateWorkspace } = await fixture(t);
const workspaces = await new BotWorkspaceStore(path, { defaultWorkspace }).load();
await workspaces.ensure('bot_delete');
await workspaces.setWorkspace('bot_delete', alternateWorkspace);
let bots = [{ botId: 'bot_delete' }];
let releaseDelete;
let markDeleteStarted;
const deleteStarted = new Promise((resolveStarted) => { markDeleteStarted = resolveStarted; });
const deleteGate = new Promise((resolveDelete) => { releaseDelete = resolveDelete; });
let clears = 0;
const controller = createWorkspaceAwareController({
status() { return { bots }; },
async deleteBot() {
markDeleteStarted();
await deleteGate;
bots = [];
return { bots };
},
}, {
workspaces,
stateFor: async () => ({ async clearSessions() { clears += 1; } }),
});
const deleting = controller.deleteBot('bot_delete');
await deleteStarted;
const lateUpdate = controller.updateWorkspace('bot_delete', defaultWorkspace);
releaseDelete();
await deleting;
await assert.rejects(lateUpdate, { code: 'workspace-bot-not-found' });
assert.equal(workspaces.workspaceFor('bot_delete'), defaultWorkspace);
assert.equal(clears, 1);
});
test('workspace deletion keeps the durable path until the bot config commits', async (t) => {
const { path, defaultWorkspace, alternateWorkspace } = await fixture(t);
const workspaces = await new BotWorkspaceStore(path, { defaultWorkspace }).load();
await workspaces.ensure('bot_durable');
await workspaces.setWorkspace('bot_durable', alternateWorkspace);
let bots = [{ botId: 'bot_durable' }];
let markDeleteStarted;
let releaseDelete;
const deleteStarted = new Promise((resolveDelete) => { markDeleteStarted = resolveDelete; });
const deleteGate = new Promise((resolveDelete) => { releaseDelete = resolveDelete; });
const controller = createWorkspaceAwareController({
status() { return { bots }; },
async deleteBot() {
markDeleteStarted();
await deleteGate;
bots = [];
return { bots };
},
}, { workspaces, stateFor: async () => ({ async clearSessions() {} }) });
const deleting = controller.deleteBot('bot_durable');
await deleteStarted;
assert.equal(JSON.parse(await readFile(path, 'utf8')).workspaces.bot_durable, alternateWorkspace);
releaseDelete();
await deleting;
assert.equal(workspaces.has('bot_durable'), false);
});
test('a failed bot deletion aborts the fence without rewriting its workspace', async (t) => {
const { path, defaultWorkspace, alternateWorkspace } = await fixture(t);
const workspaces = await new BotWorkspaceStore(path, { defaultWorkspace }).load();
await workspaces.ensure('bot_abort');
await workspaces.setWorkspace('bot_abort', alternateWorkspace);
const state = { async clearSessions() {} };
const scope = createBotWorkspaceScope({ async createSession() {} }, {
botId: 'bot_abort', workspaces, state,
});
const controller = createWorkspaceAwareController({
status() { return { bots: [{ botId: 'bot_abort' }] }; },
async deleteBot() { throw new Error('config removal failed'); },
}, { workspaces, stateFor: async () => state });
await assert.rejects(controller.deleteBot('bot_abort'), /config removal failed/);
assert.equal(workspaces.has('bot_abort'), true);
assert.equal(workspaces.workspaceFor('bot_abort'), alternateWorkspace);
assert.equal(await scope.harness.switchWorkspace(defaultWorkspace), defaultWorkspace);
});
test('a committed bot deletion stays retired when workspace cleanup persistence fails', async (t) => {
const { root, defaultWorkspace, alternateWorkspace } = await fixture(t);
const storeDirectory = join(root, 'delete-store');
const storePath = join(storeDirectory, 'workspaces.json');
await mkdir(storeDirectory);
const workspaces = await new BotWorkspaceStore(storePath, { defaultWorkspace }).load();
await workspaces.ensure('bot_commit');
await workspaces.setWorkspace('bot_commit', alternateWorkspace);
let bots = [{ botId: 'bot_commit' }];
let markDeleteStarted;
let releaseDelete;
const deleteStarted = new Promise((resolveDelete) => { markDeleteStarted = resolveDelete; });
const deleteGate = new Promise((resolveDelete) => { releaseDelete = resolveDelete; });
const controller = createWorkspaceAwareController({
status() { return { bots }; },
async deleteBot() {
markDeleteStarted();
await deleteGate;
bots = [];
return { bots };
},
}, { workspaces, stateFor: async () => ({ async clearSessions() {} }) });
const deleting = controller.deleteBot('bot_commit');
await deleteStarted;
await rename(storeDirectory, `${storeDirectory}-saved`);
await writeFile(storeDirectory, 'block cleanup persistence');
releaseDelete();
await deleting;
assert.equal(workspaces.has('bot_commit'), false);
await assert.rejects(workspaces.setWorkspace('bot_commit', defaultWorkspace), {
code: 'workspace-bot-not-found',
});
await rm(storeDirectory, { force: true });
await rename(`${storeDirectory}-saved`, storeDirectory);
assert.equal(JSON.parse(await readFile(storePath, 'utf8')).workspaces.bot_commit, alternateWorkspace);
await workspaces.reconcile([]);
await assert.rejects(readFile(storePath, 'utf8'), { code: 'ENOENT' });
});
test('config-store removal observation retires workspaces after the config commit', async (t) => {
const { path, defaultWorkspace } = await fixture(t);
const workspaces = await new BotWorkspaceStore(path, { defaultWorkspace }).load();
await Promise.all([workspaces.ensure('bot_remove'), workspaces.ensure('bot_feishu')]);
const tokenStore = observeBotWorkspaceRemovals({
async remove(botId) { return { botId }; },
}, { workspaces });
const feishuStore = observeBotWorkspaceRemovals({
async removeBot(id) { return { id }; },
}, {
workspaces,
method: 'removeBot',
botIdFromRemoved: (removed) => removed.id,
});
await tokenStore.remove('bot_remove');
await feishuStore.removeBot('bot_feishu');
assert.equal(workspaces.has('bot_remove'), false);
assert.equal(workspaces.has('bot_feishu'), false);
});
test('/workspace command preserves spaces and returns actionable validation messages', async (t) => {
const { alternateWorkspace } = await fixture(t);
const switched = [];
const harness = { async switchWorkspace(path) { switched.push(path); return path; } };
assert.equal(await runWorkspaceCommand('hello', harness), null);
assert.match((await runWorkspaceCommand('/workspace', harness)).message, /用法/);
assert.match(
(await runWorkspaceCommand(`/workspace ${alternateWorkspace}`, harness)).message,
new RegExp(alternateWorkspace.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')),
);
assert.deepEqual(switched, [alternateWorkspace]);
const invalidHarness = {
async switchWorkspace() {
const error = new Error('工作区路径不存在。');
error.code = 'workspace-not-found';
throw error;
},
};
const invalid = await runWorkspaceCommand('/workspace /missing/workspace', invalidHarness);
assert.match(invalid.message, /路径不存在/);
assert.match(invalid.message, /用法:\/workspace 工作区绝对路径/);
const removedHarness = {
async switchWorkspace() {
const error = new Error('bot removed');
error.code = 'workspace-bot-not-found';
throw error;
},
};
assert.match(
(await runWorkspaceCommand(`/workspace ${alternateWorkspace}`, removedHarness)).message,
/正在移除或已重新接入/,
);
});
for (const [name, Client] of [
['Weixin', WeixinHarnessClient],
['Feishu', FeishuHarnessClient],
['DingTalk', DingtalkHarnessClient],
]) {
test(`${name} Harness creates a session with an explicit workspace override`, async () => {
const client = new Client({
baseUrl: 'http://127.0.0.1:3080',
workspace: '/default-workspace',
agentPreset: 'standard',
autostart: false,
dshBin: 'dsh',
});
const calls = [];
client.ensureRunning = async () => true;
client.rpc = async (method, payload, _timeout, options) => {
calls.push({ method, payload, options });
if (method === 'workspace.list') return { items: [] };
if (method === 'workspace.create') return { workspace: { workspaceId: 'workspace-new' } };
if (method === 'session.create') return { sessionId: 'session-new' };
throw new Error(`Unexpected RPC: ${method}`);
};
const signal = new AbortController().signal;
const options = name === 'DingTalk'
? { workspace: '/explicit-workspace', signal }
: { workspace: '/explicit-workspace' };
assert.equal(await client.createSession(options), 'session-new');
assert.deepEqual(calls.map(({ method }) => method), [
'workspace.list', 'workspace.create', 'session.create',
]);
assert.equal(calls[1].payload.path, '/explicit-workspace');
if (name === 'DingTalk') assert.equal(calls[0].options.signal, signal);
});
}
test('workspace RPC validates payloads and returns the updated public status', async (t) => {
const { root, path, defaultWorkspace, alternateWorkspace } = await fixture(t);
const workspaces = await new BotWorkspaceStore(path, { defaultWorkspace }).load();
await workspaces.ensure('bot_one');
const base = {
status() { return { bots: [{ botId: 'bot_one', connected: true }] }; },
bindCredentials() { return this.status(); },
reconnectBot() { return this.status(); },
deleteBot() { return { bots: [] }; },
};
const controller = createWorkspaceAwareController(base, {
workspaces,
stateFor: async () => ({ async clearSessions() {} }),
});
const handler = createTokenBotRpcHandler(controller, { channel: 'Telegram' });
const success = await handler(TOKEN_BOT_ENDPOINTS.setWorkspace, {
botId: 'bot_one', workspace: alternateWorkspace,
});
assert.equal(success.ok, true);
assert.equal(success.value.bots[0].workspace, alternateWorkspace);
const relative = await handler(TOKEN_BOT_ENDPOINTS.setWorkspace, {
botId: 'bot_one', workspace: 'relative/path',
});
assert.equal(relative.error.code, 'bad-request');
const missing = await handler(TOKEN_BOT_ENDPOINTS.setWorkspace, {
botId: 'bot_one', workspace: join(root, 'missing'),
});
assert.equal(missing.error.code, 'workspace-not-found');
assert.match(missing.error.message, /不存在/);
});