From ac64abb4d03d906a9f69adcd0ed536bb6ac28506 Mon Sep 17 00:00:00 2001 From: tintinthong Date: Thu, 19 Feb 2026 21:12:38 +0800 Subject: [PATCH 01/35] temporarily add submission bot to newly created rooms --- packages/host/app/commands/create-ai-assistant-room.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/host/app/commands/create-ai-assistant-room.ts b/packages/host/app/commands/create-ai-assistant-room.ts index aa0faa03eab..3b15e27e974 100644 --- a/packages/host/app/commands/create-ai-assistant-room.ts +++ b/packages/host/app/commands/create-ai-assistant-room.ts @@ -54,6 +54,7 @@ export default class CreateAiAssistantRoomCommand extends HostBaseCommand< let { matrixService } = this; let userId = matrixService.userId; let aiBotFullId = matrixService.aiBotUserId; + let submissionBotFullId = matrixService.submissionBotUserId; if (!userId) { throw new Error( @@ -88,7 +89,7 @@ export default class CreateAiAssistantRoomCommand extends HostBaseCommand< const [roomResult, commandModule] = await Promise.all([ await matrixService.createRoom({ preset: matrixService.privateChatPreset, - invite: [aiBotFullId], + invite: [aiBotFullId, submissionBotFullId], name: input.name, room_alias_name: encodeURIComponent( `${input.name} - ${format( @@ -100,6 +101,7 @@ export default class CreateAiAssistantRoomCommand extends HostBaseCommand< users: { [userId]: 100, [aiBotFullId]: matrixService.aiBotPowerLevel, + [submissionBotFullId]: matrixService.aiBotPowerLevel, }, }, initial_state: [ From f55043a4311c95aa7ce1f61bda2f1e1fb18b9d86 Mon Sep 17 00:00:00 2001 From: tintinthong Date: Thu, 19 Feb 2026 21:12:58 +0800 Subject: [PATCH 02/35] build architecture to run in headless chrome --- packages/base/command.gts | 7 + packages/base/matrix-event.gts | 4 +- packages/host/app/commands/show-card.ts | 8 +- packages/host/app/router.ts | 1 + packages/host/app/routes/command-runner.ts | 189 ++++++++++++++++++ .../host/app/templates/command-runner.gts | 28 +++ .../host/tests/acceptance/commands-test.gts | 39 ++++ .../realm-server/prerender/manager-app.ts | 7 +- .../realm-server/prerender/prerender-app.ts | 142 +++++++++++++ .../realm-server/prerender/prerenderer.ts | 52 +++++ .../prerender/remote-prerenderer.ts | 24 ++- .../realm-server/prerender/render-runner.ts | 140 +++++++++++++ packages/realm-server/prerender/utils.ts | 27 ++- .../tests/definition-lookup-test.ts | 18 ++ .../tests/prerender-manager-test.ts | 90 ++++++++- .../tests/prerender-proxy-test.ts | 11 +- .../tests/prerender-server-test.ts | 72 +++++++ packages/runtime-common/bot-command.ts | 19 +- packages/runtime-common/bot-trigger.ts | 17 +- packages/runtime-common/code-ref.ts | 9 + packages/runtime-common/index.ts | 19 +- packages/runtime-common/jobs/run-command.ts | 21 ++ packages/runtime-common/matrix-constants.ts | 1 - packages/runtime-common/tasks/index.ts | 1 + packages/runtime-common/tasks/run-command.ts | 75 +++++++ packages/runtime-common/worker.ts | 1 + 26 files changed, 978 insertions(+), 44 deletions(-) create mode 100644 packages/host/app/routes/command-runner.ts create mode 100644 packages/host/app/templates/command-runner.gts create mode 100644 packages/runtime-common/jobs/run-command.ts create mode 100644 packages/runtime-common/tasks/run-command.ts diff --git a/packages/base/command.gts b/packages/base/command.gts index d79224ba871..6312221906f 100644 --- a/packages/base/command.gts +++ b/packages/base/command.gts @@ -385,6 +385,12 @@ export class CreateListingPRRequestInput extends CardDef { @field listingId = contains(StringField); } +export class CreateShowCardRequestInput extends CardDef { + @field roomId = contains(StringField); + @field cardId = contains(StringField); + @field format = contains(StringField); +} + export class ListingCreateInput extends CardDef { @field openCardId = contains(StringField); @field codeRef = contains(CodeRefField); @@ -434,6 +440,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/host/app/commands/show-card.ts b/packages/host/app/commands/show-card.ts index c45ff422f52..272a0f73214 100644 --- a/packages/host/app/commands/show-card.ts +++ b/packages/host/app/commands/show-card.ts @@ -13,7 +13,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,7 +35,7 @@ export default class ShowCardCommand extends HostBaseCommand< protected async run( input: BaseCommandModule.ShowCardInput, - ): Promise { + ): Promise { let { operatorModeStateService, store } = this; if (operatorModeStateService.workspaceChooserOpened) { operatorModeStateService.closeWorkspaceChooser(); @@ -50,6 +51,7 @@ export default class ShowCardCommand extends HostBaseCommand< (input.format as 'isolated' | 'edit') || 'isolated', ); operatorModeStateService.addItemToStack(newStackItem); + return await store.get(input.cardId); } else if (operatorModeStateService.state?.submode === 'code') { let cardInstance = await store.get(input.cardId); let cardDefRef = identifyCard( @@ -75,11 +77,13 @@ 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 store.get(input.cardId); } } } diff --git a/packages/host/app/router.ts b/packages/host/app/router.ts index 7712e81c2ee..1cabbd518df 100644 --- a/packages/host/app/router.ts +++ b/packages/host/app/router.ts @@ -18,6 +18,7 @@ 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/: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..39a00519b5b --- /dev/null +++ b/packages/host/app/routes/command-runner.ts @@ -0,0 +1,189 @@ +import { getOwner, setOwner } from '@ember/owner'; +import Route from '@ember/routing/route'; +import type RouterService from '@ember/routing/router-service'; +import type { PublicTransition } from '@ember/routing/transition'; +import { service } from '@ember/service'; +import { tracked } from '@glimmer/tracking'; + +import type { + Command, + CommandContext, + CommandInvocation, + CodeRef, +} from '@cardstack/runtime-common'; +import { + assertIsResolvedCodeRef, + CommandContextStamp, + getClass, + isResolvedCodeRef, +} 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 value: CardDef | null = null; + @tracked error: Error | null = null; + @tracked result: string | null = null; + @tracked commandRef: string | null = null; + @tracked commandInput: 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: { nonce: string }, + transition: PublicTransition, + ): CommandRunnerModel { + let model = new CommandRunState(params.nonce); + let queryParams = transition?.to?.queryParams ?? {}; + let command = parseResolvedCodeRef(getQueryParam(queryParams, 'command')); + let commandInput = parseJson(getQueryParam(queryParams, 'input')); + + if (command) { + model.commandRef = JSON.stringify(command); + } + if (commandInput) { + model.commandInput = JSON.stringify(commandInput); + } + + 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: CodeRef, + commandInput: Record | undefined, + ) { + try { + if (!isResolvedCodeRef(command)) { + throw new Error('Command must be a resolved code ref'); + } + 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.value = resultCard ?? null; + let serialized = resultCard + ? await this.cardService.serializeCard(resultCard) + : null; + model.result = 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 parseResolvedCodeRef(raw: string | undefined): CodeRef | undefined { + if (!raw) { + return undefined; + } + try { + let decoded = decodeURIComponent(raw); + let parsed = JSON.parse(decoded) as CodeRef; + assertIsResolvedCodeRef(parsed); + return parsed; + } catch { + return undefined; + } +} + +function parseJson( + raw: string | undefined, +): Record | undefined { + if (!raw) { + return undefined; + } + try { + let decoded = decodeURIComponent(raw); + return JSON.parse(decoded) as Record; + } catch { + return undefined; + } +} + +function getQueryParam( + queryParams: Record, + key: string, +): string | undefined { + let value = queryParams[key]; + return typeof value === 'string' ? value : undefined; +} diff --git a/packages/host/app/templates/command-runner.gts b/packages/host/app/templates/command-runner.gts new file mode 100644 index 00000000000..6069d1e525c --- /dev/null +++ b/packages/host/app/templates/command-runner.gts @@ -0,0 +1,28 @@ +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..e845c28e460 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'; @@ -251,6 +252,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 +411,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 +491,20 @@ module('Acceptance | Commands tests', function (hooks) { .includesText('Change the topic of the meeting to "Meeting with Hassan"'); }); + test('command-runner route renders command result card', async function (assert) { + let commandRef = { + module: `${testRealmURL}command-runner-hello-command`, + name: 'default', + }; + let commandParam = encodeURIComponent(JSON.stringify(commandRef)); + await visit(`/command-runner/1?command=${commandParam}`); + + await waitFor('[data-prerender][data-prerender-status="ready"]'); + assert + .dom('[data-test-command-runner-greeting]') + .includesText('Hello from command runner'); + }); + test('a scripted command can create a card, update it and show it', async function (assert) { await visitOperatorMode({ stacks: [ diff --git a/packages/realm-server/prerender/manager-app.ts b/packages/realm-server/prerender/manager-app.ts index f7fd75a3cd2..ee3a3549603 100644 --- a/packages/realm-server/prerender/manager-app.ts +++ b/packages/realm-server/prerender/manager-app.ts @@ -779,8 +779,10 @@ export function buildPrerenderManagerApp(options?: { attempts.add(target); const targetURL = `${normalizeURL(target)}/${pathSuffix}`; + let logTarget = + attrs.url ?? attrs.command?.module ?? 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 +915,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..c4cdcf2c4bc 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, @@ -337,6 +338,147 @@ export function buildPrerenderApp(options: { }, }); + router.post('/run-command', async (ctxt: Koa.Context) => { + try { + let request = await fetchRequestFromContext(ctxt); + let raw = await request.text(); + let body: any; + try { + body = raw ? JSON.parse(raw) : {}; + } catch (e) { + ctxt.status = 400; + ctxt.body = { + errors: [{ status: 400, message: 'Invalid JSON body' }], + }; + return; + } + + let attrs = body?.data?.attributes ?? {}; + let rawRealm = attrs.realm; + let rawAuth = attrs.auth; + let command = attrs.command; + let commandInput = attrs.commandInput; + + let isNonEmptyString = (value: unknown): value is string => + typeof value === 'string' && value.trim().length > 0; + + let missing: string[] = []; + if (!isNonEmptyString(rawRealm)) missing.push('realm'); + if (!isNonEmptyString(rawAuth)) missing.push('auth'); + if (!command) missing.push('command'); + + log.debug( + `received command-runner ${command?.module ?? command?.name ?? ''}: realm=${rawRealm}`, + ); + if (missing.length > 0) { + log.warn( + 'Rejecting command-runner due to missing attributes (%s); realm=%s authProvided=%s commandProvided=%s', + missing.join(', '), + (rawRealm as string | undefined) ?? '', + typeof rawAuth === 'string' && rawAuth.trim().length > 0, + Boolean(command), + ); + ctxt.status = 400; + ctxt.body = { + errors: [ + { + status: 400, + message: + 'Missing or invalid required attributes: realm, auth, command', + }, + ], + }; + return; + } + + let realm = rawRealm as string; + let auth = rawAuth as string; + + let start = Date.now(); + let execPromise = prerenderer + .runCommand({ + realm, + auth, + command, + commandInput, + }) + .then((result) => ({ result })); + let drainPromise = options.drainingPromise + ? options.drainingPromise.then(() => ({ draining: true as const })) + : null; + let raceResult = drainPromise + ? await Promise.race([execPromise, drainPromise]) + : await execPromise; + if ('draining' in raceResult) { + execPromise.catch((e) => + log.debug('command-runner settled after drain (ignored):', e), + ); + ctxt.status = PRERENDER_SERVER_DRAINING_STATUS_CODE; + ctxt.set( + PRERENDER_SERVER_STATUS_HEADER, + PRERENDER_SERVER_STATUS_DRAINING, + ); + ctxt.body = { + errors: [ + { + status: PRERENDER_SERVER_DRAINING_STATUS_CODE, + message: 'Prerender server draining', + }, + ], + }; + return; + } + let { response, timings, pool } = raceResult.result; + let totalMs = Date.now() - start; + let poolFlags = Object.entries({ + reused: pool.reused, + evicted: pool.evicted, + timedOut: pool.timedOut, + }) + .filter(([, value]) => value === true) + .map(([key]) => key) + .join(', '); + let poolFlagSuffix = poolFlags.length > 0 ? ` flags=[${poolFlags}]` : ''; + log.info( + 'command-runner total=%dms launch=%dms render=%dms pageId=%s realm=%s%s', + totalMs, + timings.launchMs, + timings.renderMs, + pool.pageId, + pool.realm, + poolFlagSuffix, + ); + ctxt.status = 201; + ctxt.set('Content-Type', 'application/vnd.api+json'); + ctxt.body = { + data: { + type: 'command-result', + id: command?.module ?? 'command', + attributes: response as RunCommandResponse, + }, + meta: { + timing: { + launchMs: timings.launchMs, + renderMs: timings.renderMs, + totalMs, + }, + pool, + }, + }; + } catch (err) { + log.error('Error running command in prerender server:', err); + ctxt.status = 500; + ctxt.body = { + errors: [ + { + status: 500, + message: 'Error running command', + }, + ], + }; + } + }); + // 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..c5ba9ed5b61 100644 --- a/packages/realm-server/prerender/prerenderer.ts +++ b/packages/realm-server/prerender/prerenderer.ts @@ -6,6 +6,8 @@ import { type FileRenderResponse, type FileRenderArgs, logger, + type RunCommandResponse, + type ResolvedCodeRef, } from '@cardstack/runtime-common'; import { BrowserManager } from './browser-manager'; import { PagePool } from './page-pool'; @@ -339,6 +341,56 @@ export class Prerenderer { throw new Error(`module prerender attempts exhausted for ${url}`); } + async runCommand({ + realm, + auth, + command, + commandInput, + opts, + }: { + realm: string; + auth: string; + command: ResolvedCodeRef; + 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}) with error, restarting browser`, + e, + ); + await this.#restartBrowser(); + return await this.#renderRunner.runCommandAttempt({ + realm, + auth, + command, + commandInput, + opts, + }); + } + } + async prerenderFileExtract({ realm, url, diff --git a/packages/realm-server/prerender/remote-prerenderer.ts b/packages/realm-server/prerender/remote-prerenderer.ts index c26af651994..c63c1bdac66 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,18 @@ 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' && !attrs.command) { + 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..8f88cc7ba04 100644 --- a/packages/realm-server/prerender/render-runner.ts +++ b/packages/realm-server/prerender/render-runner.ts @@ -7,6 +7,8 @@ import { type FileRenderResponse, type FileRenderArgs, type RenderRouteOptions, + type RunCommandResponse, + type ResolvedCodeRef, serializeRenderRouteOptions, logger, } from '@cardstack/runtime-common'; @@ -27,6 +29,7 @@ import { type FileExtractCapture, withTimeout, transitionTo, + buildCommandRunnerURL, buildInvalidModuleResponseError, buildInvalidFileExtractResponseError, } from './utils'; @@ -133,6 +136,10 @@ export class RenderRunner { await page.evaluate((sessionAuth) => { localStorage.setItem('boxel-session', sessionAuth); }, auth); + log.info( + 'command-runner session set: %s', + await page.evaluate(() => localStorage.getItem('boxel-session')), + ); let renderStart = Date.now(); let error: RenderError | undefined; @@ -386,6 +393,139 @@ export class RenderRunner { } } + async runCommandAttempt({ + realm, + auth, + command, + commandInput, + opts, + }: { + realm: string; + auth: string; + command: ResolvedCodeRef; + 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?.module ?? command?.name ?? ''}, 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(JSON.stringify(command)); + let encodedInput = + commandInput != null + ? encodeURIComponent(JSON.stringify(commandInput)) + : undefined; + await transitionTo(page, 'command-runner', String(this.#nonce), { + queryParams: { + command: encodedCommand, + ...(encodedInput ? { input: encodedInput } : {}), + }, + }); + log.info( + 'command-runner url: %s', + buildCommandRunnerURL( + page, + String(this.#nonce), + encodedCommand, + encodedInput, + ), + ); + + await withTimeout( + page, + async () => { + return await captureResult(page, 'textContent', { + simulateTimeoutMs: opts?.simulateTimeoutMs, + }); + }, + opts?.timeoutMs, + ); + + let payload = await page.evaluate(() => { + let container = document.querySelector( + '[data-prerender]', + ) as HTMLElement | null; + let status = + (container?.dataset.prerenderStatus as + | 'ready' + | 'error' + | 'unusable' + | undefined) ?? 'error'; + let errorElement = container?.querySelector( + '[data-prerender-error]', + ) as HTMLElement | null; + let resultElement = container?.querySelector( + '[data-command-result]', + ) as HTMLElement | null; + let error = (errorElement?.textContent ?? '').trim(); + let result = (resultElement?.textContent ?? '').trim(); + return { + status, + error: error.length > 0 ? error : null, + result: result.length > 0 ? result : null, + }; + }); + + let response: RunCommandResponse = { + status: payload.status, + result: payload.result ?? 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..38dda5f85d5 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,25 @@ 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 { + // best effort; fall back to raw page url + } + let url = `${origin}/command-runner/${encodeURIComponent(nonce)}?command=${encodedCommand}`; + if (encodedInput) { + url += `&input=${encodedInput}`; + } + return url; +} + 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..21024b8bead 100644 --- a/packages/realm-server/tests/prerender-manager-test.ts +++ b/packages/realm-server/tests/prerender-manager-test.ts @@ -283,6 +283,46 @@ 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 = { + module: `${realm}/commands/say-hello`, + name: '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.module, + '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 +1377,7 @@ function makeMockPrerender(): { responder: ( ctxt: Koa.Context, body: any, - type: 'card' | 'module', + type: 'card' | 'module' | 'command', ) => Promise | void, ) => void; } { @@ -1350,7 +1390,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 +1402,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 +1423,7 @@ function makeMockPrerender(): { }, }, }); - } else { + } else if (type === 'module') { ctxt.body = JSON.stringify({ data: { type: 'prerender-module-result', @@ -1409,6 +1449,26 @@ function makeMockPrerender(): { }, }, }); + } else { + ctxt.body = JSON.stringify({ + data: { + type: 'command-result', + id: body?.data?.attributes?.command?.module || 'command', + attributes: { + status: 'ready', + result: 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 +1481,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 +1533,23 @@ function makeModuleBody(realm: string, url: string) { }; } +function makeCommandBody( + realm: string, + command: { module: string; name?: 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..676c7559264 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', + result: 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..6d990990e16 100644 --- a/packages/realm-server/tests/prerender-server-test.ts +++ b/packages/realm-server/tests/prerender-server-test.ts @@ -44,6 +44,33 @@ 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' }); + } + } + `, }, }); @@ -194,6 +221,51 @@ module(basename(__filename), function () { assert.ok(res.body.meta?.pool?.pageId, 'has pool.pageId'); }); + 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 = { + module: `${realmURL.href}command-runner-test`, + name: '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.module, + 'id is command module', + ); + assert.strictEqual( + res.body.data.attributes.status, + 'ready', + 'command status ready', + ); + assert.notOk(res.body.data.attributes.error, 'no 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/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/index.ts b/packages/runtime-common/index.ts index ccf9faa6ee0..7b23bd3b71f 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: ResolvedCodeRef; + commandInput?: Record | null; +}; + +export type RunCommandResponse = { + status: 'ready' | 'error' | 'unusable'; + result?: 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'; @@ -279,9 +293,6 @@ export type { RealmSession, } from './realm'; -import type { CodeRef } from './code-ref'; -export type { CodeRef }; - export * from './code-ref'; export * from './serializers'; 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..1739c646ce1 --- /dev/null +++ b/packages/runtime-common/tasks/run-command.ts @@ -0,0 +1,75 @@ +import type * as JSONTypes from 'json-typescript'; + +import type { Task } from './index'; + +import { + type ResolvedCodeRef, + fetchRealmPermissions, + jobIdentity, + type RunCommandResponse, + ensureFullMatrixUserId, + ensureTrailingSlash, +} from '../index'; + +export interface RunCommandArgs extends JSONTypes.Object { + realmURL: string; + realmUsername: string; + runAs: string; + command: ResolvedCodeRef; + commandInput?: Record | 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 result = await prerenderer.runCommand({ + realm: normalizedRealmURL, + auth, + command, + commandInput: commandInput ?? undefined, + }); + + reportStatus(jobInfo, 'finish'); + return result; + }; 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(); } From a284ca539ee3f6a9a401190d8e74ae6473a5d965 Mon Sep 17 00:00:00 2001 From: tintinthong Date: Thu, 19 Feb 2026 21:13:23 +0800 Subject: [PATCH 03/35] update bot runner --- packages/bot-runner/lib/timeline-handler.ts | 189 +++++++++++++++++++- packages/bot-runner/main.ts | 1 + 2 files changed, 183 insertions(+), 7 deletions(-) diff --git a/packages/bot-runner/lib/timeline-handler.ts b/packages/bot-runner/lib/timeline-handler.ts index 94074715158..d02bed6d579 100644 --- a/packages/bot-runner/lib/timeline-handler.ts +++ b/packages/bot-runner/lib/timeline-handler.ts @@ -1,6 +1,21 @@ -import { isBotTriggerEvent, logger, param, query } from '@cardstack/runtime-common'; +import { + isBotTriggerEvent, + isBotCommandFilter, + assertIsResolvedCodeRef, + logger, + param, + query, + userInitiatedPriority, + ensureTrailingSlash, +} 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 { ResolvedCodeRef } from '@cardstack/runtime-common'; import type { MatrixEvent, Room } from 'matrix-js-sdk'; const log = logger('bot-runner'); @@ -13,11 +28,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 +51,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 +66,7 @@ export function onTimelineEvent({ ); let submissionBotRegistrations = await getRegistrationsForUser( dbAdapter, - submissionBotUsername, + submissionBotUserId, ); if (!registrations.length && !submissionBotRegistrations.length) { return; @@ -67,6 +87,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 +113,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 +132,143 @@ 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 commandURL = commandRegistration?.command; + let command = commandURL + ? commandUrlToCodeRef(commandURL, realmURL) + : undefined; + let commandInput: Record | null = input; + + if (!realmURL || !commandURL || !command) { + log.warn( + 'bot trigger missing required input for command (need realmURL and command)', + { realmURL, commandURL, command }, + ); + return; + } + + assertIsResolvedCodeRef(command); + + 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; +} + +function commandUrlToCodeRef( + commandUrl: string, + realmURL: string | undefined, +): ResolvedCodeRef | undefined { + if (!commandUrl) { + return undefined; + } + + try { + let url = new URL(commandUrl); + let path = url.pathname; + + // TODO: boxel-host commands are not exposed internally as HTTP URLs; they + // are only available via module specifiers, so we map those URLs to code refs. + let boxelHostPrefix = '/boxel-host/commands/'; + if (path.includes(boxelHostPrefix)) { + let rest = path.split(boxelHostPrefix)[1] ?? ''; + let [commandName, exportName = 'default'] = rest.split('/'); + if (!commandName) { + return undefined; + } + return { + module: `@cardstack/boxel-host/commands/${commandName}`, + name: exportName || 'default', + }; + } + + let commandsPrefix = '/commands/'; + if (path.includes(commandsPrefix)) { + if (!realmURL) { + return undefined; + } + let rest = path.split(commandsPrefix)[1] ?? ''; + let [commandName, exportName = 'default'] = rest.split('/'); + if (!commandName) { + return undefined; + } + return { + module: `${ensureTrailingSlash(realmURL)}commands/${commandName}`, + name: exportName || 'default', + }; + } + } catch { + // ignore invalid URLs + } + + return undefined; +} + async function getRegistrationsForUser( dbAdapter: DBAdapter, username: string, @@ -121,13 +296,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); From 55753c5561fd316cc8accef973d18f045233040b Mon Sep 17 00:00:00 2001 From: tintinthong Date: Thu, 19 Feb 2026 21:13:29 +0800 Subject: [PATCH 04/35] update setup-submissions --- .../matrix/scripts/setup-submission-bot.ts | 67 +++++++++++++++---- 1 file changed, 54 insertions(+), 13 deletions(-) diff --git a/packages/matrix/scripts/setup-submission-bot.ts b/packages/matrix/scripts/setup-submission-bot.ts index 8898dfc20ba..2f16f00c085 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: `${realmServerURL}/commands/create-listing-pr/default`, + filter: { + type: 'matrix-event', + event_type: 'app.boxel.bot-trigger', + content_type: 'create-listing-pr', + }, + }, + { + name: 'show-card', + commandURL: `${realmServerURL}/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: `${realmServerURL}/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,22 @@ async function fetchBotCommands(jwt: string, botId?: string) { return json?.data ?? []; } -async function ensureBotCommandId(jwt: string, botId: string) { +async function ensureBotCommandId( + jwt: string, + botId: string, + command: (typeof botCommands)[number], +) { const commands = await fetchBotCommands(jwt, botId); - const existing = commands[0]; + const existing = commands.find((entry: any) => { + return ( + entry?.attributes?.command === command.commandURL && + entry?.attributes?.filter?.content_type === command.filter.content_type + ); + }); 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 +168,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); From c2e12025df45bde502456363f1abec41ec5ad20f Mon Sep 17 00:00:00 2001 From: tintinthong Date: Thu, 19 Feb 2026 21:13:47 +0800 Subject: [PATCH 05/35] add test --- packages/bot-runner/tests/bot-runner-test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/bot-runner/tests/bot-runner-test.ts b/packages/bot-runner/tests/bot-runner-test.ts index 157c469f479..23765fb15dd 100644 --- a/packages/bot-runner/tests/bot-runner-test.ts +++ b/packages/bot-runner/tests/bot-runner-test.ts @@ -25,6 +25,7 @@ function makeBotTriggerEvent( content: { type: 'create-listing-pr', input: {}, + realm: 'http://localhost:4201/test/', }, }, getSender: () => sender, From 019702314a0fadc1ff8c8548d4fc4cb6cf1b9a55 Mon Sep 17 00:00:00 2001 From: tintinthong Date: Thu, 19 Feb 2026 21:14:04 +0800 Subject: [PATCH 06/35] add command requests in experiments --- .../create-patch-card-instance-request.ts | 92 +++++++++++++++++++ .../commands/create-show-card-request.ts | 88 ++++++++++++++++++ .../create-listing-pr-request.ts | 10 +- .../bot-requests/create-show-card-request.ts | 86 +++++++++++++++++ .../send-bot-trigger-event.ts | 9 +- packages/host/app/commands/index.ts | 10 +- .../commands/send-bot-trigger-event-test.gts | 24 +++-- 7 files changed, 299 insertions(+), 20 deletions(-) create mode 100644 packages/experiments-realm/commands/create-patch-card-instance-request.ts create mode 100644 packages/experiments-realm/commands/create-show-card-request.ts rename packages/host/app/commands/{ => bot-requests}/create-listing-pr-request.ts (89%) create mode 100644 packages/host/app/commands/bot-requests/create-show-card-request.ts rename packages/host/app/commands/{ => bot-requests}/send-bot-trigger-event.ts (80%) 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..7409cd69542 --- /dev/null +++ b/packages/experiments-realm/commands/create-patch-card-instance-request.ts @@ -0,0 +1,92 @@ +import { Command } from '@cardstack/runtime-common'; + +import { PatchCardInput } from 'https://cardstack.com/base/command'; + +import UseAiAssistantCommand from '@cardstack/boxel-host/commands/ai-assistant'; +import InviteUserToRoomCommand from '@cardstack/boxel-host/commands/invite-user-to-room'; +import SendBotTriggerEventCommand from '@cardstack/boxel-host/commands/send-bot-trigger-event'; + +export default class CreatePatchCardInstanceRequestCommand extends Command< + typeof PatchCardInput, + undefined +> { + description = 'Request patching a card instance via the bot runner.'; + + async getInputType() { + return PatchCardInput; + } + + protected async run(input: PatchCardInput): Promise { + let cardId = input.cardId?.trim(); + if (!cardId) { + throw new Error('cardId 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: realmURLFromCardId(cardId), + type: 'patch-card-instance', + input: { + cardId, + patch: input.patch, + roomId, + }, + }); + } +} + +async function ensureSubmissionBotIsInRoom( + commandContext: CreatePatchCardInstanceRequestCommand['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; + } + } +} + +function realmURLFromCardId(cardId: string): string { + let url: URL; + try { + url = new URL(cardId); + } catch { + throw new Error('cardId must be an absolute URL'); + } + + let pathSegments = url.pathname.split('/').filter(Boolean); + if (pathSegments.length < 2) { + throw new Error('cardId must include card type and card slug'); + } + + let realmSegments = pathSegments.slice(0, -2); + url.pathname = `/${realmSegments.join('/')}${realmSegments.length ? '/' : ''}`; + url.search = ''; + url.hash = ''; + return url.href; +} 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..c6107167057 --- /dev/null +++ b/packages/experiments-realm/commands/create-show-card-request.ts @@ -0,0 +1,88 @@ +import { Command } from '@cardstack/runtime-common'; + +import { CreateShowCardRequestInput } from 'https://cardstack.com/base/command'; + +import UseAiAssistantCommand from '@cardstack/boxel-host/commands/ai-assistant'; +import InviteUserToRoomCommand from '@cardstack/boxel-host/commands/invite-user-to-room'; +import SendBotTriggerEventCommand from '@cardstack/boxel-host/commands/send-bot-trigger-event'; + +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(); + if (!cardId) { + throw new Error('cardId is required'); + } + + let roomId = input.roomId?.trim(); + if (!roomId) { + let createRoomResult = await new UseAiAssistantCommand( + this.commandContext, + ).execute({ + roomId: 'new', + roomName: `Show Card: ${cardId}`, + openRoom: true, + }); + roomId = createRoomResult.roomId; + } + + await ensureSubmissionBotIsInRoom(this.commandContext, roomId); + + await new SendBotTriggerEventCommand(this.commandContext).execute({ + roomId, + realm: realmURLFromCardId(cardId), + type: 'show-card', + input: { + cardId, + format: input.format?.trim() || 'isolated', + }, + }); + } +} + +async function ensureSubmissionBotIsInRoom( + commandContext: CreateShowCardRequestCommand['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; + } + } +} + +function realmURLFromCardId(cardId: string): string { + let url: URL; + try { + url = new URL(cardId); + } catch { + throw new Error('cardId must be an absolute URL'); + } + + let pathSegments = url.pathname.split('/').filter(Boolean); + if (pathSegments.length < 2) { + throw new Error('cardId must include card type and card slug'); + } + + let realmSegments = pathSegments.slice(0, -2); + url.pathname = `/${realmSegments.join('/')}${realmSegments.length ? '/' : ''}`; + url.search = ''; + url.hash = ''; + return url.href; +} 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/bot-requests/create-show-card-request.ts b/packages/host/app/commands/bot-requests/create-show-card-request.ts new file mode 100644 index 00000000000..b5fa39084c8 --- /dev/null +++ b/packages/host/app/commands/bot-requests/create-show-card-request.ts @@ -0,0 +1,86 @@ +import { service } from '@ember/service'; + +import { isCardInstance, realmURL } from '@cardstack/runtime-common'; + +import type { CardDef } from 'https://cardstack.com/base/card-api'; +import type * as BaseCommandModule from 'https://cardstack.com/base/command'; + +import HostBaseCommand from '../../lib/host-base-command'; + +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'; + +export default class CreateShowCardRequestCommand extends HostBaseCommand< + typeof BaseCommandModule.CreateShowCardRequestInput +> { + // TODO: remove this command once the temporary card menu item is removed. + @service declare private matrixService: MatrixService; + @service declare private store: StoreService; + + description = 'Request showing a card via the bot runner.'; + + async getInputType() { + let commandModule = await this.loadCommandModule(); + const { CreateShowCardRequestInput } = commandModule; + return CreateShowCardRequestInput; + } + + requireInputFields = ['cardId']; + + protected async run( + input: BaseCommandModule.CreateShowCardRequestInput, + ): Promise { + await this.matrixService.ready; + + let { cardId, format, roomId } = input; + let cardTitle: string | undefined; + let targetRealm: string | undefined; + + if (!roomId) { + let card = await this.store.get(cardId); + if (card && isCardInstance(card)) { + cardTitle = card.cardTitle ?? card.id; + targetRealm = card[realmURL]?.href; + } + let useAiAssistantCommand = new UseAiAssistantCommand( + this.commandContext, + ); + let createRoomResult = await useAiAssistantCommand.execute({ + roomId: 'new', + roomName: `Rename Card: ${cardTitle ?? cardId ?? 'Card'}`, + openRoom: true, + }); + roomId = createRoomResult.roomId; + } + + let submissionBotId = this.matrixService.submissionBotUserId; + if (!(await this.matrixService.isUserInRoom(roomId, submissionBotId))) { + await this.matrixService.inviteUserToRoom(roomId, submissionBotId); + } + + if (!targetRealm) { + let card = await this.store.get(cardId); + if (card && isCardInstance(card)) { + targetRealm = card[realmURL]?.href; + } + } + + if (!targetRealm) { + throw new Error('Realm URL is required to request show card'); + } + + await new SendBotTriggerEventCommand(this.commandContext).execute({ + roomId, + realm: targetRealm, + type: 'show-card', + input: { + cardId, + format, + }, + }); + } +} 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..ab51a57290b 100644 --- a/packages/host/app/commands/index.ts +++ b/packages/host/app/commands/index.ts @@ -5,6 +5,9 @@ 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 CreateShowCardRequestCommandModule from './bot-requests/create-show-card-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 +15,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 +52,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'; @@ -108,6 +109,10 @@ export function shimHostCommands(virtualNetwork: VirtualNetwork) { '@cardstack/boxel-host/commands/create-specs', CreateSpecCommandModule, ); + virtualNetwork.shimModule( + '@cardstack/boxel-host/commands/create-show-card-request', + CreateShowCardRequestCommandModule, + ); virtualNetwork.shimModule( '@cardstack/boxel-host/commands/check-correctness', CheckCorrectnessCommandModule, @@ -357,6 +362,7 @@ export const HostCommandClasses: (typeof HostBaseCommand)[] = [ ListingRemixCommandModule.default, CreateListingPRCommandModule.default, CreateListingPRRequestCommandModule.default, + CreateShowCardRequestCommandModule.default, ListingUpdateSpecsCommandModule.default, ListingUseCommandModule.default, OneShotLlmRequestCommandModule.default, 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..66a19cec40e 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,16 +61,18 @@ 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) { + test('allows custom trigger types', async function (assert) { let roomId = createAndJoinRoom({ sender: '@testuser:localhost', name: 'room-test', @@ -78,13 +80,15 @@ module('Integration | commands | send-bot-trigger-event', function (hooks) { 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/, - ); + await command.execute({ + roomId, + type: 'not-a-real-command', + realm: testRealmURL, + input: {}, + }); + + let event = getRoomEvents(roomId).pop()!; + assert.ok(isBotTriggerEvent(event)); + assert.strictEqual(event.content.type, 'not-a-real-command'); }); }); From 16cc21ac3d3ede80c0b163751dc7f6095bee90b6 Mon Sep 17 00:00:00 2001 From: tintinthong Date: Thu, 19 Feb 2026 21:14:22 +0800 Subject: [PATCH 07/35] introduce bot-request-demo in experiments harness --- .../BotRequestDemo/bot-request-demo.json | 31 ++ .../experiments-realm/bot-request-demo.gts | 513 ++++++++++++++++++ 2 files changed, 544 insertions(+) create mode 100644 packages/experiments-realm/BotRequestDemo/bot-request-demo.json create mode 100644 packages/experiments-realm/bot-request-demo.gts 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..517c63afe43 --- /dev/null +++ b/packages/experiments-realm/bot-request-demo.gts @@ -0,0 +1,513 @@ +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 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, + }; + } + if (this.isPatchCardInstanceTab) { + return { + cardId: this.showCardId, + patch: this.patchCardPatch, + }; + } + + 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.experimentsRealmURL, + 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.experimentsRealmURL, + 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 new URL('/experiments/commands/show-card/default', this.showCardId) + .href; + } + if (this.isPatchCardInstanceTab) { + return new URL( + '/boxel-host/commands/patch-card-instance/default', + this.showCardId, + ).href; + } + + return new URL('/commands/create-listing-pr/default', this.showCardId).href; + } + + 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: new URL( + './commands/create-listing-pr', + this.createListingPRTargetRealm, + ).href, + 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(JSON.stringify(this.codeRef)); + let encodedInput = encodeURIComponent( + JSON.stringify(this.activeCommandInput), + ); + return `${hostOrigin}/command-runner/${encodeURIComponent(nonce)}?command=${encodedCommand}&input=${encodedInput}`; + } + + 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, + }); + } + + @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, + }); + } + + @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; +} From 4c10b2fe97166c304174e477e6b197d9812869db Mon Sep 17 00:00:00 2001 From: tintinthong Date: Thu, 19 Feb 2026 22:01:18 +0800 Subject: [PATCH 08/35] fix lint --- packages/bot-runner/tests/bot-runner-test.ts | 8 ++++++++ packages/runtime-common/tasks/run-command.ts | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/packages/bot-runner/tests/bot-runner-test.ts b/packages/bot-runner/tests/bot-runner-test.ts index 23765fb15dd..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, @@ -95,6 +96,7 @@ module('timeline handler', () => { | ((sql: string, opts?: ExecuteOptions) => void) | undefined; let dbAdapter: DBAdapter; + let queuePublisher: QueuePublisher; let handleTimelineEvent: ReturnType; dbAdapter = { @@ -108,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/runtime-common/tasks/run-command.ts b/packages/runtime-common/tasks/run-command.ts index 1739c646ce1..fda1a8deff6 100644 --- a/packages/runtime-common/tasks/run-command.ts +++ b/packages/runtime-common/tasks/run-command.ts @@ -16,7 +16,7 @@ export interface RunCommandArgs extends JSONTypes.Object { realmUsername: string; runAs: string; command: ResolvedCodeRef; - commandInput?: Record | null; + commandInput: JSONTypes.Object | null; } export { runCommand }; From 00585a4d794fbf57123ba2b73a04b7770c2f835c Mon Sep 17 00:00:00 2001 From: tintinthong Date: Thu, 19 Feb 2026 22:03:26 +0800 Subject: [PATCH 09/35] update log naming --- packages/realm-server/prerender/render-runner.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/realm-server/prerender/render-runner.ts b/packages/realm-server/prerender/render-runner.ts index 8f88cc7ba04..501a2ff7c3f 100644 --- a/packages/realm-server/prerender/render-runner.ts +++ b/packages/realm-server/prerender/render-runner.ts @@ -137,7 +137,7 @@ export class RenderRunner { localStorage.setItem('boxel-session', sessionAuth); }, auth); log.info( - 'command-runner session set: %s', + 'prerender session set: %s', await page.evaluate(() => localStorage.getItem('boxel-session')), ); From bc38e7e1e622fe6c8655be46b0c97465a2238317 Mon Sep 17 00:00:00 2001 From: tintinthong Date: Thu, 19 Feb 2026 22:18:05 +0800 Subject: [PATCH 10/35] return a serialized result no matter what --- packages/host/app/templates/command-runner.gts | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/packages/host/app/templates/command-runner.gts b/packages/host/app/templates/command-runner.gts index 6069d1e525c..3b065336105 100644 --- a/packages/host/app/templates/command-runner.gts +++ b/packages/host/app/templates/command-runner.gts @@ -17,10 +17,15 @@ const CommandRunner = satisfies TemplateOnlyComponent<{ model: CommandRunnerModel }>; From f8eb80262438f5b955661d7a5646f3eb79047020 Mon Sep 17 00:00:00 2001 From: tintinthong Date: Thu, 19 Feb 2026 22:21:10 +0800 Subject: [PATCH 11/35] refactor bot-request-utils --- .../commands/bot-request-utils.ts | 42 ++++++++++++++++++ .../create-patch-card-instance-request.ts | 44 ++----------------- .../commands/create-show-card-request.ts | 44 ++----------------- 3 files changed, 50 insertions(+), 80 deletions(-) create mode 100644 packages/experiments-realm/commands/bot-request-utils.ts 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..6ee226fc973 --- /dev/null +++ b/packages/experiments-realm/commands/bot-request-utils.ts @@ -0,0 +1,42 @@ +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; + } + } +} + +export function realmURLFromCardId(cardId: string): string { + let url: URL; + try { + url = new URL(cardId); + } catch { + throw new Error('cardId must be an absolute URL'); + } + + let pathSegments = url.pathname.split('/').filter(Boolean); + if (pathSegments.length < 2) { + throw new Error('cardId must include card type and card slug'); + } + + let realmSegments = pathSegments.slice(0, -2); + url.pathname = `/${realmSegments.join('/')}${realmSegments.length ? '/' : ''}`; + url.search = ''; + url.hash = ''; + return url.href; +} diff --git a/packages/experiments-realm/commands/create-patch-card-instance-request.ts b/packages/experiments-realm/commands/create-patch-card-instance-request.ts index 7409cd69542..1b822dfe401 100644 --- a/packages/experiments-realm/commands/create-patch-card-instance-request.ts +++ b/packages/experiments-realm/commands/create-patch-card-instance-request.ts @@ -3,8 +3,11 @@ import { Command } from '@cardstack/runtime-common'; import { PatchCardInput } from 'https://cardstack.com/base/command'; import UseAiAssistantCommand from '@cardstack/boxel-host/commands/ai-assistant'; -import InviteUserToRoomCommand from '@cardstack/boxel-host/commands/invite-user-to-room'; import SendBotTriggerEventCommand from '@cardstack/boxel-host/commands/send-bot-trigger-event'; +import { + ensureSubmissionBotIsInRoom, + realmURLFromCardId, +} from './bot-request-utils'; export default class CreatePatchCardInstanceRequestCommand extends Command< typeof PatchCardInput, @@ -51,42 +54,3 @@ export default class CreatePatchCardInstanceRequestCommand extends Command< }); } } - -async function ensureSubmissionBotIsInRoom( - commandContext: CreatePatchCardInstanceRequestCommand['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; - } - } -} - -function realmURLFromCardId(cardId: string): string { - let url: URL; - try { - url = new URL(cardId); - } catch { - throw new Error('cardId must be an absolute URL'); - } - - let pathSegments = url.pathname.split('/').filter(Boolean); - if (pathSegments.length < 2) { - throw new Error('cardId must include card type and card slug'); - } - - let realmSegments = pathSegments.slice(0, -2); - url.pathname = `/${realmSegments.join('/')}${realmSegments.length ? '/' : ''}`; - url.search = ''; - url.hash = ''; - return url.href; -} diff --git a/packages/experiments-realm/commands/create-show-card-request.ts b/packages/experiments-realm/commands/create-show-card-request.ts index c6107167057..fd30d94f547 100644 --- a/packages/experiments-realm/commands/create-show-card-request.ts +++ b/packages/experiments-realm/commands/create-show-card-request.ts @@ -3,8 +3,11 @@ import { Command } from '@cardstack/runtime-common'; import { CreateShowCardRequestInput } from 'https://cardstack.com/base/command'; import UseAiAssistantCommand from '@cardstack/boxel-host/commands/ai-assistant'; -import InviteUserToRoomCommand from '@cardstack/boxel-host/commands/invite-user-to-room'; import SendBotTriggerEventCommand from '@cardstack/boxel-host/commands/send-bot-trigger-event'; +import { + ensureSubmissionBotIsInRoom, + realmURLFromCardId, +} from './bot-request-utils'; export default class CreateShowCardRequestCommand extends Command< typeof CreateShowCardRequestInput, @@ -47,42 +50,3 @@ export default class CreateShowCardRequestCommand extends Command< }); } } - -async function ensureSubmissionBotIsInRoom( - commandContext: CreateShowCardRequestCommand['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; - } - } -} - -function realmURLFromCardId(cardId: string): string { - let url: URL; - try { - url = new URL(cardId); - } catch { - throw new Error('cardId must be an absolute URL'); - } - - let pathSegments = url.pathname.split('/').filter(Boolean); - if (pathSegments.length < 2) { - throw new Error('cardId must include card type and card slug'); - } - - let realmSegments = pathSegments.slice(0, -2); - url.pathname = `/${realmSegments.join('/')}${realmSegments.length ? '/' : ''}`; - url.search = ''; - url.hash = ''; - return url.href; -} From ecd9392cbf643e33bae8767f33b43e80a4f728af Mon Sep 17 00:00:00 2001 From: tintinthong Date: Thu, 19 Feb 2026 22:26:29 +0800 Subject: [PATCH 12/35] fix nonce scope --- .../realm-server/prerender/render-runner.ts | 63 ++++++++++++++++--- 1 file changed, 54 insertions(+), 9 deletions(-) diff --git a/packages/realm-server/prerender/render-runner.ts b/packages/realm-server/prerender/render-runner.ts index 501a2ff7c3f..f5b36465d48 100644 --- a/packages/realm-server/prerender/render-runner.ts +++ b/packages/realm-server/prerender/render-runner.ts @@ -463,20 +463,65 @@ export class RenderRunner { ), ); - await withTimeout( + let waitResult = await withTimeout( page, async () => { - return await captureResult(page, 'textContent', { - simulateTimeoutMs: opts?.simulateTimeoutMs, - }); + 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, ); - let payload = await page.evaluate(() => { - let container = document.querySelector( - '[data-prerender]', - ) as HTMLElement | null; + 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' @@ -496,7 +541,7 @@ export class RenderRunner { error: error.length > 0 ? error : null, result: result.length > 0 ? result : null, }; - }); + }, String(this.#nonce)); let response: RunCommandResponse = { status: payload.status, From cb54b80ac1e59443a4767211fee66097b939ebcc Mon Sep 17 00:00:00 2001 From: tintinthong Date: Thu, 19 Feb 2026 22:42:42 +0800 Subject: [PATCH 13/35] fix lint --- packages/host/app/commands/show-card.ts | 22 ++++++++++++++----- .../host/app/components/card-prerender.gts | 10 +++++++++ packages/host/app/routes/command-runner.ts | 7 ++---- 3 files changed, 29 insertions(+), 10 deletions(-) diff --git a/packages/host/app/commands/show-card.ts b/packages/host/app/commands/show-card.ts index 272a0f73214..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'; @@ -36,7 +40,7 @@ export default class ShowCardCommand extends HostBaseCommand< protected async run( input: BaseCommandModule.ShowCardInput, ): Promise { - let { operatorModeStateService, store } = this; + let { operatorModeStateService } = this; if (operatorModeStateService.workspaceChooserOpened) { operatorModeStateService.closeWorkspaceChooser(); } @@ -51,9 +55,9 @@ export default class ShowCardCommand extends HostBaseCommand< (input.format as 'isolated' | 'edit') || 'isolated', ); operatorModeStateService.addItemToStack(newStackItem); - return await store.get(input.cardId); + 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; @@ -83,7 +87,15 @@ export default class ShowCardCommand extends HostBaseCommand< 'Unknown submode:', this.operatorModeStateService.state?.submode, ); - return await store.get(input.cardId); + 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/routes/command-runner.ts b/packages/host/app/routes/command-runner.ts index 39a00519b5b..9ee4b56041b 100644 --- a/packages/host/app/routes/command-runner.ts +++ b/packages/host/app/routes/command-runner.ts @@ -1,7 +1,7 @@ import { getOwner, setOwner } from '@ember/owner'; import Route from '@ember/routing/route'; import type RouterService from '@ember/routing/router-service'; -import type { PublicTransition } from '@ember/routing/transition'; +import type Transition from '@ember/routing/transition'; import { service } from '@ember/service'; import { tracked } from '@glimmer/tracking'; @@ -76,10 +76,7 @@ export default class CommandRunnerRoute extends Route { (globalThis as any).__boxelRenderContext = undefined; } - model( - params: { nonce: string }, - transition: PublicTransition, - ): CommandRunnerModel { + model(params: { nonce: string }, transition: Transition): CommandRunnerModel { let model = new CommandRunState(params.nonce); let queryParams = transition?.to?.queryParams ?? {}; let command = parseResolvedCodeRef(getQueryParam(queryParams, 'command')); From 2d78dd9fdce952d3615ea9d0538fa7ddddb4b2ac Mon Sep 17 00:00:00 2001 From: tintinthong Date: Thu, 19 Feb 2026 22:45:56 +0800 Subject: [PATCH 14/35] add submission bot to every room --- packages/host/app/commands/create-ai-assistant-room.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/host/app/commands/create-ai-assistant-room.ts b/packages/host/app/commands/create-ai-assistant-room.ts index 3b15e27e974..f798c394b09 100644 --- a/packages/host/app/commands/create-ai-assistant-room.ts +++ b/packages/host/app/commands/create-ai-assistant-room.ts @@ -89,6 +89,7 @@ export default class CreateAiAssistantRoomCommand extends HostBaseCommand< const [roomResult, commandModule] = await Promise.all([ await matrixService.createRoom({ preset: matrixService.privateChatPreset, + //TODO: Remove this once we handle race-condition of invitation to submission bot invite: [aiBotFullId, submissionBotFullId], name: input.name, room_alias_name: encodeURIComponent( From f5499de7378006ef28c3ccb7deb13486cc48f876 Mon Sep 17 00:00:00 2001 From: tintinthong Date: Thu, 19 Feb 2026 23:29:49 +0800 Subject: [PATCH 15/35] clean up create-show-card-requests --- packages/base/command.gts | 6 -- .../commands/create-show-card-request.ts | 26 +++--- .../bot-requests/create-show-card-request.ts | 86 ------------------- packages/host/app/commands/index.ts | 6 -- 4 files changed, 14 insertions(+), 110 deletions(-) delete mode 100644 packages/host/app/commands/bot-requests/create-show-card-request.ts diff --git a/packages/base/command.gts b/packages/base/command.gts index 6312221906f..a86ed3485a6 100644 --- a/packages/base/command.gts +++ b/packages/base/command.gts @@ -385,12 +385,6 @@ export class CreateListingPRRequestInput extends CardDef { @field listingId = contains(StringField); } -export class CreateShowCardRequestInput extends CardDef { - @field roomId = contains(StringField); - @field cardId = contains(StringField); - @field format = contains(StringField); -} - export class ListingCreateInput extends CardDef { @field openCardId = contains(StringField); @field codeRef = contains(CodeRefField); diff --git a/packages/experiments-realm/commands/create-show-card-request.ts b/packages/experiments-realm/commands/create-show-card-request.ts index fd30d94f547..c25ed7338ba 100644 --- a/packages/experiments-realm/commands/create-show-card-request.ts +++ b/packages/experiments-realm/commands/create-show-card-request.ts @@ -1,6 +1,6 @@ import { Command } from '@cardstack/runtime-common'; -import { CreateShowCardRequestInput } from 'https://cardstack.com/base/command'; +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'; @@ -9,6 +9,11 @@ import { realmURLFromCardId, } from './bot-request-utils'; +export class CreateShowCardRequestInput extends CardDef { + @field cardId = contains(StringField); + @field format = contains(StringField); +} + export default class CreateShowCardRequestCommand extends Command< typeof CreateShowCardRequestInput, undefined @@ -25,17 +30,14 @@ export default class CreateShowCardRequestCommand extends Command< throw new Error('cardId is required'); } - let roomId = input.roomId?.trim(); - if (!roomId) { - let createRoomResult = await new UseAiAssistantCommand( - this.commandContext, - ).execute({ - roomId: 'new', - roomName: `Show Card: ${cardId}`, - openRoom: true, - }); - roomId = createRoomResult.roomId; - } + 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); diff --git a/packages/host/app/commands/bot-requests/create-show-card-request.ts b/packages/host/app/commands/bot-requests/create-show-card-request.ts deleted file mode 100644 index b5fa39084c8..00000000000 --- a/packages/host/app/commands/bot-requests/create-show-card-request.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { service } from '@ember/service'; - -import { isCardInstance, realmURL } from '@cardstack/runtime-common'; - -import type { CardDef } from 'https://cardstack.com/base/card-api'; -import type * as BaseCommandModule from 'https://cardstack.com/base/command'; - -import HostBaseCommand from '../../lib/host-base-command'; - -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'; - -export default class CreateShowCardRequestCommand extends HostBaseCommand< - typeof BaseCommandModule.CreateShowCardRequestInput -> { - // TODO: remove this command once the temporary card menu item is removed. - @service declare private matrixService: MatrixService; - @service declare private store: StoreService; - - description = 'Request showing a card via the bot runner.'; - - async getInputType() { - let commandModule = await this.loadCommandModule(); - const { CreateShowCardRequestInput } = commandModule; - return CreateShowCardRequestInput; - } - - requireInputFields = ['cardId']; - - protected async run( - input: BaseCommandModule.CreateShowCardRequestInput, - ): Promise { - await this.matrixService.ready; - - let { cardId, format, roomId } = input; - let cardTitle: string | undefined; - let targetRealm: string | undefined; - - if (!roomId) { - let card = await this.store.get(cardId); - if (card && isCardInstance(card)) { - cardTitle = card.cardTitle ?? card.id; - targetRealm = card[realmURL]?.href; - } - let useAiAssistantCommand = new UseAiAssistantCommand( - this.commandContext, - ); - let createRoomResult = await useAiAssistantCommand.execute({ - roomId: 'new', - roomName: `Rename Card: ${cardTitle ?? cardId ?? 'Card'}`, - openRoom: true, - }); - roomId = createRoomResult.roomId; - } - - let submissionBotId = this.matrixService.submissionBotUserId; - if (!(await this.matrixService.isUserInRoom(roomId, submissionBotId))) { - await this.matrixService.inviteUserToRoom(roomId, submissionBotId); - } - - if (!targetRealm) { - let card = await this.store.get(cardId); - if (card && isCardInstance(card)) { - targetRealm = card[realmURL]?.href; - } - } - - if (!targetRealm) { - throw new Error('Realm URL is required to request show card'); - } - - await new SendBotTriggerEventCommand(this.commandContext).execute({ - roomId, - realm: targetRealm, - type: 'show-card', - input: { - cardId, - format, - }, - }); - } -} diff --git a/packages/host/app/commands/index.ts b/packages/host/app/commands/index.ts index ab51a57290b..35afdc96d36 100644 --- a/packages/host/app/commands/index.ts +++ b/packages/host/app/commands/index.ts @@ -6,7 +6,6 @@ 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 CreateShowCardRequestCommandModule from './bot-requests/create-show-card-request'; import * as SendBotTriggerEventCommandModule from './bot-requests/send-bot-trigger-event'; import * as CheckCorrectnessCommandModule from './check-correctness'; import * as CopyAndEditCommandModule from './copy-and-edit'; @@ -109,10 +108,6 @@ export function shimHostCommands(virtualNetwork: VirtualNetwork) { '@cardstack/boxel-host/commands/create-specs', CreateSpecCommandModule, ); - virtualNetwork.shimModule( - '@cardstack/boxel-host/commands/create-show-card-request', - CreateShowCardRequestCommandModule, - ); virtualNetwork.shimModule( '@cardstack/boxel-host/commands/check-correctness', CheckCorrectnessCommandModule, @@ -362,7 +357,6 @@ export const HostCommandClasses: (typeof HostBaseCommand)[] = [ ListingRemixCommandModule.default, CreateListingPRCommandModule.default, CreateListingPRRequestCommandModule.default, - CreateShowCardRequestCommandModule.default, ListingUpdateSpecsCommandModule.default, ListingUseCommandModule.default, OneShotLlmRequestCommandModule.default, From 36d749893b879dc4afea0d52b05b1e085ffecaad Mon Sep 17 00:00:00 2001 From: tintinthong Date: Thu, 19 Feb 2026 23:57:02 +0800 Subject: [PATCH 16/35] since there is no more filter we remove this test --- .../server-endpoints/bot-commands-test.ts | 49 ------------------- 1 file changed, 49 deletions(-) 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..706921523d6 100644 --- a/packages/realm-server/tests/server-endpoints/bot-commands-test.ts +++ b/packages/realm-server/tests/server-endpoints/bot-commands-test.ts @@ -452,55 +452,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( From b2d4eb134aee47c0374584d6759e637652029980 Mon Sep 17 00:00:00 2001 From: tintinthong Date: Fri, 20 Feb 2026 00:04:48 +0800 Subject: [PATCH 17/35] remove test that checks for filter types --- .../commands/send-bot-trigger-event-test.gts | 20 ------------------- 1 file changed, 20 deletions(-) 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 66a19cec40e..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 @@ -71,24 +71,4 @@ module('Integration | commands | send-bot-trigger-event', function (hooks) { assert.strictEqual(event.content.realm, testRealmURL); assert.deepEqual(event.content.input, { listingId: 'catalog/listing-1' }); }); - - test('allows custom 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 command.execute({ - roomId, - type: 'not-a-real-command', - realm: testRealmURL, - input: {}, - }); - - let event = getRoomEvents(roomId).pop()!; - assert.ok(isBotTriggerEvent(event)); - assert.strictEqual(event.content.type, 'not-a-real-command'); - }); }); From 5382690fa2e009bd65bdf53759e05448dc318b8c Mon Sep 17 00:00:00 2001 From: tintinthong Date: Fri, 20 Feb 2026 11:35:29 +0800 Subject: [PATCH 18/35] race condition for submission bot is fixed --- packages/host/app/commands/create-ai-assistant-room.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/packages/host/app/commands/create-ai-assistant-room.ts b/packages/host/app/commands/create-ai-assistant-room.ts index f798c394b09..aa0faa03eab 100644 --- a/packages/host/app/commands/create-ai-assistant-room.ts +++ b/packages/host/app/commands/create-ai-assistant-room.ts @@ -54,7 +54,6 @@ export default class CreateAiAssistantRoomCommand extends HostBaseCommand< let { matrixService } = this; let userId = matrixService.userId; let aiBotFullId = matrixService.aiBotUserId; - let submissionBotFullId = matrixService.submissionBotUserId; if (!userId) { throw new Error( @@ -89,8 +88,7 @@ export default class CreateAiAssistantRoomCommand extends HostBaseCommand< const [roomResult, commandModule] = await Promise.all([ await matrixService.createRoom({ preset: matrixService.privateChatPreset, - //TODO: Remove this once we handle race-condition of invitation to submission bot - invite: [aiBotFullId, submissionBotFullId], + invite: [aiBotFullId], name: input.name, room_alias_name: encodeURIComponent( `${input.name} - ${format( @@ -102,7 +100,6 @@ export default class CreateAiAssistantRoomCommand extends HostBaseCommand< users: { [userId]: 100, [aiBotFullId]: matrixService.aiBotPowerLevel, - [submissionBotFullId]: matrixService.aiBotPowerLevel, }, }, initial_state: [ From 59fa926fb7d1b0fb88eb443e5538481b86459ae0 Mon Sep 17 00:00:00 2001 From: tintinthong Date: Fri, 20 Feb 2026 12:02:53 +0800 Subject: [PATCH 19/35] refactor CommandInvocation to explicitly call .value as .cardResult --- packages/base/realm.gts | 4 ++-- packages/base/resources/command-data.ts | 8 ++++---- .../catalog-app/components/listing-fitted.gts | 2 +- .../catalog-app/listing/listing.gts | 4 ++-- .../experiments-realm/google-image-search.gts | 18 +++++++++--------- .../experiments-realm/simple-search-card.gts | 4 ++-- packages/runtime-common/commands.ts | 2 +- 7 files changed, 21 insertions(+), 21 deletions(-) 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/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/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/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; From a605a05ed412153b4bb6d747529181ca8bb2c401 Mon Sep 17 00:00:00 2001 From: tintinthong Date: Fri, 20 Feb 2026 12:04:05 +0800 Subject: [PATCH 20/35] Rename .result to .resultString --- packages/host/app/routes/command-runner.ts | 10 ++++++---- packages/host/app/templates/command-runner.gts | 8 ++++---- packages/realm-server/prerender/render-runner.ts | 11 +++++++---- packages/realm-server/tests/prerender-manager-test.ts | 2 +- packages/realm-server/tests/prerender-proxy-test.ts | 2 +- packages/runtime-common/index.ts | 2 +- 6 files changed, 20 insertions(+), 15 deletions(-) diff --git a/packages/host/app/routes/command-runner.ts b/packages/host/app/routes/command-runner.ts index 9ee4b56041b..da071015962 100644 --- a/packages/host/app/routes/command-runner.ts +++ b/packages/host/app/routes/command-runner.ts @@ -31,9 +31,9 @@ import type RealmService from '../services/realm'; class CommandRunState implements CommandInvocation { @tracked status: CommandInvocation['status'] = 'pending'; - @tracked value: CardDef | null = null; + @tracked cardResult: CardDef | null = null; @tracked error: Error | null = null; - @tracked result: string | null = null; + @tracked cardResultString: string | null = null; @tracked commandRef: string | null = null; @tracked commandInput: string | null = null; @@ -132,11 +132,13 @@ export default class CommandRunnerRoute extends Route { resultCard = await commandInstance.execute(); } - model.value = resultCard ?? null; + model.cardResult = resultCard ?? null; let serialized = resultCard ? await this.cardService.serializeCard(resultCard) : null; - model.result = serialized ? JSON.stringify(serialized, null, 2) : ''; + model.cardResultString = serialized + ? JSON.stringify(serialized, null, 2) + : ''; model.status = 'success'; } catch (error) { console.error('Command runner failed', { diff --git a/packages/host/app/templates/command-runner.gts b/packages/host/app/templates/command-runner.gts index 3b065336105..6d3eb92d422 100644 --- a/packages/host/app/templates/command-runner.gts +++ b/packages/host/app/templates/command-runner.gts @@ -18,13 +18,13 @@ const CommandRunner =