feat(workflow-executor): add debug dashboard with SSE events#1515
feat(workflow-executor): add debug dashboard with SSE events#1515Scra3 wants to merge 7 commits intofeat/prd-214-setup-workflow-executor-packagefrom
Conversation
Add info level to Logger interface. Log step execution start (with context: runId, stepId, stepType, collection) and completion (with status) around doExecute in BaseStepExecutor. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…ests Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…tdown - Remove duplicate info method in Logger interface and ConsoleLogger - Update console-logger test to match console.log implementation with level field - Add missing info mock in database-store tests Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…eLogger console.log (info) vs console.error (error) already distinguishes the level via stdout/stderr. The JSON level field is redundant — users who need it can implement their own Logger. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Add EventEmitter to Runner (optional) emitting poll/step/drain events - Add GET /debug/events SSE endpoint with heartbeat (public, before JWT) - Add GET /debug serving a React dashboard (Vite + singlefile build) - Dashboard features: paired event visualization, duration bars, status filters, attempt history for awaiting-input steps, copy-to-clipboard - Wire EventEmitter in factory, log dashboard URL on startup - Add demo script for local testing with simulated events - Add tests for SSE headers, event forwarding, and Runner event emissions Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
17 new issues
|
The file is rebuilt from dashboard/ sources via `build:dashboard`. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
| function syntaxHighlight(json: string): string { | ||
| return json | ||
| .replace(/"([^"]+)":/g, '<span class="json-key">"$1"</span>:') | ||
| .replace(/"([^"]*)"/g, '<span class="json-str">"$1"</span>') | ||
| .replace(/\b(\d+\.?\d*)\b/g, '<span class="json-num">$1</span>') | ||
| .replace(/\b(true|false|null)\b/g, '<span class="json-bool">$1</span>'); | ||
| } |
There was a problem hiding this comment.
🟠 High components/EventRow.tsx:85
The syntaxHighlight function produces malformed HTML due to overlapping regex replacements. After line 87 inserts <span class="json-key">"foo"</span>, line 88's "([^"]*)" pattern matches the "json-key" attribute value inside that tag, wrapping it in another span and breaking the HTML structure. This causes visible rendering errors and potential React hydration mismatches. Consider using a single-pass parser or escaping the JSON before applying syntax highlighting.
function syntaxHighlight(json: string): string {
+ const escaped = json
+ .replace(/&/g, '&')
+ .replace(/</g, '<')
+ .replace(/>/g, '>');
+ return escaped
- .replace(/"([^"]+)":/g, '<span class="json-key">"$1"</span>:')
- .replace(/"([^"]*)"/g, '<span class="json-str">"$1"</span>')
+ .replace(/"([^&]+)":/g, '<span class="json-key">"$1"</span>:')
+ .replace(/"([^&]*)"/g, '<span class="json-str">"$1"</span>')
.replace(/\b(\d+\.?\d*)\b/g, '<span class="json-num">$1</span>')
.replace(/\b(true|false|null)\b/g, '<span class="json-bool">$1</span>');
}🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file packages/workflow-executor/dashboard/src/components/EventRow.tsx around lines 85-91:
The `syntaxHighlight` function produces malformed HTML due to overlapping regex replacements. After line 87 inserts `<span class="json-key">"foo"</span>`, line 88's `"([^"]*)"` pattern matches the `"json-key"` attribute value inside that tag, wrapping it in another span and breaking the HTML structure. This causes visible rendering errors and potential React hydration mismatches. Consider using a single-pass parser or escaping the JSON before applying syntax highlighting.
Evidence trail:
packages/workflow-executor/dashboard/src/components/EventRow.tsx lines 85-91 at REVIEWED_COMMIT. The syntaxHighlight function applies sequential regex replacements where line 87 inserts `<span class="json-key">`, and line 88's regex `"([^"]*)"` will match the `"json-key"` attribute value, causing malformed HTML output.
| let key: string; | ||
| if (category === 'poll') { | ||
| pollCounter.current += 1; | ||
| key = `poll-${pollCounter.current}`; |
There was a problem hiding this comment.
🟡 Medium hooks/use-event-stream.ts:87
When handling poll:end or drain:end events, the code uses pollCounter.current or drainCounter.current to construct the key. If multiple polls/drains overlap, the end event uses the counter's current value rather than the value from when that specific poll/drain started. For example: poll A starts (counter=1), poll B starts (counter=2), poll A ends — the code looks up poll-2 instead of poll-1, either corrupting poll B's data or creating an orphan for poll A.
Include the start counter value in the start event's data so the end event can reference the correct key.
- pollCounter.current += 1;
- key = `poll-${pollCounter.current}`;
+ const counter = pollCounter.current += 1;
+ key = `poll-${counter}`;
+ (data as Record<string, unknown>).pollCounter = counter;🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file packages/workflow-executor/dashboard/src/hooks/use-event-stream.ts around lines 87-90:
When handling `poll:end` or `drain:end` events, the code uses `pollCounter.current` or `drainCounter.current` to construct the key. If multiple polls/drains overlap, the end event uses the counter's *current* value rather than the value from when that specific poll/drain started. For example: poll A starts (counter=1), poll B starts (counter=2), poll A ends — the code looks up `poll-2` instead of `poll-1`, either corrupting poll B's data or creating an orphan for poll A.
Include the start counter value in the start event's `data` so the end event can reference the correct key.
Evidence trail:
packages/workflow-executor/dashboard/src/hooks/use-event-stream.ts lines 80-87 (start event handling increments counter and uses it for key) and lines 106-111 (end event handling uses current counter value for key lookup) at REVIEWED_COMMIT
…filters - Add "by run" view mode grouping steps by runId in accordions - Add search input to filter by runId with match highlighting - Add RunGroup component with status dot, mini stats, copy runId - Disable poll/drain filters in grouped mode - Auto-open run groups with errors/running/awaiting steps - Move currentAttempt to utils to avoid circular imports - Use react-tooltip border prop instead of style.border Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
| <span className="mini-stat mini-stat-awaiting">⏸{group.statuses.awaiting}</span> | ||
| )} | ||
| </span> | ||
| <span className="run-group-duration">{formatDuration(group.totalDurationMs || undefined)}</span> |
There was a problem hiding this comment.
🟢 Low components/RunGroup.tsx:89
When group.totalDurationMs is 0, the expression group.totalDurationMs || undefined coerces 0 to undefined, causing formatDuration to return "..." instead of "0ms" for runs that completed in 0 milliseconds. Consider using group.totalDurationMs ?? undefined or explicitly handling the zero case.
- <span className="run-group-duration">{formatDuration(group.totalDurationMs || undefined)}</span>
+ <span className="run-group-duration">{formatDuration(group.totalDurationMs ?? undefined)}</span>🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file packages/workflow-executor/dashboard/src/components/RunGroup.tsx around line 89:
When `group.totalDurationMs` is `0`, the expression `group.totalDurationMs || undefined` coerces `0` to `undefined`, causing `formatDuration` to return `"..."` instead of `"0ms"` for runs that completed in 0 milliseconds. Consider using `group.totalDurationMs ?? undefined` or explicitly handling the zero case.
Evidence trail:
packages/workflow-executor/dashboard/src/components/RunGroup.tsx line 89 shows `formatDuration(group.totalDurationMs || undefined)`. packages/workflow-executor/dashboard/src/utils.ts lines 21-26 show formatDuration returns '...' for undefined and '${ms}ms' for values < 1000 (including 0). JavaScript's || operator treats 0 as falsy, so `0 || undefined` returns `undefined`.
| export function currentAttempt(pair: EventPair): PairAttempt { | ||
| return pair.attempts[pair.attempts.length - 1]; | ||
| } |
There was a problem hiding this comment.
🟢 Low src/utils.ts:3
currentAttempt returns pair.attempts[pair.attempts.length - 1], which is undefined when attempts is empty (index -1). The return type declares PairAttempt but callers like currentAttempt(p).status will crash with Cannot read properties of undefined (reading 'status') if any EventPair has an empty attempts array. Consider guarding against empty arrays or updating the return type to PairAttempt | undefined and handling that case in callers.
+export function currentAttempt(pair: EventPair): PairAttempt | undefined {
+ return pair.attempts[pair.attempts.length - 1];
+}Also found in 1 other location(s)
packages/workflow-executor/dashboard/src/components/EventStream.tsx:34
Potential crash if
pair.attemptsis empty:currentAttempt(p).statuson line 34 will throw when accessing.statusonundefined(returned whenattempts.length === 0). The code at line 36 uses optional chaining (p.attempts[0]?.startData.runId), suggesting the developer considers empty attempts arrays possible, but line 34 lacks the same protection. The call path is:pairsfromuseEventStream()→pairs.filter()→currentAttempt(p)returnsundefined→.statusthrows.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file packages/workflow-executor/dashboard/src/utils.ts around lines 3-5:
`currentAttempt` returns `pair.attempts[pair.attempts.length - 1]`, which is `undefined` when `attempts` is empty (index -1). The return type declares `PairAttempt` but callers like `currentAttempt(p).status` will crash with `Cannot read properties of undefined (reading 'status')` if any `EventPair` has an empty `attempts` array. Consider guarding against empty arrays or updating the return type to `PairAttempt | undefined` and handling that case in callers.
Evidence trail:
packages/workflow-executor/dashboard/src/utils.ts lines 3-5 (REVIEWED_COMMIT) - function implementation with return type `PairAttempt`; packages/workflow-executor/dashboard/src/types.ts line 20 (REVIEWED_COMMIT) - `attempts: PairAttempt[]` allows empty arrays; packages/workflow-executor/dashboard/src/utils.ts line 35 (REVIEWED_COMMIT) - caller `currentAttempt(p).status` would crash if undefined; packages/workflow-executor/dashboard/src/hooks/use-event-stream.ts lines 113, 142 (REVIEWED_COMMIT) - current producers create non-empty arrays but this isn't type-enforced
Also found in 1 other location(s):
- packages/workflow-executor/dashboard/src/components/EventStream.tsx:34 -- Potential crash if `pair.attempts` is empty: `currentAttempt(p).status` on line 34 will throw when accessing `.status` on `undefined` (returned when `attempts.length === 0`). The code at line 36 uses optional chaining (`p.attempts[0]?.startData.runId`), suggesting the developer considers empty attempts arrays possible, but line 34 lacks the same protection. The call path is: `pairs` from `useEventStream()` → `pairs.filter()` → `currentAttempt(p)` returns `undefined` → `.status` throws.
Summary
EventEmitterto Runner (optional) emittingpoll:start/end,step:start/end/error,drain:start/endeventsGET /debug/eventsSSE endpoint (public, before JWT) with 20s heartbeatGET /debugserving a React dashboard (Vite +vite-plugin-singlefilebuild)demo/debug-dashboard.ts) for local testing with simulated eventsDashboard features
Test plan
yarn workspace @forestadmin/workflow-executor test— 417 tests passyarn workspace @forestadmin/workflow-executor tsc --noEmit— compilesnpx ts-node packages/workflow-executor/demo/debug-dashboard.ts→ open http://localhost:3142/debug🤖 Generated with Claude Code
Note
Add debug dashboard with SSE event streaming to workflow-executor
GET /debug(serves an HTML dashboard) andGET /debug/events(SSE stream) toExecutorHttpServer; both routes are unauthenticated.Runnernow emitspoll:start,poll:end,step:start,step:end,step:error,drain:start, anddrain:endevents via an optional sharedEventEmitterwired throughbuildInMemoryExecutorandbuildDatabaseExecutor.demo/debug-dashboard.tssimulates poll cycles and step executions for local development.Loggerinterface now requiresinfo(previously optional), and all test mocks andBaseStepExecutorare updated accordingly./debugand/debug/eventsare publicly accessible without JWT authentication.Macroscope summarized 57da0d7.