8000 fix(orchestration): validate state.json before it reaches the renderer; add error boundary by tawman · Pull Request #93 · amirlehmam/wmux · GitHub
[go: up one dir, main page]

Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 44 additions & 4 deletions src/main/orchestration-watcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,16 +44,54 @@ function listOrchDirs(): string[] {
}
}

/**
* Shape-check a parsed state.json before it is allowed near the renderer.
* state.json is written by a separate process (the Claude Code plugin, or an
* agent hand-rolling the file), so it is untrusted input. A run missing `id`
* or `waves` used to be broadcast anyway and then throw inside the sidebar's
* render — and an uncaught throw in render unmounts the entire React tree,
* leaving a black window that only a restart clears. Anything that does not
* satisfy the contract is ignored rather than shipped to the UI.
*/
export function isValidState(value: unknown): value is OrchestrationState {
if (!value || typeof value !== 'object') return false;
const s = value as Record<string, unknown>;
if (typeof s.id !== 'string' || s.id === '') return false;
if (typeof s.status !== 'string') return false;
if (!Array.isArray(s.waves)) return false;
return s.waves.every((w) => {
if (!w || typeof w !== 'object') return false;
const wave = w as Record<string, unknown>;
return typeof wave.index === 'number' && Array.isArray(wave.agents);
});
}

// Dirs we've already rejected, so a bad file doesn't log once per poll tick.
const warned = new Set<string>();

function readState(orchDir: string): OrchestrationState | null {
const stateFile = path.join(orchDir, 'state.json');
let parsed: unknown;
try {
const raw = fs.readFileSync(stateFile, 'utf-8');
const parsed = JSON.parse(raw) as OrchestrationState;
parsed._orchDir = orchDir;
return parsed;
parsed = JSON.parse(fs.readFileSync(stateFile, 'utf-8'));
} catch {
// Missing, unreadable, or mid-write (partial JSON) — try again next tick.
return null;
}

if (!isValidState(parsed)) {
if (!warned.has(orchDir)) {
warned.add(orchDir);
console.warn(
`[orchestration-watcher] ignoring malformed state.json (needs string "id", string "status", array "waves"): ${stateFile}`,
);
}
return null;
}
warned.delete(orchDir);

parsed._orchDir = orchDir;
return parsed;
}

