Skip to content

Commit

Permalink
feat: added terminal error capturing and automated fix prompt
Browse files Browse the repository at this point in the history
  • Loading branch information
thecodacus committed Dec 17, 2024
1 parent 42bde1c commit d327cfe
Show file tree
Hide file tree
Showing 8 changed files with 475 additions and 214 deletions.
2 changes: 1 addition & 1 deletion app/commit.json
Original file line number Diff line number Diff line change
@@ -1 +1 @@
{ "commit": "1e72d52278730f7d22448be9d5cf2daf12559486", "version": "0.0.2" }
{ "commit": "42bde1cae43de887a6bc5a72f6352a63f65677e6" }
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

0 comments on commit d327cfe

Please sign in to comment.