Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions packages/base/command.gts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
1 change: 1 addition & 0 deletions packages/host/app/services/matrix-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -952,6 +952,7 @@ export default class MatrixService extends Service {
});

await this.appendRealmToAccountData(personalRealmURL.href);
return personalRealmURL;
}

public async appendRealmToAccountData(realmURLString: string) {
Expand Down
86 changes: 86 additions & 0 deletions packages/host/app/tools/create-workspace.ts
Original file line number Diff line number Diff line change
@@ -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<undefined> {
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, '');
}
77 changes: 77 additions & 0 deletions packages/host/app/tools/delete-workspace.ts
Original file line number Diff line number Diff line change
@@ -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<undefined> {
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;
}
}
14 changes: 14 additions & 0 deletions packages/host/app/tools/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -506,6 +518,8 @@ export const HostToolClasses: (typeof HostBaseTool<any, any>)[] = [
CreateAndOpenSubmissionWorkflowCard.default,
CreateSubmissionWorkflowToolModule.default,
RetrySubmissionWorkflowToolModule.default,
CreateWorkspaceToolModule.default,
DeleteWorkspaceToolModule.default,
OpenInInteractModeModule.default,
OpenWorkspaceToolModule.default,
GenerateThemeExampleToolModule.default,
Expand Down
Loading
Loading