jo3t / app /lib /runtime /action-runner.ts
samifalouti1
Fresh start without binaries
55d48a7
import type { WebContainer } from '@webcontainer/api';
import { path as nodePath } from '~/utils/path';
import { atom, map, type MapStore } from 'nanostores';
import type { ActionAlert, BoltAction, DeployAlert, FileHistory, SupabaseAction, SupabaseAlert } 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) {
// Create a formatted message that includes both the error message and output
const formattedMessage = `Failed To Execute Shell Command: ${message}\n\nOutput:\n${output}`;
super(formattedMessage);
// Set the output separately so it can be accessed programmatically
this._header = message;
this._output = output;
// Maintain proper prototype chain
Object.setPrototypeOf(this, ActionCommandError.prototype);
// Set the name of the error for better debugging
this.name = 'ActionCommandError';
}
// Optional: Add a method to get just the terminal output
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;
onSupabaseAlert?: (alert: SupabaseAlert) => void;
onDeployAlert?: (alert: DeployAlert) => void;
buildOutput?: { path: string; exitCode: number; output: string };
constructor(
webcontainerPromise: Promise<WebContainer>,
getShellTerminal: () => BoltShell,
onAlert?: (alert: ActionAlert) => void,
onSupabaseAlert?: (alert: SupabaseAlert) => void,
onDeployAlert?: (alert: DeployAlert) => void,
) {
this.#webcontainer = webcontainerPromise;
this.#shellTerminal = getShellTerminal;
this.onAlert = onAlert;
this.onSupabaseAlert = onSupabaseAlert;
this.onDeployAlert = onDeployAlert;
}
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) => {
logger.error('Action execution promise 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 'supabase': {
try {
await this.handleSupabaseAction(action as SupabaseAction);
} catch (error: any) {
// Update action status
this.#updateAction(actionId, {
status: 'failed',
error: error instanceof Error ? error.message : 'Supabase action failed',
});
// Return early without re-throwing
return;
}
break;
}
case 'build': {
const buildOutput = await this.#runBuildAction(action);
// Store build output for deployment
this.buildOutput = buildOutput;
break;
}
case 'start': {
// making the start app non blocking
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,
});
});
/*
* 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) {
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,
});
// 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');
}
// Pre-validate command for common issues
const validationResult = await this.#validateShellCommand(action.content);
if (validationResult.shouldModify && validationResult.modifiedCommand) {
logger.debug(`Modified command: ${action.content} -> ${validationResult.modifiedCommand}`);
action.content = validationResult.modifiedCommand;
}
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) {
const enhancedError = this.#createEnhancedShellError(action.content, resp?.exitCode, resp?.output);
throw new ActionCommandError(enhancedError.title, enhancedError.details);
}
}
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);
// 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(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 webcontainer = await this.#webcontainer;
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');
}
// Trigger build started alert
this.onDeployAlert?.({
type: 'info',
title: 'Building Application',
description: 'Building your application...',
stage: 'building',
buildStatus: 'running',
deployStatus: 'pending',
source: 'netlify',
});
const webcontainer = await this.#webcontainer;
// Create a new terminal specifically for the build
const buildProcess = await webcontainer.spawn('npm', ['run', 'build']);
let output = '';
const outputPromise = buildProcess.output.pipeTo(
new WritableStream({
write(data) {
output += data;
},
}),
);
const exitCode = await buildProcess.exit;
await outputPromise.catch(() => {
// Ignore output piping errors; we still have whatever was captured
});
let buildDir = '';
if (exitCode !== 0) {
const buildResult = {
path: buildDir,
exitCode,
output,
};
this.buildOutput = buildResult;
// Trigger build failed alert
this.onDeployAlert?.({
type: 'error',
title: 'Build Failed',
description: 'Your application build failed',
content: output || 'No build output available',
stage: 'building',
buildStatus: 'failed',
deployStatus: 'pending',
source: 'netlify',
});
throw new ActionCommandError('Build Failed', output || 'No Output Available');
}
// Trigger build success alert
this.onDeployAlert?.({
type: 'success',
title: 'Build Completed',
description: 'Your application was built successfully',
stage: 'deploying',
buildStatus: 'complete',
deployStatus: 'running',
source: 'netlify',
});
// Check for common build directories
const commonBuildDirs = ['dist', 'build', 'out', 'output', '.next', 'public'];
// Try to find the first existing build directory
for (const dir of commonBuildDirs) {
const dirPath = nodePath.join(webcontainer.workdir, dir);
try {
await webcontainer.fs.readdir(dirPath);
buildDir = dirPath;
break;
} catch {
continue;
}
}
// If no build directory was found, use the default (dist)
if (!buildDir) {
buildDir = nodePath.join(webcontainer.workdir, 'dist');
}
const buildResult = {
path: buildDir,
exitCode,
output,
};
this.buildOutput = buildResult;
return buildResult;
}
async handleSupabaseAction(action: SupabaseAction) {
const { operation, content, filePath } = action;
logger.debug('[Supabase Action]:', { operation, filePath, content });
switch (operation) {
case 'migration':
if (!filePath) {
throw new Error('Migration requires a filePath');
}
// Show alert for migration action
this.onSupabaseAlert?.({
type: 'info',
title: 'Supabase Migration',
description: `Create migration file: ${filePath}`,
content,
source: 'supabase',
});
// Only create the migration file
await this.#runFileAction({
type: 'file',
filePath,
content,
changeSource: 'supabase',
} as any);
return { success: true };
case 'query': {
// Always show the alert and let the SupabaseAlert component handle connection state
this.onSupabaseAlert?.({
type: 'info',
title: 'Supabase Query',
description: 'Execute database query',
content,
source: 'supabase',
});
// The actual execution will be triggered from SupabaseChatAlert
return { pending: true };
}
default:
throw new Error(`Unknown operation: ${operation}`);
}
}
// Add this method declaration to the class
handleDeployAction(
stage: 'building' | 'deploying' | 'complete',
status: ActionStatus,
details?: {
url?: string;
error?: string;
source?: 'netlify' | 'vercel' | 'github' | 'gitlab';
},
): void {
if (!this.onDeployAlert) {
logger.debug('No deploy alert handler registered');
return;
}
const alertType = status === 'failed' ? 'error' : status === 'complete' ? 'success' : 'info';
const title =
stage === 'building'
? 'Building Application'
: stage === 'deploying'
? 'Deploying Application'
: 'Deployment Complete';
const description =
status === 'failed'
? `${stage === 'building' ? 'Build' : 'Deployment'} failed`
: status === 'running'
? `${stage === 'building' ? 'Building' : 'Deploying'} your application...`
: status === 'complete'
? `${stage === 'building' ? 'Build' : 'Deployment'} completed successfully`
: `Preparing to ${stage === 'building' ? 'build' : 'deploy'} your application`;
const buildStatus =
stage === 'building' ? status : stage === 'deploying' || stage === 'complete' ? 'complete' : 'pending';
const deployStatus = stage === 'building' ? 'pending' : status;
this.onDeployAlert({
type: alertType,
title,
description,
content: details?.error || '',
url: details?.url,
stage,
buildStatus: buildStatus as any,
deployStatus: deployStatus as any,
source: details?.source || 'netlify',
});
}
async #validateShellCommand(command: string): Promise<{
shouldModify: boolean;
modifiedCommand?: string;
warning?: string;
}> {
const trimmedCommand = command.trim();
// Handle rm commands that might fail due to missing files
if (trimmedCommand.startsWith('rm ') && !trimmedCommand.includes(' -f')) {
const rmMatch = trimmedCommand.match(/^rm\s+(.+)$/);
if (rmMatch) {
const filePaths = rmMatch[1].split(/\s+/);
// Check if any of the files exist using WebContainer
try {
const webcontainer = await this.#webcontainer;
const existingFiles = [];
for (const filePath of filePaths) {
if (filePath.startsWith('-')) {
continue;
} // Skip flags
try {
await webcontainer.fs.readFile(filePath);
existingFiles.push(filePath);
} catch {
// File doesn't exist, skip it
}
}
if (existingFiles.length === 0) {
// No files exist, modify command to use -f flag to avoid error
return {
shouldModify: true,
modifiedCommand: `rm -f ${filePaths.join(' ')}`,
warning: 'Added -f flag to rm command as target files do not exist',
};
} else if (existingFiles.length < filePaths.length) {
// Some files don't exist, modify to only remove existing ones with -f for safety
return {
shouldModify: true,
modifiedCommand: `rm -f ${filePaths.join(' ')}`,
warning: 'Added -f flag to rm command as some target files do not exist',
};
}
} catch (error) {
logger.debug('Could not validate rm command files:', error);
}
}
}
// Handle cd commands to non-existent directories
if (trimmedCommand.startsWith('cd ')) {
const cdMatch = trimmedCommand.match(/^cd\s+(.+)$/);
if (cdMatch) {
const targetDir = cdMatch[1].trim();
try {
const webcontainer = await this.#webcontainer;
await webcontainer.fs.readdir(targetDir);
} catch {
return {
shouldModify: true,
modifiedCommand: `mkdir -p ${targetDir} && cd ${targetDir}`,
warning: 'Directory does not exist, created it first',
};
}
}
}
// Handle cp/mv commands with missing source files
if (trimmedCommand.match(/^(cp|mv)\s+/)) {
const parts = trimmedCommand.split(/\s+/);
if (parts.length >= 3) {
const sourceFile = parts[1];
try {
const webcontainer = await this.#webcontainer;
await webcontainer.fs.readFile(sourceFile);
} catch {
return {
shouldModify: false,
warning: `Source file '${sourceFile}' does not exist`,
};
}
}
}
return { shouldModify: false };
}
#createEnhancedShellError(
command: string,
exitCode: number | undefined,
output: string | undefined,
): {
title: string;
details: string;
} {
const trimmedCommand = command.trim();
const firstWord = trimmedCommand.split(/\s+/)[0];
// Common error patterns and their explanations
const errorPatterns = [
{
pattern: /cannot remove.*No such file or directory/,
title: 'File Not Found',
getMessage: () => {
const fileMatch = output?.match(/'([^']+)'/);
const fileName = fileMatch ? fileMatch[1] : 'file';
return `The file '${fileName}' does not exist and cannot be removed.\n\nSuggestion: Use 'ls' to check what files exist, or use 'rm -f' to ignore missing files.`;
},
},
{
pattern: /No such file or directory/,
title: 'File or Directory Not Found',
getMessage: () => {
if (trimmedCommand.startsWith('cd ')) {
const dirMatch = trimmedCommand.match(/cd\s+(.+)/);
const dirName = dirMatch ? dirMatch[1] : 'directory';
return `The directory '${dirName}' does not exist.\n\nSuggestion: Use 'mkdir -p ${dirName}' to create it first, or check available directories with 'ls'.`;
}
return `The specified file or directory does not exist.\n\nSuggestion: Check the path and use 'ls' to see available files.`;
},
},
{
pattern: /Permission denied/,
title: 'Permission Denied',
getMessage: () =>
`Permission denied for '${firstWord}'.\n\nSuggestion: The file may not be executable. Try 'chmod +x filename' first.`,
},
{
pattern: /command not found/,
title: 'Command Not Found',
getMessage: () =>
`The command '${firstWord}' is not available in WebContainer.\n\nSuggestion: Check available commands or use a package manager to install it.`,
},
{
pattern: /Is a directory/,
title: 'Target is a Directory',
getMessage: () =>
`Cannot perform this operation - target is a directory.\n\nSuggestion: Use 'ls' to list directory contents or add appropriate flags.`,
},
{
pattern: /File exists/,
title: 'File Already Exists',
getMessage: () => `File already exists.\n\nSuggestion: Use a different name or add '-f' flag to overwrite.`,
},
];
// Try to match known error patterns
for (const errorPattern of errorPatterns) {
if (output && errorPattern.pattern.test(output)) {
return {
title: errorPattern.title,
details: errorPattern.getMessage(),
};
}
}
// Generic error with suggestions based on command type
let suggestion = '';
if (trimmedCommand.startsWith('npm ')) {
suggestion = '\n\nSuggestion: Try running "npm install" first or check package.json.';
} else if (trimmedCommand.startsWith('git ')) {
suggestion = "\n\nSuggestion: Check if you're in a git repository or if remote is configured.";
} else if (trimmedCommand.match(/^(ls|cat|rm|cp|mv)/)) {
suggestion = '\n\nSuggestion: Check file paths and use "ls" to see available files.';
}
return {
title: `Command Failed (exit code: ${exitCode})`,
details: `Command: ${trimmedCommand}\n\nOutput: ${output || 'No output available'}${suggestion}`,
};
}
}