diff --git a/src/client/App.tsx b/src/client/App.tsx index e5cc6466..a683a4e7 100644 --- a/src/client/App.tsx +++ b/src/client/App.tsx @@ -140,7 +140,16 @@ export function App() { setState(s); setWorkspace(w); setNeedsAuth(false); - setSelectedDot((previous) => previous || w.dots[0]?.id || ''); + setSelectedDot((previous) => + w.dots.some((item) => item.id === previous) + ? previous + : (w.dots[0]?.id ?? ''), + ); + setSelectedThread((previous) => + w.conversations.some((item) => item.id === previous) + ? previous + : undefined, + ); setNotices((current) => applyRefreshResult(current, { ok: true })); } catch (e) { if (e instanceof ApiError && e.status === 401) setNeedsAuth(true); @@ -184,17 +193,25 @@ export function App() { clearInterval(timer); }; }, [selectedThread]); - const mutate = async (path: string, method: string, body?: unknown) => { + const mutate = async ( + path: string, + method: string, + body?: unknown, + ): Promise<{ ok: true } | { ok: false; error: string }> => { setError(''); try { await api(path, method, body); await refresh(); - if (taskDetail) + if (method === 'DELETE' && path.startsWith('/dots/')) { + setTaskDetail(undefined); + } + if (taskDetail && !(method === 'DELETE' && path.startsWith('/dots/'))) setTaskDetail(await api(`/tasks/${taskDetail.task.id}`)); - return true; + return { ok: true }; } catch (e) { - setError(e instanceof Error ? e.message : 'Could not save.'); - return false; + const message = e instanceof Error ? e.message : 'Could not save.'; + setError(message); + return { ok: false, error: message }; } }; const dot = diff --git a/src/client/WorkspaceDialog.tsx b/src/client/WorkspaceDialog.tsx index ece3361b..8832ea23 100644 --- a/src/client/WorkspaceDialog.tsx +++ b/src/client/WorkspaceDialog.tsx @@ -1,5 +1,5 @@ import { useEffect, useRef, useState } from 'react'; -import { X } from 'lucide-react'; +import { Trash2, X } from 'lucide-react'; import type { Dot, Memory, State, WorkspaceState } from '../shared/types'; import { ConnectionsSection } from './ConnectionsSection'; import { @@ -24,7 +24,11 @@ export function WorkspaceDialog({ state: State; workspace: WorkspaceState; onClose: () => void; - mutate: (path: string, method: string, body?: unknown) => Promise; + mutate: ( + path: string, + method: string, + body?: unknown, + ) => Promise<{ ok: true } | { ok: false; error: string }>; }) { const [name, setName] = useState( dialog.type === 'dot' ? (dialog.dot?.name ?? '') : '', @@ -71,6 +75,8 @@ export function WorkspaceDialog({ ); const [busy, setBusy] = useState(false); const [error, setError] = useState(''); + const [confirmDelete, setConfirmDelete] = useState(false); + const [confirmName, setConfirmName] = useState(''); const [theme, setTheme] = useState(themePreference); const container = useRef(null); useEffect(() => { @@ -88,7 +94,7 @@ export function WorkspaceDialog({ ...(container.current?.querySelectorAll( 'button:not([disabled]),input,textarea,select,a[href]', ) ?? []), - ]; + ].filter((item) => !item.closest('[hidden]')); if (event.shiftKey && document.activeElement === items[0]) { event.preventDefault(); items.at(-1)?.focus(); @@ -101,9 +107,16 @@ export function WorkspaceDialog({ document.addEventListener('keydown', key); return () => { document.removeEventListener('keydown', key); - previous?.focus(); + if (previous?.isConnected) previous.focus(); + else document.querySelector('.dots-nav .dot-nav')?.focus(); }; }, []); + useEffect(() => { + if (confirmDelete) + container.current + ?.querySelector('#confirm-dot-name') + ?.focus(); + }, [confirmDelete]); const title = dialog.type === 'space' ? 'A space for something.' @@ -135,7 +148,66 @@ export function WorkspaceDialog({ OPENDOTS TEMPLATE

{title}

+ {confirmDelete && dialog.type === 'dot' && dialog.dot && ( +
+

+ Deleting {dialog.dot.name} removes it and its + conversations, calls, scheduled tasks, connections, and specialist + settings from this workspace. Shared Spaces and pages will remain. + Transcripts held by a configured conversation provider may be + retained there. +

+ + setConfirmName(event.target.value)} + /> + {error && ( +

+ {error} +

+ )} +
+ + +
+
+ )} diff --git a/src/client/style.css b/src/client/style.css index 75be32de..9fd037be 100644 --- a/src/client/style.css +++ b/src/client/style.css @@ -1299,6 +1299,48 @@ h3 { right: 15px; top: 15px; } +.danger-link, +.danger-button { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 7px; + border: 1px solid #d78a83; + border-radius: 8px; + color: #a7463e; + background: transparent; + padding: 9px 12px; + font-size: 11px; +} +.danger-link:hover:not(:disabled), +.danger-button:hover:not(:disabled) { + background: #fff2ef; +} +.danger-link:disabled, +.danger-button:disabled { + opacity: 0.55; + cursor: not-allowed; +} +.dot-delete-confirmation > p { + font-size: 12px; + line-height: 1.7; + color: #777b89; +} +.dot-delete-confirmation .field-label { + margin-top: 24px; +} +.dot-delete-confirmation > input { + width: 100%; +} +.dot-delete-actions { + display: flex; + justify-content: flex-end; + gap: 10px; + margin-top: 18px; +} +.dot-delete-actions button { + min-height: 38px; +} .field-label { display: block; font-size: 11px; diff --git a/src/server/dot-agent.ts b/src/server/dot-agent.ts index f5e34ab7..8acabc43 100644 --- a/src/server/dot-agent.ts +++ b/src/server/dot-agent.ts @@ -63,6 +63,7 @@ export class DotAgent extends AbstractAgent { } run(input: RunAgentInput): Observable { return new Observable((subscriber) => { + const endDotRun = this.workspace.trackDotRun(this.dotId); const controller = new AbortController(); this.controller = controller; let subscription: { unsubscribe(): void } | undefined; @@ -467,6 +468,7 @@ export class DotAgent extends AbstractAgent { fail(error); } return () => { + endDotRun(); clearTimeout(timeout); clearInterval(watcher); controller.abort(); diff --git a/src/server/store.ts b/src/server/store.ts index 05d7d86a..2aaeb500 100644 --- a/src/server/store.ts +++ b/src/server/store.ts @@ -110,6 +110,19 @@ export class Store { .prepare('SELECT * FROM tasks WHERE id=?') .get(id) as unknown as Task | undefined; } + deleteTasks(ids: string[]) { + if (!ids.length) return; + this.transaction(() => { + const marks = ids.map(() => '?').join(','); + this.db + .prepare(`DELETE FROM events WHERE taskId IN (${marks})`) + .run(...ids); + this.db + .prepare(`DELETE FROM runs WHERE taskId IN (${marks})`) + .run(...ids); + this.db.prepare(`DELETE FROM tasks WHERE id IN (${marks})`).run(...ids); + }); + } createTask(prompt: string, intervalSeconds: number | null = null): Task { const now = Date.now(); const id = randomUUID(); diff --git a/src/server/workspace-routes.ts b/src/server/workspace-routes.ts index 672ffda1..d79b2acf 100644 --- a/src/server/workspace-routes.ts +++ b/src/server/workspace-routes.ts @@ -141,6 +141,47 @@ export function workspaceRoutes(platform: Platform, voice: VoiceService) { } return c.json(platform.workspace.updateDot(c.req.param('id'), data.data)); }); + app.delete('/dots/:id', (c) => { + const id = c.req.param('id'); + const dots = platform.workspace.dots(); + if (!dots.some((dot) => dot.id === id)) + return c.json({ error: 'Dot not found.' }, 404); + if (dots.length === 1) + return c.json({ error: 'Keep at least one Dot in the workspace.' }, 409); + const slackEnabled = !!( + platform.config.slackChannel && + platform.config.slackTeam && + platform.config.slackUsers.length + ); + const slackDotId = platform.config.slackDotId ?? dots[0]?.id; + if (slackEnabled && slackDotId === id) + return c.json( + { + error: + 'This Dot is assigned to the Slack integration. Change SLACK_DOT_ID and restart the app before deleting it.', + }, + 409, + ); + const blocker = platform.workspace.dotDeletionBlocker(id); + if (blocker) return c.json({ error: blocker }, 409); + const taskIds = platform.workspace.taskIdsForDot(id); + if ( + platform.store + .tasks() + .some((task) => taskIds.includes(task.id) && task.status === 'running') + ) + return c.json( + { + error: + 'Cancel or wait for this Dot’s running scheduled task before deleting it.', + }, + 409, + ); + const deleted = platform.workspace.deleteDot(id); + if (!deleted) return c.json({ error: 'Dot not found.' }, 404); + platform.store.deleteTasks(deleted.taskIds); + return c.json({ ok: true }); + }); app.post('/conversations', async (c) => { const data = z .object({ diff --git a/src/server/workspace.ts b/src/server/workspace.ts index e98061c3..eafb4531 100644 --- a/src/server/workspace.ts +++ b/src/server/workspace.ts @@ -9,6 +9,7 @@ import { validateLearningSettings } from '../shared/learning.js'; import type { CallReceipt, Conversation, Dot, Space } from '../shared/types.js'; export class WorkspaceStore { private db: DatabaseSync; + private activeDotRuns = new Map(); readonly pages: Pages; readonly computers: ComputerStore; readonly connections: ConnectionStore; @@ -123,6 +124,110 @@ export class WorkspaceStore { dot(id: string) { return this.dots().find((dot) => dot.id === id); } + trackDotRun(id: string) { + this.activeDotRuns.set(id, (this.activeDotRuns.get(id) ?? 0) + 1); + let active = true; + return () => { + if (!active) return; + active = false; + const count = (this.activeDotRuns.get(id) ?? 1) - 1; + if (count) this.activeDotRuns.set(id, count); + else this.activeDotRuns.delete(id); + }; + } + taskIdsForDot(id: string): string[] { + return this.db + .prepare( + 'SELECT task_threads.taskId FROM task_threads JOIN thread_bindings ON thread_bindings.id=task_threads.threadId WHERE thread_bindings.dotId=?', + ) + .all(id) + .map((row) => String(row.taskId)); + } + dotDeletionBlocker(id: string): string | undefined { + if (this.activeDotRuns.has(id)) + return 'Wait for this Dot’s active conversation turn to finish before deleting it.'; + const threadIds = this.db + .prepare('SELECT id FROM thread_bindings WHERE dotId=?') + .all(id) + .map((row) => String(row.id)); + if (threadIds.length) { + const marks = threadIds.map(() => '?').join(','); + if ( + this.db + .prepare( + `SELECT 1 FROM calls WHERE threadId IN (${marks}) AND endedAt IS NULL LIMIT 1`, + ) + .get(...threadIds) + ) + return 'End this Dot’s active call before deleting it.'; + if ( + this.db + .prepare( + `SELECT 1 FROM mcp_actions WHERE threadId IN (${marks}) AND status='running' LIMIT 1`, + ) + .get(...threadIds) + ) + return 'Wait for this Dot’s connected tool action to finish before deleting it.'; + } + if ( + this.db + .prepare( + `SELECT 1 FROM computer_audit WHERE dotId=? AND outcome='pending' LIMIT 1`, + ) + .get(id) + ) + return 'Wait for this Dot’s computer action to finish before deleting it.'; + return undefined; + } + deleteDot( + id: string, + ): { threadIds: string[]; taskIds: string[] } | undefined { + if (!this.dot(id)) return undefined; + const threadIds = this.db + .prepare('SELECT id FROM thread_bindings WHERE dotId=?') + .all(id) + .map((row) => String(row.id)); + const taskIds = this.taskIdsForDot(id); + const marks = threadIds.map(() => '?').join(','); + this.db.exec('BEGIN IMMEDIATE'); + try { + if (threadIds.length) { + this.db + .prepare(`DELETE FROM calls WHERE threadId IN (${marks})`) + .run(...threadIds); + this.db + .prepare(`DELETE FROM captures WHERE threadId IN (${marks})`) + .run(...threadIds); + this.db + .prepare(`DELETE FROM page_reviews WHERE threadId IN (${marks})`) + .run(...threadIds); + this.db + .prepare(`DELETE FROM mcp_actions WHERE threadId IN (${marks})`) + .run(...threadIds); + this.db + .prepare(`DELETE FROM mcp_approvals WHERE threadId IN (${marks})`) + .run(...threadIds); + this.db + .prepare(`DELETE FROM task_threads WHERE threadId IN (${marks})`) + .run(...threadIds); + this.db + .prepare(`DELETE FROM thread_bindings WHERE id IN (${marks})`) + .run(...threadIds); + } + this.db.prepare('DELETE FROM page_threads WHERE dotId=?').run(id); + this.db.prepare('DELETE FROM mcp_approvals WHERE dotId=?').run(id); + this.db.prepare('DELETE FROM mcp_connections WHERE dotId=?').run(id); + this.db.prepare('DELETE FROM computer_permissions WHERE dotId=?').run(id); + this.db.prepare('DELETE FROM computer_audit WHERE dotId=?').run(id); + this.db.prepare('DELETE FROM dot_spaces WHERE dotId=?').run(id); + this.db.prepare('DELETE FROM dots WHERE id=?').run(id); + this.db.exec('COMMIT'); + return { threadIds, taskIds }; + } catch (error) { + this.db.exec('ROLLBACK'); + throw error; + } + } createDot( spaceId: string, name: string, diff --git a/tests/page-routes.test.ts b/tests/page-routes.test.ts index 210e5992..b9d1d4bf 100644 --- a/tests/page-routes.test.ts +++ b/tests/page-routes.test.ts @@ -21,6 +21,7 @@ function fixture(ownerToken?: string) { runtimeUrl: '', }); return { + store, ws, platform, app: createApp({ @@ -38,6 +39,119 @@ const request = (body: unknown, method = 'POST') => ({ body: JSON.stringify(body), }); +it('deletes a Dot and its scheduled-task history through the owner API', async () => { + const { store, ws, app } = fixture(); + const [remaining] = ws.dots(); + const target = ws.createDot( + remaining.spaceId, + 'Temporary', + 'A temporary specialist', + true, + true, + ); + const thread = ws.bindThread('temporary-thread', target.id, 'Temporary work'); + const task = store.createTask('Temporary scheduled work'); + ws.bindTask(task.id, thread.id); + const page = ws.pages.create( + remaining.spaceId, + { title: 'Shared', content: 'Shared content' }, + thread.id, + ); + + const response = await app.request(`/api/dots/${target.id}`, { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + }); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ ok: true }); + expect(ws.dot(target.id)).toBeUndefined(); + expect(store.task(task.id)).toBeUndefined(); + expect(ws.conversations()).toEqual([]); + expect(ws.spaces()).toHaveLength(1); + expect(ws.pages.list(remaining.spaceId)).toContainEqual( + expect.objectContaining({ id: page.id, content: 'Shared content' }), + ); +}); + +it('prevents deletion of the final Dot and reports a missing Dot', async () => { + const { ws, app } = fixture(); + const [onlyDot] = ws.dots(); + const last = await app.request(`/api/dots/${onlyDot.id}`, { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + }); + expect(last.status).toBe(409); + expect(await last.json()).toEqual({ + error: 'Keep at least one Dot in the workspace.', + }); + const missing = await app.request('/api/dots/missing', { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + }); + expect(missing.status).toBe(404); + expect(await missing.json()).toEqual({ error: 'Dot not found.' }); +}); + +it('blocks deletion when a Dot has a running scheduled task or active call', async () => { + const { store, ws, app } = fixture(); + const [remaining] = ws.dots(); + const target = ws.createDot( + remaining.spaceId, + 'Busy', + 'A busy specialist', + true, + true, + ); + const thread = ws.bindThread('busy-thread', target.id, 'Busy work'); + const task = store.createTask('Run this scheduled work'); + ws.bindTask(task.id, thread.id); + const claim = store.claim(); + expect(claim?.id).toBe(task.id); + const runningTask = await app.request(`/api/dots/${target.id}`, { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + }); + expect(runningTask.status).toBe(409); + expect((await runningTask.json()).error).toMatch(/running scheduled task/); + store.action(task.id, 'cancel'); + + const call = ws.createCall(thread.id); + const activeCall = await app.request(`/api/dots/${target.id}`, { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + }); + expect(activeCall.status).toBe(409); + expect((await activeCall.json()).error).toMatch(/active call/); + ws.setCall(call.id, 'ended', ''); + expect( + ( + await app.request(`/api/dots/${target.id}`, { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + }) + ).status, + ).toBe(200); +}); + +it('blocks deletion of the Dot assigned to the configured Slack integration', async () => { + const { ws, platform, app } = fixture(); + const [slackDot] = ws.dots(); + ws.createDot(slackDot.spaceId, 'Other', 'Another specialist', true, true); + platform.config.slackChannel = 'team-channel'; + platform.config.slackTeam = 'team-id'; + platform.config.slackUsers = ['user-id']; + + const response = await app.request(`/api/dots/${slackDot.id}`, { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + }); + + expect(response.status).toBe(409); + expect((await response.json()).error).toMatch(/SLACK_DOT_ID/); + expect(ws.dot(slackDot.id)).toBeDefined(); +}); + it('saves Learning settings through the owner API and rejects malformed container IDs', async () => { const { ws, app } = fixture(); const dot = ws.dots()[0]; diff --git a/tests/workspace-dialog.test.tsx b/tests/workspace-dialog.test.tsx new file mode 100644 index 00000000..21388511 --- /dev/null +++ b/tests/workspace-dialog.test.tsx @@ -0,0 +1,123 @@ +import { createElement } from 'react'; +import { act, create } from 'react-test-renderer'; +import { beforeEach, expect, it, vi } from 'vitest'; +const api = vi.hoisted(() => vi.fn()); +vi.mock('../src/client/api', () => ({ api })); +import { WorkspaceDialog } from '../src/client/WorkspaceDialog'; +import type { Dot } from '../src/shared/types'; + +const dot: Dot = { + id: 'dot-to-delete', + spaceId: 'space', + spaceIds: ['space'], + name: 'Research Scout', + instructions: 'Research carefully', + researchAllowed: true, + memoryAllowed: true, + appsAllowed: false, + composioToolkits: [], + createdAt: 1, +}; +const contents = (node: unknown): string => + typeof node === 'string' + ? node + : Array.isArray(node) + ? node.map(contents).join('') + : node && typeof node === 'object' && 'children' in node + ? contents((node as { children: unknown }).children) + : ''; +const button = (root: ReturnType, label: string) => + root.root.findAll( + (node) => + node.type === 'button' && contents(node.props.children).includes(label), + )[0]; + +beforeEach(() => { + api.mockReset().mockResolvedValue([]); + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + vi.stubGlobal('HTMLElement', class HTMLElementMock {}); + vi.stubGlobal('document', { + activeElement: null, + addEventListener: () => {}, + removeEventListener: () => {}, + querySelector: () => null, + }); +}); + +const props = (mutate: ReturnType) => ({ + dialog: { type: 'dot' as const, dot, spaceId: dot.spaceId }, + state: { + settings: { + researchAllowed: true, + memoryAllowed: true, + appsAllowed: false, + }, + } as never, + workspace: { + spaces: [{ id: dot.spaceId, name: 'Everyday' }], + dots: [dot], + setup: { composio: false }, + } as never, + onClose: vi.fn(), + mutate: mutate as unknown as ( + path: string, + method: string, + body?: unknown, + ) => Promise<{ ok: true } | { ok: false; error: string }>, +}); + +it('requires typing the specialist name before issuing the delete request', async () => { + const mutate = vi.fn().mockResolvedValue({ ok: true }); + const p = props(mutate); + let root!: ReturnType; + await act(async () => { + root = create(createElement(WorkspaceDialog, p)); + }); + await act(async () => { + button(root, 'Delete this Dot').props.onClick(); + }); + expect(contents(root.toJSON())).toContain('Research Scout'); + const confirm = button(root, 'Delete Dot'); + expect(confirm.props.disabled).toBe(true); + const nameInput = root.root.find( + (node) => node.type === 'input' && node.props.id === 'confirm-dot-name', + ); + await act(async () => { + nameInput.props.onChange({ target: { value: 'Research Scout' } }); + }); + const enabledConfirm = button(root, 'Delete Dot'); + expect(enabledConfirm.props.disabled).toBe(false); + await act(async () => { + await enabledConfirm.props.onClick(); + }); + expect(mutate).toHaveBeenCalledWith('/dots/dot-to-delete', 'DELETE'); + expect(p.onClose).toHaveBeenCalledTimes(1); +}); + +it('keeps the confirmation open and shows dependency errors from the API', async () => { + const mutate = vi.fn().mockResolvedValue({ + ok: false, + error: 'End this Dot’s active call before deleting it.', + }); + const p = props(mutate); + let root!: ReturnType; + await act(async () => { + root = create(createElement(WorkspaceDialog, p)); + }); + await act(async () => { + button(root, 'Delete this Dot').props.onClick(); + }); + const nameInput = root.root.find( + (node) => node.type === 'input' && node.props.id === 'confirm-dot-name', + ); + await act(async () => { + nameInput.props.onChange({ target: { value: dot.name } }); + }); + await act(async () => { + await button(root, 'Delete Dot').props.onClick(); + }); + expect(contents(root.toJSON())).toContain( + 'End this Dot’s active call before deleting it.', + ); + expect(p.onClose).not.toHaveBeenCalled(); +}); diff --git a/tests/workspace.test.ts b/tests/workspace.test.ts index f5a9ef4f..1a4e34ce 100644 --- a/tests/workspace.test.ts +++ b/tests/workspace.test.ts @@ -24,6 +24,113 @@ it('rejects a dot in a nonexistent space and does not rebind an existing thread' store.close(); }); +it('deletes one Dot and its private records while preserving shared workspace data', () => { + const store = new WorkspaceStore(':memory:', 'owner'); + const remaining = store.dots()[0]; + const target = store.createDot( + remaining.spaceId, + 'Researcher', + 'Research carefully', + true, + true, + ); + const targetThread = store.bindThread('target-thread', target.id, 'Research'); + const remainingThread = store.bindThread( + 'remaining-thread', + remaining.id, + 'Keep this', + ); + const page = store.pages.create( + remaining.spaceId, + { title: 'Shared page', content: 'Keep this content' }, + targetThread.id, + ); + store.pages.reserveThread(page.id, target.id, targetThread.id); + store.pages.finishThread(page.id, target.id); + store.createCall(targetThread.id); + const endedCall = store.calls(targetThread.id)[0]; + store.setCall(endedCall.id, 'ended', 'Call transcript'); + store.bindTask('target-task', targetThread.id); + store.bindTask('remaining-task', remainingThread.id); + store.connections.create( + target.id, + { name: 'Private connection', url: 'https://example.com/mcp' }, + [], + ); + store.computers.patch(target.id, { enabled: true }); + + const deleted = store.deleteDot(target.id); + + expect(deleted).toEqual({ + threadIds: ['target-thread'], + taskIds: ['target-task'], + }); + expect(store.dot(target.id)).toBeUndefined(); + expect(store.dots()).toHaveLength(1); + expect(store.conversations().map((thread) => thread.id)).toEqual([ + 'remaining-thread', + ]); + expect(store.taskThread('target-task')).toBeUndefined(); + expect(store.taskThread('remaining-task')).toBe('remaining-thread'); + expect(store.calls()).toEqual([]); + expect(store.connections.list(target.id)).toEqual([]); + expect(store.computers.permissions(target.id)).toEqual({ + enabled: false, + browser: false, + files: false, + shell: false, + }); + expect(store.spaces()).toHaveLength(1); + expect(store.pages.list(remaining.spaceId)).toContainEqual( + expect.objectContaining({ id: page.id, content: 'Keep this content' }), + ); + store.close(); +}); + +it('reports active call, tool, and computer work as deletion blockers', () => { + const store = new WorkspaceStore(':memory:', 'owner'); + const [remaining] = store.dots(); + const dot = store.createDot(remaining.spaceId, 'Busy', 'Working', true, true); + const thread = store.bindThread('busy-thread', dot.id, 'Work'); + const call = store.createCall(thread.id); + expect(store.dotDeletionBlocker(dot.id)).toMatch(/active call/); + store.setCall(call.id, 'ended', ''); + const endDotRun = store.trackDotRun(dot.id); + expect(store.dotDeletionBlocker(dot.id)).toMatch(/active conversation turn/); + endDotRun(); + + const connection = store.connections.create( + dot.id, + { name: 'Tools', url: 'https://example.com/mcp' }, + [], + ); + const approvalId = store.connections.createApproval({ + threadId: thread.id, + dotId: dot.id, + connectionId: connection.id, + tool: 'send_message', + arguments: {}, + }); + store.connections.claimAction( + thread.id, + 'tool-call', + approvalId, + connection.id, + 'send_message', + ); + expect(store.dotDeletionBlocker(dot.id)).toMatch(/tool action/); + + store.connections.finishAction('busy-thread', 'tool-call', { + isError: false, + text: 'Done', + }); + const auditId = store.computers.begin(dot.id, 'click', 'agent'); + expect(store.dotDeletionBlocker(dot.id)).toMatch(/computer action/); + store.computers.finish(auditId, 'succeeded'); + expect(store.dotDeletionBlocker(dot.id)).toBeUndefined(); + store.close(); +}); + it('migrates legacy Space ownership once and never restores revoked access on restart', () => { const dir = mkdtempSync(join(tmpdir(), 'opendots-access-')); const path = join(dir, 'workspace.sqlite');