Skip to content

Commit f337698

Browse files
fix(workflow): isolate active run graph from live canvas edits
1 parent 992da28 commit f337698

5 files changed

Lines changed: 101 additions & 23 deletions

File tree

apps/server/src/routes/workflows.ts

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,15 @@ async function persistResult(engine: WorkflowEngine, result: WorkflowRunResult)
4444
}
4545
}
4646

47+
function getEngineWorkflow(engine: WorkflowEngine): WorkflowGraph | undefined {
48+
// Backward compatibility: fall back to private graph if getGraph is unavailable.
49+
const engineAny = engine as WorkflowEngine & { getGraph?: () => WorkflowGraph };
50+
if (typeof engineAny.getGraph === 'function') {
51+
return engineAny.getGraph();
52+
}
53+
return Reflect.get(engine, 'graph') as WorkflowGraph | undefined;
54+
}
55+
4756
export function createWorkflowRouter(llm?: WorkflowLLM): Router {
4857
const router = createRouter();
4958

@@ -75,7 +84,8 @@ export function createWorkflowRouter(llm?: WorkflowLLM): Router {
7584
removeWorkflow(runId);
7685
}
7786

78-
res.json(result);
87+
const workflow = getEngineWorkflow(engine) ?? graph;
88+
res.json({ ...result, workflow });
7989
} catch (error) {
8090
const message = error instanceof Error ? error.message : String(error);
8191
logger.error('Failed to execute workflow', message);
@@ -129,7 +139,8 @@ export function createWorkflowRouter(llm?: WorkflowLLM): Router {
129139
removeWorkflow(runId);
130140
}
131141

132-
sendEvent({ type: 'done', result });
142+
const workflow = getEngineWorkflow(engine) ?? graph;
143+
sendEvent({ type: 'done', result: { ...result, workflow } });
133144
} catch (error) {
134145
const message = error instanceof Error ? error.message : String(error);
135146
logger.error('Failed to execute workflow stream', message);
@@ -160,7 +171,8 @@ export function createWorkflowRouter(llm?: WorkflowLLM): Router {
160171
removeWorkflow(runId);
161172
}
162173

163-
res.json(result);
174+
const workflow = getEngineWorkflow(engine);
175+
res.json(workflow ? { ...result, workflow } : result);
164176
} catch (error) {
165177
const message = error instanceof Error ? error.message : String(error);
166178
logger.error('Failed to resume workflow', message);
@@ -201,7 +213,9 @@ export function createWorkflowRouter(llm?: WorkflowLLM): Router {
201213
// Check in-memory first — catches engines that are still running or paused
202214
const engine = getWorkflow(runId);
203215
if (engine) {
204-
res.json(engine.getResult());
216+
const result = engine.getResult();
217+
const workflow = getEngineWorkflow(engine);
218+
res.json(workflow ? { ...result, workflow } : result);
205219
return;
206220
}
207221

@@ -221,6 +235,7 @@ export function createWorkflowRouter(llm?: WorkflowLLM): Router {
221235
state: record.state ?? {},
222236
waitingForInput: record.waitingForInput ?? false,
223237
currentNodeId: record.currentNodeId ?? null,
238+
workflow: record.workflow
224239
};
225240
res.json(result);
226241
} catch (error) {

apps/web/src/app/workflow-editor.ts

Lines changed: 79 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
// Bespoke Agent Builder - Client Logic
22

3-
import type { WorkflowConnection, WorkflowNode, WorkflowRunResult } from '@agentic/types';
3+
import type { WorkflowConnection, WorkflowGraph, WorkflowNode, WorkflowRunResult } from '@agentic/types';
44
import { runWorkflowStream, resumeWorkflow, fetchConfig, fetchRun } from '../services/api';
55
import { renderMarkdown, escapeHtml } from './markdown';
66

@@ -223,6 +223,8 @@ export class WorkflowEditor {
223223

224224
private currentRunId: string | null;
225225

226+
private activeRunGraph: WorkflowGraphPayload | null;
227+
226228
private runHistory: RunHistoryEntry[];
227229

228230
private getErrorMessage(error: unknown): string {
@@ -241,6 +243,36 @@ export class WorkflowEditor {
241243
});
242244
}
243245

246+
private cloneGraphPayload(graph: WorkflowGraphPayload): WorkflowGraphPayload {
247+
return JSON.parse(JSON.stringify(graph)) as WorkflowGraphPayload;
248+
}
249+
250+
private setActiveRunGraph(graph: WorkflowGraphPayload | null): void {
251+
this.activeRunGraph = graph ? this.cloneGraphPayload(graph) : null;
252+
}
253+
254+
private syncActiveRunGraphFromResult(result: WorkflowRunResult): void {
255+
const workflow: WorkflowGraph | undefined = result.workflow;
256+
if (!workflow || !Array.isArray(workflow.nodes) || !Array.isArray(workflow.connections)) return;
257+
this.setActiveRunGraph({
258+
nodes: workflow.nodes as EditorNode[],
259+
connections: workflow.connections
260+
});
261+
}
262+
263+
private getRunNodes(): EditorNode[] {
264+
return this.activeRunGraph?.nodes ?? this.nodes;
265+
}
266+
267+
private getRunConnections(): WorkflowConnection[] {
268+
return this.activeRunGraph?.connections ?? this.connections;
269+
}
270+
271+
private getRunNodeById(nodeId: string | null | undefined): EditorNode | undefined {
272+
if (!nodeId) return undefined;
273+
return this.getRunNodes().find((node) => node.id === nodeId);
274+
}
275+
244276
constructor() {
245277
this.modelOptions = [...DEFAULT_MODEL_OPTIONS];
246278
this.modelEfforts = { ...DEFAULT_MODEL_EFFORTS };
@@ -279,6 +311,7 @@ export class WorkflowEditor {
279311
this.activeRunController = null;
280312
this.lastLlmResponseContent = null;
281313
this.currentRunId = null;
314+
this.activeRunGraph = null;
282315
this.runHistory = [];
283316

284317
this.splitPanelCtorPromise = null;
@@ -431,7 +464,7 @@ export class WorkflowEditor {
431464
}
432465

433466
getPrimaryAgentName() {
434-
const agentNode = this.nodes.find((n) => n.type === 'agent');
467+
const agentNode = this.getRunNodes().find((n) => n.type === 'agent');
435468
if (agentNode && agentNode.data) {
436469
const name = (agentNode.data.agentName || '').trim();
437470
if (name) return name;
@@ -651,6 +684,7 @@ export class WorkflowEditor {
651684
this.clearApprovalMessage();
652685
this.appendStatusMessage('Cancelled');
653686
this.currentRunId = null;
687+
this.setActiveRunGraph(null);
654688
this.setWorkflowState('idle');
655689
}
656690
}
@@ -724,6 +758,16 @@ export class WorkflowEditor {
724758
return null;
725759
}
726760

761+
getClearDisableReason(): string | null {
762+
if (this.workflowState === 'running') {
763+
return 'Cannot clear canvas while workflow is running.';
764+
}
765+
if (this.workflowState === 'paused') {
766+
return 'Cannot clear canvas while workflow is paused waiting for approval.';
767+
}
768+
return null;
769+
}
770+
727771
updateRunButton() {
728772
if (!this.runButton) return;
729773
if (this.cancelRunButton) {
@@ -733,9 +777,7 @@ export class WorkflowEditor {
733777
}
734778

735779
if (this.clearButton) {
736-
const clearDisabledReason = this.workflowState === 'running'
737-
? 'Cannot clear canvas while workflow is running.'
738-
: null;
780+
const clearDisabledReason = this.getClearDisableReason();
739781
this.clearButton.disabled = Boolean(clearDisabledReason);
740782
this.setClearButtonHint(clearDisabledReason);
741783
}
@@ -935,7 +977,7 @@ export class WorkflowEditor {
935977
}
936978
if (this.clearButton) {
937979
this.clearButton.addEventListener('click', async () => {
938-
if (this.workflowState === 'running') return;
980+
if (this.workflowState !== 'idle') return;
939981
const confirmed = await this.openConfirmModal({
940982
title: 'Clear Canvas',
941983
message: 'Remove all nodes and connections from the canvas?',
@@ -1326,7 +1368,8 @@ export class WorkflowEditor {
13261368
duplicateBtn.innerHTML = '<span class="icon icon-content icon-primary"></span>';
13271369
duplicateBtn.title = 'Duplicate Agent';
13281370
duplicateBtn.setAttribute('data-tooltip', 'Duplicate Agent');
1329-
duplicateBtn.addEventListener('mousedown', (e: any) => {
1371+
duplicateBtn.setAttribute('aria-label', 'Duplicate Agent');
1372+
duplicateBtn.addEventListener('click', (e: any) => {
13301373
e.stopPropagation();
13311374
this.duplicateAgentNode(node);
13321375
});
@@ -2031,7 +2074,7 @@ export class WorkflowEditor {
20312074
showApprovalMessage(nodeId: any) {
20322075
if (!this.chatMessages) return;
20332076
this.clearApprovalMessage();
2034-
const node = this.nodes.find((n: any) => n.id === nodeId);
2077+
const node = this.getRunNodeById(nodeId);
20352078
const messageText = node?.data?.prompt || 'Approval required before continuing.';
20362079

20372080
const message = document.createElement('div');
@@ -2093,11 +2136,11 @@ export class WorkflowEditor {
20932136
}
20942137

20952138
getApprovalNextNode(nodeId: string, decision: 'approve' | 'reject'): EditorNode | undefined {
2096-
const connection = this.connections.find(
2139+
const connection = this.getRunConnections().find(
20972140
(conn: any) => conn.source === nodeId && conn.sourceHandle === decision
20982141
);
20992142
if (!connection) return undefined;
2100-
return this.nodes.find((node: any) => node.id === connection.target);
2143+
return this.getRunNodes().find((node: any) => node.id === connection.target);
21012144
}
21022145

21032146
extractWaitingNodeId(logs: any = []) {
@@ -2174,12 +2217,15 @@ export class WorkflowEditor {
21742217

21752218
isApprovalInputLog(entry: any): boolean {
21762219
if (!entry || entry.type !== 'input_received') return false;
2177-
const node = this.nodes.find((n: any) => n.id === entry.nodeId);
2220+
const node = this.getRunNodeById(entry.nodeId);
21782221
return node?.type === 'approval' || node?.type === 'input';
21792222
}
21802223

21812224
parseApprovalInputLog(content: string): { decision: 'approve' | 'reject'; note: string } {
2182-
const decision = content.toLowerCase().includes('rejected') ? 'reject' : 'approve';
2225+
const decisionPrefixMatch = content.match(/(?:^|\n)\s*(?:Decision|Status)\s*:\s*(approve|approved|reject|rejected)\b/i);
2226+
const sentencePrefixMatch = content.match(/(?:^|\n)\s*User\s+(approved|rejected)\b/i);
2227+
const rawDecision = (decisionPrefixMatch?.[1] || sentencePrefixMatch?.[1] || 'approve').toLowerCase();
2228+
const decision = rawDecision.startsWith('reject') ? 'reject' : 'approve';
21832229
const feedbackMatch = content.match(/feedback:\s*(.*)$/i);
21842230
const note = feedbackMatch?.[1]?.trim() || '';
21852231
return { decision, note };
@@ -2195,14 +2241,14 @@ export class WorkflowEditor {
21952241
}
21962242

21972243
getAgentNameForNode(nodeId: string): string {
2198-
const node = this.nodes.find((n: any) => n.id === nodeId);
2244+
const node = this.getRunNodeById(nodeId);
21992245
return (node?.data?.agentName || '').trim() || 'Agent';
22002246
}
22012247

22022248
onLogEntry(entry: any) {
22032249
const type = entry.type || '';
22042250
if (type === 'step_start') {
2205-
const node = this.nodes.find((n: any) => n.id === entry.nodeId);
2251+
const node = this.getRunNodeById(entry.nodeId);
22062252
if (node?.type === 'agent') {
22072253
this.showAgentSpinner(this.getAgentNameForNode(entry.nodeId));
22082254
}
@@ -2271,10 +2317,11 @@ export class WorkflowEditor {
22712317
if (!startNode.data) startNode.data = {};
22722318
startNode.data.initialInput = this.currentPrompt;
22732319

2274-
const graph = {
2320+
const graph = this.cloneGraphPayload({
22752321
nodes: this.nodes,
22762322
connections: this.connections
2277-
};
2323+
});
2324+
this.setActiveRunGraph(graph);
22782325
const controller = new AbortController();
22792326
this.activeRunController = controller;
22802327

@@ -2291,6 +2338,7 @@ export class WorkflowEditor {
22912338
this.appendChatMessage(this.getErrorMessage(e), 'error');
22922339
this.appendStatusMessage('Failed', 'failed');
22932340
this.hideAgentSpinner();
2341+
this.setActiveRunGraph(null);
22942342
this.setWorkflowState('idle');
22952343
} finally {
22962344
if (this.activeRunController === controller) {
@@ -2300,6 +2348,7 @@ export class WorkflowEditor {
23002348
}
23012349

23022350
handleRunResult(result: WorkflowRunResult, fromStream = false) {
2351+
this.syncActiveRunGraphFromResult(result);
23032352
if (!fromStream && result.logs) {
23042353
this.renderChatFromLogs(result.logs);
23052354
}
@@ -2324,17 +2373,20 @@ export class WorkflowEditor {
23242373
this.hideAgentSpinner();
23252374
this.setWorkflowState('idle');
23262375
this.currentRunId = null;
2376+
this.setActiveRunGraph(null);
23272377
} else if (result.status === 'failed') {
23282378
this.clearRunId();
23292379
this.clearApprovalMessage();
23302380
this.appendStatusMessage('Failed', 'failed');
23312381
this.hideAgentSpinner();
23322382
this.setWorkflowState('idle');
23332383
this.currentRunId = null;
2384+
this.setActiveRunGraph(null);
23342385
} else {
23352386
this.clearApprovalMessage();
23362387
this.hideAgentSpinner();
23372388
this.setWorkflowState('idle');
2389+
this.setActiveRunGraph(null);
23382390
}
23392391
}
23402392

@@ -2350,7 +2402,13 @@ export class WorkflowEditor {
23502402
// runId so recovery can be reattempted on the next page load.
23512403
return;
23522404
}
2353-
if (!result) { this.clearRunId(); return; } // 404 — run genuinely gone
2405+
if (!result) {
2406+
this.clearRunId();
2407+
this.setActiveRunGraph(null);
2408+
return;
2409+
} // 404 — run genuinely gone
2410+
2411+
this.syncActiveRunGraphFromResult(result);
23542412

23552413
if (result.status === 'running') {
23562414
// Engine still executing on server — show partial chat and poll for updates
@@ -2366,6 +2424,7 @@ export class WorkflowEditor {
23662424
this.renderChatFromLogs(result.logs);
23672425
this.appendStatusMessage('Previous paused run was lost (server restarted).', 'failed');
23682426
this.setWorkflowState('idle');
2427+
this.setActiveRunGraph(null);
23692428
} else {
23702429
// completed, failed, or paused-with-waitingForInput —
23712430
// handleRunResult covers all three cases
@@ -2393,8 +2452,10 @@ export class WorkflowEditor {
23932452
// 404 — run is genuinely gone from server and disk
23942453
this.clearRunId();
23952454
this.setWorkflowState('idle');
2455+
this.setActiveRunGraph(null);
23962456
return;
23972457
}
2458+
this.syncActiveRunGraphFromResult(result);
23982459
// Re-render chat if new log entries arrived since last poll
23992460
const logs = Array.isArray(result.logs) ? result.logs : [];
24002461
if (logs.length > knownLogCount) {
@@ -2434,6 +2495,7 @@ export class WorkflowEditor {
24342495
this.appendStatusMessage('Failed', 'failed');
24352496
this.hideAgentSpinner();
24362497
this.setWorkflowState('idle');
2498+
this.setActiveRunGraph(null);
24372499
} finally {
24382500
if (this.activeRunController === controller) {
24392501
this.activeRunController = null;

apps/web/src/workflow-editor.css

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -636,7 +636,7 @@ path.connection-line.active {
636636
}
637637

638638
.prompt-highlight-wrapper.is-editing .prompt-highlight-input::selection {
639-
background: rgba(92, 148, 255, 0.24);
639+
background: color-mix(in srgb, var(--Colors-Primary-Medium) 24%, transparent);
640640
color: inherit;
641641
-webkit-text-fill-color: inherit;
642642
}

packages/types/src/index.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ export interface WorkflowRunResult {
3434
state: Record<string, unknown>;
3535
waitingForInput: boolean;
3636
currentNodeId: string | null;
37+
workflow?: WorkflowGraph;
3738
}
3839
export interface ApprovalInput {
3940
decision: 'approve' | 'reject';

packages/types/src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ export interface WorkflowRunResult {
4141
state: Record<string, unknown>;
4242
waitingForInput: boolean;
4343
currentNodeId: string | null;
44+
workflow?: WorkflowGraph;
4445
}
4546

4647
export interface WorkflowRunRecord {
@@ -60,4 +61,3 @@ export interface ApprovalInput {
6061
}
6162

6263
export interface WorkflowEngineResult extends WorkflowRunResult {}
63-

0 commit comments

Comments
 (0)