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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 12 additions & 3 deletions docs/proposals/single-editor-hierarchy.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Proposal: one editor for a whole workflow hierarchy

**Status:** phases 1 to 3 are implemented (in-place navigation, the root's run, the view in the chat's context, and editing nested views). Phases 4 and 5 (per-instance layout, the hierarchy export and the outline) are not.
**Status:** phases 1 to 4 are implemented (in-place navigation, the root's run, the view in the chat's context, editing nested views, and per-instance layout). Phase 5 (the hierarchy export and the outline) is not.
**Affects:** dialogram (most of it), wfpy (a hierarchy export), wfpy-ide (one
profile flag).

Expand Down Expand Up @@ -222,8 +222,17 @@ comes from the same export, and is the cheap part of it: names, files and sizes.
changed. A save or an on-disk change already refreshed every editor, and now
reaches the view shown. Unsaved edits to a nested file preview in the views
showing it.
4. **Layout per instance**, in the root's layout file, falling back to the
defining file's.
4. **Layout per instance.** *Done.* With `nestedNavigation: 'in-place'`
(now also in the storage options), a view at a trail of two or more crumbs
reads its layout from the root file's store, keyed
`root/<instance path>/<workflow>` (`layout-target.ts`). Until it has one, it
starts from the workflow's standalone layout. The target is recorded on the
diagram model at load (`layoutTarget`), and every handler that saves a
layout uses it (`layoutTargetOf`): moving a node, rerouting, resetting
routes, the layout commands, placing new nodes. Eight of them had each
recomputed the key from the shown workflow's name, so a hierarchical
runtime's per-instance layouts were read per instance and saved per
workflow. A rename also moves the node in the instance's layout.
5. **The hierarchy export, the cache and the outline.** wfpy `--hierarchy`,
instance-elaborated nested graphs, the server cache, then the outline panel.

Expand Down
13 changes: 2 additions & 11 deletions packages/diagram-server/src/operations/change-bounds-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ import { GEdge, GNode, GPort } from '@eclipse-glsp/graph';
import { ChangeBoundsOperation } from '@eclipse-glsp/protocol';
import { GModelChangeBoundsOperationHandler, GModelSerializer, GModelRecordingCommand, type Command, type MaybePromise } from '@eclipse-glsp/server';
import { inject, injectable } from 'inversify';
import { URI } from 'vscode-uri';

import { WorkflowDiagramMetadata } from '@dialogram/shared';
import { LayoutPersistenceService } from '../services/layout-persistence-service';
Expand All @@ -20,6 +19,7 @@ import {
WORKFLOW_REROUTE_EDGES_AVOID_OVERLAPS_OPERATION_KIND,
WorkflowRerouteEdgesAvoidOverlapsOperationHandler
} from './reroute-edges-avoid-overlaps-handler';
import { layoutTargetOf } from '../server/layout-target';

@injectable()
export class WorkflowChangeBoundsOperationHandler extends GModelChangeBoundsOperationHandler {
Expand Down Expand Up @@ -66,8 +66,7 @@ export class WorkflowChangeBoundsOperationHandler extends GModelChangeBoundsOper
return;
}

const workflowFilePath = URI.parse(diagramModel.documentUri).fsPath;
const networkId = this.getNetworkId(diagramModel);
const { filePath: workflowFilePath, networkId } = layoutTargetOf(diagramModel as any);

void (async () => {
const positions = (await this.layoutPersistence.loadLayout(workflowFilePath, networkId)) ?? new Map();
Expand Down Expand Up @@ -197,12 +196,4 @@ export class WorkflowChangeBoundsOperationHandler extends GModelChangeBoundsOper
}
return undefined;
}

