From 01e4d988275027d4fc6119cb23080c3e9496e08e Mon Sep 17 00:00:00 2001 From: Endri Bezati Date: Fri, 2 Oct 2026 15:04:37 +0200 Subject: [PATCH] Go to Error: from any view, to where a run failed deep in the hierarchy A run failing deep in a nested hierarchy showed its error only as a name, matched against whatever view was open: a node merely named like the one that failed was marked, the nested workflow containing the failure was not, and there was no way from the root to it. With the failure's instance path in the overlay (huawei-csl/wfpy#46), each view now marks the node the failure is in -- the actor that failed, or the nested workflow containing it, which carries `wf:errorWithin` -- and the root carries `wf:errorPath`. Right-clicking that nested workflow, or the canvas when the failure is out of view, offers "Go to Error": it opens the view the failure is in, at the trail a drill-down would give (built from the hierarchy outline), and selects the node that failed once the view has loaded (`FocusAfterLoadService`). The trail helpers move to the shared package so the server's menu and the client's outline build trails the same way. The outline panel imports SelectAction/CenterAction from @eclipse-glsp/protocol, as the rest of the client does. Claude-Session: https://claude.ai/code/session_015VK7fH1c4aKbexnq2QcuKU --- .../src/editing-action-handlers.ts | 55 +++++- .../diagram-client/src/focus-after-load.ts | 40 +++++ .../src/hierarchy-outline-tree.ts | 28 +--- .../diagram-client/src/hierarchy-outline.ts | 3 +- .../src/stock-features.module.ts | 8 + .../test/container-parity.test.ts | 7 +- .../fixtures/container-parity.baseline.json | 16 ++ .../diagram-client/test/go-to-error.test.ts | 60 +++++++ .../src/server/context-menu-item-provider.ts | 61 ++++++- .../src/server/source-model-storage.ts | 41 ++++- .../diagram-server/test/go-to-error.test.ts | 157 ++++++++++++++++++ packages/shared/src/hierarchy.ts | 57 +++++++ packages/shared/src/index.ts | 1 + 13 files changed, 500 insertions(+), 34 deletions(-) create mode 100644 packages/diagram-client/src/focus-after-load.ts create mode 100644 packages/diagram-client/test/go-to-error.test.ts create mode 100644 packages/diagram-server/test/go-to-error.test.ts create mode 100644 packages/shared/src/hierarchy.ts diff --git a/packages/diagram-client/src/editing-action-handlers.ts b/packages/diagram-client/src/editing-action-handlers.ts index 1f25fb9..fc5dd89 100644 --- a/packages/diagram-client/src/editing-action-handlers.ts +++ b/packages/diagram-client/src/editing-action-handlers.ts @@ -1,10 +1,11 @@ import { inject, injectable, optional } from 'inversify'; import { EditorContextService, IActionDispatcher, IGridManager, TYPES } from '@eclipse-glsp/client'; -import { Action as GlspAction, ApplyLabelEditOperation, ICommand, IActionHandler, NavigateToExternalTargetAction } from '@eclipse-glsp/sprotty'; +import { Action as GlspAction, ApplyLabelEditOperation, ICommand, IActionHandler, NavigateToExternalTargetAction, RequestModelAction } from '@eclipse-glsp/sprotty'; import { VscodeUi, type VscodeQuickPickItem } from './vscode-ui'; import { clientBehavior, commandId, settingsNamespace } from './profile'; import { rerunActorFor } from './navigation-ui'; import { NETWORK_NAME_ARG, OPEN_DIAGRAM_ARG } from './network-navigation-target'; +import { FocusAfterLoadService } from './focus-after-load'; import { EXECUTION_OVERLAY_ACTION_KIND, type ExecutionOverlayActionPayload } from '@dialogram/shared'; export type WorkflowWorkspaceEntity = { @@ -104,6 +105,24 @@ export namespace WorkflowOpenInOwnEditorAction { } } +export namespace WorkflowGoToErrorAction { + export const KIND = 'dialogram.goToError'; + + export interface Action extends GlspAction { + kind: typeof KIND; + /** From the editor's root to the view the failure is in. */ + trail: Array<{ sourceUri: string; workflowName: string; workflowInstanceName?: string }>; + /** The node in that view that failed. */ + nodeName: string; + } + + export function is(action: unknown): action is Action { + return !!action && typeof action === 'object' && (action as any).kind === KIND + && Array.isArray((action as any).trail) && (action as any).trail.length > 0 + && typeof (action as any).nodeName === 'string'; + } +} + export namespace WorkflowRenameEntityOperation { export const KIND = 'dialogram.renameEntity'; @@ -1266,3 +1285,37 @@ export class WorkflowOpenInOwnEditorActionHandler implements IActionHandler { }) as never); } } + + +/** + * "Go to Error": open the view where the shown run failed, at the trail a + * drill-down would give it, and select the node that failed once it loads. + */ +@injectable() +export class WorkflowGoToErrorActionHandler implements IActionHandler { + @inject(TYPES.IActionDispatcher) + protected readonly actionDispatcher!: IActionDispatcher; + + @inject(EditorContextService) + protected readonly editorContext!: EditorContextService; + + @inject(FocusAfterLoadService) + protected readonly focusAfterLoad!: FocusAfterLoadService; + + handle(action: GlspAction): ICommand | GlspAction | void { + if (!WorkflowGoToErrorAction.is(action)) { + return; + } + const view = action.trail[action.trail.length - 1]; + this.focusAfterLoad.focusWhenLoaded(action.nodeName); + void this.actionDispatcher.dispatch(RequestModelAction.create({ + requestId: `go-to-error-${Date.now()}`, + options: { + sourceUri: view.sourceUri, + diagramType: this.editorContext.diagramType, + networkName: view.workflowName, + 'wf:navTrail': JSON.stringify(action.trail) + } + }) as never); + } +} diff --git a/packages/diagram-client/src/focus-after-load.ts b/packages/diagram-client/src/focus-after-load.ts new file mode 100644 index 0000000..1afc19c --- /dev/null +++ b/packages/diagram-client/src/focus-after-load.ts @@ -0,0 +1,40 @@ +/** + * Select and center a node once the view it is in has loaded. + * + * Opening another view is a model request; the node exists only when the new + * model arrives. "Go to Error" asks for the failing node here before opening + * its view, and this listener selects it when the view comes in. + */ +import { inject, injectable } from 'inversify'; +import { TYPES, type IActionDispatcher } from '@eclipse-glsp/client'; +import { CenterAction, SelectAction } from '@eclipse-glsp/protocol'; +import { findNodeIdByEntityName } from './hierarchy-outline'; + +@injectable() +export class FocusAfterLoadService { + @inject(TYPES.IActionDispatcher) protected readonly dispatcher!: IActionDispatcher; + + private pending: string | undefined; + + /** Select the node named `entityName` in the next view that has it. */ + focusWhenLoaded(entityName: string): void { + this.pending = entityName; + } + + modelRootChanged(root: unknown): void { + const name = this.pending; + if (!name) { + return; + } + const id = findNodeIdByEntityName(root, name); + if (!id) { + return; // not this view: keep waiting for the one that has it + } + this.pending = undefined; + // After the view has rendered it. + setTimeout(() => { + void this.dispatcher.dispatch(SelectAction.create({ selectedElementsIDs: [id] }) as never); + void this.dispatcher.dispatch(CenterAction.create([id], { animate: true, retainZoom: true }) as never); + }, 0); + } +} diff --git a/packages/diagram-client/src/hierarchy-outline-tree.ts b/packages/diagram-client/src/hierarchy-outline-tree.ts index 56a1a0c..d10c22a 100644 --- a/packages/diagram-client/src/hierarchy-outline-tree.ts +++ b/packages/diagram-client/src/hierarchy-outline-tree.ts @@ -3,7 +3,7 @@ * testable headlessly (the panel imports GLSP values and cannot load without a * DOM) -- the split mlir-viewer's outline uses, for the same reason. */ -import type { HierarchyOutlineEntry } from '@dialogram/shared'; +import { hierarchyEntryAt, hierarchyTrailTo, type HierarchyOutlineEntry } from '@dialogram/shared'; export interface OutlineCrumb { sourceUri: string; @@ -37,30 +37,14 @@ export function outlineMatches(entry: HierarchyOutlineEntry, query: string): boo } /** The entry at `path`, if the outline has it. */ -export function entryAt(root: HierarchyOutlineEntry, path: string[]): HierarchyOutlineEntry | undefined { - let current: HierarchyOutlineEntry | undefined = root; - for (const name of path) { - current = current?.children.find(child => child.path[child.path.length - 1] === name); - } - return current; -} +export const entryAt = hierarchyEntryAt; /** - * The navigation trail to the instance at `path`: the root, then one crumb per - * instance on the way. What a breadcrumb and a drill-down produce, so a view - * opened from the outline is the same view either way would open. + * The navigation trail to the instance at `path` -- see `hierarchyTrailTo`, + * shared with the server's "Go to Error". */ -export function trailTo(root: HierarchyOutlineEntry, rootSourceUri: string, path: string[]): OutlineCrumb[] | undefined { - const trail: OutlineCrumb[] = [{ sourceUri: root.sourceUri ?? rootSourceUri, workflowName: root.workflowName }]; - for (let i = 1; i <= path.length; i++) { - const entry = entryAt(root, path.slice(0, i)); - if (!entry?.sourceUri) { - return undefined; - } - trail.push({ sourceUri: entry.sourceUri, workflowName: entry.workflowName, workflowInstanceName: path[i - 1] }); - } - return trail; -} +export const trailTo: (root: HierarchyOutlineEntry, rootSourceUri: string, path: string[]) => OutlineCrumb[] | undefined = + hierarchyTrailTo; /** The path the shown view is at, from its trail. */ export function pathOfTrail(trail: Array<{ workflowName: string; workflowInstanceName?: string }>): string[] { diff --git a/packages/diagram-client/src/hierarchy-outline.ts b/packages/diagram-client/src/hierarchy-outline.ts index f0897f4..d90ee98 100644 --- a/packages/diagram-client/src/hierarchy-outline.ts +++ b/packages/diagram-client/src/hierarchy-outline.ts @@ -17,7 +17,8 @@ */ import { inject, injectable } from 'inversify'; import { EditorContextService, TYPES, type IActionDispatcher } from '@eclipse-glsp/client'; -import { CenterAction, RequestModelAction, SelectAction } from '@eclipse-glsp/sprotty'; +import { RequestModelAction } from '@eclipse-glsp/sprotty'; +import { CenterAction, SelectAction } from '@eclipse-glsp/protocol'; import { WorkflowDiagramMetadata, type HierarchyOutlineEntry } from '@dialogram/shared'; import { navigatesInPlace } from './navigation-ui'; import { diff --git a/packages/diagram-client/src/stock-features.module.ts b/packages/diagram-client/src/stock-features.module.ts index d2dd964..830b45e 100644 --- a/packages/diagram-client/src/stock-features.module.ts +++ b/packages/diagram-client/src/stock-features.module.ts @@ -24,6 +24,7 @@ import { WorkflowElkLiveDragRouter } from './elk-live-drag-router'; import { WorkflowChangeBoundsTool } from './change-bounds-drag-threshold'; import { LibavoidEdgeRouter } from './libavoid-edge-router'; import { HierarchyOutlinePanel } from './hierarchy-outline'; +import { FocusAfterLoadService } from './focus-after-load'; import { preloadLibavoid } from './libavoid-loader'; import { WorkflowPromptLabelEditAction, @@ -36,6 +37,8 @@ import { WorkflowRerunFromHereActionHandler, WorkflowOpenInOwnEditorAction, WorkflowOpenInOwnEditorActionHandler, + WorkflowGoToErrorAction, + WorkflowGoToErrorActionHandler, WorkflowEditParametersAction, WorkflowEditParametersActionHandler, WorkflowEditAnnotationsAction, @@ -71,6 +74,10 @@ export const workflowFeaturesModule = new ContainerModule((bind, unbind, isBound bind(HierarchyOutlinePanel).toSelf().inSingletonScope(); bind(TYPES.IGModelRootListener).toService(HierarchyOutlinePanel); + // Selects a node once the view it is in has loaded ("Go to Error"). + bind(FocusAfterLoadService).toSelf().inSingletonScope(); + bind(TYPES.IGModelRootListener).toService(FocusAfterLoadService); + // Register integrated chat panel (slides up from bottom of diagram). // Bound as an IDiagramStartup so GLSP eagerly instantiates it on diagram // startup — a plain singleton binding is never resolved, so the panel and @@ -112,6 +119,7 @@ export const workflowFeaturesModule = new ContainerModule((bind, unbind, isBound configureActionHandler(context, WorkflowPromptRenameEntityAction.KIND, WorkflowPromptRenameEntityActionHandler); configureActionHandler(context, WorkflowRerunFromHereAction.KIND, WorkflowRerunFromHereActionHandler); configureActionHandler(context, WorkflowOpenInOwnEditorAction.KIND, WorkflowOpenInOwnEditorActionHandler); + configureActionHandler(context, WorkflowGoToErrorAction.KIND, WorkflowGoToErrorActionHandler); configureActionHandler(context, WorkflowEditParametersAction.KIND, WorkflowEditParametersActionHandler); configureActionHandler(context, WorkflowEditAnnotationsAction.KIND, WorkflowEditAnnotationsActionHandler); configureActionHandler(context, WorkflowShowWorkspaceEntitiesAction.KIND, WorkflowShowWorkspaceEntitiesActionHandler); diff --git a/packages/diagram-client/test/container-parity.test.ts b/packages/diagram-client/test/container-parity.test.ts index e82fbec..6aa4710 100644 --- a/packages/diagram-client/test/container-parity.test.ts +++ b/packages/diagram-client/test/container-parity.test.ts @@ -15,13 +15,14 @@ * * The baseline is no longer a pure capture of that commit: features added since * the split are appended to it deliberately, and the fixture currently carries - * six additions — `IEdgeRouter -> LibavoidEdgeRouter` (the client-side live + * seven additions — `IEdgeRouter -> LibavoidEdgeRouter` (the client-side live * routing tier), `ChangeBoundsTool -> WorkflowChangeBoundsTool` (the mouse-drag * threshold) and `IDiagramStartup -> WorkflowPaletteIconStartup` (draws the * palette icons a product contributed), the `dialogram.rerunFromHere` action handler * (resumes the shown run before a node's last firing) and the `dialogram.openInOwnEditor` - * one (opens a nested workflow's file as a root of its own), and the hierarchy outline panel - * (bound to itself and as a model-root listener) — plus one change: `label:boundary:type` is bound to + * one (opens a nested workflow's file as a root of its own), the hierarchy outline panel + * (bound to itself and as a model-root listener), and "Go to Error" (its action handler and + * the focus-after-load service, bound the same two ways) — plus one change: `label:boundary:type` is bound to * the non-editable `BoundaryLabel`, because editing it renamed the port. * The oracle still does its job: it fails on any binding this composition gains * or loses, and updating the fixture is the deliberate act of accepting one. diff --git a/packages/diagram-client/test/fixtures/container-parity.baseline.json b/packages/diagram-client/test/fixtures/container-parity.baseline.json index 4137239..242283f 100644 --- a/packages/diagram-client/test/fixtures/container-parity.baseline.json +++ b/packages/diagram-client/test/fixtures/container-parity.baseline.json @@ -396,5 +396,21 @@ "id": "Symbol(stub.IGModelRootListener)", "op": "bind", "to": "service:HierarchyOutlinePanel" + }, + { + "handler": "WorkflowGoToErrorActionHandler", + "kind": "dialogram.goToError", + "op": "actionHandler" + }, + { + "id": "FocusAfterLoadService", + "op": "bind", + "scope": "singleton", + "to": "self" + }, + { + "id": "Symbol(stub.IGModelRootListener)", + "op": "bind", + "to": "service:FocusAfterLoadService" } ] diff --git a/packages/diagram-client/test/go-to-error.test.ts b/packages/diagram-client/test/go-to-error.test.ts new file mode 100644 index 0000000..3e5cf65 --- /dev/null +++ b/packages/diagram-client/test/go-to-error.test.ts @@ -0,0 +1,60 @@ +/** + * "Go to Error" on the client: open the view where the run failed, then select + * the node that failed once that view has loaded. + */ +import { describe, expect, it, vi } from 'vitest'; +import { WorkflowDiagramMetadata } from '@dialogram/shared'; +import { WorkflowGoToErrorActionHandler } from '../src/editing-action-handlers'; +import { FocusAfterLoadService } from '../src/focus-after-load'; + +const trail = [ + { sourceUri: 'file:///w/top.py', workflowName: 'top' }, + { sourceUri: 'file:///w/mid.py', workflowName: 'middle', workflowInstanceName: 'm' } +]; + +describe('Go to Error', () => { + it('opens the failure’s view at its trail and waits to select the node', () => { + const handler: any = new WorkflowGoToErrorActionHandler(); + const dispatch = vi.fn(); + const focusWhenLoaded = vi.fn(); + handler.actionDispatcher = { dispatch }; + handler.editorContext = { diagramType: 'workflow-diagram' }; + handler.focusAfterLoad = { focusWhenLoaded }; + + handler.handle({ kind: 'dialogram.goToError', trail, nodeName: 'x' }); + + expect(focusWhenLoaded).toHaveBeenCalledWith('x'); + const request = dispatch.mock.calls[0][0]; + expect(request.options.sourceUri).toBe('file:///w/mid.py'); + expect(request.options.networkName).toBe('middle'); + expect(JSON.parse(request.options['wf:navTrail'])).toEqual(trail); + }); +}); + +describe('selecting a node once its view loads', () => { + const view = (...names: string[]) => ({ + id: 'root', + children: names.map(name => ({ id: `node-${name}`, args: { [WorkflowDiagramMetadata.ENTITY_NAME]: name } })) + }); + + it('waits for the view that has it, then selects and centers it once', async () => { + const service: any = new FocusAfterLoadService(); + const dispatch = vi.fn(); + service.dispatcher = { dispatch }; + + service.focusWhenLoaded('x'); + service.modelRootChanged(view('a', 'b')); // the view being left + await new Promise(resolve => setTimeout(resolve, 0)); + expect(dispatch).not.toHaveBeenCalled(); + + service.modelRootChanged(view('x', 'y')); + await new Promise(resolve => setTimeout(resolve, 0)); + expect(dispatch.mock.calls.map(c => c[0].kind)).toEqual(['elementSelected', 'center']); + expect(dispatch.mock.calls[0][0].selectedElementsIDs).toEqual(['node-x']); + + dispatch.mockClear(); + service.modelRootChanged(view('x')); + await new Promise(resolve => setTimeout(resolve, 0)); + expect(dispatch).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/diagram-server/src/server/context-menu-item-provider.ts b/packages/diagram-server/src/server/context-menu-item-provider.ts index 5476102..a51d5b0 100644 --- a/packages/diagram-server/src/server/context-menu-item-provider.ts +++ b/packages/diagram-server/src/server/context-menu-item-provider.ts @@ -8,7 +8,13 @@ import { NavigateToExternalTargetAction, Point } from '@eclipse-glsp/protocol'; -import { WorkflowDiagramMetadata, WorkflowDiagramTypes } from '@dialogram/shared'; +import { + hierarchyTrailTo, + WorkflowDiagramMetadata, + WorkflowDiagramTypes, + type HierarchyCrumb, + type HierarchyOutlineEntry +} from '@dialogram/shared'; const SHOW_OPTIONS_ARG = 'jsonOpenerOptions'; const NAVIGATE_PREFER_DEFINITION_ARG = 'wf:navigatePreferDefinition'; @@ -21,6 +27,46 @@ const EDIT_PARAMETERS_KIND = 'dialogram.editParameters'; const PROMPT_RENAME_ENTITY_KIND = 'dialogram.promptRenameEntity'; const RERUN_FROM_HERE_KIND = 'dialogram.rerunFromHere'; const OPEN_IN_OWN_EDITOR_KIND = 'dialogram.openInOwnEditor'; +const GO_TO_ERROR_KIND = 'dialogram.goToError'; + +/** + * "Go to Error": the action that opens the view where the shown run failed and + * selects the node that did, built from the hierarchy on the root (`wf:hierarchy`) + * and the failure's instance path (`wf:errorPath`). Nothing when the root does + * not carry both, or the failure is already in the view shown. + */ +export function goToErrorAction(rootArgs: Args | undefined): { kind: string; trail: HierarchyCrumb[]; nodeName: string } | undefined { + const errorPath = rootArgs?.['wf:errorPath'] as unknown; + const rawHierarchy = rootArgs?.['wf:hierarchy']; + if (!Array.isArray(errorPath) || errorPath.length === 0 || typeof rawHierarchy !== 'string') { + return undefined; + } + let outline: HierarchyOutlineEntry; + try { + outline = JSON.parse(rawHierarchy); + } catch { + return undefined; + } + const viewPath = (() => { + try { + const trail = JSON.parse(String(rootArgs?.['wf:navTrail'] ?? '[]')); + return Array.isArray(trail) + ? trail.map((c: any) => (typeof c?.workflowInstanceName === 'string' ? c.workflowInstanceName : '')).filter(Boolean) + : []; + } catch { + return []; + } + })(); + const parentPath = (errorPath as string[]).slice(0, -1); + if (parentPath.join('/') === viewPath.join('/')) { + return undefined; + } + const trail = hierarchyTrailTo(outline, outline.sourceUri ?? '', parentPath); + if (!trail) { + return undefined; + } + return { kind: GO_TO_ERROR_KIND, trail, nodeName: (errorPath as string[])[errorPath.length - 1] }; +} const RESET_EDGE_ROUTES_KIND = 'dialogram.resetEdgeRoutes'; const REROUTE_EDGES_AVOID_OVERLAPS_KIND = 'dialogram.rerouteEdgesAvoidOverlaps'; const LAYOUT_BOUNDARY_FLOW_KIND = 'dialogram.layoutBoundaryFlow'; @@ -296,6 +342,14 @@ export class WorkflowContextMenuItemProvider extends ContextMenuItemProvider { elementType === WorkflowDiagramTypes.NODE_EXTERNAL_ACTOR ) { const entityName = elementArgs?.[WorkflowDiagramMetadata.ENTITY_NAME]; + // The node a failure deep down is inside: go to where it failed. + const errorWithin = elementArgs?.['wf:errorWithin'] as unknown; + if (Array.isArray(errorWithin) && errorWithin.length > 0) { + const goToError = goToErrorAction((this.modelState.root as any)?.args as Args | undefined); + if (goToError) { + items.push({ id: 'dialogram.goToError', label: 'Go to Error', sortString: 'a0', actions: [goToError as any] }); + } + } // A nested workflow defined in another file can be opened as a root // of its own: its own editor, chat and runs. Navigating in place, a // double-click shows it here instead, so this is the way to the old @@ -419,6 +473,11 @@ export class WorkflowContextMenuItemProvider extends ContextMenuItemProvider { } if (selectedElementIds.length === 0) { + // The shown run failed somewhere this view does not show. + const goToError = goToErrorAction((this.modelState.root as any)?.args as Args | undefined); + if (goToError) { + items.push({ id: 'dialogram.goToError', label: 'Go to Error', sortString: 'a0', actions: [goToError as any] }); + } items.push({ id: 'dialogram.layoutDiagramBoundaryFlow', label: 'Layout Diagram', diff --git a/packages/diagram-server/src/server/source-model-storage.ts b/packages/diagram-server/src/server/source-model-storage.ts index a2d4292..9401779 100644 --- a/packages/diagram-server/src/server/source-model-storage.ts +++ b/packages/diagram-server/src/server/source-model-storage.ts @@ -18,6 +18,7 @@ import { RequestModelAction, SaveModelAction } from '@eclipse-glsp/protocol'; import { inject, injectable, optional } from 'inversify'; import { URI } from 'vscode-uri'; import { instanceLayoutFor, type LayoutTarget } from './layout-target'; +import { errorInView } from '@dialogram/shared'; import * as path from 'node:path'; import { promises as fs } from 'node:fs'; import { @@ -1135,9 +1136,28 @@ export class WorkflowSourceModelStorage implements SourceModelStorage { // The failure happened after the trace's last step: at an earlier // step the node had not failed yet. const showsLastStep = !queueTrace || queueTrace.selectedStep >= queueTrace.stepCount - 1; - const errorEntityName = showsLastStep && typeof overlay.error?.entityInstanceName === 'string' - ? overlay.error.entityInstanceName.trim() - : undefined; + // Where the run failed, by instance path from the root. In this + // view it shows on the node it is in: the actor that failed, or the + // nested workflow containing it (which "Go to Error" opens). A run + // whose error carries no path is matched by name, as before. + const rawErrorPath = overlay.error?.entityInstancePath; + const errorPath = showsLastStep && Array.isArray(rawErrorPath) + ? rawErrorPath.filter((part): part is string => typeof part === 'string' && part.trim() !== '') + : []; + const viewInstancePath = navigationTrail + .map(entry => entry.workflowInstanceName?.trim() ?? '') + .filter(part => part !== ''); + const errorLocation = errorPath.length > 0 ? errorInView(errorPath, viewInstancePath) : undefined; + const errorEntityName = errorPath.length > 0 + ? errorLocation?.nodeName + : showsLastStep && typeof overlay.error?.entityInstanceName === 'string' + ? overlay.error.entityInstanceName.trim() + : undefined; + if (errorPath.length > 0) { + (root as any).args['wf:errorPath'] = errorPath; + } else { + delete (root as any).args['wf:errorPath']; + } // ── Pre-pass: clear stale execution-glow state synchronously ───── // This MUST happen immediately before the visitor that re-applies @@ -1235,6 +1255,10 @@ export class WorkflowSourceModelStorage implements SourceModelStorage { if (matchesError && errorEntityName) { element.args = element.args || {}; (element.args as any)[WorkflowDiagramMetadata.IS_ERRORED] = true; + if (errorLocation && errorLocation.within.length > 0) { + // The failure is inside this node, this far down. + (element.args as any)['wf:errorWithin'] = errorLocation.within; + } const cssClasses = Array.isArray(element.cssClasses) ? element.cssClasses : []; if (!cssClasses.includes('cal-node-error')) { element.cssClasses = [...cssClasses, 'cal-node-error']; @@ -2225,7 +2249,7 @@ export class WorkflowSourceModelStorage implements SourceModelStorage { running: boolean; edges: Record; active?: Array<{ entityInstanceName?: string; entityInstancePath?: string[] }>; - error?: { entityInstanceName?: string; message?: string }; + error?: { entityInstanceName?: string; entityInstancePath?: string[]; message?: string }; } | undefined> { const vscode = await import('vscode'); const fs = await import('fs/promises'); @@ -2270,7 +2294,7 @@ export class WorkflowSourceModelStorage implements SourceModelStorage { running: boolean; edges: Record; active?: Array<{ entityInstanceName?: string; entityInstancePath?: string[] }>; - error?: { entityInstanceName?: string; message?: string }; + error?: { entityInstanceName?: string; entityInstancePath?: string[]; message?: string }; } | undefined> => { let overlayText: string; try { @@ -2332,6 +2356,11 @@ export class WorkflowSourceModelStorage implements SourceModelStorage { const error = errorRaw && typeof errorRaw === 'object' ? { entityInstanceName: typeof errorRaw.entityInstanceName === 'string' ? errorRaw.entityInstanceName : undefined, + // Where it failed under the nested workflows: what places + // the error in each view and what "Go to Error" follows. + ...(Array.isArray(errorRaw.entityInstancePath) + ? { entityInstancePath: errorRaw.entityInstancePath.filter((p: unknown): p is string => typeof p === 'string') } + : {}), message: typeof errorRaw.message === 'string' ? errorRaw.message : undefined } : undefined; @@ -2352,7 +2381,7 @@ export class WorkflowSourceModelStorage implements SourceModelStorage { running: boolean; edges: Record; active?: Array<{ entityInstanceName?: string; entityInstancePath?: string[] }>; - error?: { entityInstanceName?: string; message?: string }; + error?: { entityInstanceName?: string; entityInstancePath?: string[]; message?: string }; } | undefined> => { if (preferredRunId && entry.runId !== preferredRunId) { return undefined; diff --git a/packages/diagram-server/test/go-to-error.test.ts b/packages/diagram-server/test/go-to-error.test.ts new file mode 100644 index 0000000..84a5e0a --- /dev/null +++ b/packages/diagram-server/test/go-to-error.test.ts @@ -0,0 +1,157 @@ +/** + * "Go to Error": a run failed deep in a nested hierarchy. Each view marks the + * node the failure is in -- the actor that failed, or the nested workflow + * containing it -- and right-clicking that node (or the canvas, when the + * failure is not in view) offers to go to it. + */ +import { afterEach, describe, expect, it } from 'vitest'; +import * as fs from 'node:fs/promises'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { GModelRoot, GNode } from '@eclipse-glsp/server'; +import { WorkflowDiagramMetadata, WorkflowDiagramTypes, errorInView, hierarchyTrailTo } from '@dialogram/shared'; +import { WorkflowSourceModelStorage } from '../src/server/source-model-storage'; +import { WorkflowContextMenuItemProvider, goToErrorAction } from '../src/server/context-menu-item-provider'; + +const TOP = 'file:///w/top.py'; +const MID = 'file:///w/mid.py'; +const INNER = 'file:///w/inner.py'; +const outline = { + path: [], workflowName: 'top', sourceUri: TOP, + children: [{ + path: ['m'], workflowName: 'middle', sourceUri: MID, + children: [{ path: ['m', 'i'], workflowName: 'inner', sourceUri: INNER, children: [] }] + }] +}; +const ERROR_PATH = ['m', 'i', 'x']; + +describe('where a failure shows in a view', () => { + it('is the node of the view it is in, with what lies inside it', () => { + expect(errorInView(ERROR_PATH, [])).toEqual({ nodeName: 'm', within: ['i', 'x'] }); + expect(errorInView(ERROR_PATH, ['m'])).toEqual({ nodeName: 'i', within: ['x'] }); + expect(errorInView(ERROR_PATH, ['m', 'i'])).toEqual({ nodeName: 'x', within: [] }); + }); + + it('is nowhere in a view it is not under', () => { + expect(errorInView(ERROR_PATH, ['other'])).toBeUndefined(); + expect(errorInView(ERROR_PATH, ['m', 'i', 'x'])).toBeUndefined(); + }); + + it('is reached by the trail a drill-down would give', () => { + expect(hierarchyTrailTo(outline, TOP, ['m', 'i'])).toEqual([ + { sourceUri: TOP, workflowName: 'top' }, + { sourceUri: MID, workflowName: 'middle', workflowInstanceName: 'm' }, + { sourceUri: INNER, workflowName: 'inner', workflowInstanceName: 'i' } + ]); + }); +}); + +const tempDirs: string[] = []; +afterEach(async () => { + await Promise.all(tempDirs.splice(0).map(dir => fs.rm(dir, { recursive: true, force: true }))); +}); + +async function markAt(viewTrail: object[]) { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'go-to-error-')); + tempDirs.push(dir); + const storage: any = new (WorkflowSourceModelStorage as any)(); + storage.storageOptions = { settingsNamespace: 'wf', operationPrefix: 'wf' }; + storage.isLiveExecutionGlowEnabled = async () => false; + storage.tryLoadLatestViewerOverlay = async () => ({ + runId: 'r', outDir: dir, running: false, active: [], edges: {}, + error: { entityInstanceName: 'x', entityInstancePath: ERROR_PATH, message: 'ValueError: deep down' } + }); + storage.ensureModelSource = () => ({ + analysis: { + buildOverlayAstPathCandidates: () => [], + buildOverlaySignatureCandidates: () => [], + buildOverlayNodeIdentityCandidates: (args: any) => (args?.name ? [args.name] : []), + resolveOverlayActiveEntityName: () => undefined + } + }); + const root = new GModelRoot(); + root.id = 'root'; + root.args = {}; + const node = (name: string) => { + const n = new GNode(); + n.id = name; + n.args = { name }; + return n; + }; + const nodes = { m: node('m'), i: node('i'), x: node('x'), other: node('other') }; + root.children = Object.values(nodes); + await storage.applyViewerOverlayToEdges(root, '/w/top.py', 'top', viewTrail, ['top'], undefined, false, ['/w/top.py']); + return { root, nodes }; +} + +const errored = (n: GNode) => (n.args as any)?.[WorkflowDiagramMetadata.IS_ERRORED] === true; + +describe('the error mark', () => { + it('is on the nested workflow containing the failure, in the root view', async () => { + const { root, nodes } = await markAt([{ sourceUri: TOP, workflowName: 'top' }]); + + expect(errored(nodes.m)).toBe(true); + expect((nodes.m.args as any)['wf:errorWithin']).toEqual(['i', 'x']); + // Not on a node merely named like the one that failed. + expect(errored(nodes.x)).toBe(false); + expect((root.args as any)['wf:errorPath']).toEqual(ERROR_PATH); + }); + + it('is on the actor that failed, in its own view', async () => { + const { nodes } = await markAt([ + { sourceUri: TOP, workflowName: 'top' }, + { sourceUri: MID, workflowName: 'middle', workflowInstanceName: 'm' }, + { sourceUri: INNER, workflowName: 'inner', workflowInstanceName: 'i' } + ]); + + expect(errored(nodes.x)).toBe(true); + expect((nodes.x.args as any)['wf:errorWithin']).toBeUndefined(); + expect(errored(nodes.m)).toBe(false); + }); +}); + +describe('Go to Error', () => { + const rootArgs = (viewTrail: object[]) => ({ + 'wf:errorPath': ERROR_PATH, + 'wf:hierarchy': JSON.stringify(outline), + 'wf:navTrail': JSON.stringify(viewTrail) + }); + + it('opens the view the failure is in and selects the node that failed', () => { + expect(goToErrorAction(rootArgs([{ sourceUri: TOP, workflowName: 'top' }]) as any)).toEqual({ + kind: 'dialogram.goToError', + trail: hierarchyTrailTo(outline, TOP, ['m', 'i']), + nodeName: 'x' + }); + }); + + it('is not offered in the view the failure already shows in, or without the hierarchy', () => { + const atFailure = rootArgs([ + { sourceUri: TOP, workflowName: 'top' }, + { sourceUri: MID, workflowName: 'middle', workflowInstanceName: 'm' }, + { sourceUri: INNER, workflowName: 'inner', workflowInstanceName: 'i' } + ]); + expect(goToErrorAction(atFailure as any)).toBeUndefined(); + expect(goToErrorAction({ 'wf:errorPath': ERROR_PATH } as any)).toBeUndefined(); + }); + + function menuFor(selected: string[], nodeArgs: Record = {}) { + const root: any = { id: 'root', type: 'graph', args: rootArgs([{ sourceUri: TOP, workflowName: 'top' }]) }; + const node: any = { + id: 'm', type: WorkflowDiagramTypes.NODE_NETWORK, + args: { [WorkflowDiagramMetadata.ENTITY_NAME]: 'm', ...nodeArgs }, parent: root + }; + const provider: any = new WorkflowContextMenuItemProvider(); + provider.modelState = { sourceUri: TOP, root, index: { find: (id: string) => (id === 'm' ? node : undefined) } }; + return provider.getItems(selected, { x: 0, y: 0 }).map((i: any) => i.id); + } + + it('is on the nested workflow the failure is inside', () => { + expect(menuFor(['m'], { 'wf:errorWithin': ['i', 'x'] })).toContain('dialogram.goToError'); + expect(menuFor(['m'])).not.toContain('dialogram.goToError'); + }); + + it('is on the canvas when the failure is out of view', () => { + expect(menuFor([])).toContain('dialogram.goToError'); + }); +}); diff --git a/packages/shared/src/hierarchy.ts b/packages/shared/src/hierarchy.ts new file mode 100644 index 0000000..eb04ed3 --- /dev/null +++ b/packages/shared/src/hierarchy.ts @@ -0,0 +1,57 @@ +/** + * Navigating a workflow hierarchy by instance path -- shared by the client + * (the outline, "Go to Error") and the server (the menu that offers it). + */ +import type { HierarchyOutlineEntry } from './diagram-seams'; + +export interface HierarchyCrumb { + sourceUri: string; + workflowName: string; + workflowInstanceName?: string; +} + +/** The entry at `path` (instance names from the root), if the outline has it. */ +export function hierarchyEntryAt(root: HierarchyOutlineEntry, path: string[]): HierarchyOutlineEntry | undefined { + let current: HierarchyOutlineEntry | undefined = root; + for (const name of path) { + current = current?.children.find(child => child.path[child.path.length - 1] === name); + } + return current; +} + +/** + * The navigation trail to the instance at `path`: the root, then one crumb per + * instance on the way -- what a drill-down and the breadcrumb produce, so a view + * opened this way is the view either would open. + */ +export function hierarchyTrailTo( + root: HierarchyOutlineEntry, + rootSourceUri: string, + path: string[] +): HierarchyCrumb[] | undefined { + const trail: HierarchyCrumb[] = [{ sourceUri: root.sourceUri ?? rootSourceUri, workflowName: root.workflowName }]; + for (let i = 1; i <= path.length; i++) { + const entry = hierarchyEntryAt(root, path.slice(0, i)); + if (!entry?.sourceUri) { + return undefined; + } + trail.push({ sourceUri: entry.sourceUri, workflowName: entry.workflowName, workflowInstanceName: path[i - 1] }); + } + return trail; +} + +/** + * Where a failure at `errorPath` shows in a view at `viewPath` (both instance + * paths from the root): the node of this view it is in, and what lies inside + * that node on the way to it (empty when that node is the one that failed). + * Nothing when the failure is not under this view. + */ +export function errorInView( + errorPath: string[], + viewPath: string[] +): { nodeName: string; within: string[] } | undefined { + if (errorPath.length <= viewPath.length || !viewPath.every((part, i) => part === errorPath[i])) { + return undefined; + } + return { nodeName: errorPath[viewPath.length], within: errorPath.slice(viewPath.length + 1) }; +} diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 13390c9..42311b7 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -12,5 +12,6 @@ export * from './port-stub'; export * from './boundary-port-geometry'; export * from './binding-keys'; export * from './diagram-seams'; +export * from './hierarchy'; export * from './chat-seams'; export * from './create-task-type-operation';