Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: enhanced Terminal Error Handling and Alert System #797

Merged
merged 11 commits into from
Dec 23, 2024
2 changes: 1 addition & 1 deletion app/commit.json
Original file line number Diff line number Diff line change
@@ -1 +1 @@
{ "commit": "eb6d4353565be31c6e20bfca2c5aea29e4f45b6d", "version": "0.0.3" }
{ "commit": "d327cfea2958c1cf2e053b01c4964daf5adcad22" }
431 changes: 229 additions & 202 deletions app/components/chat/BaseChat.tsx

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions app/components/chat/Chat.client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ export const ChatImpl = memo(
const [imageDataList, setImageDataList] = useState<string[]>([]); // Move here
const [searchParams, setSearchParams] = useSearchParams();
const files = useStore(workbenchStore.files);
const actionAlert = useStore(workbenchStore.alert);
const { activeProviders, promptId } = useSettings();

const [model, setModel] = useState(() => {
Expand Down Expand Up @@ -387,6 +388,8 @@ export const ChatImpl = memo(
setUploadedFiles={setUploadedFiles}
imageDataList={imageDataList}
setImageDataList={setImageDataList}
actionAlert={actionAlert}
clearAlert={() => workbenchStore.clearAlert()}
/>
);
},
Expand Down
81 changes: 81 additions & 0 deletions app/components/chat/ChatAlert.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import type { ActionAlert } from '~/types/actions';
import { classNames } from '~/utils/classNames';

interface Props {
alert: ActionAlert;
clearAlert: () => void;
postMessage: (message: string) => void;
}

export default function ChatAlert({ alert, clearAlert, postMessage }: Props) {
const { type, title, description, content } = alert;

const iconColor =
type === 'error' ? 'text-bolt-elements-button-danger-text' : 'text-bolt-elements-button-primary-text';

return (
<div className={`rounded-lg border border-bolt-elements-borderColor bg-bolt-elements-background-depth-2 p-4`}>
<div className="flex items-start">
{/* Icon */}
<div className="flex-shrink-0">
{type === 'error' ? (
<div className={`i-ph:x text-xl ${iconColor}`}></div>
) : (
<svg className={`h-5 w-5 ${iconColor}`} viewBox="0 0 20 20" fill="currentColor">
<path
fillRule="evenodd"
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z"
clipRule="evenodd"
/>
</svg>
)}
</div>

{/* Content */}
<div className="ml-3 flex-1">
<h3 className={`text-sm font-medium text-bolt-elements-textPrimary`}>{title}</h3>
<div className={`mt-2 text-sm text-bolt-elements-textSecondary`}>
<p>{description}</p>
{/* {content && (
<pre className="mt-2 whitespace-pre-wrap font-mono text-xs bg-white bg-opacity-50 p-2 rounded">
{content}
</pre>
)} */}
</div>

{/* Actions */}
<div className="mt-4">
<div className={classNames(' flex gap-2')}>
{type === 'error' && (
<button
onClick={() => postMessage(`*Fix this error on terminal* \n\`\`\`\n${content}\n\`\`\`\n`)}
className={classNames(
`px-2 py-1.5 rounded-md text-sm font-medium`,
'bg-bolt-elements-button-primary-background',
'hover:bg-bolt-elements-button-primary-backgroundHover',
'focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-bolt-elements-button-danger-background',
'text-bolt-elements-button-primary-text',
)}
>
Fix Issue
</button>
)}
<button
onClick={clearAlert}
className={classNames(
`px-2 py-1.5 rounded-md text-sm font-medium`,
'bg-bolt-elements-button-secondary-background',
'hover:bg-bolt-elements-button-secondary-backgroundHover',
'focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-bolt-elements-button-secondary-background',
'text-bolt-elements-button-secondary-text',
)}
>
Dismiss
</button>
</div>
</div>
</div>
</div>
</div>
);
}
88 changes: 81 additions & 7 deletions app/lib/runtime/action-runner.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
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 type { ActionAlert, BoltAction } from '~/types/actions';
import { createScopedLogger } from '~/utils/logger';
import { unreachable } from '~/utils/unreachable';
import type { ActionCallbackData } from './message-parser';
Expand Down Expand Up @@ -34,16 +34,51 @@ export type ActionStateUpdate =

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;

constructor(webcontainerPromise: Promise<WebContainer>, getShellTerminal: () => BoltShell) {
constructor(
webcontainerPromise: Promise<WebContainer>,
getShellTerminal: () => BoltShell,
onAlert?: (alert: ActionAlert) => void,
) {
this.#webcontainer = webcontainerPromise;
this.#shellTerminal = getShellTerminal;
this.onAlert = onAlert;
}

addAction(data: ActionCallbackData) {
Expand Down Expand Up @@ -126,7 +161,25 @@ export class ActionRunner {

this.#runStartAction(action)
.then(() => this.#updateAction(actionId, { status: 'complete' }))
.catch(() => this.#updateAction(actionId, { status: 'failed', error: 'Action failed' }));
.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
Expand All @@ -142,9 +195,24 @@ export class ActionRunner {
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;
}
Expand All @@ -162,11 +230,14 @@ export class ActionRunner {
unreachable('Shell terminal not found');
}

const resp = await shell.executeCommand(this.runnerId.get(), action.content);
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 Error('Failed To Execute Shell Command');
throw new ActionCommandError(`Failed To Execute Shell Command`, resp?.output || 'No Output Available');
}
}

Expand All @@ -186,11 +257,14 @@ export class ActionRunner {
unreachable('Shell terminal not found');
}

const resp = await shell.executeCommand(this.runnerId.get(), action.content);
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 Error('Failed To Start Application');
throw new ActionCommandError('Failed To Start Application', resp?.output || 'No Output Available');
}

return resp;
Expand Down
16 changes: 15 additions & 1 deletion app/lib/stores/workbench.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { extractRelativePath } from '~/utils/diff';
import { description } from '~/lib/persistence';
import Cookies from 'js-cookie';
import { createSampler } from '~/utils/sampler';
import type { ActionAlert } from '~/types/actions';

export interface ArtifactState {
id: string;
Expand All @@ -43,6 +44,8 @@ export class WorkbenchStore {
showWorkbench: WritableAtom<boolean> = import.meta.hot?.data.showWorkbench ?? atom(false);
currentView: WritableAtom<WorkbenchViewType> = import.meta.hot?.data.currentView ?? atom('code');
unsavedFiles: WritableAtom<Set<string>> = import.meta.hot?.data.unsavedFiles ?? atom(new Set<string>());
actionAlert: WritableAtom<ActionAlert | undefined> =
import.meta.hot?.data.unsavedFiles ?? atom<ActionAlert | undefined>(undefined);
modifiedFiles = new Set<string>();
artifactIdList: string[] = [];
#globalExecutionQueue = Promise.resolve();
Expand All @@ -52,6 +55,7 @@ export class WorkbenchStore {
import.meta.hot.data.unsavedFiles = this.unsavedFiles;
import.meta.hot.data.showWorkbench = this.showWorkbench;
import.meta.hot.data.currentView = this.currentView;
import.meta.hot.data.actionAlert = this.actionAlert;
}
}

Expand Down Expand Up @@ -89,6 +93,12 @@ export class WorkbenchStore {
get boltTerminal() {
return this.#terminalStore.boltTerminal;
}
get alert() {
return this.actionAlert;
}
clearAlert() {
this.actionAlert.set(undefined);
}

toggleTerminal(value?: boolean) {
this.#terminalStore.toggleTerminal(value);
Expand Down Expand Up @@ -249,7 +259,11 @@ export class WorkbenchStore {
title,
closed: false,
type,
runner: new ActionRunner(webcontainer, () => this.boltTerminal),
runner: new ActionRunner(
webcontainer,
() => this.boltTerminal,
(alert) => this.actionAlert.set(alert),
),
});
}

Expand Down
7 changes: 7 additions & 0 deletions app/types/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,10 @@ export interface StartAction extends BaseAction {
export type BoltAction = FileAction | ShellAction | StartAction;

export type BoltActionData = BoltAction | BaseAction;

export interface ActionAlert {
type: 'error' | 'info';
title: string;
description: string;
content: string;
}
Loading
Loading