import { WebContainer } from '@webcontainer/api'; import { atom, map, type MapStore } from 'nanostores'; import * as nodePath from 'node:path'; import type { BoltAction } from '~/types/actions'; import { createScopedLogger } from '~/utils/logger'; import { unreachable } from '~/utils/unreachable'; import type { ActionCallbackData } from './message-parser'; import type { BoltShell } from '~/utils/shell'; const logger = createScopedLogger('ActionRunner'); export type ActionStatus = 'pending' | 'running' | 'complete' | 'aborted' | 'failed'; export type BaseActionState = BoltAction & { status: Exclude; abort: () => void; executed: boolean; abortSignal: AbortSignal; }; export type FailedActionState = BoltAction & Omit & { status: Extract; error: string; }; export type ActionState = BaseActionState | FailedActionState; type BaseActionUpdate = Partial>; export type ActionStateUpdate = | BaseActionUpdate | (Omit & { status: 'failed'; error: string }); type ActionsMap = MapStore>; export class ActionRunner { #webcontainer: Promise; #currentExecutionPromise: Promise = Promise.resolve(); #shellTerminal: () => BoltShell; runnerId = atom(`${Date.now()}`); actions: ActionsMap = map({}); constructor(webcontainerPromise: Promise, getShellTerminal: () => BoltShell) { this.#webcontainer = webcontainerPromise; this.#shellTerminal = getShellTerminal; } addAction(data: ActionCallbackData) { const { actionId } = data; const actions = this.actions.get(); const action = actions[actionId]; if (action) { // action already added return; } const abortController = new AbortController(); this.actions.setKey(actionId, { ...data.action, status: 'pending', executed: false, abort: () => { abortController.abort(); this.#updateAction(actionId, { status: 'aborted' }); }, abortSignal: abortController.signal, }); this.#currentExecutionPromise.then(() => { this.#updateAction(actionId, { status: 'running' }); }); } async runAction(data: ActionCallbackData, isStreaming: boolean = false) { const { actionId } = data; const action = this.actions.get()[actionId]; if (!action) { unreachable(`Action ${actionId} not found`); } if (action.executed) { return; } if (isStreaming && action.type !== 'file') { return; } this.#updateAction(actionId, { ...action, ...data.action, executed: !isStreaming }); // eslint-disable-next-line consistent-return return (this.#currentExecutionPromise = this.#currentExecutionPromise .then(() => { this.#executeAction(actionId, isStreaming); }) .catch((error) => { console.error('Action failed:', error); })); } async #executeAction(actionId: string, isStreaming: boolean = false) { const action = this.actions.get()[actionId]; this.#updateAction(actionId, { status: 'running' }); try { switch (action.type) { case 'shell': { await this.#runShellAction(action); break; } case 'file': { await this.#runFileAction(action); break; } case 'start': { // making the start app non blocking this.#runStartAction(action) .then(() => this.#updateAction(actionId, { status: 'complete' })) .catch(() => this.#updateAction(actionId, { status: 'failed', error: 'Action failed' })); /* * adding a delay to avoid any race condition between 2 start actions * i am up for a better approach */ await new Promise((resolve) => setTimeout(resolve, 2000)); return; } } this.#updateAction(actionId, { status: isStreaming ? 'running' : action.abortSignal.aborted ? 'aborted' : 'complete', }); } catch (error) { this.#updateAction(actionId, { status: 'failed', error: 'Action failed' }); logger.error(`[${action.type}]:Action failed\n\n`, error); // re-throw the error to be caught in the promise chain throw error; } } async #runShellAction(action: ActionState) { if (action.type !== 'shell') { unreachable('Expected shell action'); } const shell = this.#shellTerminal(); await shell.ready(); if (!shell || !shell.terminal || !shell.process) { unreachable('Shell terminal not found'); } const resp = await shell.executeCommand(this.runnerId.get(), action.content); logger.debug(`${action.type} Shell Response: [exit code:${resp?.exitCode}]`); if (resp?.exitCode != 0) { throw new Error('Failed To Execute Shell Command'); } } async #runStartAction(action: ActionState) { if (action.type !== 'start') { unreachable('Expected shell action'); } if (!this.#shellTerminal) { unreachable('Shell terminal not found'); } const shell = this.#shellTerminal(); await shell.ready(); if (!shell || !shell.terminal || !shell.process) { unreachable('Shell terminal not found'); } const resp = await shell.executeCommand(this.runnerId.get(), action.content); logger.debug(`${action.type} Shell Response: [exit code:${resp?.exitCode}]`); if (resp?.exitCode != 0) { throw new Error('Failed To Start Application'); } return resp; } async #runFileAction(action: ActionState) { if (action.type !== 'file') { unreachable('Expected file action'); } const webcontainer = await this.#webcontainer; let folder = nodePath.dirname(action.filePath); // remove trailing slashes folder = folder.replace(/\/+$/g, ''); if (folder !== '.') { try { await webcontainer.fs.mkdir(folder, { recursive: true }); logger.debug('Created folder', folder); } catch (error) { logger.error('Failed to create folder\n\n', error); } } try { await webcontainer.fs.writeFile(action.filePath, action.content); logger.debug(`File written ${action.filePath}`); } catch (error) { logger.error('Failed to write file\n\n', error); } } #updateAction(id: string, newState: ActionStateUpdate) { const actions = this.actions.get(); this.actions.setKey(id, { ...actions[id], ...newState }); } }