|
import { WebContainer } from '@webcontainer/api'; |
|
import { 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'; |
|
|
|
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(); |
|
|
|
actions: ActionsMap = map({}); |
|
|
|
constructor(webcontainerPromise: Promise<WebContainer>) { |
|
this.#webcontainer = webcontainerPromise; |
|
} |
|
|
|
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) { |
|
const { actionId } = data; |
|
const action = this.actions.get()[actionId]; |
|
|
|
if (!action) { |
|
unreachable(`Action ${actionId} not found`); |
|
} |
|
|
|
if (action.executed) { |
|
return; |
|
} |
|
|
|
this.#updateAction(actionId, { ...action, ...data.action, executed: true }); |
|
|
|
this.#currentExecutionPromise = this.#currentExecutionPromise |
|
.then(() => { |
|
return this.#executeAction(actionId); |
|
}) |
|
.catch((error) => { |
|
console.error('Action failed:', error); |
|
}); |
|
} |
|
|
|
async #executeAction(actionId: string) { |
|
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; |
|
} |
|
} |
|
|
|
this.#updateAction(actionId, { status: action.abortSignal.aborted ? 'aborted' : 'complete' }); |
|
} catch (error) { |
|
this.#updateAction(actionId, { status: 'failed', error: 'Action failed' }); |
|
|
|
|
|
throw error; |
|
} |
|
} |
|
|
|
async #runShellAction(action: ActionState) { |
|
if (action.type !== 'shell') { |
|
unreachable('Expected shell action'); |
|
} |
|
|
|
const webcontainer = await this.#webcontainer; |
|
|
|
const process = await webcontainer.spawn('jsh', ['-c', action.content]); |
|
|
|
action.abortSignal.addEventListener('abort', () => { |
|
process.kill(); |
|
}); |
|
|
|
process.output.pipeTo( |
|
new WritableStream({ |
|
write(data) { |
|
console.log(data); |
|
}, |
|
}), |
|
); |
|
|
|
const exitCode = await process.exit; |
|
|
|
logger.debug(`Process terminated with code ${exitCode}`); |
|
} |
|
|
|
async #runFileAction(action: ActionState) { |
|
if (action.type !== 'file') { |
|
unreachable('Expected file action'); |
|
} |
|
|
|
const webcontainer = await this.#webcontainer; |
|
|
|
let folder = nodePath.dirname(action.filePath); |
|
|
|
|
|
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 }); |
|
} |
|
} |
|
|