|
import { WebContainer } from '@webcontainer/api'; |
|
import { atom, map, type MapStore } from 'nanostores'; |
|
import * as nodePath from 'node:path'; |
|
import type { ActionAlert, 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<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; |
|
|
|
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 '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 }); |
|
} |
|
} |
|
|