diff --git a/docs/commands-in-headless-chrome.md b/docs/commands-in-headless-chrome.md new file mode 100644 index 00000000000..a28e4e5f086 --- /dev/null +++ b/docs/commands-in-headless-chrome.md @@ -0,0 +1,169 @@ +## Demo + +https://www.loom.com/share/2fe9a3e58a7c459ba574b2c0f747a667 + +## Testing Locally + +1. Start `bot-runner` with `pnpm start:development`. +2. Start realm server with `pnpm start:all`. +3. Inside `packages/matrix`, run `pnpm setup-submission-bot`. +4. Open http://localhost:4200/experiments/BotRequestDemo/bot-request-demo. +5. Click **Send Show Card Bot Request** on the demo card. + +## Flow Diagram + +### User issuing task + +Example source is the experiments demo card, but the same flow is used for any `app.boxel.bot-trigger` event. + +```mermaid +flowchart TD + + B["Trigger 'Send Show Card Bot Request' from UI"] + B --> D["CreateShowCardRequestCommand.execute()"] + D --> E["SendBotTriggerEventCommand.execute()"] + E --> F["sendEvent('app.boxel.bot-trigger')"] + F --> G["bot-runner receives timeline event"] + G --> H["enqueueRunCommandJob() to jobs table"] +``` + +### Command runner architecture + +```mermaid +flowchart TD + + A["bot-runner"] + A -->|enqueue run-command job with command string| B["jobs table"] + B --> C["runtime-common worker"] + C -->|runCommand task| D["remote prerenderer"] + D -->|POST /run-command| E["prerender manager"] + E -->|proxy| F["prerender server app"] + F -->|transitionTo command-runner route| G["headless Chrome tab"] + G --> H["host command-runner route"] + H -->|command executes + DOM status| G + G -->|capture data-prerender + data-command-result| F + F --> D + D --> C + C --> B +``` + +## Matrix Event Payload (`app.boxel.bot-trigger`) + +```ts +const event = { + type: 'app.boxel.bot-trigger', + content: { + type: 'show-card', + realm: 'http://localhost:4201/experiments/', + input: { + cardId: 'http://localhost:4201/experiments/Author/jane-doe', + format: 'isolated', + }, + }, +}; +``` + +## Bot Trigger Data Structures + +```ts +type SendBotTriggerEventInput = { + roomId: string; + type: string; + realm: string; + input: Record; +}; + +type BotTriggerContent = { + type: string; + realm: string; + input: unknown; +}; +``` + +## Submission Bot Commands (DB) + +`setup-submission-bot` now writes canonical scoped command specifiers into `bot_commands.command`. + +```json +[ + { + "name": "create-listing-pr", + "command": "@cardstack/boxel-host/commands/create-listing-pr/default", + "filter": { + "type": "matrix-event", + "event_type": "app.boxel.bot-trigger", + "content_type": "create-listing-pr" + } + }, + { + "name": "show-card", + "command": "@cardstack/boxel-host/commands/show-card/default", + "filter": { + "type": "matrix-event", + "event_type": "app.boxel.bot-trigger", + "content_type": "show-card" + } + }, + { + "name": "patch-card-instance", + "command": "@cardstack/boxel-host/commands/patch-card-instance/default", + "filter": { + "type": "matrix-event", + "event_type": "app.boxel.bot-trigger", + "content_type": "patch-card-instance" + } + } +] +``` + +## Run Command Job Payload + +`RunCommandArgs.realmURL` is derived from `event.content.realm`. + +```json +{ + "realmURL": "http://localhost:4201/experiments/", + "realmUsername": "@alice:localhost", + "runAs": "@alice:localhost", + "command": "@cardstack/boxel-host/commands/show-card/default", + "commandInput": { + "cardId": "http://localhost:4201/experiments/Author/jane-doe", + "format": "isolated" + } +} +``` + +### Command Normalization in `run-command` task + +- Command stays a `string` across bot-runner, job queue, and prerender request. +- `runtime-common/tasks/run-command.ts` normalizes legacy realm-server URL forms (`/commands//`) into a realm-local module specifier path when needed. +- String -> `ResolvedCodeRef` conversion happens in the host `command-runner` route when it parses `:command`. + +## Host `command-runner` Route + +```ts +route: /command-runner/:command/:input/:nonce + +type CommandRunnerRouteParams = { + command: string; + input: string; + nonce: string; +}; +``` + +### Example (`command` and `input` as path params) + +```ts +const command = '@cardstack/boxel-host/commands/show-card/default'; +const input = JSON.stringify({ + cardId: 'http://localhost:4201/experiments/Author/jane-doe', + format: 'isolated', +}); +const nonce = '2'; + +const url = `http://localhost:4200/command-runner/${encodeURIComponent(command)}/${encodeURIComponent(input)}/${encodeURIComponent(nonce)}`; +``` + +```txt +http://localhost:4200/command-runner/%40cardstack%2Fboxel-host%2Fcommands%2Fshow-card%2Fdefault/%7B%22cardId%22%3A%22http%3A%2F%2Flocalhost%3A4201%2Fexperiments%2FAuthor%2Fjane-doe%22%2C%22format%22%3A%22isolated%22%7D/2 +``` diff --git a/packages/base/command.gts b/packages/base/command.gts index b8bfb6b07e6..6d51529e052 100644 --- a/packages/base/command.gts +++ b/packages/base/command.gts @@ -434,6 +434,7 @@ export class SendBotTriggerEventInput extends CardDef { @field roomId = contains(StringField); @field type = contains(StringField); @field input = contains(JsonField); + @field realm = contains(StringField); } export class PreviewFormatInput extends CardDef { diff --git a/packages/base/matrix-event.gts b/packages/base/matrix-event.gts index dcc8a5ae0f2..a731c984452 100644 --- a/packages/base/matrix-event.gts +++ b/packages/base/matrix-event.gts @@ -285,10 +285,10 @@ export interface CommandResultEvent extends BaseMatrixEvent { } export const BOT_TRIGGER_EVENT_TYPE = 'app.boxel.bot-trigger'; -export const BOT_TRIGGER_COMMAND_TYPES = ['create-listing-pr'] as const; export interface BotTriggerContent { - type: (typeof BOT_TRIGGER_COMMAND_TYPES)[number]; + type: string; + realm: string; input: unknown; } diff --git a/packages/base/realm.gts b/packages/base/realm.gts index efc6e296fe1..b7f68c64fc2 100644 --- a/packages/base/realm.gts +++ b/packages/base/realm.gts @@ -32,10 +32,10 @@ class EditComponent extends Component { get writableRealms(): RealmMeta[] { let resource = this.allRealmsInfoResource; - if (!resource || !resource.isSuccess || !resource.value) { + if (!resource || !resource.isSuccess || !resource.cardResult) { return []; } - let results = (resource.value.results ?? []) as RealmMeta[]; + let results = (resource.cardResult.results ?? []) as RealmMeta[]; return results.filter((realm) => realm.canWrite); } diff --git a/packages/base/resources/command-data.ts b/packages/base/resources/command-data.ts index 369ae1c25f2..700360ec02c 100644 --- a/packages/base/resources/command-data.ts +++ b/packages/base/resources/command-data.ts @@ -16,7 +16,7 @@ export class CommandExecutionState implements CommandInvocation { @tracked status: 'pending' | 'success' | 'error' = 'pending'; - @tracked value: CardInstance | null = null; + @tracked cardResult: CardInstance | null = null; @tracked error: Error | null = null; get isSuccess() { @@ -29,19 +29,19 @@ export class CommandExecutionState setLoading() { this.status = 'pending'; - this.value = null; + this.cardResult = null; this.error = null; } setSuccess(result: CardInstance) { this.status = 'success'; - this.value = result; + this.cardResult = result; this.error = null; } setError(error: Error) { this.status = 'error'; - this.value = null; + this.cardResult = null; this.error = error; } } diff --git a/packages/bot-runner/lib/timeline-handler.ts b/packages/bot-runner/lib/timeline-handler.ts index 94074715158..7549f9483fe 100644 --- a/packages/bot-runner/lib/timeline-handler.ts +++ b/packages/bot-runner/lib/timeline-handler.ts @@ -1,6 +1,18 @@ -import { isBotTriggerEvent, logger, param, query } from '@cardstack/runtime-common'; +import { + isBotTriggerEvent, + isBotCommandFilter, + logger, + param, + query, + userInitiatedPriority, +} from '@cardstack/runtime-common'; +import { enqueueRunCommandJob } from '@cardstack/runtime-common/jobs/run-command'; import * as Sentry from '@sentry/node'; -import type { DBAdapter, PgPrimitive } from '@cardstack/runtime-common'; +import type { + DBAdapter, + PgPrimitive, + QueuePublisher, +} from '@cardstack/runtime-common'; import type { MatrixEvent, Room } from 'matrix-js-sdk'; const log = logger('bot-runner'); @@ -13,11 +25,13 @@ export interface BotRegistration { export interface TimelineHandlerOptions { authUserId: string; dbAdapter: DBAdapter; + queuePublisher: QueuePublisher; } export function onTimelineEvent({ authUserId, dbAdapter, + queuePublisher, }: TimelineHandlerOptions) { return async function handleTimelineEvent( event: MatrixEvent, @@ -34,11 +48,14 @@ export function onTimelineEvent({ } let eventContent = rawEvent.content; log.debug('event content', eventContent); - let senderUsername = getRoomCreator(room); + let senderUsername = + event.getSender?.() ?? + (typeof rawEvent.sender === 'string' ? rawEvent.sender : undefined) ?? + getRoomCreator(room); if (!senderUsername) { return; } - let submissionBotUsername = authUserId; + let submissionBotUserId = authUserId; let registrations = await getRegistrationsForUser( dbAdapter, @@ -46,7 +63,7 @@ export function onTimelineEvent({ ); let submissionBotRegistrations = await getRegistrationsForUser( dbAdapter, - submissionBotUsername, + submissionBotUserId, ); if (!registrations.length && !submissionBotRegistrations.length) { return; @@ -67,6 +84,17 @@ export function onTimelineEvent({ `handling event for bot runner registration ${registration.id} in room ${room.roomId}`, eventContent, ); + let allowedCommands = await getCommandsForRegistration( + dbAdapter, + registration.id, + ); + await maybeEnqueueCommand({ + dbAdapter, + queuePublisher, + runAs: senderUsername, + eventContent, + allowedCommands, + }); } for (let registration of registrations) { let createdAt = Date.parse(registration.created_at); @@ -82,6 +110,17 @@ export function onTimelineEvent({ `handling event for registration ${registration.id} in room ${room.roomId}`, eventContent, ); + let allowedCommands = await getCommandsForRegistration( + dbAdapter, + registration.id, + ); + await maybeEnqueueCommand({ + dbAdapter, + queuePublisher, + runAs: senderUsername, + eventContent, + allowedCommands, + }); } } catch (error) { log.error('error handling timeline event', error); @@ -90,10 +129,89 @@ export function onTimelineEvent({ }; } +async function maybeEnqueueCommand({ + dbAdapter, + queuePublisher, + runAs, + eventContent, + allowedCommands, +}: { + dbAdapter: DBAdapter; + queuePublisher: QueuePublisher; + runAs: string; + eventContent: { type?: unknown; input?: unknown; realm?: unknown }; + allowedCommands: { type: string; command: string }[]; +}) { + if ( + !allowedCommands.length || + typeof eventContent.type !== 'string' || + !allowedCommands.some((entry) => entry.type === eventContent.type) + ) { + return; + } + + if (!eventContent?.input || typeof eventContent.input !== 'object') { + return; + } + + let input = eventContent.input as Record; + let realmURL = + typeof eventContent.realm === 'string' ? eventContent.realm : undefined; + let commandRegistration = allowedCommands.find( + (entry) => entry.type === eventContent.type, + ); + let command = commandRegistration?.command?.trim(); + let commandInput: Record | null = input; + + if (!realmURL || !command) { + log.warn( + 'bot trigger missing required input for command (need realmURL and command)', + { realmURL, command }, + ); + return; + } + + await enqueueRunCommandJob( + { + realmURL, + realmUsername: runAs, + runAs, + command, + commandInput, + }, + queuePublisher, + dbAdapter, + userInitiatedPriority, + ); +} + function getRoomCreator(room: Room | undefined): string | undefined { return room?.getCreator?.() ?? undefined; } +async function getCommandsForRegistration( + dbAdapter: DBAdapter, + registrationId: string, +): Promise<{ type: string; command: string }[]> { + let rows = await query(dbAdapter, [ + `SELECT command_filter, command FROM bot_commands WHERE bot_id = `, + param(registrationId), + ]); + + let commands: { type: string; command: string }[] = []; + for (let row of rows) { + let filter = row.command_filter; + if (!isBotCommandFilter(filter)) { + continue; + } + if (typeof row.command !== 'string' || !row.command.trim()) { + continue; + } + commands.push({ type: filter.content_type, command: row.command }); + } + return commands; +} + async function getRegistrationsForUser( dbAdapter: DBAdapter, username: string, @@ -121,13 +239,13 @@ function toBotRegistration( if ( typeof row.id !== 'string' || typeof row.username !== 'string' || - typeof row.created_at !== 'string' + typeof row.created_at !== 'object' ) { return null; } return { id: row.id, username: row.username, - created_at: row.created_at, + created_at: String(row.created_at), }; } diff --git a/packages/bot-runner/main.ts b/packages/bot-runner/main.ts index 3823e45f508..254cbe8e9f8 100644 --- a/packages/bot-runner/main.ts +++ b/packages/bot-runner/main.ts @@ -62,6 +62,7 @@ const botPassword = process.env.SUBMISSION_BOT_PASSWORD || 'password'; let handleTimelineEvent = onTimelineEvent({ authUserId: auth.user_id, dbAdapter, + queuePublisher, }); client.on(RoomEvent.Timeline, async (event, room, toStartOfTimeline) => { await handleTimelineEvent(event, room, toStartOfTimeline); diff --git a/packages/bot-runner/tests/bot-runner-test.ts b/packages/bot-runner/tests/bot-runner-test.ts index 157c469f479..ab031d9f4c5 100644 --- a/packages/bot-runner/tests/bot-runner-test.ts +++ b/packages/bot-runner/tests/bot-runner-test.ts @@ -3,6 +3,7 @@ import type { DBAdapter, ExecuteOptions, PgPrimitive, + QueuePublisher, } from '@cardstack/runtime-common'; import type { MatrixClient, @@ -25,6 +26,7 @@ function makeBotTriggerEvent( content: { type: 'create-listing-pr', input: {}, + realm: 'http://localhost:4201/test/', }, }, getSender: () => sender, @@ -94,6 +96,7 @@ module('timeline handler', () => { | ((sql: string, opts?: ExecuteOptions) => void) | undefined; let dbAdapter: DBAdapter; + let queuePublisher: QueuePublisher; let handleTimelineEvent: ReturnType; dbAdapter = { @@ -107,9 +110,15 @@ module('timeline handler', () => { getColumnNames: async () => [], } as DBAdapter; + queuePublisher = { + publish: async () => ({ id: 1, done: Promise.resolve(undefined) }) as any, + destroy: async () => {}, + }; + handleTimelineEvent = onTimelineEvent({ authUserId: '@submissionbot:localhost', dbAdapter, + queuePublisher, }); function mockGetRegistrations( diff --git a/packages/catalog-realm/catalog-app/components/listing-fitted.gts b/packages/catalog-realm/catalog-app/components/listing-fitted.gts index 485952b13ff..1a09b1bd66c 100644 --- a/packages/catalog-realm/catalog-app/components/listing-fitted.gts +++ b/packages/catalog-realm/catalog-app/components/listing-fitted.gts @@ -24,7 +24,7 @@ export class ListingFittedTemplate extends Component { get writableRealms(): { name: string; url: string; iconURL?: string }[] { const commandResource = this.allRealmsInfoResource; if (commandResource?.isSuccess && commandResource) { - const result = commandResource.value; + const result = commandResource.cardResult; if (result?.results) { return result.results .filter( diff --git a/packages/catalog-realm/catalog-app/listing/listing.gts b/packages/catalog-realm/catalog-app/listing/listing.gts index c18dc7506c6..90d0b733264 100644 --- a/packages/catalog-realm/catalog-app/listing/listing.gts +++ b/packages/catalog-realm/catalog-app/listing/listing.gts @@ -68,8 +68,8 @@ class EmbeddedTemplate extends Component { get writableRealms(): { name: string; url: string; iconURL?: string }[] { const commandResource = this.allRealmsInfoResource; - if (commandResource?.isSuccess && commandResource.value) { - const result = commandResource.value as GetAllRealmMetasResult; + if (commandResource?.isSuccess && commandResource.cardResult) { + const result = commandResource.cardResult as GetAllRealmMetasResult; if (result.results) { return result.results .filter( diff --git a/packages/experiments-realm/BotRequestDemo/bot-request-demo.json b/packages/experiments-realm/BotRequestDemo/bot-request-demo.json new file mode 100644 index 00000000000..34e927920a4 --- /dev/null +++ b/packages/experiments-realm/BotRequestDemo/bot-request-demo.json @@ -0,0 +1,31 @@ +{ + "data": { + "meta": { + "adoptsFrom": { + "name": "BotRequestDemo", + "module": "../bot-request-demo" + } + }, + "type": "card", + "attributes": { + "cardInfo": { + "notes": null, + "name": null, + "summary": null, + "cardThumbnailURL": null + }, + "cardId": "", + "format": "isolated", + "roomId": "", + "realm": "http://localhost:4201/experiments/", + "listingId": "" + }, + "relationships": { + "cardInfo.theme": { + "links": { + "self": null + } + } + } + } +} diff --git a/packages/experiments-realm/bot-request-demo.gts b/packages/experiments-realm/bot-request-demo.gts new file mode 100644 index 00000000000..c66152fc531 --- /dev/null +++ b/packages/experiments-realm/bot-request-demo.gts @@ -0,0 +1,516 @@ +import { CardDef, Component } from 'https://cardstack.com/base/card-api'; +import { action } from '@ember/object'; +import { tracked } from '@glimmer/tracking'; +import { on } from '@ember/modifier'; + +import CreateListingPRRequestCommand from '@cardstack/boxel-host/commands/create-listing-pr-request'; +import CreateShowCardRequestCommand from './commands/create-show-card-request'; +import CreatePatchCardInstanceRequestCommand from './commands/create-patch-card-instance-request'; +import { Button } from '@cardstack/boxel-ui/components'; + +type CommandTab = 'show-card' | 'patch-card-instance' | 'create-listing-pr'; + +const DEFAULT_LISTING_ID = '95cbe2c7-9b60-4afd-8a3c-1382b610e316'; + +class Isolated extends Component { + @tracked isSubmitting = false; + @tracked statusMessage: string | null = null; + @tracked errorMessage: string | null = null; + @tracked activeTab: CommandTab = 'show-card'; + + get hasCommandContext() { + return Boolean(this.args.context?.commandContext); + } + + get isSubmitDisabled() { + return this.isSubmitting || !this.hasCommandContext; + } + + get isShowCardTab() { + return this.activeTab === 'show-card'; + } + + get isCreateListingPRTab() { + return this.activeTab === 'create-listing-pr'; + } + + get isPatchCardInstanceTab() { + return this.activeTab === 'patch-card-instance'; + } + + get experimentsRealmURL() { + return new URL('./', import.meta.url).href; + } + + get catalogRealmURL() { + return new URL('../catalog/', this.experimentsRealmURL).href; + } + + get showCardId() { + return new URL('./Author/jane-doe', this.experimentsRealmURL).href; + } + + get showCardTargetRealm() { + return this.experimentsRealmURL; + } + + get showCardFormat() { + return 'isolated'; + } + + get listingId() { + return new URL(`./AppListing/${DEFAULT_LISTING_ID}`, this.catalogRealmURL) + .href; + } + + get createListingPRTargetRealm() { + return this.experimentsRealmURL; + } + + get patchCardPatch() { + return { + attributes: { + quote: 'Bot Request Patch', + }, + }; + } + + get activeCommandDisplayName() { + if (this.isShowCardTab) { + return 'show-card'; + } + if (this.isPatchCardInstanceTab) { + return 'patch-card-instance'; + } + return 'create-listing-pr'; + } + + get activeCommandInput() { + if (this.isShowCardTab) { + return { + cardId: this.showCardId, + format: this.showCardFormat, + realm: this.showCardTargetRealm, + }; + } + if (this.isPatchCardInstanceTab) { + return { + cardId: this.showCardId, + patch: this.patchCardPatch, + realm: this.showCardTargetRealm, + }; + } + + return { + realm: this.createListingPRTargetRealm, + listingId: this.listingId, + }; + } + + get hardcodedInputsPreview() { + return JSON.stringify( + { + command: this.activeCommandDisplayName, + input: this.activeCommandInput, + }, + null, + 2, + ); + } + + get payloadPreview() { + if (this.isShowCardTab) { + return JSON.stringify( + { + type: 'app.boxel.bot-trigger', + content: { + type: 'show-card', + realm: this.showCardTargetRealm, + input: { + cardId: this.showCardId, + format: this.showCardFormat, + }, + }, + }, + null, + 2, + ); + } + + if (this.isPatchCardInstanceTab) { + return JSON.stringify( + { + type: 'app.boxel.bot-trigger', + content: { + type: 'patch-card-instance', + realm: this.showCardTargetRealm, + input: { + cardId: this.showCardId, + patch: this.patchCardPatch, + roomId: '', + }, + }, + }, + null, + 2, + ); + } + + return JSON.stringify( + { + type: 'app.boxel.bot-trigger', + content: { + type: 'create-listing-pr', + realm: this.createListingPRTargetRealm, + input: { + roomId: '', + realm: this.createListingPRTargetRealm, + listingId: this.listingId, + }, + }, + }, + null, + 2, + ); + } + + get commandURLPreview() { + if (this.isShowCardTab) { + return '@cardstack/boxel-host/commands/show-card/default'; + } + if (this.isPatchCardInstanceTab) { + return '@cardstack/boxel-host/commands/patch-card-instance/default'; + } + + return '@cardstack/boxel-host/commands/create-listing-pr/default'; + } + + get codeRef() { + if (this.isShowCardTab) { + return { + module: '@cardstack/boxel-host/commands/show-card', + name: 'default', + }; + } + if (this.isPatchCardInstanceTab) { + return { + module: '@cardstack/boxel-host/commands/patch-card-instance', + name: 'default', + }; + } + + return { + module: '@cardstack/boxel-host/commands/create-listing-pr', + name: 'default', + }; + } + + get codeRefPreview() { + return JSON.stringify(this.codeRef, null, 2); + } + + get commandRunnerURL() { + let hostOrigin = + typeof window !== 'undefined' + ? window.location.origin + : new URL(this.showCardId).origin; + let nonce = 'demo'; + let encodedCommand = encodeURIComponent( + `${this.codeRef.module}/${this.codeRef.name}`, + ); + let encodedInput = encodeURIComponent( + JSON.stringify(this.activeCommandInput ?? null), + ); + return `${hostOrigin}/command-runner/${encodedCommand}/${encodedInput}/${encodeURIComponent(nonce)}`; + } + + get sendButtonLabel() { + if (this.isShowCardTab) { + return 'Send Show Card Bot Request'; + } + if (this.isPatchCardInstanceTab) { + return 'Send Patch Card Instance Bot Request'; + } + return 'Send Create Listing PR Request'; + } + + @action + clearMessages() { + this.statusMessage = null; + this.errorMessage = null; + } + + @action + selectShowCardTab() { + this.activeTab = 'show-card'; + this.clearMessages(); + } + + @action + selectCreateListingPRTab() { + this.activeTab = 'create-listing-pr'; + this.clearMessages(); + } + + @action + selectPatchCardInstanceTab() { + this.activeTab = 'patch-card-instance'; + this.clearMessages(); + } + + @action + async requestShowCard() { + let commandContext = this.args.context?.commandContext; + if (!commandContext) { + this.errorMessage = + 'Command context is unavailable. Open this card in host interact mode.'; + return; + } + + await new CreateShowCardRequestCommand(commandContext).execute({ + cardId: this.showCardId, + format: this.showCardFormat, + realm: this.showCardTargetRealm, + }); + } + + @action + async requestCreateListingPR() { + let commandContext = this.args.context?.commandContext; + if (!commandContext) { + this.errorMessage = + 'Command context is unavailable. Open this card in host interact mode.'; + return; + } + + await new CreateListingPRRequestCommand(commandContext).execute({ + realm: this.createListingPRTargetRealm, + listingId: this.listingId, + }); + } + + @action + async requestPatchCardInstance() { + let commandContext = this.args.context?.commandContext; + if (!commandContext) { + this.errorMessage = + 'Command context is unavailable. Open this card in host interact mode.'; + return; + } + + await new CreatePatchCardInstanceRequestCommand(commandContext).execute({ + cardId: this.showCardId, + patch: this.patchCardPatch, + realm: this.showCardTargetRealm, + }); + } + + @action + async sendActiveRequest() { + this.clearMessages(); + this.isSubmitting = true; + + try { + if (this.isShowCardTab) { + await this.requestShowCard(); + this.statusMessage = + 'Show Card request sent. A room was created/opened automatically.'; + } else if (this.isPatchCardInstanceTab) { + await this.requestPatchCardInstance(); + this.statusMessage = + 'Patch Card Instance request sent. A room was created/opened automatically.'; + } else { + await this.requestCreateListingPR(); + this.statusMessage = + 'Create Listing PR request sent. A room was created/opened automatically.'; + } + } catch (error) { + this.errorMessage = + error instanceof Error + ? error.message + : 'Failed to send bot-runner request.'; + } finally { + this.isSubmitting = false; + } + } + + +} + +export class BotRequestDemo extends CardDef { + static displayName = 'Bot Request Demo'; + + static isolated = Isolated; +} diff --git a/packages/experiments-realm/commands/bot-request-utils.ts b/packages/experiments-realm/commands/bot-request-utils.ts new file mode 100644 index 00000000000..19b91c27d83 --- /dev/null +++ b/packages/experiments-realm/commands/bot-request-utils.ts @@ -0,0 +1,22 @@ +import type { CommandContext } from '@cardstack/runtime-common'; + +import InviteUserToRoomCommand from '@cardstack/boxel-host/commands/invite-user-to-room'; + +export async function ensureSubmissionBotIsInRoom( + commandContext: CommandContext, + roomId: string, +) { + try { + await new InviteUserToRoomCommand(commandContext).execute({ + roomId, + userId: 'submissionbot', + }); + } catch (error) { + if ( + !(error instanceof Error) || + !error.message.includes('user already in room') + ) { + throw error; + } + } +} diff --git a/packages/experiments-realm/commands/create-patch-card-instance-request.ts b/packages/experiments-realm/commands/create-patch-card-instance-request.ts new file mode 100644 index 00000000000..5ad64a1725b --- /dev/null +++ b/packages/experiments-realm/commands/create-patch-card-instance-request.ts @@ -0,0 +1,64 @@ +import { Command } from '@cardstack/runtime-common'; + +import { PatchCardInput } from 'https://cardstack.com/base/command'; +import { StringField, contains, field } from 'https://cardstack.com/base/card-api'; + +import UseAiAssistantCommand from '@cardstack/boxel-host/commands/ai-assistant'; +import SendBotTriggerEventCommand from '@cardstack/boxel-host/commands/send-bot-trigger-event'; +import { ensureSubmissionBotIsInRoom } from './bot-request-utils'; + +export class CreatePatchCardInstanceRequestInput extends PatchCardInput { + @field realm = contains(StringField); +} + +export default class CreatePatchCardInstanceRequestCommand extends Command< + typeof CreatePatchCardInstanceRequestInput, + undefined +> { + description = 'Request patching a card instance via the bot runner.'; + + async getInputType() { + return CreatePatchCardInstanceRequestInput; + } + + protected async run( + input: CreatePatchCardInstanceRequestInput, + ): Promise { + let cardId = input.cardId?.trim(); + let realm = input.realm?.trim(); + if (!cardId) { + throw new Error('cardId is required'); + } + if (!realm) { + throw new Error('realm is required'); + } + if (!input.patch || typeof input.patch !== 'object') { + throw new Error('patch is required'); + } + + let roomId = input.roomId?.trim(); + if (!roomId) { + let createRoomResult = await new UseAiAssistantCommand( + this.commandContext, + ).execute({ + roomId: 'new', + roomName: `Patch Card: ${cardId}`, + openRoom: true, + }); + roomId = createRoomResult.roomId; + } + + await ensureSubmissionBotIsInRoom(this.commandContext, roomId); + + await new SendBotTriggerEventCommand(this.commandContext).execute({ + roomId, + realm, + type: 'patch-card-instance', + input: { + cardId, + patch: input.patch, + roomId, + }, + }); + } +} diff --git a/packages/experiments-realm/commands/create-show-card-request.ts b/packages/experiments-realm/commands/create-show-card-request.ts new file mode 100644 index 00000000000..ae7229e948f --- /dev/null +++ b/packages/experiments-realm/commands/create-show-card-request.ts @@ -0,0 +1,56 @@ +import { Command } from '@cardstack/runtime-common'; + +import { CardDef, StringField, contains, field } from 'https://cardstack.com/base/card-api'; + +import UseAiAssistantCommand from '@cardstack/boxel-host/commands/ai-assistant'; +import SendBotTriggerEventCommand from '@cardstack/boxel-host/commands/send-bot-trigger-event'; +import { ensureSubmissionBotIsInRoom } from './bot-request-utils'; + +export class CreateShowCardRequestInput extends CardDef { + @field cardId = contains(StringField); + @field format = contains(StringField); + @field realm = contains(StringField); +} + +export default class CreateShowCardRequestCommand extends Command< + typeof CreateShowCardRequestInput, + undefined +> { + description = 'Request showing a card via the bot runner.'; + + async getInputType() { + return CreateShowCardRequestInput; + } + + protected async run(input: CreateShowCardRequestInput): Promise { + let cardId = input.cardId?.trim(); + let realm = input.realm?.trim(); + if (!cardId) { + throw new Error('cardId is required'); + } + if (!realm) { + throw new Error('realm is required'); + } + + let createRoomResult = await new UseAiAssistantCommand( + this.commandContext, + ).execute({ + roomId: 'new', + roomName: `Show Card: ${cardId}`, + openRoom: true, + }); + let roomId = createRoomResult.roomId; + + await ensureSubmissionBotIsInRoom(this.commandContext, roomId); + + await new SendBotTriggerEventCommand(this.commandContext).execute({ + roomId, + realm, + type: 'show-card', + input: { + cardId, + format: input.format?.trim() || 'isolated', + }, + }); + } +} diff --git a/packages/experiments-realm/google-image-search.gts b/packages/experiments-realm/google-image-search.gts index 57c710fca67..ee311e90718 100644 --- a/packages/experiments-realm/google-image-search.gts +++ b/packages/experiments-realm/google-image-search.gts @@ -44,22 +44,22 @@ export class GoogleImageSearch extends CardDef { get searchResults() { const resource = this.searchResource; - if (resource?.isSuccess && resource.value?.images) { - return resource.value.images; + if (resource?.isSuccess && resource.cardResult?.images) { + return resource.cardResult.images; } return []; } get searchInfo() { const resource = this.searchResource; - if (resource?.isSuccess && resource.value) { + if (resource?.isSuccess && resource.cardResult) { return { - totalResults: resource.value.totalResults, - formattedTotalResults: resource.value.formattedTotalResults, - formattedSearchTime: resource.value.formattedSearchTime, - hasNextPage: resource.value.hasNextPage, - nextPageStartIndex: resource.value.nextPageStartIndex, - currentStartIndex: resource.value.currentStartIndex, + totalResults: resource.cardResult.totalResults, + formattedTotalResults: resource.cardResult.formattedTotalResults, + formattedSearchTime: resource.cardResult.formattedSearchTime, + hasNextPage: resource.cardResult.hasNextPage, + nextPageStartIndex: resource.cardResult.nextPageStartIndex, + currentStartIndex: resource.cardResult.currentStartIndex, }; } return null; diff --git a/packages/experiments-realm/simple-search-card.gts b/packages/experiments-realm/simple-search-card.gts index 24ea3fdfd3a..cd3e863a587 100644 --- a/packages/experiments-realm/simple-search-card.gts +++ b/packages/experiments-realm/simple-search-card.gts @@ -28,8 +28,8 @@ export class SimpleSearchCard extends CardDef { get searchResults() { const resource = this.searchResource; - if (resource?.isSuccess && resource.value?.cardIds) { - return resource.value.cardIds; + if (resource?.isSuccess && resource.cardResult?.cardIds) { + return resource.cardResult.cardIds; } return []; } diff --git a/packages/host/app/commands/create-listing-pr-request.ts b/packages/host/app/commands/bot-requests/create-listing-pr-request.ts similarity index 89% rename from packages/host/app/commands/create-listing-pr-request.ts rename to packages/host/app/commands/bot-requests/create-listing-pr-request.ts index d16b98399f5..432f55c0b65 100644 --- a/packages/host/app/commands/create-listing-pr-request.ts +++ b/packages/host/app/commands/bot-requests/create-listing-pr-request.ts @@ -4,13 +4,14 @@ import { isCardInstance } from '@cardstack/runtime-common'; import type * as BaseCommandModule from 'https://cardstack.com/base/command'; -import HostBaseCommand from '../lib/host-base-command'; +import HostBaseCommand from '../../lib/host-base-command'; + +import UseAiAssistantCommand from '../ai-assistant'; -import UseAiAssistantCommand from './ai-assistant'; import SendBotTriggerEventCommand from './send-bot-trigger-event'; -import type MatrixService from '../services/matrix-service'; -import type StoreService from '../services/store'; +import type MatrixService from '../../services/matrix-service'; +import type StoreService from '../../services/store'; import type { Listing } from '@cardstack/catalog/listing/listing'; export default class CreateListingPRRequestCommand extends HostBaseCommand< @@ -62,6 +63,7 @@ export default class CreateListingPRRequestCommand extends HostBaseCommand< await new SendBotTriggerEventCommand(this.commandContext).execute({ roomId, + realm, type: 'create-listing-pr', input: { roomId, diff --git a/packages/host/app/commands/send-bot-trigger-event.ts b/packages/host/app/commands/bot-requests/send-bot-trigger-event.ts similarity index 80% rename from packages/host/app/commands/send-bot-trigger-event.ts rename to packages/host/app/commands/bot-requests/send-bot-trigger-event.ts index e7c5ecc6696..2601f59b7e0 100644 --- a/packages/host/app/commands/send-bot-trigger-event.ts +++ b/packages/host/app/commands/bot-requests/send-bot-trigger-event.ts @@ -5,9 +5,9 @@ import { isBotTriggerEvent } from '@cardstack/runtime-common'; import type * as BaseCommandModule from 'https://cardstack.com/base/command'; import type { BotTriggerEvent } from 'https://cardstack.com/base/matrix-event'; -import HostBaseCommand from '../lib/host-base-command'; +import HostBaseCommand from '../../lib/host-base-command'; -import type MatrixService from '../services/matrix-service'; +import type MatrixService from '../../services/matrix-service'; export default class SendBotTriggerEventCommand extends HostBaseCommand< typeof BaseCommandModule.SendBotTriggerEventInput @@ -22,7 +22,7 @@ export default class SendBotTriggerEventCommand extends HostBaseCommand< return SendBotTriggerEventInput; } - requireInputFields = ['roomId', 'type', 'input']; + requireInputFields = ['roomId', 'type', 'input', 'realm']; protected async run( input: BaseCommandModule.SendBotTriggerEventInput, @@ -35,11 +35,12 @@ export default class SendBotTriggerEventCommand extends HostBaseCommand< content: { type: input.type, input: input.input, + realm: input.realm, }, } as BotTriggerEvent; if (!isBotTriggerEvent(event)) { - throw new Error(`Unsupported bot trigger event type: ${input.type}`); + throw new Error(`Invalid bot trigger event payload`); } await this.matrixService.sendEvent(input.roomId, event.type, event.content); diff --git a/packages/host/app/commands/index.ts b/packages/host/app/commands/index.ts index d2c3fad5c2d..35afdc96d36 100644 --- a/packages/host/app/commands/index.ts +++ b/packages/host/app/commands/index.ts @@ -5,6 +5,8 @@ import * as UseAiAssistantCommandModule from './ai-assistant'; import * as ApplyMarkdownEditCommandModule from './apply-markdown-edit'; import * as ApplySearchReplaceBlockCommandModule from './apply-search-replace-block'; import * as AskAiCommandModule from './ask-ai'; +import * as CreateListingPRRequestCommandModule from './bot-requests/create-listing-pr-request'; +import * as SendBotTriggerEventCommandModule from './bot-requests/send-bot-trigger-event'; import * as CheckCorrectnessCommandModule from './check-correctness'; import * as CopyAndEditCommandModule from './copy-and-edit'; import * as CopyCardToRealmModule from './copy-card'; @@ -12,7 +14,6 @@ import * as CopyCardToStackCommandModule from './copy-card-to-stack'; import * as CopySourceCommandModule from './copy-source'; import * as CreateAIAssistantRoomCommandModule from './create-ai-assistant-room'; import * as CreateListingPRCommandModule from './create-listing-pr'; -import * as CreateListingPRRequestCommandModule from './create-listing-pr-request'; import * as CreateSpecCommandModule from './create-specs'; import * as GenerateExampleCardsCommandModule from './generate-example-cards'; import * as GenerateReadmeSpecCommandModule from './generate-readme-spec'; @@ -50,7 +51,6 @@ import * as SearchAndChooseCommandModule from './search-and-choose'; import * as SearchCardsCommandModule from './search-cards'; import * as SearchGoogleImagesCommandModule from './search-google-images'; import * as SendAiAssistantMessageModule from './send-ai-assistant-message'; -import * as SendBotTriggerEventCommandModule from './send-bot-trigger-event'; import * as SendRequestViaProxyCommandModule from './send-request-via-proxy'; import * as SetActiveLlmModule from './set-active-llm'; import * as SetUserSystemCardCommandModule from './set-user-system-card'; diff --git a/packages/host/app/commands/show-card.ts b/packages/host/app/commands/show-card.ts index c45ff422f52..6101aff695e 100644 --- a/packages/host/app/commands/show-card.ts +++ b/packages/host/app/commands/show-card.ts @@ -1,7 +1,11 @@ import { service } from '@ember/service'; import type { ResolvedCodeRef } from '@cardstack/runtime-common'; -import { identifyCard, internalKeyFor } from '@cardstack/runtime-common'; +import { + identifyCard, + internalKeyFor, + isCardErrorJSONAPI, +} from '@cardstack/runtime-common'; import type { CardDef, Format } from 'https://cardstack.com/base/card-api'; import type * as BaseCommandModule from 'https://cardstack.com/base/command'; @@ -13,7 +17,8 @@ import type PlaygroundPanelService from '../services/playground-panel-service'; import type StoreService from '../services/store'; export default class ShowCardCommand extends HostBaseCommand< - typeof BaseCommandModule.ShowCardInput + typeof BaseCommandModule.ShowCardInput, + typeof CardDef > { @service declare private operatorModeStateService: OperatorModeStateService; @service declare private playgroundPanelService: PlaygroundPanelService; @@ -34,8 +39,8 @@ export default class ShowCardCommand extends HostBaseCommand< protected async run( input: BaseCommandModule.ShowCardInput, - ): Promise { - let { operatorModeStateService, store } = this; + ): Promise { + let { operatorModeStateService } = this; if (operatorModeStateService.workspaceChooserOpened) { operatorModeStateService.closeWorkspaceChooser(); } @@ -50,8 +55,9 @@ export default class ShowCardCommand extends HostBaseCommand< (input.format as 'isolated' | 'edit') || 'isolated', ); operatorModeStateService.addItemToStack(newStackItem); + return await this.loadCard(input.cardId); } else if (operatorModeStateService.state?.submode === 'code') { - let cardInstance = await store.get(input.cardId); + let cardInstance = await this.loadCard(input.cardId); let cardDefRef = identifyCard( cardInstance.constructor as typeof CardDef, ) as ResolvedCodeRef; @@ -75,11 +81,21 @@ export default class ShowCardCommand extends HostBaseCommand< (input.format as Format) || 'isolated', undefined, ); + return cardInstance; } else { console.error( 'Unknown submode:', this.operatorModeStateService.state?.submode, ); + return await this.loadCard(input.cardId); } } + + private async loadCard(cardId: string): Promise { + let maybeCard = await this.store.get(cardId); + if (isCardErrorJSONAPI(maybeCard)) { + throw new Error(maybeCard.message); + } + return maybeCard; + } } diff --git a/packages/host/app/components/card-prerender.gts b/packages/host/app/components/card-prerender.gts index 5ff2ed514d2..03396fc85b2 100644 --- a/packages/host/app/components/card-prerender.gts +++ b/packages/host/app/components/card-prerender.gts @@ -25,6 +25,8 @@ import { type FileRenderResponse, type FileRenderArgs, type Prerenderer, + type RunCommandArgs, + type RunCommandResponse, type Format, type PrerenderMeta, type RenderRouteOptions, @@ -96,6 +98,7 @@ export default class CardPrerender extends Component { prerenderModule: this.prerenderModule.bind(this), prerenderFileExtract: this.prerenderFileExtract.bind(this), prerenderFileRender: this.prerenderFileRenderPublic.bind(this), + runCommand: this.runCommand.bind(this), }; this.localIndexer.setup(this.#prerendererDelegate); window.addEventListener('boxel-render-error', this.#handleRenderErrorEvent); @@ -224,6 +227,13 @@ export default class CardPrerender extends Component { }); } + private async runCommand(_args: RunCommandArgs): Promise { + return { + status: 'error', + error: 'runCommand is not supported by the card-prerender delegate', + }; + } + // This emulates the job of the Prerenderer that runs in the server private prerenderTask = enqueueTask( async ({ diff --git a/packages/host/app/router.ts b/packages/host/app/router.ts index 7712e81c2ee..5c3e95199fe 100644 --- a/packages/host/app/router.ts +++ b/packages/host/app/router.ts @@ -18,6 +18,9 @@ Router.map(function () { this.route('module', { path: '/module/:id/:nonce/:options' }); this.route('connect', { path: '/connect/:origin' }); this.route('standby'); + this.route('command-runner', { + path: '/command-runner/:command/:input/:nonce', + }); this.route('index', { path: '/*path' }); this.route('index-root', { path: '/' }); diff --git a/packages/host/app/routes/command-runner.ts b/packages/host/app/routes/command-runner.ts new file mode 100644 index 00000000000..94109618ad0 --- /dev/null +++ b/packages/host/app/routes/command-runner.ts @@ -0,0 +1,226 @@ +import { getOwner, setOwner } from '@ember/owner'; +import Route from '@ember/routing/route'; +import type RouterService from '@ember/routing/router-service'; +import { service } from '@ember/service'; +import { tracked } from '@glimmer/tracking'; + +import type { + Command, + CommandContext, + CommandInvocation, + ResolvedCodeRef, +} from '@cardstack/runtime-common'; +import { + CommandContextStamp, + getClass, + parseBoxelHostCommandSpecifier, +} from '@cardstack/runtime-common'; + +import type { + CardDef, + CardDefConstructor, +} from 'https://cardstack.com/base/card-api'; + +import { registerBoxelTransitionTo } from '../utils/register-boxel-transition'; + +import type CardService from '../services/card-service'; +import type LoaderService from '../services/loader-service'; +import type RealmService from '../services/realm'; + +class CommandRunState implements CommandInvocation { + @tracked status: CommandInvocation['status'] = 'pending'; + @tracked cardResult: CardDef | null = null; + @tracked error: Error | null = null; + @tracked cardResultString: string | null = null; + + constructor(readonly nonce: string) {} + + get isSuccess() { + return this.status === 'success'; + } + + get isLoading() { + return this.status === 'pending'; + } + + get prerenderStatus(): 'ready' | 'error' | undefined { + if (this.status === 'success') { + return 'ready'; + } + if (this.status === 'error') { + return 'error'; + } + return undefined; + } +} + +export type CommandRunnerModel = CommandRunState; + +export default class CommandRunnerRoute extends Route { + @service declare router: RouterService; + @service declare loaderService: LoaderService; + @service declare cardService: CardService; + @service declare realm: RealmService; + + async beforeModel() { + registerBoxelTransitionTo(this.router); + (globalThis as any).__boxelRenderContext = true; + this.realm.restoreSessionsFromStorage(); + } + + deactivate() { + (globalThis as any).__boxelRenderContext = undefined; + } + + model(params: { + command: string; + input: string; + nonce: string; + }): CommandRunnerModel { + let model = new CommandRunState(params.nonce); + let command = parseCommandParam(params.command); + let commandInput = parseCommandInput(params.input); + + if (!command) { + model.status = 'error'; + model.error = new Error('Missing or invalid command'); + return model; + } + + void this.#runCommand(model, command, commandInput); + return model; + } + + get commandContext(): CommandContext { + let result = { + [CommandContextStamp]: true, + } as CommandContext; + setOwner(result, getOwner(this)!); + return result; + } + + async #runCommand( + model: CommandRunState, + command: ResolvedCodeRef, + commandInput: Record | undefined, + ) { + try { + let CommandConstructor = (await getClass( + command, + this.loaderService.loader, + )) as { new (context: CommandContext): Command } | undefined; + if (!CommandConstructor) { + throw new Error('Command not found for provided CodeRef'); + } + + let commandInstance = new CommandConstructor(this.commandContext); + let resultCard: CardDef | undefined; + if (commandInput) { + resultCard = await commandInstance.execute(commandInput as any); + } else { + resultCard = await commandInstance.execute(); + } + + model.cardResult = resultCard ?? null; + let serialized = resultCard + ? await this.cardService.serializeCard(resultCard) + : null; + model.cardResultString = serialized + ? JSON.stringify(serialized, null, 2) + : ''; + model.status = 'success'; + } catch (error) { + console.error('Command runner failed', { + command, + error, + }); + model.error = error instanceof Error ? error : new Error(String(error)); + model.status = 'error'; + } + } +} + +function parseJSONish(raw: string | undefined): unknown { + if (!raw || raw === 'null' || raw === 'undefined') { + return undefined; + } + + // Support both raw JSON path segments and URI-encoded JSON segments. + try { + return JSON.parse(raw); + } catch { + // noop + } + + try { + return JSON.parse(decodeURIComponent(raw)); + } catch { + return undefined; + } +} + +function parseCommandParam( + raw: string | undefined, +): ResolvedCodeRef | undefined { + if (typeof raw !== 'string') { + return undefined; + } + let value = safeDecodeURIComponent(raw).trim(); + if (!value) { + return undefined; + } + + let specifier = parseBoxelHostCommandSpecifier(value); + if (specifier) { + return specifier; + } + if (isBoxelHostCommandSpecifierWithoutExport(value)) { + return undefined; + } + + try { + let url = new URL(value); + let pathname = url.pathname.replace(/\/+$/, ''); + let index = pathname.lastIndexOf('/'); + if (index <= 0 || index >= pathname.length - 1) { + return undefined; + } + return { + module: `${url.origin}${pathname.slice(0, index)}`, + name: pathname.slice(index + 1), + }; + } catch { + // Accept module specifier forms like "/". + } + + let index = value.lastIndexOf('/'); + if (index <= 0 || index >= value.length - 1) { + return undefined; + } + return { + module: value.slice(0, index), + name: value.slice(index + 1), + }; +} + +function isBoxelHostCommandSpecifierWithoutExport(value: string): boolean { + return /^@?cardstack\/boxel-host\/commands\/[^/?#\s]+$/.test(value); +} + +function safeDecodeURIComponent(value: string): string { + try { + return decodeURIComponent(value); + } catch { + return value; + } +} + +function parseCommandInput( + raw: string | undefined, +): Record | undefined { + let parsed = parseJSONish(raw); + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + return undefined; + } + return parsed as Record; +} diff --git a/packages/host/app/templates/command-runner.gts b/packages/host/app/templates/command-runner.gts new file mode 100644 index 00000000000..6d3eb92d422 --- /dev/null +++ b/packages/host/app/templates/command-runner.gts @@ -0,0 +1,33 @@ +import type { TemplateOnlyComponent } from '@ember/component/template-only'; + +import RouteTemplate from 'ember-route-template'; + +import { CardContainer } from '@cardstack/boxel-ui/components'; + +import CardRenderer from '@cardstack/host/components/card-renderer'; + +import type { CommandRunnerModel } from '../routes/command-runner'; + +const CommandRunner = satisfies TemplateOnlyComponent<{ model: CommandRunnerModel }>; + +export default RouteTemplate(CommandRunner); diff --git a/packages/host/tests/acceptance/commands-test.gts b/packages/host/tests/acceptance/commands-test.gts index c72b9a90a4b..e9b1782cd43 100644 --- a/packages/host/tests/acceptance/commands-test.gts +++ b/packages/host/tests/acceptance/commands-test.gts @@ -6,6 +6,7 @@ import { findAll, waitUntil, settled, + visit, } from '@ember/test-helpers'; import { fillIn } from '@ember/test-helpers'; @@ -28,6 +29,7 @@ import { APP_BOXEL_MESSAGE_MSGTYPE, APP_BOXEL_COMMAND_RESULT_EVENT_TYPE, APP_BOXEL_COMMAND_RESULT_REL_TYPE, + APP_BOXEL_COMMAND_RESULT_WITH_OUTPUT_MSGTYPE, APP_BOXEL_COMMAND_RESULT_WITH_NO_OUTPUT_MSGTYPE, APP_BOXEL_LLM_MODE, } from '@cardstack/runtime-common/matrix-constants'; @@ -251,6 +253,26 @@ module('Acceptance | Commands tests', function (hooks) { } } + class GreetingCard extends CardDef { + static displayName = 'GreetingCard'; + @field message = contains(StringField); + static isolated = class Isolated extends Component { + + }; + } + + class HelloCommand extends Command { + static displayName = 'HelloCommand'; + async getInputType() { + return undefined; + } + protected async run(): Promise { + return new GreetingCard({ message: 'Hello from command runner' }); + } + } + class Person extends CardDef { static displayName = 'Person'; @field firstName = contains(StringField); @@ -390,6 +412,10 @@ module('Acceptance | Commands tests', function (hooks) { friends: [mangoPet], }), 'maybe-boom-command.ts': { default: MaybeBoomCommand }, + 'command-runner-hello-command.ts': { + GreetingCard, + default: HelloCommand, + }, 'search-and-open-card-command.ts': { default: SearchAndOpenCardCommand, }, @@ -466,6 +492,34 @@ module('Acceptance | Commands tests', function (hooks) { .includesText('Change the topic of the meeting to "Meeting with Hassan"'); }); + module('command-runner', function () { + test('route renders command result card', async function (assert) { + let commandParam = encodeURIComponent( + `${testRealmURL}command-runner-hello-command/default`, + ); + let inputParam = encodeURIComponent(JSON.stringify(null)); + await visit(`/command-runner/${commandParam}/${inputParam}/1`); + + await waitFor('[data-prerender][data-prerender-status="ready"]'); + assert + .dom('[data-test-command-runner-greeting]') + .includesText('Hello from command runner'); + }); + + test('route captures command runtime error', async function (assert) { + maybeBoomShouldBoom = true; + let commandParam = encodeURIComponent( + `${testRealmURL}maybe-boom-command/default`, + ); + let inputParam = encodeURIComponent(JSON.stringify(null)); + await visit(`/command-runner/${commandParam}/${inputParam}/2`); + + await waitFor('[data-prerender][data-prerender-status="error"]'); + assert.dom('[data-prerender-error]').includesText('Boom!'); + assert.dom('[data-test-command-runner-greeting]').doesNotExist(); + }); + }); + test('a scripted command can create a card, update it and show it', async function (assert) { await visitOperatorMode({ stacks: [ @@ -878,7 +932,7 @@ module('Acceptance | Commands tests', function (hooks) { let message = getRoomEvents(roomId).pop()!; assert.strictEqual( message.content.msgtype, - APP_BOXEL_COMMAND_RESULT_WITH_NO_OUTPUT_MSGTYPE, + APP_BOXEL_COMMAND_RESULT_WITH_OUTPUT_MSGTYPE, ); assert.strictEqual( message.content['m.relates_to']?.rel_type, diff --git a/packages/host/tests/integration/commands/send-bot-trigger-event-test.gts b/packages/host/tests/integration/commands/send-bot-trigger-event-test.gts index b2279aecdb3..f0283c4f7e7 100644 --- a/packages/host/tests/integration/commands/send-bot-trigger-event-test.gts +++ b/packages/host/tests/integration/commands/send-bot-trigger-event-test.gts @@ -6,7 +6,7 @@ import { module, test } from 'qunit'; import { isBotTriggerEvent } from '@cardstack/runtime-common'; -import SendBotTriggerEventCommand from '@cardstack/host/commands/send-bot-trigger-event'; +import SendBotTriggerEventCommand from '@cardstack/host/commands/bot-requests/send-bot-trigger-event'; import RealmService from '@cardstack/host/services/realm'; @@ -61,30 +61,14 @@ module('Integration | commands | send-bot-trigger-event', function (hooks) { await command.execute({ roomId, type: 'create-listing-pr', + realm: testRealmURL, input: { listingId: 'catalog/listing-1' }, }); let event = getRoomEvents(roomId).pop()!; assert.ok(isBotTriggerEvent(event)); assert.strictEqual(event.content.type, 'create-listing-pr'); + assert.strictEqual(event.content.realm, testRealmURL); assert.deepEqual(event.content.input, { listingId: 'catalog/listing-1' }); }); - - test('rejects unknown trigger types', async function (assert) { - let roomId = createAndJoinRoom({ - sender: '@testuser:localhost', - name: 'room-test', - }); - let commandService = getService('command-service'); - - let command = new SendBotTriggerEventCommand(commandService.commandContext); - await assert.rejects( - command.execute({ - roomId, - type: 'not-a-real-command', - input: {}, - }), - /Unsupported bot trigger event type/, - ); - }); }); diff --git a/packages/matrix/scripts/setup-submission-bot.ts b/packages/matrix/scripts/setup-submission-bot.ts index 8898dfc20ba..e651621633e 100644 --- a/packages/matrix/scripts/setup-submission-bot.ts +++ b/packages/matrix/scripts/setup-submission-bot.ts @@ -2,12 +2,35 @@ import { registerRealmUser } from './register-realm-user-using-api'; const realmServerURL = process.env.REALM_SERVER_URL || 'http://localhost:4201'; -const commandURL = 'https://example.com/bot/command/default'; -const commandFilter = { - type: 'matrix-event', - event_type: 'app.boxel.bot-trigger', - content_type: 'create-listing-pr', -}; +const botCommands = [ + { + name: 'create-listing-pr', + commandURL: '@cardstack/boxel-host/commands/create-listing-pr/default', + filter: { + type: 'matrix-event', + event_type: 'app.boxel.bot-trigger', + content_type: 'create-listing-pr', + }, + }, + { + name: 'show-card', + commandURL: '@cardstack/boxel-host/commands/show-card/default', + filter: { + type: 'matrix-event', + event_type: 'app.boxel.bot-trigger', + content_type: 'show-card', + }, + }, + { + name: 'patch-card-instance', + commandURL: '@cardstack/boxel-host/commands/patch-card-instance/default', + filter: { + type: 'matrix-event', + event_type: 'app.boxel.bot-trigger', + content_type: 'patch-card-instance', + }, + }, +]; async function fetchBotRegistrations(jwt: string) { const response = await fetch(`${realmServerURL}/_bot-registrations`, { @@ -66,7 +89,11 @@ async function ensureBotRegistration(jwt: string, matrixUserId: string) { return createBotRegistration(jwt, matrixUserId); } -async function addBotCommand(jwt: string, botId: string) { +async function addBotCommand( + jwt: string, + botId: string, + command: (typeof botCommands)[number], +) { const response = await fetch(`${realmServerURL}/_bot-commands`, { method: 'POST', headers: { @@ -78,8 +105,8 @@ async function addBotCommand(jwt: string, botId: string) { type: 'bot-command', attributes: { botId, - command: commandURL, - filter: commandFilter, + command: command.commandURL, + filter: command.filter, }, }, }), @@ -116,13 +143,60 @@ async function fetchBotCommands(jwt: string, botId?: string) { return json?.data ?? []; } -async function ensureBotCommandId(jwt: string, botId: string) { +async function deleteBotCommand(jwt: string, botCommandId: string) { + const response = await fetch(`${realmServerURL}/_bot-commands`, { + method: 'DELETE', + headers: { + Authorization: jwt, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + data: { + type: 'bot-command', + id: botCommandId, + }, + }), + }); + + if (!response.ok) { + const text = await response.text(); + throw new Error( + `Failed to delete bot command ${botCommandId}: ${response.status} ${text}`, + ); + } +} + +async function ensureBotCommandId( + jwt: string, + botId: string, + command: (typeof botCommands)[number], +) { const commands = await fetchBotCommands(jwt, botId); - const existing = commands[0]; + const contentType = command.filter.content_type; + const matchingType = commands.filter( + (entry: any) => entry?.attributes?.filter?.content_type === contentType, + ); + + // Ensure submission bot rows converge to the canonical command string. + for (let entry of matchingType) { + let existingCommand = entry?.attributes?.command; + let existingId = entry?.id; + if ( + existingCommand !== command.commandURL && + typeof existingId === 'string' && + existingId.length > 0 + ) { + await deleteBotCommand(jwt, existingId); + } + } + + const existing = matchingType.find( + (entry: any) => entry?.attributes?.command === command.commandURL, + ); if (existing?.id) { return existing.id as string; } - return addBotCommand(jwt, botId); + return addBotCommand(jwt, botId, command); } // registerRealmUser is idempotent: it logs in and ensures the realm user exists. @@ -132,7 +206,12 @@ async function ensureBotCommandId(jwt: string, botId: string) { if (!botRegistrationId) { throw new Error('Bot registration did not return an id'); } - await ensureBotCommandId(jwt, botRegistrationId); + for (let command of botCommands) { + console.log( + `Registering bot command "${command.name}" for registration ${botRegistrationId}`, + ); + await ensureBotCommandId(jwt, botRegistrationId, command); + } console.log(`Submission bot setup complete for ${userId}`); })().catch((error) => { console.error('setup-submission-bot failed', error); diff --git a/packages/realm-server/handlers/handle-bot-commands.ts b/packages/realm-server/handlers/handle-bot-commands.ts index e59947dd8ff..1a6179cb216 100644 --- a/packages/realm-server/handlers/handle-bot-commands.ts +++ b/packages/realm-server/handlers/handle-bot-commands.ts @@ -3,7 +3,6 @@ import { validate as uuidValidate } from 'uuid'; import { assertIsBotCommandFilter, dbExpression, - isUrlLike, param, query, SupportedMimeType, @@ -99,11 +98,6 @@ export function handleBotCommandsRequest({ return; } - if (!isUrlLike(command)) { - await sendResponseForBadRequest(ctxt, 'command must be a URL'); - return; - } - try { assertIsBotCommandFilter(filter); } catch (e: any) { diff --git a/packages/realm-server/prerender/manager-app.ts b/packages/realm-server/prerender/manager-app.ts index f7fd75a3cd2..9c852b3e972 100644 --- a/packages/realm-server/prerender/manager-app.ts +++ b/packages/realm-server/prerender/manager-app.ts @@ -779,8 +779,9 @@ export function buildPrerenderManagerApp(options?: { attempts.add(target); const targetURL = `${normalizeURL(target)}/${pathSuffix}`; + let logTarget = attrs.url ?? attrs.command ?? ''; log.info( - `proxying ${label} prerender request for ${attrs.url} to ${targetURL}`, + `proxying ${label} prerender request for ${logTarget} to ${targetURL}`, ); let abortedDueToDrain = false; const ac = new AbortController(); @@ -913,6 +914,9 @@ export function buildPrerenderManagerApp(options?: { router.post('/prerender-file-render', (ctxt) => proxyPrerenderRequest(ctxt, 'prerender-file-render', 'file-render'), ); + router.post('/run-command', (ctxt) => + proxyPrerenderRequest(ctxt, 'run-command', 'command'), + ); let verboseManagerLogs = process.env.PRERENDER_MANAGER_VERBOSE_LOGS === 'true'; diff --git a/packages/realm-server/prerender/prerender-app.ts b/packages/realm-server/prerender/prerender-app.ts index 91f00b742a8..818d21eda76 100644 --- a/packages/realm-server/prerender/prerender-app.ts +++ b/packages/realm-server/prerender/prerender-app.ts @@ -11,6 +11,7 @@ import { type ModuleRenderResponse, type FileExtractResponse, type FileRenderResponse, + type RunCommandResponse, } from '@cardstack/runtime-common'; import { ecsMetadata, @@ -78,13 +79,30 @@ export function buildPrerenderApp(options: { ctxt.status = 200; }); - type PrerenderArgs = { + type RouteBaseArgs = { realm: string; - url: string; auth: string; renderOptions: RenderRouteOptions; }; + type PrerenderArgs = RouteBaseArgs & { + url: string; + }; + + type RunCommandRouteArgs = RouteBaseArgs & { + command: string; + commandInput?: unknown; + }; + + type RouteParseResult = { + args?: A; + missing: string[]; + missingMessage: string; + logTarget: string; + responseId: string; + rejectionLogDetails: string; + }; + type PrerenderExecResult = { response: R; timings: { launchMs: number; renderMs: number }; @@ -97,16 +115,105 @@ export function buildPrerenderApp(options: { }; }; - function registerPrerenderRoute( + let isNonEmptyString = (value: unknown): value is string => + typeof value === 'string' && value.trim().length > 0; + + let parseRenderOptions = (attrs: any): RenderRouteOptions => + attrs.renderOptions && + typeof attrs.renderOptions === 'object' && + !Array.isArray(attrs.renderOptions) + ? (attrs.renderOptions as RenderRouteOptions) + : {}; + + let missingAttrs = (attrsToCheck: { value: unknown; name: string }[]) => + attrsToCheck + .filter(({ value }) => !isNonEmptyString(value)) + .map(({ name }) => name); + + let parseDefaultPrerenderAttributes = ( + attrs: any, + ): RouteParseResult => { + let rawUrl = attrs.url; + let rawAuth = attrs.auth; + let rawRealm = attrs.realm; + let renderOptions = parseRenderOptions(attrs); + let missing = missingAttrs([ + { value: rawUrl, name: 'url' }, + { value: rawRealm, name: 'realm' }, + { value: rawAuth, name: 'auth' }, + ]); + return { + args: + missing.length > 0 + ? undefined + : { + realm: rawRealm as string, + url: rawUrl as string, + auth: rawAuth as string, + renderOptions, + }, + missing, + missingMessage: + 'Missing or invalid required attributes: url, auth, realm', + logTarget: (rawUrl as string | undefined) ?? '', + responseId: (rawUrl as string | undefined) ?? 'unknown', + rejectionLogDetails: `realm=${ + (rawRealm as string | undefined) ?? '' + } url=${(rawUrl as string | undefined) ?? ''} authProvided=${ + typeof rawAuth === 'string' && rawAuth.trim().length > 0 + }`, + }; + }; + + let parseRunCommandAttributes = ( + attrs: any, + ): RouteParseResult => { + let rawAuth = attrs.auth; + let rawRealm = attrs.realm; + let command = attrs.command; + let commandInput = attrs.commandInput; + let renderOptions = parseRenderOptions(attrs); + let missing: string[] = []; + if (!isNonEmptyString(rawRealm)) missing.push('realm'); + if (!isNonEmptyString(rawAuth)) missing.push('auth'); + if (!isNonEmptyString(command)) missing.push('command'); + let commandValue = isNonEmptyString(command) ? command : undefined; + return { + args: + missing.length > 0 + ? undefined + : { + realm: rawRealm as string, + auth: rawAuth as string, + command: command as string, + commandInput, + renderOptions, + }, + missing, + missingMessage: + 'Missing or invalid required attributes: realm, auth, command', + logTarget: commandValue ?? '', + responseId: commandValue ?? 'command', + rejectionLogDetails: `realm=${ + (rawRealm as string | undefined) ?? '' + } authProvided=${ + typeof rawAuth === 'string' && rawAuth.trim().length > 0 + } commandProvided=${Boolean(commandValue)}`, + }; + }; + + function registerPrerenderRoute( path: string, options: { requestDescription: string; responseType: string; infoLabel: string; - warnTimeoutMessage: (url: string) => string; + warnTimeoutMessage: (target: string) => string; errorContext: string; - execute: (args: PrerenderArgs) => Promise>; - afterResponse?: (url: string, response: R) => void; + execute: (args: A) => Promise>; + afterResponse?: (target: string, response: R) => void; + parseAttributes: (attrs: any) => RouteParseResult; + errorMessage?: string | ((err: any) => string); drainingPromise?: Promise; }, ) { @@ -131,67 +238,36 @@ export function buildPrerenderApp(options: { } let attrs = body?.data?.attributes ?? {}; - let rawUrl = attrs.url; - let rawAuth = attrs.auth; - let rawRealm = attrs.realm; - let renderOptions: RenderRouteOptions = - attrs.renderOptions && - typeof attrs.renderOptions === 'object' && - !Array.isArray(attrs.renderOptions) - ? (attrs.renderOptions as RenderRouteOptions) - : {}; - - let isNonEmptyString = (value: unknown): value is string => - typeof value === 'string' && value.trim().length > 0; - - let missingAttrs = (attrsToCheck: { value: unknown; name: string }[]) => - attrsToCheck - .filter(({ value }) => !isNonEmptyString(value)) - .map(({ name }) => name); - - let missing = missingAttrs([ - { value: rawUrl, name: 'url' }, - { value: rawRealm, name: 'realm' }, - { value: rawAuth, name: 'auth' }, - ]); + let parsed = options.parseAttributes(attrs); + let routeArgs = parsed.args; + let realmForLog = routeArgs?.realm ?? (attrs.realm as string); + let renderOptionsForLog = routeArgs?.renderOptions ?? {}; log.debug( - `received ${options.requestDescription} ${rawUrl}: realm=${rawRealm} options=${JSON.stringify(renderOptions)}`, + `received ${options.requestDescription} ${parsed.logTarget}: realm=${realmForLog} options=${JSON.stringify(renderOptionsForLog)}`, ); - if (missing.length > 0) { + if (parsed.missing.length > 0 || !routeArgs) { log.warn( - 'Rejecting %s due to missing attributes (%s); realm=%s url=%s authProvided=%s', + 'Rejecting %s due to missing attributes (%s); %s', options.requestDescription, - missing.join(', '), - (rawRealm as string | undefined) ?? '', - (rawUrl as string | undefined) ?? '', - typeof rawAuth === 'string' && rawAuth.trim().length > 0, + parsed.missing.join(', '), + parsed.rejectionLogDetails, ); ctxt.status = 400; ctxt.body = { errors: [ { status: 400, - message: - 'Missing or invalid required attributes: url, auth, realm', + message: parsed.missingMessage, }, ], }; return; } - let realm = rawRealm as string; - let url = rawUrl as string; - let auth = rawAuth as string; - let start = Date.now(); let execPromise = options - .execute({ - realm, - url, - auth, - renderOptions, - }) + .execute(routeArgs) .then((result) => ({ result })); let drainPromise = options.drainingPromise ? options.drainingPromise.then(() => ({ draining: true as const })) @@ -234,7 +310,7 @@ export function buildPrerenderApp(options: { log.info( '%s %s total=%dms launch=%dms render=%dms pageId=%s realm=%s%s', options.infoLabel, - url, + parsed.logTarget, totalMs, timings.launchMs, timings.renderMs, @@ -247,7 +323,7 @@ export function buildPrerenderApp(options: { ctxt.body = { data: { type: options.responseType, - id: url, + id: parsed.responseId, attributes: response, }, meta: { @@ -260,18 +336,22 @@ export function buildPrerenderApp(options: { }, }; if (pool.timedOut) { - log.warn(options.warnTimeoutMessage(url)); + log.warn(options.warnTimeoutMessage(parsed.logTarget)); } - options.afterResponse?.(url, response); + options.afterResponse?.(parsed.logTarget, response); } catch (err: any) { Sentry.captureException(err); log.error(`Unhandled error in ${options.errorContext}:`, err); ctxt.status = 500; + let message = + typeof options.errorMessage === 'function' + ? options.errorMessage(err) + : (options.errorMessage ?? err?.message ?? 'Unknown error'); ctxt.body = { errors: [ { status: 500, - message: err?.message ?? 'Unknown error', + message, }, ], }; @@ -285,6 +365,7 @@ export function buildPrerenderApp(options: { infoLabel: 'prerendered', warnTimeoutMessage: (url) => `render of ${url} timed out`, errorContext: '/prerender-card', + parseAttributes: parseDefaultPrerenderAttributes, execute: (args) => prerenderer.prerenderCard(args), drainingPromise: options.drainingPromise, afterResponse: (url, response) => { @@ -307,6 +388,7 @@ export function buildPrerenderApp(options: { infoLabel: 'module prerendered', warnTimeoutMessage: (url) => `module render of ${url} timed out`, errorContext: '/prerender-module', + parseAttributes: parseDefaultPrerenderAttributes, execute: (args) => prerenderer.prerenderModule(args), drainingPromise: options.drainingPromise, afterResponse: (url, response) => { @@ -325,6 +407,7 @@ export function buildPrerenderApp(options: { infoLabel: 'file extract prerendered', warnTimeoutMessage: (url) => `file extract render of ${url} timed out`, errorContext: '/prerender-file-extract', + parseAttributes: parseDefaultPrerenderAttributes, execute: (args) => prerenderer.prerenderFileExtract(args), drainingPromise: options.drainingPromise, afterResponse: (url, response) => { @@ -337,6 +420,27 @@ export function buildPrerenderApp(options: { }, }); + registerPrerenderRoute( + '/run-command', + { + requestDescription: 'command-runner', + responseType: 'command-result', + infoLabel: 'command-runner', + warnTimeoutMessage: (target) => `command run of ${target} timed out`, + errorContext: '/run-command', + errorMessage: 'Error running command', + parseAttributes: parseRunCommandAttributes, + execute: (args) => + prerenderer.runCommand({ + realm: args.realm, + auth: args.auth, + command: args.command, + commandInput: args.commandInput as Record | null, + }), + drainingPromise: options.drainingPromise, + }, + ); + // File render route needs additional attributes (fileData, types) // beyond what registerPrerenderRoute handles, so we register it directly. router.post('/prerender-file-render', async (ctxt: Koa.Context) => { diff --git a/packages/realm-server/prerender/prerenderer.ts b/packages/realm-server/prerender/prerenderer.ts index 22e2a96a9f6..ff7cca14b79 100644 --- a/packages/realm-server/prerender/prerenderer.ts +++ b/packages/realm-server/prerender/prerenderer.ts @@ -6,6 +6,7 @@ import { type FileRenderResponse, type FileRenderArgs, logger, + type RunCommandResponse, } from '@cardstack/runtime-common'; import { BrowserManager } from './browser-manager'; import { PagePool } from './page-pool'; @@ -339,6 +340,46 @@ export class Prerenderer { throw new Error(`module prerender attempts exhausted for ${url}`); } + async runCommand({ + realm, + auth, + command, + commandInput, + opts, + }: { + realm: string; + auth: string; + command: string; + commandInput?: Record | null; + opts?: { timeoutMs?: number; simulateTimeoutMs?: number }; + }): Promise<{ + response: RunCommandResponse; + timings: { launchMs: number; renderMs: number }; + pool: { + pageId: string; + realm: string; + reused: boolean; + evicted: boolean; + timedOut: boolean; + }; + }> { + if (this.#stopped) { + throw new Error('Prerenderer has been stopped and cannot be used'); + } + try { + return await this.#renderRunner.runCommandAttempt({ + realm, + auth, + command, + commandInput, + opts, + }); + } catch (e) { + log.error(`command run attempt failed (realm ${realm})`, e); + throw e; + } + } + async prerenderFileExtract({ realm, url, diff --git a/packages/realm-server/prerender/remote-prerenderer.ts b/packages/realm-server/prerender/remote-prerenderer.ts index c26af651994..c9a7325023f 100644 --- a/packages/realm-server/prerender/remote-prerenderer.ts +++ b/packages/realm-server/prerender/remote-prerenderer.ts @@ -6,6 +6,7 @@ import { type FileRenderResponse, type FileRenderArgs, type RenderRouteOptions, + type RunCommandResponse, logger, } from '@cardstack/runtime-common'; import { @@ -59,7 +60,7 @@ export function createRemotePrerenderer( type: string, attributes: { realm: string; - url: string; + url?: string; auth: string; renderOptions?: RenderRouteOptions; [key: string]: any; @@ -220,6 +221,18 @@ export function createRemotePrerenderer( }, ); }, + async runCommand({ realm, auth, command, commandInput }) { + return await requestWithRetry( + 'run-command', + 'run-command-request', + { + realm, + auth, + command, + commandInput, + }, + ); + }, }; } @@ -229,6 +242,7 @@ function validatePrerenderAttributes( realm?: string; url?: string; auth?: string; + command?: unknown; }, ) { let missing: string[] = []; @@ -236,12 +250,21 @@ function validatePrerenderAttributes( if (typeof attrs.realm !== 'string' || attrs.realm.trim().length === 0) { missing.push('realm'); } - if (typeof attrs.url !== 'string' || attrs.url.trim().length === 0) { + if ( + requestType !== 'run-command-request' && + (typeof attrs.url !== 'string' || attrs.url.trim().length === 0) + ) { missing.push('url'); } if (typeof attrs.auth !== 'string' || attrs.auth.trim().length === 0) { missing.push('auth'); } + if ( + requestType === 'run-command-request' && + (typeof attrs.command !== 'string' || attrs.command.trim().length === 0) + ) { + missing.push('command'); + } if (missing.length > 0) { throw new Error( diff --git a/packages/realm-server/prerender/render-runner.ts b/packages/realm-server/prerender/render-runner.ts index 2baadd5b1ef..0efac001455 100644 --- a/packages/realm-server/prerender/render-runner.ts +++ b/packages/realm-server/prerender/render-runner.ts @@ -7,6 +7,7 @@ import { type FileRenderResponse, type FileRenderArgs, type RenderRouteOptions, + type RunCommandResponse, serializeRenderRouteOptions, logger, } from '@cardstack/runtime-common'; @@ -27,6 +28,7 @@ import { type FileExtractCapture, withTimeout, transitionTo, + buildCommandRunnerURL, buildInvalidModuleResponseError, buildInvalidFileExtractResponseError, } from './utils'; @@ -147,7 +149,7 @@ export class RenderRunner { }; log.debug( - `manually visit prerendered url ${url} at: ${this.#boxelHostURL}/render/${encodeURIComponent(url)}/${this.#nonce}/${optionsSegment}/html/isolated/0 with localStorage boxel-session=${auth}`, + `manually visit prerendered url ${url} at: ${this.#boxelHostURL}/render/${encodeURIComponent(url)}/${this.#nonce}/${optionsSegment}/html/isolated/0`, ); // We need to render the isolated HTML view first, as the template will pull linked fields. @@ -386,6 +388,187 @@ export class RenderRunner { } } + async runCommandAttempt({ + realm, + auth, + command, + commandInput, + opts, + }: { + realm: string; + auth: string; + command: string; + commandInput?: Record | null; + opts?: { timeoutMs?: number; simulateTimeoutMs?: number }; + }): Promise<{ + response: RunCommandResponse; + timings: { launchMs: number; renderMs: number }; + pool: { + pageId: string; + realm: string; + reused: boolean; + evicted: boolean; + timedOut: boolean; + }; + }> { + this.#nonce++; + log.info( + `running command ${command ?? ''}, nonce=${this.#nonce} realm=${realm}`, + ); + + const { page, reused, launchMs, pageId, release } = + await this.#getPageForRealm(realm, auth); + const poolInfo = { + pageId: pageId ?? 'unknown', + realm, + reused, + evicted: false, + timedOut: false, + }; + this.#pagePool.resetConsoleErrors(pageId); + const markTimeout = (status?: RunCommandResponse['status']) => { + if (!poolInfo.timedOut && status === 'unusable') { + poolInfo.timedOut = true; + } + }; + try { + await page.evaluate((sessionAuth) => { + localStorage.setItem('boxel-session', sessionAuth); + }, auth); + + let renderStart = Date.now(); + let encodedCommand = encodeURIComponent(command); + let encodedInput = encodeURIComponent( + JSON.stringify(commandInput ?? null), + ); + await transitionTo( + page, + 'command-runner', + encodedCommand, + encodedInput, + String(this.#nonce), + ); + log.info( + 'command-runner url: %s', + buildCommandRunnerURL( + page, + String(this.#nonce), + encodedCommand, + encodedInput, + ), + ); + + let waitResult = await withTimeout( + page, + async () => { + await page.waitForFunction( + (expectedNonce: string) => { + let containers = Array.from( + document.querySelectorAll( + '[data-prerender][data-prerender-id="command-runner"]', + ), + ) as HTMLElement[]; + let container = + containers.find( + (candidate) => + candidate.dataset.prerenderNonce === expectedNonce, + ) ?? null; + if (!container) { + return false; + } + let status = container.dataset.prerenderStatus ?? ''; + return ['ready', 'error', 'unusable'].includes(status); + }, + {}, + String(this.#nonce), + ); + + if (opts?.simulateTimeoutMs) { + await new Promise((resolve) => + setTimeout(resolve, opts.simulateTimeoutMs), + ); + } + + return true; + }, + opts?.timeoutMs, + ); + + if (isRenderError(waitResult)) { + let response: RunCommandResponse = { + status: 'unusable', + error: waitResult.error.message, + }; + markTimeout(response.status); + return { + response, + timings: { launchMs, renderMs: Date.now() - renderStart }, + pool: poolInfo, + }; + } + + let payload = await page.evaluate((expectedNonce: string) => { + let containers = Array.from( + document.querySelectorAll( + '[data-prerender][data-prerender-id="command-runner"]', + ), + ) as HTMLElement[]; + let container = + containers.find( + (candidate) => candidate.dataset.prerenderNonce === expectedNonce, + ) ?? null; + let status = + (container?.dataset.prerenderStatus as + | 'ready' + | 'error' + | 'unusable' + | undefined) ?? 'error'; + let errorElement = container?.querySelector( + '[data-prerender-error]', + ) as HTMLElement | null; + let cardResultStringElement = container?.querySelector( + '[data-command-result]', + ) as HTMLElement | null; + let error = (errorElement?.textContent ?? '').trim(); + let cardResultString = ( + cardResultStringElement?.textContent ?? '' + ).trim(); + return { + status, + error: error.length > 0 ? error : null, + cardResultString: + cardResultString.length > 0 ? cardResultString : null, + }; + }, String(this.#nonce)); + + let response: RunCommandResponse = { + status: payload.status, + cardResultString: payload.cardResultString ?? undefined, + error: payload.error ?? undefined, + }; + markTimeout(response.status); + + return { + response, + timings: { launchMs, renderMs: Date.now() - renderStart }, + pool: poolInfo, + }; + } catch (e) { + log.error('Error running command in headless chrome:', e); + let response: RunCommandResponse = { + status: 'error', + error: e instanceof Error ? `${e.name}: ${e.message}` : `${e}`, + }; + return { + response, + timings: { launchMs, renderMs: 0 }, + pool: poolInfo, + }; + } finally { + release(); + } + } + async prerenderModuleAttempt({ realm, url, diff --git a/packages/realm-server/prerender/utils.ts b/packages/realm-server/prerender/utils.ts index 33a260205b3..aa6e8742cff 100644 --- a/packages/realm-server/prerender/utils.ts +++ b/packages/realm-server/prerender/utils.ts @@ -47,10 +47,16 @@ export interface FileExtractCapture { nonce?: string; } +type TransitionParam = + | string + | { + queryParams?: Record; + }; + export async function transitionTo( page: Page, routeName: string, - ...params: string[] + ...params: TransitionParam[] ): Promise { await page.evaluate( (routeName, params) => { @@ -61,6 +67,27 @@ export async function transitionTo( ); } +export function buildCommandRunnerURL( + page: Page, + nonce: string, + encodedCommand: string, + encodedInput: string, +): string { + let origin = page.url(); + try { + origin = new URL(origin).origin; + } catch (error) { + let detail = + error instanceof Error ? error.message : 'Unknown URL parsing error'; + throw new Error( + `Could not build command-runner URL from page URL "${origin}": ${detail}`, + ); + } + return `${origin}/command-runner/${encodedCommand}/${encodedInput}/${encodeURIComponent( + nonce, + )}`; +} + export async function renderHTML( page: Page, format: string, diff --git a/packages/realm-server/tests/definition-lookup-test.ts b/packages/realm-server/tests/definition-lookup-test.ts index c6bf58f99f4..047ed8a25a1 100644 --- a/packages/realm-server/tests/definition-lookup-test.ts +++ b/packages/realm-server/tests/definition-lookup-test.ts @@ -150,6 +150,9 @@ module(basename(__filename), function () { async prerenderFileRender() { throw new Error('Not implemented in mock'); }, + async runCommand() { + throw new Error('Not implemented in mock'); + }, }; definitionLookup = new CachingDefinitionLookup( dbAdapter, @@ -329,6 +332,9 @@ module(basename(__filename), function () { async prerenderFileRender() { throw new Error('Not implemented in mock'); }, + async runCommand() { + throw new Error('Not implemented in mock'); + }, async prerenderModule(args: ModulePrerenderArgs) { calls++; let moduleAlias = trimExecutableExtension(new URL(args.url)).href; @@ -419,6 +425,9 @@ module(basename(__filename), function () { async prerenderFileRender() { throw new Error('Not implemented in mock'); }, + async runCommand() { + throw new Error('Not implemented in mock'); + }, async prerenderModule(args: ModulePrerenderArgs) { calls++; if (!modulePresent) { @@ -496,6 +505,9 @@ module(basename(__filename), function () { async prerenderFileRender() { throw new Error('Not implemented in mock'); }, + async runCommand() { + throw new Error('Not implemented in mock'); + }, async prerenderModule(args: ModulePrerenderArgs) { calls.set(args.url, (calls.get(args.url) ?? 0) + 1); switch (args.url) { @@ -642,6 +654,9 @@ module(basename(__filename), function () { async prerenderFileRender() { throw new Error('Not implemented in mock'); }, + async runCommand() { + throw new Error('Not implemented in mock'); + }, async prerenderModule(args: ModulePrerenderArgs) { calls.set(args.url, (calls.get(args.url) ?? 0) + 1); switch (args.url) { @@ -797,6 +812,9 @@ module(basename(__filename), function () { async prerenderFileRender() { throw new Error('Not implemented in mock'); }, + async runCommand() { + throw new Error('Not implemented in mock'); + }, async prerenderModule(args: ModulePrerenderArgs) { switch (args.url) { case deepModule: { diff --git a/packages/realm-server/tests/prerender-manager-test.ts b/packages/realm-server/tests/prerender-manager-test.ts index e6e2fba89d2..c079802d16b 100644 --- a/packages/realm-server/tests/prerender-manager-test.ts +++ b/packages/realm-server/tests/prerender-manager-test.ts @@ -283,6 +283,43 @@ module(basename(__filename), function () { ); }); + test('proxies run-command requests', async function (assert) { + let { app } = buildPrerenderManagerApp(); + let request: SuperTest = supertest(app.callback()); + + // Register a single server + await request.post('/prerender-servers').send({ + data: { + type: 'prerender-server', + attributes: { capacity: 2, url: serverUrlA }, + }, + }); + + let realm = 'https://realm.example/CMD'; + let command = `${realm}/commands/say-hello/SayHelloCommand`; + + let proxyResponse = await request + .post('/run-command') + .send(makeCommandBody(realm, command)); + + assert.strictEqual(proxyResponse.status, 201, 'proxy request successful'); + assert.strictEqual( + proxyResponse.headers['x-boxel-prerender-target'], + serverUrlA, + 'command request routed to registered prerender server', + ); + assert.strictEqual( + proxyResponse.body?.data?.type, + 'command-result', + 'command result type returned', + ); + assert.strictEqual( + proxyResponse.body?.data?.id, + command, + 'command result id echoed', + ); + }); + test('heartbeat: url required; heartbeat updates warmed realms and status', async function (assert) { let { app } = buildPrerenderManagerApp(); let request: SuperTest = supertest(app.callback()); @@ -1337,7 +1374,7 @@ function makeMockPrerender(): { responder: ( ctxt: Koa.Context, body: any, - type: 'card' | 'module', + type: 'card' | 'module' | 'command', ) => Promise | void, ) => void; } { @@ -1350,7 +1387,7 @@ function makeMockPrerender(): { let responder: ( ctxt: Koa.Context, body: any, - type: 'card' | 'module', + type: 'card' | 'module' | 'command', ) => Promise | void = defaultResponder; async function readBody(ctxt: Koa.Context) { return await new Promise((resolve) => { @@ -1362,7 +1399,7 @@ function makeMockPrerender(): { function defaultResponder( ctxt: Koa.Context, body: any, - type: 'card' | 'module', + type: 'card' | 'module' | 'command', ) { ctxt.status = 201; ctxt.set('Content-Type', 'application/vnd.api+json'); @@ -1383,7 +1420,7 @@ function makeMockPrerender(): { }, }, }); - } else { + } else if (type === 'module') { ctxt.body = JSON.stringify({ data: { type: 'prerender-module-result', @@ -1409,6 +1446,26 @@ function makeMockPrerender(): { }, }, }); + } else { + ctxt.body = JSON.stringify({ + data: { + type: 'command-result', + id: body?.data?.attributes?.command || 'command', + attributes: { + status: 'ready', + cardResultString: null, + }, + }, + meta: { + timing: { launchMs: 0, renderMs: 0, totalMs: 0 }, + pool: { + pageId: 'p', + realm: body?.data?.attributes?.realm, + reused: false, + evicted: false, + }, + }, + }); } } router.post('/prerender-card', async (ctxt) => { @@ -1421,6 +1478,11 @@ function makeMockPrerender(): { let body = raw ? JSON.parse(raw) : {}; await responder(ctxt, body, 'module'); }); + router.post('/run-command', async (ctxt) => { + let raw = await readBody(ctxt); + let body = raw ? JSON.parse(raw) : {}; + await responder(ctxt, body, 'command'); + }); app.use(router.routes()); let server = createServer(app.callback()).listen(0); let stopped = false; @@ -1468,6 +1530,20 @@ function makeModuleBody(realm: string, url: string) { }; } +function makeCommandBody(realm: string, command: string) { + let auth = makeAuth(realm); + return { + data: { + type: 'command-request', + attributes: { + realm, + auth, + command, + }, + }, + }; +} + function makeAuth(realm: string) { return testCreatePrerenderAuth('@user:localhost', { [realm]: ['read', 'write', 'realm-owner'], diff --git a/packages/realm-server/tests/prerender-proxy-test.ts b/packages/realm-server/tests/prerender-proxy-test.ts index 5ef72e53f4f..72370fbc08f 100644 --- a/packages/realm-server/tests/prerender-proxy-test.ts +++ b/packages/realm-server/tests/prerender-proxy-test.ts @@ -33,10 +33,10 @@ module(basename(__filename), function () { function makePrerenderer() { let renderCalls: Array<{ - kind: 'card' | 'module' | 'file-extract' | 'file-render'; + kind: 'card' | 'module' | 'file-extract' | 'file-render' | 'command'; args: { realm: string; - url: string; + url?: string; auth: string; renderOptions?: RenderRouteOptions; }; @@ -93,6 +93,13 @@ module(basename(__filename), function () { iconHTML: null, }; }, + async runCommand(args) { + renderCalls.push({ kind: 'command', args }); + return { + status: 'ready', + cardResultString: null, + }; + }, }; return { prerenderer, renderCalls }; diff --git a/packages/realm-server/tests/prerender-server-test.ts b/packages/realm-server/tests/prerender-server-test.ts index f1f2c582ef2..6767f20bad8 100644 --- a/packages/realm-server/tests/prerender-server-test.ts +++ b/packages/realm-server/tests/prerender-server-test.ts @@ -44,6 +44,46 @@ module(basename(__filename), function () { }, }, }, + 'command-runner-test.gts': ` + import { Command } from '@cardstack/runtime-common'; + import { + CardDef, + field, + contains, + StringField, + } from 'https://cardstack.com/base/card-api'; + + export class CommandResult extends CardDef { + static displayName = 'CommandResult'; + @field message = contains(StringField); + } + + export class SayHelloCommand extends Command< + undefined, + typeof CommandResult + > { + static displayName = 'SayHelloCommand'; + async getInputType() { + return undefined; + } + protected async run(): Promise { + return new CommandResult({ message: 'hello from command' }); + } + } + + export class ThrowErrorCommand extends Command< + undefined, + typeof CommandResult + > { + static displayName = 'ThrowErrorCommand'; + async getInputType() { + return undefined; + } + protected async run(): Promise { + throw new Error('command exploded'); + } + } + `, }, }); @@ -194,6 +234,111 @@ module(basename(__filename), function () { assert.ok(res.body.meta?.pool?.pageId, 'has pool.pageId'); }); + module('run-command', function () { + test('it handles run-command request', async function (assert) { + let permissions = { + [realmURL.href]: ['read', 'write', 'realm-owner'] as ( + | 'read' + | 'write' + | 'realm-owner' + )[], + }; + let auth = testCreatePrerenderAuth(testUserId, permissions); + let command = `${realmURL.href}command-runner-test/SayHelloCommand`; + let res = await request + .post('/run-command') + .set('Accept', 'application/vnd.api+json') + .set('Content-Type', 'application/json') + .send({ + data: { + type: 'command-request', + attributes: { + realm: realmURL.href, + auth, + command, + }, + }, + }); + + assert.strictEqual(res.status, 201, 'HTTP 201'); + assert.strictEqual(res.body.data.type, 'command-result', 'type ok'); + assert.strictEqual(res.body.data.id, command, 'id is command'); + assert.strictEqual( + res.body.data.attributes.status, + 'ready', + 'command status ready', + ); + assert.notOk(res.body.data.attributes.error, 'no command error'); + let cardResultString = res.body.data.attributes.cardResultString; + assert.strictEqual( + typeof cardResultString, + 'string', + 'returns serialized command card', + ); + assert.notOk( + res.body.data.attributes.cardResult, + 'does not return raw card instance over HTTP', + ); + assert.ok(cardResultString.length > 0, 'serialized card is non-empty'); + assert.ok( + cardResultString.includes('hello from command'), + 'serialized card includes command output', + ); + assert.ok(res.body.meta?.timing?.totalMs >= 0, 'has timing'); + assert.ok(res.body.meta?.pool?.pageId, 'has pool.pageId'); + }); + + test('it captures run-command error state', async function (assert) { + let permissions = { + [realmURL.href]: ['read', 'write', 'realm-owner'] as ( + | 'read' + | 'write' + | 'realm-owner' + )[], + }; + let auth = testCreatePrerenderAuth(testUserId, permissions); + let command = `${realmURL.href}command-runner-test/ThrowErrorCommand`; + let res = await request + .post('/run-command') + .set('Accept', 'application/vnd.api+json') + .set('Content-Type', 'application/json') + .send({ + data: { + type: 'command-request', + attributes: { + realm: realmURL.href, + auth, + command, + }, + }, + }); + + assert.strictEqual(res.status, 201, 'HTTP 201'); + assert.strictEqual(res.body.data.type, 'command-result', 'type ok'); + assert.strictEqual( + res.body.data.attributes.status, + 'error', + 'command status error', + ); + assert.ok( + (res.body.data.attributes.error as string).includes( + 'command exploded', + ), + 'returns command error message', + ); + assert.notOk( + res.body.data.attributes.cardResultString, + 'no serialized card result on command error', + ); + assert.notOk( + res.body.data.attributes.cardResult, + 'no raw card instance on command error', + ); + assert.ok(res.body.meta?.timing?.totalMs >= 0, 'has timing'); + assert.ok(res.body.meta?.pool?.pageId, 'has pool.pageId'); + }); + }); + test('reports draining status when shutting down', async function (assert) { draining = true; const permissions: Record = diff --git a/packages/realm-server/tests/server-endpoints/bot-commands-test.ts b/packages/realm-server/tests/server-endpoints/bot-commands-test.ts index 28ef49ebc3d..0aed614f7e5 100644 --- a/packages/realm-server/tests/server-endpoints/bot-commands-test.ts +++ b/packages/realm-server/tests/server-endpoints/bot-commands-test.ts @@ -118,6 +118,61 @@ module(`server-endpoints/${basename(__filename)}`, function () { assert.ok(rows[0].created_at, 'created_at is persisted'); }); + test('accepts @cardstack/boxel-host command specifier', async function (assert) { + let matrixUserId = '@user:localhost'; + await insertUser( + context.dbAdapter, + matrixUserId, + 'cus_123', + 'user@example.com', + ); + + let botRegistrationId = uuidv4(); + await query(context.dbAdapter, [ + `INSERT INTO bot_registrations (id, username, created_at) VALUES (`, + param(botRegistrationId), + `,`, + param(matrixUserId), + `,`, + `CURRENT_TIMESTAMP`, + `)`, + ]); + + let commandSpecifier = '@cardstack/boxel-host/commands/show-card/default'; + let response = await context.request2 + .post('/_bot-commands') + .set('Accept', 'application/vnd.api+json') + .set('Content-Type', 'application/vnd.api+json') + .set( + 'Authorization', + `Bearer ${createRealmServerJWT( + { user: matrixUserId, sessionRoom: 'session-room-test' }, + realmSecretSeed, + )}`, + ) + .send({ + data: { + type: 'bot-command', + attributes: { + botId: botRegistrationId, + command: commandSpecifier, + filter: { + type: 'matrix-event', + event_type: 'app.boxel.bot-trigger', + content_type: 'show-card', + }, + }, + }, + }); + + assert.strictEqual(response.status, 201, 'HTTP 201 status'); + assert.strictEqual( + response.body.data.attributes.command, + commandSpecifier, + 'persists scoped command specifier', + ); + }); + test('lists bot commands for authenticated user', async function (assert) { let matrixUserId = '@user:localhost'; let otherMatrixUserId = '@other-user:localhost'; @@ -452,55 +507,6 @@ module(`server-endpoints/${basename(__filename)}`, function () { ); }); - test('rejects unsupported filter', async function (assert) { - let matrixUserId = '@user:localhost'; - await insertUser( - context.dbAdapter, - matrixUserId, - 'cus_123', - 'user@example.com', - ); - - let botRegistrationId = uuidv4(); - await query(context.dbAdapter, [ - `INSERT INTO bot_registrations (id, username, created_at) VALUES (`, - param(botRegistrationId), - `,`, - param(matrixUserId), - `,`, - `CURRENT_TIMESTAMP`, - `)`, - ]); - - let response = await context.request2 - .post('/_bot-commands') - .set('Accept', 'application/vnd.api+json') - .set('Content-Type', 'application/vnd.api+json') - .set( - 'Authorization', - `Bearer ${createRealmServerJWT( - { user: matrixUserId, sessionRoom: 'session-room-test' }, - realmSecretSeed, - )}`, - ) - .send({ - data: { - type: 'bot-command', - attributes: { - botId: botRegistrationId, - command: 'https://example.com/bot/command/default', - filter: { - type: 'matrix-event', - event_type: 'app.boxel.bot-trigger', - content_type: 'unsupported', - }, - }, - }, - }); - - assert.strictEqual(response.status, 400, 'HTTP 400 status'); - }); - test('rejects non-matrix filter type', async function (assert) { let matrixUserId = '@user:localhost'; await insertUser( diff --git a/packages/runtime-common/bot-command.ts b/packages/runtime-common/bot-command.ts index acc61d55fd5..6f7450f909b 100644 --- a/packages/runtime-common/bot-command.ts +++ b/packages/runtime-common/bot-command.ts @@ -1,12 +1,9 @@ -import { - BOT_TRIGGER_COMMAND_TYPES, - BOT_TRIGGER_EVENT_TYPE, -} from './matrix-constants'; +import { BOT_TRIGGER_EVENT_TYPE } from './matrix-constants'; export interface BotCommandMatrixFilter { type: 'matrix-event'; event_type: typeof BOT_TRIGGER_EVENT_TYPE; - content_type: (typeof BOT_TRIGGER_COMMAND_TYPES)[number]; + content_type: string; } export type BotCommandFilter = BotCommandMatrixFilter; @@ -34,9 +31,7 @@ export function isBotCommandFilter(value: unknown): value is BotCommandFilter { return false; } - return BOT_TRIGGER_COMMAND_TYPES.includes( - filter.content_type as (typeof BOT_TRIGGER_COMMAND_TYPES)[number], - ); + return true; } export function assertIsBotCommandFilter( @@ -64,11 +59,5 @@ export function assertIsBotCommandFilter( throw new Error('filter.content_type must be a string'); } - if ( - !BOT_TRIGGER_COMMAND_TYPES.includes( - filter.content_type as (typeof BOT_TRIGGER_COMMAND_TYPES)[number], - ) - ) { - throw new Error('filter.content_type is not supported'); - } + // Any string content_type is allowed. Matching is handled by registration data. } diff --git a/packages/runtime-common/bot-trigger.ts b/packages/runtime-common/bot-trigger.ts index 8a660f87bf1..41ebf0b4fd0 100644 --- a/packages/runtime-common/bot-trigger.ts +++ b/packages/runtime-common/bot-trigger.ts @@ -1,8 +1,5 @@ import type { BotTriggerEvent } from 'https://cardstack.com/base/matrix-event'; -import { - BOT_TRIGGER_COMMAND_TYPES, - BOT_TRIGGER_EVENT_TYPE, -} from './matrix-constants'; +import { BOT_TRIGGER_EVENT_TYPE } from './matrix-constants'; export function isBotTriggerEvent(value: unknown): value is BotTriggerEvent { if (!value || typeof value !== 'object') { @@ -18,16 +15,16 @@ export function isBotTriggerEvent(value: unknown): value is BotTriggerEvent { return false; } - let content = event.content as { type?: unknown; input?: unknown }; + let content = event.content as { + type?: string; + input?: Record; + realm?: string; + }; if (typeof content.type !== 'string') { return false; } - if ( - !BOT_TRIGGER_COMMAND_TYPES.includes( - content.type as (typeof BOT_TRIGGER_COMMAND_TYPES)[number], - ) - ) { + if (typeof content.realm !== 'string') { return false; } diff --git a/packages/runtime-common/code-ref.ts b/packages/runtime-common/code-ref.ts index f9b5e451002..8f2e90764a4 100644 --- a/packages/runtime-common/code-ref.ts +++ b/packages/runtime-common/code-ref.ts @@ -53,6 +53,15 @@ export function isResolvedCodeRef(ref?: CodeRef | {}): ref is ResolvedCodeRef { } } +export function assertIsResolvedCodeRef( + ref: unknown, + message = 'Expected ResolvedCodeRef', +): asserts ref is ResolvedCodeRef { + if (!isResolvedCodeRef(ref as CodeRef | {})) { + throw new Error(message); + } +} + export function isCodeRef(ref: any): ref is CodeRef { if (!ref || typeof ref !== 'object') { return false; diff --git a/packages/runtime-common/command-parsing-utils.ts b/packages/runtime-common/command-parsing-utils.ts new file mode 100644 index 00000000000..7b95f0ffaa3 --- /dev/null +++ b/packages/runtime-common/command-parsing-utils.ts @@ -0,0 +1,113 @@ +import type { ResolvedCodeRef } from './code-ref'; +import { ensureTrailingSlash } from './paths'; + +export function parseBoxelHostCommandSpecifier( + commandRef: string, +): ResolvedCodeRef | undefined { + let match = commandRef.match( + /^@cardstack\/boxel-host\/commands\/([^/?#\s]+)\/([^/?#\s]+)$/, + ); + if (!match) { + return undefined; + } + return { + module: `@cardstack/boxel-host/commands/${match[1]}`, + name: match[2], + }; +} + +export function commandUrlToCodeRef( + commandUrl: string, + realmURL: string | undefined, +): ResolvedCodeRef | undefined { + if (!commandUrl) { + return undefined; + } + + let commandRef = commandUrl.trim(); + if (!commandRef) { + return undefined; + } + + let fromSpecifier = parseBoxelHostCommandSpecifier(commandRef); + if (fromSpecifier) { + return fromSpecifier; + } + + let path = toCommandPath(commandRef); + if (!path || !realmURL) { + return undefined; + } + + let parsedPath = parseCommandPath(path); + if (!parsedPath) { + return undefined; + } + + return { + module: `${ensureTrailingSlash(realmURL)}commands/${parsedPath.commandName}`, + name: parsedPath.exportName, + }; +} + +function toCommandPath(commandRef: string): string | undefined { + try { + return new URL(commandRef).pathname; + } catch { + // Accept absolute URL command references only. + } + return undefined; +} + +type ParsedCommandPath = { + commandName: string; + exportName: string; +}; + +function parseCommandPath(pathname: string): ParsedCommandPath | undefined { + if (!pathname.startsWith('/commands/')) { + return undefined; + } + + // Accept only /commands/ or /commands//. This avoids + // matching nested /commands/ paths and traversal-like payloads. + let segments = pathname.split('/').filter(Boolean); + if ( + segments[0] !== 'commands' || + segments.length < 2 || + segments.length > 3 + ) { + return undefined; + } + + let [_, rawCommandName, rawExportName] = segments; + let commandName = decodePathSegment(rawCommandName); + if (!commandName || isUnsafeCommandSegment(commandName)) { + return undefined; + } + + let exportName = rawExportName ? decodePathSegment(rawExportName) : 'default'; + if (!exportName || isUnsafeCommandSegment(exportName)) { + return undefined; + } + + return { commandName, exportName }; +} + +function decodePathSegment(segment: string): string | undefined { + try { + return decodeURIComponent(segment); + } catch { + return undefined; + } +} + +function isUnsafeCommandSegment(segment: string): boolean { + return ( + segment === '.' || + segment === '..' || + segment.includes('/') || + segment.includes('\\') || + /\s/.test(segment) + ); +} diff --git a/packages/runtime-common/commands.ts b/packages/runtime-common/commands.ts index 0d833e149af..7eec4122ca4 100644 --- a/packages/runtime-common/commands.ts +++ b/packages/runtime-common/commands.ts @@ -22,7 +22,7 @@ export interface CommandContext { } export interface CommandInvocation { - value: CardInstance | null; + cardResult: CardInstance | null; error: Error | null; status: 'pending' | 'success' | 'error'; readonly isSuccess: boolean; diff --git a/packages/runtime-common/index.ts b/packages/runtime-common/index.ts index d6b5a99e71f..0c07a6ce9ff 100644 --- a/packages/runtime-common/index.ts +++ b/packages/runtime-common/index.ts @@ -6,7 +6,7 @@ import type { Meta, Saved, } from './resource-types'; -import type { ResolvedCodeRef } from './code-ref'; +import type { CodeRef, ResolvedCodeRef } from './code-ref'; import type { RenderRouteOptions } from './render-route-options'; import type { Definition } from './definitions'; import type { SerializedError } from './error'; @@ -124,11 +124,25 @@ export type ModulePrerenderArgs = { export type PrerenderCardArgs = ModulePrerenderArgs; +export type RunCommandArgs = { + realm: string; + auth: string; + command: string; + commandInput?: Record | null; +}; + +export type RunCommandResponse = { + status: 'ready' | 'error' | 'unusable'; + cardResultString?: string | null; + error?: string | null; +}; + export interface Prerenderer { prerenderCard(args: PrerenderCardArgs): Promise; prerenderModule(args: ModulePrerenderArgs): Promise; prerenderFileExtract(args: ModulePrerenderArgs): Promise; prerenderFileRender(args: FileRenderArgs): Promise; + runCommand(args: RunCommandArgs): Promise; } export type RealmAction = 'read' | 'write' | 'realm-owner' | 'assume-user'; @@ -280,10 +294,8 @@ export type { RealmSession, } from './realm'; -import type { CodeRef } from './code-ref'; -export type { CodeRef }; - export * from './code-ref'; +export * from './command-parsing-utils'; export * from './serializers'; export type { diff --git a/packages/runtime-common/jobs/run-command.ts b/packages/runtime-common/jobs/run-command.ts new file mode 100644 index 00000000000..8459fdc62f1 --- /dev/null +++ b/packages/runtime-common/jobs/run-command.ts @@ -0,0 +1,21 @@ +import type { QueuePublisher } from '../queue'; +import type { RunCommandResponse, DBAdapter } from '../index'; +import type { RunCommandArgs } from '../tasks/run-command'; + +export const RUN_COMMAND_JOB_TIMEOUT_SEC = 60; + +export async function enqueueRunCommandJob( + args: RunCommandArgs, + queue: QueuePublisher, + _dbAdapter: DBAdapter, + priority: number, +) { + let job = await queue.publish({ + jobType: 'run-command', + concurrencyGroup: `command:${args.realmURL}`, + timeout: RUN_COMMAND_JOB_TIMEOUT_SEC, + priority, + args, + }); + return job; +} diff --git a/packages/runtime-common/matrix-constants.ts b/packages/runtime-common/matrix-constants.ts index c36f77be90b..7e1d8ae7808 100644 --- a/packages/runtime-common/matrix-constants.ts +++ b/packages/runtime-common/matrix-constants.ts @@ -26,7 +26,6 @@ export const APP_BOXEL_SYSTEM_CARD_EVENT_TYPE = 'app.boxel.system-card'; export const APP_BOXEL_REALM_EVENT_TYPE = 'app.boxel.realm-event'; export const APP_BOXEL_ACTIVE_LLM = 'app.boxel.active-llm'; export const BOT_TRIGGER_EVENT_TYPE = 'app.boxel.bot-trigger'; -export const BOT_TRIGGER_COMMAND_TYPES = ['create-listing-pr'] as const; export const APP_BOXEL_REASONING_CONTENT_KEY = 'app.boxel.reasoning'; export const APP_BOXEL_HAS_CONTINUATION_CONTENT_KEY = 'app.boxel.has-continuation'; diff --git a/packages/runtime-common/tasks/index.ts b/packages/runtime-common/tasks/index.ts index 5c00aca8567..00e1a143242 100644 --- a/packages/runtime-common/tasks/index.ts +++ b/packages/runtime-common/tasks/index.ts @@ -14,6 +14,7 @@ export * from './full-reindex'; export * from './daily-credit-grant'; export * from './copy'; export * from './indexer'; +export * from './run-command'; type LoggerInstance = ReturnType; diff --git a/packages/runtime-common/tasks/run-command.ts b/packages/runtime-common/tasks/run-command.ts new file mode 100644 index 00000000000..b638339519e --- /dev/null +++ b/packages/runtime-common/tasks/run-command.ts @@ -0,0 +1,121 @@ +import type * as JSONTypes from 'json-typescript'; + +import type { Task } from './index'; + +import { + fetchRealmPermissions, + jobIdentity, + type RunCommandResponse, + ensureFullMatrixUserId, + ensureTrailingSlash, +} from '../index'; + +export interface RunCommandArgs extends JSONTypes.Object { + realmURL: string; + realmUsername: string; + runAs: string; + command: string; + commandInput: JSONTypes.Object | null; +} + +export { runCommand }; + +const runCommand: Task = ({ + reportStatus, + log, + dbAdapter, + prerenderer, + createPrerenderAuth, + matrixURL, +}) => + async function (args) { + let { jobInfo, realmURL, realmUsername, runAs, command, commandInput } = + args; + log.debug( + `${jobIdentity(jobInfo)} starting run-command for job: ${JSON.stringify({ + realmURL, + realmUsername, + runAs, + command, + })}`, + ); + reportStatus(jobInfo, 'start'); + + let normalizedRealmURL = ensureTrailingSlash(realmURL); + let realmPermissions = await fetchRealmPermissions( + dbAdapter, + new URL(normalizedRealmURL), + ); + let runAsUserId = ensureFullMatrixUserId(runAs, matrixURL); + let userPermissions = realmPermissions[runAsUserId]; + if (!userPermissions || userPermissions.length === 0) { + let message = `${jobIdentity(jobInfo)} ${runAs} does not have permissions in ${normalizedRealmURL}`; + log.error(message); + reportStatus(jobInfo, 'finish'); + return { + status: 'error', + error: message, + }; + } + + let auth = createPrerenderAuth(runAsUserId, { + [normalizedRealmURL]: userPermissions, + }); + + let normalizedCommand = normalizeCommandSpecifier( + command, + normalizedRealmURL, + ); + if (!normalizedCommand) { + let message = `${jobIdentity(jobInfo)} invalid command specifier`; + log.error(message, { command, realmURL: normalizedRealmURL }); + reportStatus(jobInfo, 'finish'); + return { + status: 'error', + error: message, + }; + } + + let result = await prerenderer.runCommand({ + realm: normalizedRealmURL, + auth, + command: normalizedCommand, + commandInput: commandInput ?? undefined, + }); + + reportStatus(jobInfo, 'finish'); + return result; + }; + +function normalizeCommandSpecifier( + command: string, + realmURL: string, +): string | undefined { + let specifier = command.trim(); + if (!specifier) { + return undefined; + } + + // Legacy bot command URLs can point at /commands// on the + // realm server host. Resolve those to the target realm before prerendering. + let path = toPathname(specifier); + if (!path || !path.startsWith('/commands/')) { + return specifier; + } + + let [commandName, exportName = 'default'] = path + .slice('/commands/'.length) + .split('/'); + if (!commandName) { + return undefined; + } + return `${ensureTrailingSlash(realmURL)}commands/${commandName}/${exportName || 'default'}`; +} + +function toPathname(commandSpecifier: string): string | undefined { + try { + return new URL(commandSpecifier).pathname; + } catch { + return undefined; + } +} diff --git a/packages/runtime-common/tests/command-parsing-utils-test.ts b/packages/runtime-common/tests/command-parsing-utils-test.ts new file mode 100644 index 00000000000..a6a4736e80b --- /dev/null +++ b/packages/runtime-common/tests/command-parsing-utils-test.ts @@ -0,0 +1,143 @@ +import { module, test } from 'qunit'; +import { + commandUrlToCodeRef, + parseBoxelHostCommandSpecifier, +} from '../command-parsing-utils'; + +module('command parsing utils', () => { + test('parseBoxelHostCommandSpecifier parses scoped command specifier', async function (assert) { + assert.deepEqual( + parseBoxelHostCommandSpecifier( + '@cardstack/boxel-host/commands/show-card/default', + ), + { + module: '@cardstack/boxel-host/commands/show-card', + name: 'default', + }, + ); + }); + + test('parseBoxelHostCommandSpecifier rejects unscoped command specifier', async function (assert) { + assert.strictEqual( + parseBoxelHostCommandSpecifier( + 'cardstack/boxel-host/commands/show-card/execute', + ), + undefined, + ); + }); + + test('parseBoxelHostCommandSpecifier rejects specifier without export name', async function (assert) { + assert.strictEqual( + parseBoxelHostCommandSpecifier('cardstack/boxel-host/commands/show-card'), + undefined, + ); + }); + + test('parseBoxelHostCommandSpecifier rejects query/hash forms', async function (assert) { + assert.strictEqual( + parseBoxelHostCommandSpecifier( + '@cardstack/boxel-host/commands/show-card/default?foo=bar', + ), + undefined, + ); + assert.strictEqual( + parseBoxelHostCommandSpecifier( + '@cardstack/boxel-host/commands/show-card/default#main', + ), + undefined, + ); + }); + + test('requires explicit export for cardstack/boxel-host command specifier', async function (assert) { + assert.strictEqual( + commandUrlToCodeRef('cardstack/boxel-host/commands/show-card', undefined), + undefined, + ); + }); + + test('parses cardstack/boxel-host command specifier with explicit export', async function (assert) { + assert.deepEqual( + commandUrlToCodeRef( + '@cardstack/boxel-host/commands/show-card/execute', + undefined, + ), + { + module: '@cardstack/boxel-host/commands/show-card', + name: 'execute', + }, + ); + }); + + test('parses absolute /commands URL into realm code ref', async function (assert) { + assert.deepEqual( + commandUrlToCodeRef( + 'http://localhost:4200/commands/create-listing-pr/default', + 'http://localhost:4201/test/', + ), + { + module: 'http://localhost:4201/test/commands/create-listing-pr', + name: 'default', + }, + ); + }); + + test('parses absolute /commands URL without export into default export', async function (assert) { + assert.deepEqual( + commandUrlToCodeRef( + 'http://localhost:4200/commands/create-listing-pr', + 'http://localhost:4201/test/', + ), + { + module: 'http://localhost:4201/test/commands/create-listing-pr', + name: 'default', + }, + ); + }); + + test('rejects nested /commands paths', async function (assert) { + assert.strictEqual( + commandUrlToCodeRef( + 'http://localhost:4200/commands/../../admin/commands/dangerous/action', + 'http://localhost:4201/test/', + ), + undefined, + ); + }); + + test('rejects traversal-like command segments', async function (assert) { + assert.strictEqual( + commandUrlToCodeRef( + 'http://localhost:4200/commands/%2E%2E/default', + 'http://localhost:4201/test/', + ), + undefined, + ); + assert.strictEqual( + commandUrlToCodeRef( + 'http://localhost:4200/commands/create-listing-pr/%2E%2E', + 'http://localhost:4201/test/', + ), + undefined, + ); + }); + + test('rejects extra path segments beyond command and export', async function (assert) { + assert.strictEqual( + commandUrlToCodeRef( + 'http://localhost:4200/commands/create-listing-pr/default/extra', + 'http://localhost:4201/test/', + ), + undefined, + ); + }); + + test('returns undefined for unknown command formats', async function (assert) { + assert.strictEqual( + commandUrlToCodeRef( + 'https://example.com/not-commands/create-listing-pr', + 'http://localhost:4201/test/', + ), + undefined, + ); + }); +}); diff --git a/packages/runtime-common/worker.ts b/packages/runtime-common/worker.ts index b5144f59385..84458b79dd2 100644 --- a/packages/runtime-common/worker.ts +++ b/packages/runtime-common/worker.ts @@ -161,6 +161,7 @@ export class Worker { `daily-credit-grant`, Tasks['dailyCreditGrant'](taskArgs), ), + this.#queue.register(`run-command`, Tasks['runCommand'](taskArgs)), ]); await this.#queue.start(); }