| import type { WebContainer } from '@webcontainer/api'; |
| import { path as nodePath } from '~/utils/path'; |
| import { atom, map, type MapStore } from 'nanostores'; |
| import type { ActionAlert, BoltAction, FileHistory } 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<ActionStatus, 'failed'>; |
| abort: () => void; |
| executed: boolean; |
| abortSignal: AbortSignal; |
| }; |
|
|
| export type FailedActionState = BoltAction & |
| Omit<BaseActionState, 'status'> & { |
| status: Extract<ActionStatus, 'failed'>; |
| error: string; |
| }; |
|
|
| export type ActionState = BaseActionState | FailedActionState; |
|
|
| type BaseActionUpdate = Partial<Pick<BaseActionState, 'status' | 'abort' | 'executed'>>; |
|
|
| export type ActionStateUpdate = |
| | BaseActionUpdate |
| | (Omit<BaseActionUpdate, 'status'> & { status: 'failed'; error: string }); |
|
|
| type ActionsMap = MapStore<Record<string, ActionState>>; |
|
|
| class ActionCommandError extends Error { |
| readonly _output: string; |
| readonly _header: string; |
|
|
| constructor(message: string, output: string) { |
| |
| const formattedMessage = `Failed To Execute Shell Command: ${message}\n\nOutput:\n${output}`; |
| super(formattedMessage); |
|
|
| |
| this._header = message; |
| this._output = output; |
|
|
| |
| Object.setPrototypeOf(this, ActionCommandError.prototype); |
|
|
| |
| this.name = 'ActionCommandError'; |
| } |
|
|
| |
| get output() { |
| return this._output; |
| } |
| get header() { |
| return this._header; |
| } |
| } |
|
|
| export class ActionRunner { |
| #webcontainer: Promise<WebContainer>; |
| #currentExecutionPromise: Promise<void> = Promise.resolve(); |
| #shellTerminal: () => BoltShell; |
| runnerId = atom<string>(`${Date.now()}`); |
| actions: ActionsMap = map({}); |
| onAlert?: (alert: ActionAlert) => void; |
| buildOutput?: { path: string; exitCode: number; output: string }; |
|
|
| constructor( |
| webcontainerPromise: Promise<WebContainer>, |
| getShellTerminal: () => BoltShell, |
| onAlert?: (alert: ActionAlert) => void, |
| ) { |
| this.#webcontainer = webcontainerPromise; |
| this.#shellTerminal = getShellTerminal; |
| this.onAlert = onAlert; |
| } |
|
|
| addAction(data: ActionCallbackData) { |
| const { actionId } = data; |
|
|
| const actions = this.actions.get(); |
| const action = actions[actionId]; |
|
|
| if (action) { |
| |
| 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 }); |
|
|
| this.#currentExecutionPromise = this.#currentExecutionPromise |
| .then(() => { |
| return this.#executeAction(actionId, isStreaming); |
| }) |
| .catch((error) => { |
| console.error('Action failed:', error); |
| }); |
|
|
| await this.#currentExecutionPromise; |
|
|
| return; |
| } |
|
|
| 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 'build': { |
| const buildOutput = await this.#runBuildAction(action); |
|
|
| |
| this.buildOutput = buildOutput; |
| break; |
| } |
| case 'start': { |
| |
|
|
| this.#runStartAction(action) |
| .then(() => this.#updateAction(actionId, { status: 'complete' })) |
| .catch((err: Error) => { |
| if (action.abortSignal.aborted) { |
| return; |
| } |
|
|
| this.#updateAction(actionId, { status: 'failed', error: 'Action failed' }); |
| logger.error(`[${action.type}]:Action failed\n\n`, err); |
|
|
| if (!(err instanceof ActionCommandError)) { |
| return; |
| } |
|
|
| this.onAlert?.({ |
| type: 'error', |
| title: 'Dev Server Failed', |
| description: err.header, |
| content: err.output, |
| }); |
| }); |
|
|
| |
| |
| |
| |
| await new Promise((resolve) => setTimeout(resolve, 2000)); |
|
|
| return; |
| } |
| } |
|
|
| this.#updateAction(actionId, { |
| status: isStreaming ? 'running' : action.abortSignal.aborted ? 'aborted' : 'complete', |
| }); |
| } catch (error) { |
| if (action.abortSignal.aborted) { |
| return; |
| } |
|
|
| this.#updateAction(actionId, { status: 'failed', error: 'Action failed' }); |
| logger.error(`[${action.type}]:Action failed\n\n`, error); |
|
|
| if (!(error instanceof ActionCommandError)) { |
| return; |
| } |
|
|
| this.onAlert?.({ |
| type: 'error', |
| title: 'Dev Server Failed', |
| description: error.header, |
| content: error.output, |
| }); |
|
|
| |
| 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}]:Aborting Action\n\n`, action); |
| action.abort(); |
| }); |
| logger.debug(`${action.type} Shell Response: [exit code:${resp?.exitCode}]`); |
|
|
| if (resp?.exitCode != 0) { |
| throw new ActionCommandError(`Failed To Execute Shell Command`, resp?.output || 'No Output Available'); |
| } |
| } |
|
|
| 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}]:Aborting Action\n\n`, action); |
| action.abort(); |
| }); |
| logger.debug(`${action.type} Shell Response: [exit code:${resp?.exitCode}]`); |
|
|
| if (resp?.exitCode != 0) { |
| throw new ActionCommandError('Failed To Start Application', resp?.output || 'No Output Available'); |
| } |
|
|
| return resp; |
| } |
|
|
| async #runFileAction(action: ActionState) { |
| if (action.type !== 'file') { |
| unreachable('Expected file action'); |
| } |
|
|
| const webcontainer = await this.#webcontainer; |
| const relativePath = nodePath.relative(webcontainer.workdir, action.filePath); |
|
|
| let folder = nodePath.dirname(relativePath); |
|
|
| |
| 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(relativePath, action.content); |
| logger.debug(`File written ${relativePath}`); |
| } 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 }); |
| } |
|
|
| async getFileHistory(filePath: string): Promise<FileHistory | null> { |
| try { |
| const webcontainer = await this.#webcontainer; |
| const historyPath = this.#getHistoryPath(filePath); |
| const content = await webcontainer.fs.readFile(historyPath, 'utf-8'); |
|
|
| return JSON.parse(content); |
| } catch (error) { |
| logger.error('Failed to get file history:', error); |
| return null; |
| } |
| } |
|
|
| async saveFileHistory(filePath: string, history: FileHistory) { |
| |
| const historyPath = this.#getHistoryPath(filePath); |
|
|
| await this.#runFileAction({ |
| type: 'file', |
| filePath: historyPath, |
| content: JSON.stringify(history), |
| changeSource: 'auto-save', |
| } as any); |
| } |
|
|
| #getHistoryPath(filePath: string) { |
| return nodePath.join('.history', filePath); |
| } |
|
|
| async #runBuildAction(action: ActionState) { |
| if (action.type !== 'build') { |
| unreachable('Expected build action'); |
| } |
|
|
| const webcontainer = await this.#webcontainer; |
|
|
| |
| const buildProcess = await webcontainer.spawn('npm', ['run', 'build']); |
|
|
| let output = ''; |
| buildProcess.output.pipeTo( |
| new WritableStream({ |
| write(data) { |
| output += data; |
| }, |
| }), |
| ); |
|
|
| const exitCode = await buildProcess.exit; |
|
|
| if (exitCode !== 0) { |
| throw new ActionCommandError('Build Failed', output || 'No Output Available'); |
| } |
|
|
| |
| const buildDir = nodePath.join(webcontainer.workdir, 'dist'); |
|
|
| return { |
| path: buildDir, |
| exitCode, |
| output, |
| }; |
| } |
| } |
|
|