private getNetworkId(diagramModel: WorkflowDiagramModel): string {
const explicit = (diagramModel as any)?.workflowName as string | undefined;
if (typeof explicit === 'string' && explicit.trim() !== '') {
return explicit.trim();
}
return 'unknown';
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,14 @@ import {
type MaybePromise
} from '@eclipse-glsp/server';
import { inject, injectable } from 'inversify';
import { URI } from 'vscode-uri';

import { WorkflowDiagramMetadata } from '@dialogram/shared';
import { LayoutPersistenceService } from '../services/layout-persistence-service';
import { WORKFLOW_LAYOUT_PERSISTENCE_KEY, WORKFLOW_NETWORK_MODEL_KEY } from '@dialogram/shared';
import { WorkflowDiagramConstants } from '@dialogram/shared';
import type { WorkflowDiagramModel } from '@dialogram/shared';
import { GModelSerializer } from '@eclipse-glsp/server';
import { layoutTargetOf } from '../server/layout-target';

@injectable()
export class WorkflowChangeRoutingPointsOperationHandler extends OperationHandler {
Expand Down Expand Up @@ -280,11 +280,7 @@ export class WorkflowChangeRoutingPointsOperationHandler extends OperationHandle
return;
}

const workflowFilePath = URI.parse(diagramModel.documentUri).fsPath;
const explicitName = (diagramModel as any).workflowName as string | undefined;
const networkId = explicitName && explicitName.trim() !== ''
? explicitName.trim()
: 'unknown';
const { filePath: workflowFilePath, networkId } = layoutTargetOf(diagramModel as any);

// Prefer persisted positions (stable keys) and only update routes.
const persistedPositions = await this.layoutPersistence.loadLayout(workflowFilePath, networkId);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ import {
GModelSerializer
} from '@eclipse-glsp/server';
import { inject, injectable, optional } from 'inversify';
import { URI } from 'vscode-uri';
import { LayoutPersistenceService } from '../services/layout-persistence-service';
import { WORKFLOW_LAYOUT_PERSISTENCE_KEY, WORKFLOW_NETWORK_MODEL_KEY } from '@dialogram/shared';
import type { WorkflowDiagramModel } from '@dialogram/shared';
Expand All @@ -22,6 +21,7 @@ import {
WORKFLOW_REROUTE_EDGES_AVOID_OVERLAPS_OPERATION_KIND,
WorkflowRerouteEdgesAvoidOverlapsOperationHandler
} from './reroute-edges-avoid-overlaps-handler';
import { layoutTargetOf } from '../server/layout-target';

export const WORKFLOW_LAYOUT_BOUNDARY_FLOW_OPERATION_KIND = 'dialogram.layoutBoundaryFlow' as const;

Expand Down Expand Up @@ -94,11 +94,7 @@ export class WorkflowLayoutBoundaryFlowOperationHandler extends OperationHandler
return;
}

const workflowFilePath = URI.parse(diagramModel.documentUri).fsPath;
const explicitName = (diagramModel as any).workflowName as string | undefined;
const networkId = explicitName && explicitName.trim() !== ''
? explicitName.trim()
: 'unknown';
const { filePath: workflowFilePath, networkId } = layoutTargetOf(diagramModel as any);
const positions = this.collectNodePositions(this.modelState.root);
const routes = this.collectEdgeRoutes(this.modelState.root);
await this.layoutPersistence.saveLayoutImmediate(workflowFilePath, networkId, positions, routes);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ import {
GModelSerializer
} from '@eclipse-glsp/server';
import { inject, injectable, optional } from 'inversify';
import { URI } from 'vscode-uri';
import { LayoutPersistenceService } from '../services/layout-persistence-service';
import {
WorkflowRerouteEdgesAvoidOverlapsOperationHandler,
Expand All @@ -24,6 +23,7 @@ import { WORKFLOW_LAYOUT_PERSISTENCE_KEY, WORKFLOW_NETWORK_MODEL_KEY } from '@di
import type { WorkflowDiagramModel } from '@dialogram/shared';
import { WorkflowDiagramMetadata, WorkflowDiagramTypes } from '@dialogram/shared';
import { WorkflowDiagramConstants } from '@dialogram/shared';
import { layoutTargetOf } from '../server/layout-target';

export const WORKFLOW_LAYOUT_SERPENTINE_MESH_OPERATION_KIND = 'dialogram.layoutSerpentineMesh' as const;

Expand Down Expand Up @@ -95,11 +95,7 @@ export class WorkflowLayoutSerpentineMeshOperationHandler extends OperationHandl
return;
}

