diff --git a/packages/base/command.gts b/packages/base/command.gts index 87850191eb8..df48240eb2d 100644 --- a/packages/base/command.gts +++ b/packages/base/command.gts @@ -961,3 +961,8 @@ export class SyncOpenRouterModelsResult extends CardDef { @field status = contains(StringField); @field errors = contains(StringField); } + +export class CreateWorkspaceInput extends CardDef { + @field name = contains(StringField); // display name; a random name is generated when omitted + @field endpoint = contains(StringField); // URL path segment (letters, digits, hyphens); derived from the name when omitted +} diff --git a/packages/host/app/services/matrix-service.ts b/packages/host/app/services/matrix-service.ts index e5cf963d493..e8b3a5a510d 100644 --- a/packages/host/app/services/matrix-service.ts +++ b/packages/host/app/services/matrix-service.ts @@ -952,6 +952,7 @@ export default class MatrixService extends Service { }); await this.appendRealmToAccountData(personalRealmURL.href); + return personalRealmURL; } public async appendRealmToAccountData(realmURLString: string) { diff --git a/packages/host/app/tools/create-workspace.ts b/packages/host/app/tools/create-workspace.ts new file mode 100644 index 00000000000..ff11e0a700b --- /dev/null +++ b/packages/host/app/tools/create-workspace.ts @@ -0,0 +1,86 @@ +import { service } from '@ember/service'; + +import HostBaseTool from '../lib/host-base-tool'; +import { generateRandomWorkspaceName } from '../lib/random-name'; +import { + cleanseString, + getRandomBackgroundURL, + iconURLFor, +} from '../lib/utils'; + +import type MatrixService from '../services/matrix-service'; +import type OperatorModeStateService from '../services/operator-mode-state-service'; +import type RealmService from '../services/realm'; +import type * as BaseToolModule from '@cardstack/base/command'; + +// Creates a workspace (realm) owned by the current user, the same way the +// "New Workspace" tile in the workspace chooser does: the realm is created on +// the user's realm server and recorded in the user's realm list, so it shows +// up in the chooser right away. The new workspace is then opened, which puts +// its URL in the context sent back with the tool result — there is no result +// card; the assistant reads the URL from the "Workspace:" line of that +// context. +export default class CreateWorkspaceTool extends HostBaseTool< + typeof BaseToolModule.CreateWorkspaceInput, + undefined +> { + @service declare private matrixService: MatrixService; + @service declare private operatorModeStateService: OperatorModeStateService; + @service declare private realm: RealmService; + + static actionVerb = 'Create'; + + description = + 'Create a new workspace (realm) owned by the current user. Both fields are optional: a random display name is generated when `name` is omitted, and `endpoint` (the workspace URL path segment) is derived from the name when omitted. Opens the new workspace when done; its URL is the Workspace in the context returned with the tool result.'; + + async getInputType() { + let commandModule = await this.loadToolModule(); + return commandModule.CreateWorkspaceInput; + } + + protected async run( + input: BaseToolModule.CreateWorkspaceInput, + ): Promise { + let name = input.name?.trim() || generateRandomWorkspaceName(); + // The server accepts only lowercase letters, digits and hyphens in an + // endpoint. Normalize whatever was given (or the name) into that shape + // rather than rejecting near-misses like "My Workspace". + let endpoint = toEndpoint(input.endpoint?.trim() || name); + if (!endpoint) { + throw new Error( + `Cannot derive a workspace endpoint from '${input.endpoint ?? name}'. Provide an endpoint made of letters, digits and hyphens.`, + ); + } + + let realmURL = await this.matrixService.createPersonalRealmForUser({ + endpoint, + name, + iconURL: iconURLFor(name), + backgroundURL: getRandomBackgroundURL(), + }); + + // Register the new realm with the realm service before opening it. The + // context sent with the tool result names the current workspace by + // looking the open card's realm up there; a realm it has not met yet + // resolves to the previous workspace, and the assistant would report the + // wrong URL. The realm exists by now, so a failure here is a transient + // info fetch problem and must not fail a creation that succeeded. + try { + await this.realm.ensureRealmMeta(realmURL.href); + } catch (error) { + console.warn( + `Could not load realm info for new workspace ${realmURL.href}`, + error, + ); + } + await this.operatorModeStateService.openWorkspace(realmURL.href); + return undefined; + } +} + +function toEndpoint(value: string): string { + return cleanseString(value) + .replace(/_/g, '-') + .replace(/-{2,}/g, '-') + .replace(/^-+|-+$/g, ''); +} diff --git a/packages/host/app/tools/delete-workspace.ts b/packages/host/app/tools/delete-workspace.ts new file mode 100644 index 00000000000..dd6cf0992e5 --- /dev/null +++ b/packages/host/app/tools/delete-workspace.ts @@ -0,0 +1,77 @@ +import { service } from '@ember/service'; + +import { RealmPaths, ensureTrailingSlash } from '@cardstack/runtime-common'; + +import HostBaseTool from '../lib/host-base-tool'; + +import type MatrixService from '../services/matrix-service'; +import type OperatorModeStateService from '../services/operator-mode-state-service'; +import type RealmService from '../services/realm'; +import type RealmServerService from '../services/realm-server'; +import type RecentFilesService from '../services/recent-files-service'; +import type * as BaseToolModule from '@cardstack/base/command'; + +// Deletes a workspace (realm) the current user owns, with the same result as +// "Delete Workspace" in the workspace chooser tile menu: the realm and all of +// its content are removed on the realm server, it leaves the user's realm +// list, and the app falls back to the workspace chooser if the deleted +// workspace was the one being viewed. +export default class DeleteWorkspaceTool extends HostBaseTool< + typeof BaseToolModule.RealmIdentifierCard, + undefined +> { + @service declare private matrixService: MatrixService; + @service declare private operatorModeStateService: OperatorModeStateService; + @service declare private realm: RealmService; + @service declare private realmServer: RealmServerService; + @service declare private recentFilesService: RecentFilesService; + + static actionVerb = 'Delete'; + + description = + 'Permanently delete a workspace (realm) owned by the current user, including all of its cards and files. This cannot be undone. Only realms the user owns can be deleted.'; + + async getInputType() { + let commandModule = await this.loadToolModule(); + return commandModule.RealmIdentifierCard; + } + + requireInputFields = ['realmIdentifier']; + + protected async run( + input: BaseToolModule.RealmIdentifierCard, + ): Promise { + if (!input.realmIdentifier) { + throw new Error('Realm identifier is required to delete a workspace.'); + } + let realmURL = ensureTrailingSlash(input.realmIdentifier); + if (!this.realm.isRealmOwner(realmURL)) { + throw new Error( + `Cannot delete workspace ${realmURL}: the current user is not its owner.`, + ); + } + + let realmPath = new RealmPaths(new URL(realmURL)); + let isActiveWorkspace = + this.operatorModeStateService.realmURL === realmURL || + this.operatorModeStateService + .getOpenCardIds() + .some((cardId) => realmPath.inRealm(cardId)) || + Boolean( + this.operatorModeStateService.codePathString?.startsWith(realmURL), + ); + + await this.realmServer.deleteRealm(realmURL); + await this.matrixService.removeRealmFromAccountData(realmURL); + this.recentFilesService.removeRecentFilesForRealmURL(realmURL); + this.realm.removeRealm(realmURL); + + if (isActiveWorkspace) { + this.operatorModeStateService.clearStacks(); + await this.operatorModeStateService.updateCodePath(null); + this.operatorModeStateService.openWorkspaceChooser(); + } + + return undefined; + } +} diff --git a/packages/host/app/tools/index.ts b/packages/host/app/tools/index.ts index f0e94ad8c2a..bbf64df1385 100644 --- a/packages/host/app/tools/index.ts +++ b/packages/host/app/tools/index.ts @@ -21,6 +21,8 @@ import * as CreateAIAssistantRoomToolModule from './create-ai-assistant-room'; import * as CreateAndOpenSubmissionWorkflowCard from './create-and-open-submission-workflow-card'; import * as CreateSpecToolModule from './create-specs'; import * as CreateSubmissionWorkflowToolModule from './create-submission-workflow'; +import * as CreateWorkspaceToolModule from './create-workspace'; +import * as DeleteWorkspaceToolModule from './delete-workspace'; import * as DownloadFileToRealmToolModule from './download-file-to-realm'; import * as EvaluateModuleToolModule from './evaluate-module'; import * as ExecuteAtomicOperationsToolModule from './execute-atomic-operations'; @@ -307,6 +309,16 @@ export function shimHostTools(virtualNetwork: VirtualNetwork) { 'retry-submission-workflow', RetrySubmissionWorkflowToolModule, ); + shimHostToolModule( + virtualNetwork, + 'create-workspace', + CreateWorkspaceToolModule, + ); + shimHostToolModule( + virtualNetwork, + 'delete-workspace', + DeleteWorkspaceToolModule, + ); shimHostToolModule(virtualNetwork, 'open-workspace', OpenWorkspaceToolModule); shimHostToolModule( virtualNetwork, @@ -506,6 +518,8 @@ export const HostToolClasses: (typeof HostBaseTool)[] = [ CreateAndOpenSubmissionWorkflowCard.default, CreateSubmissionWorkflowToolModule.default, RetrySubmissionWorkflowToolModule.default, + CreateWorkspaceToolModule.default, + DeleteWorkspaceToolModule.default, OpenInInteractModeModule.default, OpenWorkspaceToolModule.default, GenerateThemeExampleToolModule.default, diff --git a/packages/host/tests/acceptance/ai-assistant-create-workspace-test.gts b/packages/host/tests/acceptance/ai-assistant-create-workspace-test.gts new file mode 100644 index 00000000000..16fe24e04d0 --- /dev/null +++ b/packages/host/tests/acceptance/ai-assistant-create-workspace-test.gts @@ -0,0 +1,222 @@ +import { click, waitFor, waitUntil } from '@ember/test-helpers'; + +import { getService } from '@universal-ember/test-support'; +import { module, test } from 'qunit'; + +import { + buildCommandFunctionNameFromResolvedRef, + skillCardRef, +} from '@cardstack/runtime-common'; +import { + APP_BOXEL_TOOL_REQUESTS_KEY, + APP_BOXEL_MESSAGE_MSGTYPE, + APP_BOXEL_TOOL_RESULT_REL_TYPE, + APP_BOXEL_TOOL_RESULT_WITH_NO_OUTPUT_MSGTYPE, +} from '@cardstack/runtime-common/matrix-constants'; + +import type RealmServerService from '@cardstack/host/services/realm-server'; + +import { + addSkillToAiAssistant, + setupAcceptanceTestRealm, + setupAuthEndpoints, + setupLocalIndexing, + setupRealmCacheTeardown, + setupUserSubscription, + testRealmURL, + visitOperatorMode, + waitForNewRoomSkillsLoaded, + realmConfigCardJSON, +} from '../helpers'; +import { setupBaseRealm } from '../helpers/base-realm'; +import { setupMockMatrix } from '../helpers/mock-matrix'; +import { setupApplicationTest } from '../helpers/setup'; + +const newRealmURL = 'http://test-realm/testuser/team-space/'; + +const cardsGridIndex = { + data: { + type: 'card', + meta: { + adoptsFrom: { + module: '@cardstack/base/cards-grid', + name: 'CardsGrid', + }, + }, + }, +}; + +module('Acceptance | AI assistant creates a workspace', function (hooks) { + const createWorkspaceToolName = buildCommandFunctionNameFromResolvedRef({ + module: '@cardstack/boxel-host/tools/create-workspace', + name: 'default', + }); + + setupApplicationTest(hooks); + setupLocalIndexing(hooks); + setupRealmCacheTeardown(hooks); + + let mockMatrixUtils = setupMockMatrix(hooks, { + loggedInAs: '@testuser:localhost', + activeRealms: [testRealmURL], + }); + let { simulateRemoteMessage, getRoomIds, getRoomEvents } = mockMatrixUtils; + + setupBaseRealm(hooks); + + hooks.beforeEach(async function () { + setupUserSubscription(); + setupAuthEndpoints(); + + await setupAcceptanceTestRealm({ + mockMatrixUtils, + contents: { + 'index.json': cardsGridIndex, + 'realm.json': realmConfigCardJSON({ name: 'Test Workspace' }), + 'Skill/workspace-admin.json': { + data: { + type: 'card', + attributes: { + instructions: + 'Use create-workspace when the user asks for a new workspace.', + commands: [ + { + codeRef: { + name: 'default', + module: '@cardstack/boxel-host/tools/create-workspace', + }, + requiresApproval: true, + }, + ], + cardTitle: 'Workspace Admin', + cardDescription: null, + cardThumbnailURL: null, + }, + meta: { + adoptsFrom: skillCardRef, + }, + }, + }, + }, + }); + + // The realm the tool "creates" is pre-mounted with the two files + // `_create-realm` seeds, and `createRealm` is stubbed to return it: the + // realm-server mock has no realm-creation endpoint. It is not in the + // user's realm list until the tool adds it there. + await setupAcceptanceTestRealm({ + realmURL: newRealmURL, + mockMatrixUtils, + permissions: { + '@testuser:localhost': ['read', 'write', 'realm-owner'], + }, + contents: { + 'realm.json': realmConfigCardJSON({ name: 'Team Space' }), + 'index.json': cardsGridIndex, + }, + }); + }); + + test('a create-workspace tool request creates and opens the workspace', async function (assert) { + let realmServer = getService('realm-server') as RealmServerService; + let createRealmCalls: Parameters[0][] = + []; + realmServer.createRealm = async (args) => { + createRealmCalls.push(args); + return new URL(newRealmURL); + }; + + await visitOperatorMode({ + stacks: [[{ id: `${testRealmURL}index`, format: 'isolated' }]], + aiAssistantOpen: true, + }); + await waitFor('[data-room-settled]'); + let roomId = getRoomIds().pop()!; + await addSkillToAiAssistant(`${testRealmURL}Skill/workspace-admin`); + await waitForNewRoomSkillsLoaded(roomId); + + simulateRemoteMessage(roomId, '@aibot:localhost', { + body: 'Creating a workspace for the team', + msgtype: APP_BOXEL_MESSAGE_MSGTYPE, + format: 'org.matrix.custom.html', + isStreamingFinished: true, + [APP_BOXEL_TOOL_REQUESTS_KEY]: [ + { + id: 'create-ws-1', + name: createWorkspaceToolName, + arguments: JSON.stringify({ + description: 'Create the Team Space workspace', + attributes: { + name: 'Team Space', + endpoint: 'team-space', + }, + }), + }, + ], + }); + + await waitFor('[data-test-message-idx="0"] [data-test-tool-call-apply]'); + assert + .dom('[data-test-message-idx="0"] .tool-description') + .containsText('Create the Team Space workspace'); + assert.strictEqual( + createRealmCalls.length, + 0, + 'nothing is created before the user approves', + ); + + await click('[data-test-message-idx="0"] [data-test-tool-call-apply]'); + await waitFor( + '[data-test-message-idx="0"] [data-test-apply-state="applied"]', + ); + + assert.strictEqual(createRealmCalls.length, 1, 'one realm is created'); + assert.strictEqual(createRealmCalls[0].endpoint, 'team-space'); + assert.strictEqual(createRealmCalls[0].name, 'Team Space'); + + await waitUntil( + () => + getRoomEvents(roomId).find( + (m) => + m.content.msgtype === + APP_BOXEL_TOOL_RESULT_WITH_NO_OUTPUT_MSGTYPE && + m.content.commandRequestId === 'create-ws-1', + ), + { + timeout: 5000, + timeoutMessage: 'timed out waiting for the tool result event', + }, + ); + let resultEvent = getRoomEvents(roomId).find( + (m) => + m.content.msgtype === APP_BOXEL_TOOL_RESULT_WITH_NO_OUTPUT_MSGTYPE && + m.content.commandRequestId === 'create-ws-1', + )!; + assert.strictEqual( + resultEvent.content['m.relates_to']?.rel_type, + APP_BOXEL_TOOL_RESULT_REL_TYPE, + ); + assert.strictEqual(resultEvent.content['m.relates_to']?.key, 'applied'); + // `data` is a JSON string on the wire. + let resultData = JSON.parse(resultEvent.content.data as string); + assert.strictEqual( + resultData.context?.realmUrl, + newRealmURL, + 'the context sent with the result names the new workspace, so the assistant can report its URL', + ); + assert.strictEqual( + getService('operator-mode-state-service').state?.stacks[0]?.[0]?.id, + `${newRealmURL}index`, + 'the new workspace is opened', + ); + + // The chooser lists the new workspace without a reload. + await click('[data-test-workspace-chooser-toggle]'); + await waitFor( + '[data-test-workspace-list] [data-test-workspace="Team Space"]', + ); + assert + .dom('[data-test-workspace-list] [data-test-workspace="Team Space"]') + .exists('the new workspace appears in the workspace chooser'); + }); +}); diff --git a/packages/host/tests/integration/tools/create-workspace-test.gts b/packages/host/tests/integration/tools/create-workspace-test.gts new file mode 100644 index 00000000000..157a2c8e174 --- /dev/null +++ b/packages/host/tests/integration/tools/create-workspace-test.gts @@ -0,0 +1,183 @@ +import { getService } from '@universal-ember/test-support'; +import { module, test } from 'qunit'; + +import { ri } from '@cardstack/runtime-common'; + +import type RealmServerService from '@cardstack/host/services/realm-server'; +import CreateWorkspaceTool from '@cardstack/host/tools/create-workspace'; + +import { + setupIntegrationTestRealm, + setupLocalIndexing, + testRealmURL, + setupRealmCacheTeardown, + setupRealmServerEndpoints, + withCachedRealmSetup, +} from '../../helpers'; +import { setupBaseRealm } from '../../helpers/base-realm'; +import { setupMockMatrix } from '../../helpers/mock-matrix'; +import { setupRenderingTest } from '../../helpers/setup'; + +const realmServerURL = 'http://test-realm/'; + +module('Integration | tools | create-workspace', function (hooks) { + setupRenderingTest(hooks); + setupBaseRealm(hooks); + setupLocalIndexing(hooks); + setupRealmServerEndpoints(hooks); + + let mockMatrixUtils = setupMockMatrix(hooks, { + loggedInAs: '@testuser:localhost', + activeRealms: [testRealmURL], + autostart: true, + }); + + setupRealmCacheTeardown(hooks); + + // The realm-server mock has no realm-creation endpoint, so `createRealm` is + // stubbed to record its arguments and hand back the URL the server would + // mint for the given endpoint. Everything around that call — name and + // endpoint choice, the realm list update, the reported URL — runs for real. + let createRealmCalls: Parameters[0][]; + let realmMetaRequests: string[]; + hooks.beforeEach(async function () { + createRealmCalls = []; + realmMetaRequests = []; + await withCachedRealmSetup(async () => + setupIntegrationTestRealm({ + mockMatrixUtils, + contents: {}, + }), + ); + let realmServer = getService('realm-server') as RealmServerService; + realmServer.createRealm = async (args) => { + createRealmCalls.push(args); + return new URL(`${realmServerURL}testuser/${args.endpoint}/`); + }; + // The created realm is not mounted in this test, so the realm-info fetch + // the tool makes for it is recorded instead of performed. + let realmService = getService('realm'); + realmService.ensureRealmMeta = async (realmURL: string) => { + realmMetaRequests.push(realmURL); + }; + }); + + test('creates a workspace with the given name and endpoint', async function (assert) { + let toolService = getService('tool-service'); + let realmServer = getService('realm-server') as RealmServerService; + let tool = new CreateWorkspaceTool(toolService.toolContext); + + let operatorModeStateService = getService('operator-mode-state-service'); + operatorModeStateService.restore({ + stacks: [], + submode: 'interact', + workspaceChooserOpened: true, + }); + + await tool.execute({ + name: 'Sales Pipeline', + endpoint: 'sales', + }); + + assert.strictEqual(createRealmCalls.length, 1, 'one realm is created'); + assert.strictEqual(createRealmCalls[0].endpoint, 'sales'); + assert.strictEqual(createRealmCalls[0].name, 'Sales Pipeline'); + assert.ok(createRealmCalls[0].iconURL, 'an icon is chosen for the realm'); + assert.ok( + createRealmCalls[0].backgroundURL, + 'a background is chosen for the realm', + ); + + assert.deepEqual( + realmMetaRequests, + [`${realmServerURL}testuser/sales/`], + 'the realm service is told about the new realm before it is opened', + ); + assert.strictEqual( + operatorModeStateService.state?.stacks[0]?.[0]?.id, + `${realmServerURL}testuser/sales/index`, + 'the new workspace is opened, so its URL reaches the assistant as the current workspace', + ); + + assert.true( + realmServer.userRealmIdentifiers.includes( + ri(`${realmServerURL}testuser/sales/`), + ), + 'the new workspace is in the realm list the workspace chooser renders', + ); + assert.strictEqual( + realmServer.userRealmIdentifiers[0], + ri(`${realmServerURL}testuser/sales/`), + 'the new workspace is listed first, as the newest-created realm', + ); + }); + + test('derives the endpoint from the name when no endpoint is given', async function (assert) { + let toolService = getService('tool-service'); + let tool = new CreateWorkspaceTool(toolService.toolContext); + + await tool.execute({ name: "Zoë's Q3 Plan" }); + + assert.strictEqual(createRealmCalls[0].endpoint, 'zoes-q3-plan'); + assert.strictEqual(createRealmCalls[0].name, "Zoë's Q3 Plan"); + }); + + test('normalizes an endpoint into the characters the server accepts', async function (assert) { + let toolService = getService('tool-service'); + let tool = new CreateWorkspaceTool(toolService.toolContext); + + await tool.execute({ + name: 'Team', + endpoint: ' My_Team Space! ', + }); + + assert.strictEqual(createRealmCalls[0].endpoint, 'my-team-space'); + }); + + test('generates a name and endpoint when neither is given', async function (assert) { + let toolService = getService('tool-service'); + let tool = new CreateWorkspaceTool(toolService.toolContext); + + await tool.execute({}); + + let [call] = createRealmCalls; + assert.ok(call.name, 'a display name is generated'); + assert.ok( + /^[a-z0-9-]+$/.test(call.endpoint), + `the endpoint '${call.endpoint}' is in the server's accepted shape`, + ); + }); + + test('rejects an endpoint that leaves no usable characters', async function (assert) { + let toolService = getService('tool-service'); + let tool = new CreateWorkspaceTool(toolService.toolContext); + + await assert.rejects( + tool.execute({ name: 'Team', endpoint: '!!!' }), + /Cannot derive a workspace endpoint/, + ); + assert.strictEqual(createRealmCalls.length, 0, 'no realm is created'); + }); + + test('surfaces the server error when creation fails', async function (assert) { + let toolService = getService('tool-service'); + let realmServer = getService('realm-server') as RealmServerService; + realmServer.createRealm = async () => { + throw new Error( + `Could not create realm with endpoint 'sales': 400 - realm already exists`, + ); + }; + let tool = new CreateWorkspaceTool(toolService.toolContext); + + await assert.rejects( + tool.execute({ name: 'Sales', endpoint: 'sales' }), + /realm already exists/, + ); + assert.false( + realmServer.userRealmIdentifiers.includes( + ri(`${realmServerURL}testuser/sales/`), + ), + 'a failed creation adds nothing to the realm list', + ); + }); +}); diff --git a/packages/host/tests/integration/tools/delete-workspace-test.gts b/packages/host/tests/integration/tools/delete-workspace-test.gts new file mode 100644 index 00000000000..13153b04b30 --- /dev/null +++ b/packages/host/tests/integration/tools/delete-workspace-test.gts @@ -0,0 +1,96 @@ +import { getService } from '@universal-ember/test-support'; +import { module, test } from 'qunit'; + +import { ri } from '@cardstack/runtime-common'; + +import type RealmServerService from '@cardstack/host/services/realm-server'; +import DeleteWorkspaceTool from '@cardstack/host/tools/delete-workspace'; + +import { + setupIntegrationTestRealm, + setupLocalIndexing, + testRealmURL, + setupRealmCacheTeardown, + setupRealmServerEndpoints, + withCachedRealmSetup, +} from '../../helpers'; +import { setupBaseRealm } from '../../helpers/base-realm'; +import { setupMockMatrix } from '../../helpers/mock-matrix'; +import { setupRenderingTest } from '../../helpers/setup'; + +module('Integration | tools | delete-workspace', function (hooks) { + setupRenderingTest(hooks); + setupBaseRealm(hooks); + setupLocalIndexing(hooks); + setupRealmServerEndpoints(hooks); + + let mockMatrixUtils = setupMockMatrix(hooks, { + loggedInAs: '@testuser:localhost', + activeRealms: [testRealmURL], + autostart: true, + }); + + setupRealmCacheTeardown(hooks); + + let deleteRealmCalls: string[]; + hooks.beforeEach(async function () { + deleteRealmCalls = []; + await withCachedRealmSetup(async () => + setupIntegrationTestRealm({ + mockMatrixUtils, + contents: {}, + permissions: { + '@testuser:localhost': ['read', 'write', 'realm-owner'], + }, + }), + ); + let realmServer = getService('realm-server') as RealmServerService; + realmServer.deleteRealm = async (realmURL) => { + deleteRealmCalls.push(realmURL); + }; + }); + + test('deletes an owned workspace and drops it from the realm list', async function (assert) { + let toolService = getService('tool-service'); + let realmServer = getService('realm-server') as RealmServerService; + let operatorModeStateService = getService('operator-mode-state-service'); + operatorModeStateService.restore({ + stacks: [[{ id: `${testRealmURL}index`, format: 'isolated' }]], + submode: 'interact', + }); + assert.true(realmServer.userRealmIdentifiers.includes(ri(testRealmURL))); + + let tool = new DeleteWorkspaceTool(toolService.toolContext); + await tool.execute({ + realmIdentifier: testRealmURL.replace(/\/$/, ''), + }); + + assert.deepEqual(deleteRealmCalls, [testRealmURL]); + assert.false( + realmServer.userRealmIdentifiers.includes(ri(testRealmURL)), + 'the deleted workspace leaves the realm list the chooser renders', + ); + assert.strictEqual( + operatorModeStateService.state?.stacks.length, + 0, + 'stacks showing the deleted workspace are cleared', + ); + assert.true( + operatorModeStateService.state?.workspaceChooserOpened, + 'the app falls back to the workspace chooser', + ); + }); + + test('refuses to delete a workspace the user does not own', async function (assert) { + let toolService = getService('tool-service'); + let realmService = getService('realm'); + realmService.isRealmOwner = () => false; + let tool = new DeleteWorkspaceTool(toolService.toolContext); + + await assert.rejects( + tool.execute({ realmIdentifier: testRealmURL }), + /not its owner/, + ); + assert.deepEqual(deleteRealmCalls, [], 'nothing is deleted'); + }); +});