Skip to content

Commit 2b87664

Browse files
committed
feat(error-handling): implement workflow error handling and error modal
add error behavior evaluation and handling throughout workflow execution introduce error modal in TUI to display workflow errors add global error toast utility for consistent error display update workflow status types to include error state
1 parent bbcd0d9 commit 2b87664

File tree

16 files changed

+304
-24
lines changed

16 files changed

+304
-24
lines changed

src/cli/tui/app-shell.tsx

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,16 @@ export function App(props: { initialToast?: InitialToast }) {
110110
const toast = useToast()
111111
const kv = useKV()
112112

113+
// Global error handler - any part of the app can emit 'app:error' to show a toast
114+
const handleAppError = (data: { message: string; duration?: number }) => {
115+
toast.show({
116+
variant: "error",
117+
message: data.message,
118+
duration: data.duration ?? 0, // permanent by default
119+
})
120+
}
121+
;(process as NodeJS.EventEmitter).on('app:error', handleAppError)
122+
113123
const [ctrlCPressed, setCtrlCPressed] = createSignal(false)
114124
let ctrlCTimeout: NodeJS.Timeout | null = null
115125
const [view, setView] = createSignal<"home" | "onboard" | "workflow">("home")
@@ -175,7 +185,9 @@ export function App(props: { initialToast?: InitialToast }) {
175185
pendingWorkflowStart = () => {
176186
import("../../workflows/execution/queue.js").then(({ runWorkflowQueue }) => {
177187
runWorkflowQueue({ cwd, specificationPath: specPath }).catch((error) => {
178-
console.error("Workflow failed:", error)
188+
// Emit error event to show toast with actual error message
189+
const errorMsg = error instanceof Error ? error.message : String(error)
190+
;(process as NodeJS.EventEmitter).emit('app:error', { message: errorMsg })
179191
})
180192
})
181193
}

src/cli/tui/routes/workflow/adapters/opentui.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
*/
77

88
import type { WorkflowEvent } from "../../../../../workflows/events/index.js"
9+
import { debug } from "../../../../../shared/logging/logger.js"
910
import type { AgentStatus, SubAgentState, LoopState, ChainedState, InputState, TriggeredAgentState } from "../state/types.js"
1011
import { BaseUIAdapter } from "./base.js"
1112
import type { UIAdapterOptions } from "./types.js"
@@ -40,7 +41,7 @@ export interface UIActions {
4041
batchAddSubAgents(parentId: string, subAgents: SubAgentState[]): void
4142
updateSubAgentStatus(subAgentId: string, status: AgentStatus): void
4243
clearSubAgents(parentId: string): void
43-
setWorkflowStatus(status: "running" | "stopping" | "completed" | "stopped" | "checkpoint" | "paused"): void
44+
setWorkflowStatus(status: "running" | "stopping" | "completed" | "stopped" | "checkpoint" | "paused" | "error"): void
4445
setCheckpointState(checkpoint: { active: boolean; reason?: string } | null): void
4546
setInputState(inputState: InputState | null): void
4647
/** @deprecated Use setInputState instead */
@@ -94,6 +95,7 @@ export class OpenTUIAdapter extends BaseUIAdapter {
9495
break
9596

9697
case "workflow:status":
98+
debug(`[DEBUG Adapter] Received workflow:status event with status=${event.status}`)
9799
this.actions.setWorkflowStatus(event.status)
98100
break
99101

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
/** @jsxImportSource @opentui/solid */
2+
/**
3+
* Error Modal
4+
*
5+
* Displays workflow errors.
6+
*/
7+
8+
import { useKeyboard, useTerminalDimensions } from "@opentui/solid"
9+
import { useTheme } from "@tui/shared/context/theme"
10+
import { ModalBase, ModalHeader, ModalFooter } from "@tui/shared/components/modal"
11+
12+
export interface ErrorModalProps {
13+
message: string
14+
onClose: () => void
15+
}
16+
17+
export function ErrorModal(props: ErrorModalProps) {
18+
const themeCtx = useTheme()
19+
const dimensions = useTerminalDimensions()
20+
21+
const modalWidth = () => {
22+
const safeWidth = Math.max(50, (dimensions()?.width ?? 80) - 8)
23+
return Math.min(safeWidth, 80)
24+
}
25+
26+
const modalHeight = () => {
27+
const safeHeight = Math.max(15, (dimensions()?.height ?? 30) - 6)
28+
return Math.min(safeHeight, 25)
29+
}
30+
31+
useKeyboard((evt) => {
32+
if (evt.name === "return" || evt.name === "escape" || evt.name === "q") {
33+
evt.preventDefault()
34+
props.onClose()
35+
return
36+
}
37+
})
38+
39+
// Calculate content height (modal height minus header, footer, padding)
40+
const contentHeight = () => modalHeight() - 7
41+
42+
return (
43+
<ModalBase width={modalWidth()}>
44+
<ModalHeader title="Workflow Error" icon="!" iconColor={themeCtx.theme.error} />
45+
<box
46+
paddingLeft={2}
47+
paddingRight={2}
48+
paddingTop={1}
49+
paddingBottom={1}
50+
height={contentHeight()}
51+
overflow="scroll"
52+
>
53+
<text fg={themeCtx.theme.error}>{props.message}</text>
54+
</box>
55+
<box flexDirection="row" justifyContent="center" paddingBottom={1}>
56+
<box
57+
paddingLeft={2}
58+
paddingRight={2}
59+
backgroundColor={themeCtx.theme.textMuted}
60+
borderColor={themeCtx.theme.textMuted}
61+
border
62+
>
63+
<text fg={themeCtx.theme.background}>Close</text>
64+
</box>
65+
</box>
66+
<ModalFooter shortcuts="[ENTER/Esc/Q] Close" />
67+
</ModalBase>
68+
)
69+
}

src/cli/tui/routes/workflow/components/modals/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,3 +8,4 @@ export { CheckpointModal, type CheckpointModalProps } from "./checkpoint-modal"
88
export { LogViewer, type LogViewerProps } from "./log-viewer"
99
export { HistoryView, type HistoryViewProps } from "./history-view"
1010
export { StopModal, type StopModalProps } from "./stop-modal"
11+
export { ErrorModal, type ErrorModalProps } from "./error-modal"

src/cli/tui/routes/workflow/state/types.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ export interface LoopState {
5555
reason?: string
5656
}
5757

58-
export type WorkflowStatus = "running" | "stopping" | "completed" | "stopped" | "checkpoint" | "paused"
58+
export type WorkflowStatus = "running" | "stopping" | "completed" | "stopped" | "checkpoint" | "paused" | "error"
5959

6060
export interface CheckpointState {
6161
active: boolean

src/cli/tui/routes/workflow/workflow-shell.tsx

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import { useUIState } from "./context/ui-state"
1414
import { AgentTimeline } from "./components/timeline"
1515
import { OutputWindow, TelemetryBar, StatusFooter } from "./components/output"
1616
import { formatRuntime } from "./state/formatters"
17-
import { CheckpointModal, LogViewer, HistoryView, StopModal } from "./components/modals"
17+
import { CheckpointModal, LogViewer, HistoryView, StopModal, ErrorModal } from "./components/modals"
1818
import { OpenTUIAdapter } from "./adapters/opentui"
1919
import { useLogStream } from "./hooks/useLogStream"
2020
import { useSubAgentSync } from "./hooks/useSubAgentSync"
@@ -90,7 +90,21 @@ export function WorkflowShell(props: WorkflowShellProps) {
9090
ui.actions.setWorkflowStatus("stopped")
9191
}
9292

93+
// Error modal state
94+
const [errorMessage, setErrorMessage] = createSignal<string | null>(null)
95+
const isErrorModalActive = () => errorMessage() !== null
96+
97+
const handleWorkflowError = (data: { reason: string }) => {
98+
setErrorMessage(data.reason)
99+
ui.actions.setWorkflowStatus("error")
100+
}
101+
102+
const handleErrorModalClose = () => {
103+
setErrorMessage(null)
104+
}
105+
93106
onMount(() => {
107+
;(process as NodeJS.EventEmitter).on('workflow:error', handleWorkflowError)
94108
;(process as NodeJS.EventEmitter).on('workflow:stopping', handleStopping)
95109
;(process as NodeJS.EventEmitter).on('workflow:user-stop', handleUserStop)
96110
if (props.eventBus) {
@@ -102,6 +116,7 @@ export function WorkflowShell(props: WorkflowShellProps) {
102116
})
103117

104118
onCleanup(() => {
119+
;(process as NodeJS.EventEmitter).off('workflow:error', handleWorkflowError)
105120
;(process as NodeJS.EventEmitter).off('workflow:stopping', handleStopping)
106121
;(process as NodeJS.EventEmitter).off('workflow:user-stop', handleUserStop)
107122
if (adapter) {
@@ -275,7 +290,7 @@ export function WorkflowShell(props: WorkflowShellProps) {
275290
getState: state,
276291
actions: ui.actions,
277292
calculateVisibleItems: getVisibleItems,
278-
isModalBlocking: () => isCheckpointActive() || modals.isLogViewerActive() || modals.isHistoryActive() || modals.isHistoryLogViewerActive() || showStopModal(),
293+
isModalBlocking: () => isCheckpointActive() || modals.isLogViewerActive() || modals.isHistoryActive() || modals.isHistoryLogViewerActive() || showStopModal() || isErrorModalActive(),
279294
isPromptBoxFocused: () => isPromptBoxFocused(),
280295
isWaitingForInput,
281296
hasQueuedPrompts,
@@ -360,6 +375,12 @@ export function WorkflowShell(props: WorkflowShellProps) {
360375
<StopModal onConfirm={handleStopConfirm} onCancel={handleStopCancel} />
361376
</box>
362377
</Show>
378+
379+
<Show when={isErrorModalActive()}>
380+
<box position="absolute" left={0} top={0} width="100%" height="100%" zIndex={2000}>
381+
<ErrorModal message={errorMessage()!} onClose={handleErrorModalClose} />
382+
</box>
383+
</Show>
363384
</box>
364385
)
365386
}

src/cli/tui/shared/context/toast.tsx

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,9 +33,12 @@ export const { use: useToast, provider: ToastProvider } = createSimpleContext({
3333
setStore("current", rest)
3434

3535
if (timeoutHandle) clearTimeout(timeoutHandle)
36-
timeoutHandle = setTimeout(() => {
37-
setStore("current", null)
38-
}, duration)
36+
// Only set timeout if duration > 0 (duration 0 means permanent toast)
37+
if (duration > 0) {
38+
timeoutHandle = setTimeout(() => {
39+
setStore("current", null)
40+
}, duration)
41+
}
3942
},
4043
dismiss() {
4144
if (timeoutHandle) clearTimeout(timeoutHandle)

src/shared/utils/errors.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
/**
2+
* Global error toast utility
3+
*
4+
* Usage: emitAppError("Something went wrong")
5+
* Usage: emitAppError("Error message", 5000) // auto-dismiss after 5 seconds
6+
*/
7+
export function emitAppError(message: string, duration?: number): void {
8+
;(process as NodeJS.EventEmitter).emit('app:error', { message, duration })
9+
}

src/shared/utils/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
export * from './path.js';
22
export * from './terminal.js';
3+
export * from './errors.js';
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import type { WorkflowStep } from '../../templates/index.js';
2+
import { isModuleStep } from '../../templates/types.js';
3+
import { evaluateErrorBehavior } from './evaluator.js';
4+
import { formatAgentLog } from '../../../shared/logging/index.js';
5+
import type { WorkflowEventEmitter } from '../../events/emitter.js';
6+
7+
export interface ErrorDecision {
8+
shouldStopWorkflow: boolean;
9+
reason?: string;
10+
}
11+
12+
export async function handleErrorLogic(
13+
step: WorkflowStep,
14+
output: string,
15+
cwd: string,
16+
emitter?: WorkflowEventEmitter,
17+
): Promise<ErrorDecision | null> {
18+
// Only module steps can have error behavior
19+
if (!isModuleStep(step)) {
20+
return null;
21+
}
22+
23+
const errorDecision = await evaluateErrorBehavior({
24+
output,
25+
cwd,
26+
});
27+
28+
if (errorDecision?.shouldStopWorkflow) {
29+
const message = `${step.agentName} reported an error` +
30+
`${errorDecision.reason ? `: ${errorDecision.reason}` : ''}.`;
31+
32+
emitter?.logMessage(step.agentId, message);
33+
if (!emitter) {
34+
console.log(formatAgentLog(step.agentId, message));
35+
}
36+
37+
return {
38+
shouldStopWorkflow: true,
39+
reason: errorDecision.reason,
40+
};
41+
}
42+
43+
return null;
44+
}

0 commit comments

Comments
 (0)