const workflowFilePath = URI.parse(diagramModel.documentUri).fsPath;
const explicitName = (diagramModel as any).workflowName as string | undefined;
const networkId = explicitName && explicitName.trim() !== ''
? explicitName.trim()
: 'unknown';
const { filePath: workflowFilePath, networkId } = layoutTargetOf(diagramModel as any);
const positions = this.collectNodePositions(this.modelState.root);
const routes = this.collectEdgeRoutes(this.modelState.root);
await this.layoutPersistence.saveLayoutImmediate(workflowFilePath, networkId, positions, routes);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,14 @@ import {
type MaybePromise
} from '@eclipse-glsp/server';
import { inject, injectable } from 'inversify';
import { URI } from 'vscode-uri';
import { LayoutPersistenceService } from '../services/layout-persistence-service';
import { WORKFLOW_NETWORK_MODEL_KEY } from '@dialogram/shared';
import type { WorkflowDiagramModel } from '@dialogram/shared';
import { WorkflowDiagramMetadata } from '@dialogram/shared';
import { WorkflowDiagramConstants, portAnchor } from '@dialogram/shared';
import { GModelSerializer } from '@eclipse-glsp/server';
import { routeOrthogonal, type RouterConnector, type RouterObstacle } from '../routing/libavoid-router';
import { layoutTargetOf } from '../server/layout-target';

