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';