Spaces:
Sleeping
Sleeping
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<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>>; | |
export class ActionRunner { | |
#webcontainer: Promise<WebContainer>; | |
#currentExecutionPromise: Promise<void> = Promise.resolve(); | |
#shellTerminal: () => BoltShell; | |
runnerId = atom<string>(`${Date.now()}`); | |
actions: ActionsMap = map({}); | |
constructor(webcontainerPromise: Promise<WebContainer>, 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; // No return value here | |
} | |
if (isStreaming && action.type !== 'file') { | |
return; // No return value here | |
} | |
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': { | |
// 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 }); | |
} | |
} | |