/** Every segment axis-aligned? A diagonal means the polyline is not a valid route. */
export function isOrthogonalPolyline(points: readonly { x: number; y: number }[]): boolean {
Expand Down Expand Up @@ -1029,8 +1029,7 @@ export class WorkflowRerouteEdgesAvoidOverlapsOperationHandler extends Operation
if (!operation.preview) {
const dm = this.modelState.get(WORKFLOW_NETWORK_MODEL_KEY) as WorkflowDiagramModel | undefined;
if (dm) {
const workflowFilePath = URI.parse(dm.documentUri).fsPath;
const networkId = (dm as any).workflowName ?? 'unknown';
const { filePath: workflowFilePath, networkId } = layoutTargetOf(dm as any);
const positions = this.collectNodePositions(root);
const routes = this.collectEdgeRoutes(root);
await this.layoutPersistence.saveLayoutImmediate(workflowFilePath, networkId, positions, routes);
Expand Down Expand Up @@ -1232,8 +1231,7 @@ export class WorkflowRerouteEdgesAvoidOverlapsOperationHandler extends Operation
const dm = this.modelState.get(WORKFLOW_NETWORK_MODEL_KEY) as WorkflowDiagramModel | undefined;
if (!dm) { return; }

const workflowFilePath = URI.parse(dm.documentUri).fsPath;
const networkId = (dm as any).workflowName ?? 'unknown';
const { filePath: workflowFilePath, networkId } = layoutTargetOf(dm as any);

const positions = this.collectNodePositions(root);
const routes = this.collectEdgeRoutes(root);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,12 @@ import {
type MaybePromise
} from '@eclipse-glsp/server';
import { inject, injectable } from 'inversify';
import { URI } from 'vscode-uri';
import { LayoutPersistenceService } from '../services/layout-persistence-service';
import { WORKFLOW_NETWORK_MODEL_KEY } from '@dialogram/shared';
import type { WorkflowDiagramModel } from '@dialogram/shared';
import { WorkflowDiagramMetadata } from '@dialogram/shared';
import { GModelSerializer } from '@eclipse-glsp/server';
import { layoutTargetOf } from '../server/layout-target';

export const WORKFLOW_RESET_EDGE_ROUTES_OPERATION_KIND = 'dialogram.resetEdgeRoutes' as const;

Expand Down Expand Up @@ -119,11 +119,7 @@ export class WorkflowResetEdgeRoutesOperationHandler extends OperationHandler {
return;
}

const workflowFilePath = URI.parse(diagramModel.documentUri).fsPath;
const explicitName = (diagramModel as any).workflowName as string | undefined;
const networkId = explicitName && explicitName.trim() !== ''
? explicitName.trim()
: 'unknown';
const { filePath: workflowFilePath, networkId } = layoutTargetOf(diagramModel as any);

// Prefer the already-persisted node map (it uses stable keys), falling back to the
// current model snapshot if no layout file exists yet.
Expand Down
8 changes: 2 additions & 6 deletions packages/diagram-server/src/server/diagram-action-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,6 @@ import {
UpdateModelAction
} from '@eclipse-glsp/protocol';
import { inject, injectable, optional } from 'inversify';
import { URI } from 'vscode-uri';
import {
WorkflowDiagramMetadata,
type WorkflowDiagramModel,
Expand All @@ -59,6 +58,7 @@ import {
import { clearAllEdgeRoutingPoints } from '../routing/clear-edge-routes';
import { releaseBoundaryNodeConstraints, restoreNodeLayoutOptions, runBoundaryFlowLayout } from '../operations/boundary-flow-layout';
import { perfNow } from './graph-load-perf';
import { layoutTargetOf } from './layout-target';

/**
* Custom ModelSubmissionHandler - minimal logging version.
Expand Down Expand Up @@ -1747,11 +1747,7 @@ export class WorkflowLayoutOperationHandler extends OperationHandler {
return;
}

const workflowFilePath = URI.parse(diagramModel.documentUri).fsPath;
const explicitName = (diagramModel as any)?.workflowName as string | undefined;
const networkId = explicitName && explicitName.trim() !== ''
? explicitName.trim()
: 'unknown';
const { filePath: workflowFilePath, networkId } = layoutTargetOf(diagramModel as any);
const positions = this.collectNodePositions(this.modelState.root);
const routes = this.collectEdgeRoutes(this.modelState.root);
await this.layoutPersistence.saveLayoutImmediate(workflowFilePath, networkId, positions, routes);
Expand Down
82 changes: 82 additions & 0 deletions packages/diagram-server/src/server/layout-target.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
/**
* Where a view's layout lives, and under which key.
*
* A workflow shown on its own keeps its layout in its file's layout store,
* keyed by its name. A nested workflow shown in place, as a view of an editor's
* root, is an *instance*: its layout belongs to the root, in the root file's
* store, keyed by the instance path from the root (`top/b2/block`). Two uses of
* one workflow can then be arranged differently, and the root owns the layout
* of everything it shows.
*
* An instance with no layout of its own yet starts from the workflow's
* standalone layout, so every layout made before keeps applying.
*/

import { URI } from 'vscode-uri';

export interface LayoutTarget {
/** The source file whose layout store holds it. */
filePath: string;
/** The key inside that store. */
networkId: string;
}

export interface InstanceLayout {
target: LayoutTarget;
/** Read when the target holds nothing yet: the workflow's standalone layout. */
fallback: LayoutTarget;
}

export interface TrailCrumb {
sourceUri: string;
workflowName: string;
workflowInstanceName?: string;
}

/**
* The instance layout for a view at `trail`, or nothing when the view is a
* root (a trail of one, or none) or the trail does not end at it.
*/
export function instanceLayoutFor(
trail: TrailCrumb[],
shown: { filePath: string; workflowName: string },
toFilePath: (sourceUri: string) => string
): InstanceLayout | undefined {
if (trail.length < 2) {
return undefined;
}
const root = trail[0];
const last = trail[trail.length - 1];
if (last.workflowName !== shown.workflowName || toFilePath(last.sourceUri) !== shown.filePath) {
return undefined;
}
const instancePath = trail.slice(1).map(crumb => crumb.workflowInstanceName?.trim() || crumb.workflowName);
return {
target: {
filePath: toFilePath(root.sourceUri),
// The shape a hierarchical runtime's layouts already use:
// root, instance path, then the workflow shown.
networkId: [root.workflowName, ...instancePath, shown.workflowName].join('/')
},
fallback: { filePath: shown.filePath, networkId: shown.workflowName }
};
}

/**
* The layout target of the loaded view: where its layout is read from and
* saved to. Recorded on the diagram model at load (`layoutTarget`) -- the
* instance target for a nested view in place, or a hierarchical runtime's --
* so every handler that saves a layout saves it where it was read. Without one,
* the shown file and workflow, as handlers computed it before.
*/
export function layoutTargetOf(
diagramModel: { documentUri: string; workflowName?: unknown; layoutTarget?: LayoutTarget }
): LayoutTarget {
if (diagramModel.layoutTarget) {
return diagramModel.layoutTarget;
}
const name = typeof diagramModel.workflowName === 'string' && diagramModel.workflowName.trim() !== ''
? diagramModel.workflowName.trim()
: 'unknown';
return { filePath: URI.parse(diagramModel.documentUri).fsPath, networkId: name };
}
29 changes: 26 additions & 3 deletions packages/diagram-server/src/server/source-model-storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
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 * as path from 'node:path';
import { promises as fs } from 'node:fs';
import {
Expand Down Expand Up @@ -440,7 +441,7 @@ export class WorkflowSourceModelStorage implements SourceModelStorage {
(diagramModel as any).workflowName = workflowName;
(diagramModel as any).documentUri = sourceUri;

const workflowFilePath = filePath;
let workflowFilePath = filePath;
// Qualify hierarchical network IDs so each instance-path gets its own layout
// (e.g. "qwen35_model/decoder_block/decoder_layer"). Only a hierarchical runtime ever
// supplies the root-workflow arg, so gating on its presence (rather than the settings
Expand All @@ -449,13 +450,32 @@ export class WorkflowSourceModelStorage implements SourceModelStorage {
const hierarchicalRoot = typeof opts[ROOT_WORKFLOW_ARG] === 'string' && String(opts[ROOT_WORKFLOW_ARG]).trim() !== ''
? String(opts[ROOT_WORKFLOW_ARG]).trim()
: undefined;
let fallbackLayout: LayoutTarget | undefined;
if (hierarchicalRoot) {
const hierarchicalInstancePath = parseStringListArg(opts[INSTANCE_PATH_ARG]);
const parts = [hierarchicalRoot, ...hierarchicalInstancePath, networkId];
networkId = parts.join('/');
} else if (this.storageOptions?.nestedNavigation === 'in-place') {
// A nested workflow shown in place is an instance of the editor's
// root: its layout is the root's, per instance, falling back to the
// workflow's standalone layout until it has one of its own.
const instance = instanceLayoutFor(
this.parseNavigationTrailArg(opts[NAV_TRAIL_ARG]),
{ filePath: workflowFilePath, workflowName: networkId },
uri => URI.parse(uri).fsPath
);
if (instance) {
workflowFilePath = instance.target.filePath;
networkId = instance.target.networkId;
fallbackLayout = instance.fallback;
}
}
let loadedLayoutPositions = await this.layoutPersistence.loadLayout(workflowFilePath, networkId);
let edgeRoutes = await this.layoutPersistence.loadEdgeRoutes(workflowFilePath, networkId);
if (!loadedLayoutPositions && fallbackLayout) {
loadedLayoutPositions = await this.layoutPersistence.loadLayout(fallbackLayout.filePath, fallbackLayout.networkId);
edgeRoutes = await this.layoutPersistence.loadEdgeRoutes(fallbackLayout.filePath, fallbackLayout.networkId);
}
const loadedLayoutPositions = await this.layoutPersistence.loadLayout(workflowFilePath, networkId);
const edgeRoutes = await this.layoutPersistence.loadEdgeRoutes(workflowFilePath, networkId);
const hasPersistedEdgeRoutes = edgeRoutes !== undefined;
perf?.mark('layoutIO');

Expand Down Expand Up @@ -519,6 +539,9 @@ export class WorkflowSourceModelStorage implements SourceModelStorage {
);
const hasPersistedEdgeRoutesEffective = hasPersistedLayout && hasPersistedEdgeRoutes;

// Where every handler that saves this view's layout saves it.
(diagramModel as any).layoutTarget = { filePath: workflowFilePath, networkId };

this.modelState.set(WORKFLOW_LAYOUT_PERSISTENCE_KEY, {
workflowFilePath,
networkId,
Expand Down
Loading
Loading