function getMtime(orchDir: string): number {
Expand Down Expand Up @@ -87,6 +125,7 @@ function tick(): void {
for (const key of Array.from(tracked.keys())) {
if (!dirs.includes(key)) {
tracked.delete(key);
warned.delete(key);
if (active === key) {
active = null;
broadcast(IPC_CHANNELS.ORCHESTRATION_CLEAR, {});
Expand Down Expand Up @@ -160,5 +199,6 @@ export function stopOrchestrationWatcher(): void {
pollTimer = null;
}
tracked.clear();
warned.clear();
active = null;
}
65 changes: 65 additions & 0 deletions src/renderer/components/ErrorBoundary.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import React from 'react';

/**
* ErrorBoundary — stops one broken subtree from taking down the whole window.
*
* React unmounts the entire tree when a render throws and nothing catches it.
* In an Electron app that means a blank window painted in the background
* colour — indistinguishable from a hang, and only a restart clears it. wmux
* renders data it does not own (orchestration state.json written by the
* plugin, session snapshots, agent metadata), so a malformed payload must
* degrade to a broken panel, never a dead app.
*
* Wrap the root as a last-resort net, and wrap individual data-driven panels
* so the rest of the UI keeps working when one of them fails.
*/

interface Props {
children: React.ReactNode;
/** Shown in the fallback and in the console, e.g. "orchestration panel". */
label?: string;
/** Render nothing instead of the fallback card (for non-essential pane 8000 ls). */
silent?: boolean;
}

interface State {
error: Error | null;
}

export default class ErrorBoundary extends React.Component<Props, State> {
state: State = { error: null };

static getDerivedStateFromError(error: Error): State {
return { error };
}

componentDidCatch(error: Error, info: React.ErrorInfo): void {
console.error(
`[error-boundary] ${this.props.label ?? 'component'} crashed:`,
error,
info.componentStack,
);
}

private reset = (): void => {
this.setState({ error: null });
};

render(): React.ReactNode {
const { error } = this.state;
if (!error) return this.props.children;
if (this.props.silent) return null;

return (
<div className="error-boundary" role="alert">
<div className="error-boundary__title">
{this.props.label ? `${this.props.label} failed` : 'Something went wrong'}
</div>
<div className="error-boundary__message">{error.message}</div>
<button className="error-boundary__retry" >
retry
</button>
</div>
);
}
}
15 changes: 12 additions & 3 deletions src/renderer/components/Sidebar/OrchestrationPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,18 @@ export default function OrchestrationPanel() {
}, [orch?.status, orch?.id]);

if (!orch) return null;

const elapsedMs = Math.max(0, now - parseIso(orch.startedAt));
const elapsed = formatElapsed(elapsedMs);
// Defence in depth: the watcher already rejects malformed state.json, but a
// run with no `id`/`waves` must never reach the render body — throwing here
// unmounts the whole app (see ErrorBoundary).
if (typeof orch.id !== 'string' || !Array.isArray(orch.waves)) return null;

// parseIso returns 0 for a missing/unparseable startedAt, and `now - 0` is the
// whole Unix epoch — the panel rendered "495520:02:09" for a run that had been
// going ten minutes. An unknown start time is not a 56-year-old run: show that
// we don't know it. (The plugin's schema is `startedAt`; runs that write some
// other key land here.)
const startedMs = parseIso(orch.startedAt);
const elapsed = startedMs > 0 ? formatElapsed(Math.max(0, now - startedMs)) : '—';
const currentWaveIdx = findCurrentWaveIndex(orch);
const totalAgents = orch.waves.reduce((sum, w) => sum + w.agents.length, 0);
const runningAgents = countByStatus(orch, 'running');
Expand Down
5 changes: 4 additions & 1 deletion src/renderer/components/Sidebar/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import SidebarResizeHandle from './SidebarResizeHandle';
import WorkspaceContextMenu from './WorkspaceContextMenu';
import SessionMenu from './SessionMenu';
import OrchestrationPanel from './OrchestrationPanel';
import ErrorBoundary from '../ErrorBoundary';
import { useStore } from '../../store';
import '../../styles/sidebar.css';

Expand Down Expand Up @@ -266,7 +267,9 @@ export default function Sidebar({
)}
</div>

<OrchestrationPanel />
<ErrorBoundary label="orchestration" silent>
<OrchestrationPanel />
</ErrorBoundary>

<div className="sidebar__list">
{workspaces.map((ws) => (
Expand Down
9 changes: 8 additions & 1 deletion src/renderer/index.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,18 @@
import React from 'react';
import { createRoot } from 'react-dom/client';
import App from './App';
import ErrorBoundary from './components/ErrorBoundary';
import './styles/theme-vars.css';
import './styles/global.css';
import { initNotificationSound } from './notification-sound';

initNotificationSound();

const root = createRoot(document.getElementById('root')!);
root.render(<React.StrictMode><App /></React.StrictMode>);
root.render(
<React.StrictMode>
<ErrorBoundary label="wmux">
<App />
</ErrorBoundary>
</React.StrictMode>,
);
39 changes: 39 additions & 0 deletions src/renderer/styles/global.css
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,45 @@ html, body, #root {
background: rgba(var(--ui-overlay-rgb), 0.2);
}

/* Error boundary fallback — shown in place of a subtree that threw during
render, so a bad payload degrades to a visible card instead of a black
window. */
.error-boundary {
margin: 8px;
padding: 10px 12px;
border: 1px solid var(--ui-border-strong);
border-left: 3px solid #b3261e;
border-radius: 6px;
background: var(--ui-bg-1);
color: var(--ui-text-primary);
font-size: 12px;
}

.error-boundary__title {
font-weight: 600;
margin-bottom: 4px;
}

.error-boundary__message {
opacity: 0.75;
word-break: break-word;
margin-bottom: 8px;
}

.error-boundary__retry {
padding: 3px 10px;
border: 1px solid var(--ui-border);
border-radius: 4px;
background: var(--ui-bg-0);
color: var(--ui-text-primary);
font-size: 11px;
cursor: pointer;
}

.error-boundary__retry:hover {
background: var(--ui-bg-4);
}

/* Broadcast-input mode banner (issue #64) — pinned bottom-center while typing
fans out to every terminal pane in the workspace (tmux synchronize-panes). */
.broadcast-input-banner {
Expand Down
66 changes: 66 additions & 0 deletions tests/unit/orchestration-watcher.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { describe, it, expect } from 'vitest';
import { isValidState } from '../../src/main/orchestration-watcher';

/**
* state.json is written by the wmux-orchestrator plugin (or by an agent
* hand-rolling the file), so the watcher treats it as untrusted input. A run
* that reached the sidebar without an `id` threw during React's render, which
* unmounts the whole tree and leaves a black window until wmux is restarted.
*/

const validState = {
id: 'sixpack-prod',
task: 'six-pack production readiness',
status: 'running',
startedAt: '2026-07-12T06:33:32Z',
waves: [
{
index: 0,
status: 'running',
agents: [{ id: 'a', label: 'redis-hybrid-cache', status: 'running' }],
},
],
};

describe('orchestration-watcher / isValidState', () => {
it('accepts a well-formed run', () => {
expect(isValidState(validState)).toBe(true);
});

it('accepts a run with no agents in a wave', () => {
expect(isValidState({ ...validState, waves: [{ index: 0, status: 'pending', agents: [] }] })).toBe(true);
});

it('rejects the orchestrationId-instead-of-id shape that blanked the window', () => {
const { id, ...rest } = validState;
expect(isValidState({ ...rest, orchestrationId: id })).toBe(false);
});

it('rejects a missing or non-string id', () => {
expect(isValidState({ ...validState, id: undefined })).toBe(false);
expect(isValidState({ ...validState, id: '' })).toBe(false);
expect(isValidState({ ...validState, id: 42 })).toBe(false);
});

it('rejects a missing status', () => {
expect(isValidState({ ...validState, status: undefined })).toBe(false);
});

it('rejects waves that are absent or not an array', () => {
expect(isValidState({ ...validState, waves: undefined })).toBe(false);
expect(isValidState({ ...validState, waves: {} })).toBe(false);
});

it('rejects a wave missing its index or agents array', () => {
expect(isValidState({ ...validState, waves: [{ status: 'running', agents: [] }] })).toBe(false);
expect(isValidState({ ...validState, waves: [{ index: 0, status: 'running' }] })).toBe(false);
expect(isValidState({ ...validState, waves: [null] })).toBe(false);
});

it('rejects non-objects', () => {
expect(isValidState(null)).toBe(false);
expect(isValidState(undefined)).toBe(false);
expect(isValidState('running')).toBe(false);
expect(isValidState([])).toBe(false);
});
});
0