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
20 changes: 20 additions & 0 deletions docs/engineering/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -324,3 +324,23 @@ intentionally out of v0 rather than inferred from point order.
- The Python `graphforge` package's API can move independently of `@curatelabs/graphforge`'s; the host
script and `EngineBackend` mapping may need updates when it does (defensive coding, not a
compatibility guarantee).

# Visualization instance lifecycle

Graph, chart, temporal, geospatial, and figure webviews are owned by a shared
`VisualizationInstanceRegistry`. Saved visualizations derive a privacy-safe,
stable instance ID from the project root and project-relative visualization
path; unsaved visualizations receive a UUID. Opening one visualization never
replaces a different instance.

Each controller owns a monotonically increasing render generation. Host
messages establish `{ instanceId, renderGeneration }`; every subsequent
webview event echoes that context. Controllers reject replies from another
instance or an older generation. Starting a render aborts superseded work, and
disposing a panel cancels its active work and releases registered resources.

Cross-view selection, time, and spatial updates are opt-in. The registry routes
coordination events only when source and target declare the same coordination
group, rejects stale source generations, and never echoes an event to its
source. Registry identities and lifecycle diagnostics contain no project path,
result row, property, coordinate, timestamp, vector, or artifact content.
8 changes: 8 additions & 0 deletions docs/engineering/TESTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -227,3 +227,11 @@ npm run check && npm run compile && npm run test:unit
xvfb-run -a npm test # Linux only; on macOS/Windows just `npm test`
npx vsce package --no-dependencies
```

# Visualization lifecycle

`visualizationInstanceRegistry.test.ts` is the fast contract gate for
multi-instance ownership, stable private saved identities, explicit
coordination groups, stale-generation rejection, superseded-work cancellation,
and deterministic disposal. Webview typechecking additionally verifies that
graph, artifact, and figure messages carry the instance/revision context.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -640,7 +640,7 @@
"check": "tsc --noEmit && tsc --noEmit -p webview-ui && tsc --noEmit -p tsconfig.bench.json",
"lint": "npm run check",
"test": "npm run compile:host && vscode-test",
"test:unit": "npm run compile && mocha --ui tdd dist/test/projectDetector.test.js dist/test/environmentReport.test.js dist/test/uuid.test.js dist/test/arrowCodec.test.js dist/test/runtimeSelection.test.js dist/test/errorPresentation.test.js dist/test/projectKind.test.js dist/test/pythonInstallCommand.test.js dist/test/engineVersion.test.js dist/test/versionDiscovery.test.js dist/test/pythonProbe.test.js dist/test/pythonBridge.test.js dist/test/settingsSchema.test.js dist/test/getStartedContent.test.js dist/test/viewContributions.test.js dist/test/quickstartSample.test.js dist/test/projectArtifacts.test.js dist/test/visualizationRegistry.test.js dist/test/visualizationDocumentState.test.js dist/test/visualizationLoadingModel.test.js dist/test/geospatialModel.test.js dist/test/agentContext.test.js dist/test/knowledgeStatus.test.js dist/test/pickerCopy.test.js dist/test/paletteTitles.test.js dist/test/figureFromResult.test.js dist/test/figureSchema.test.js dist/test/resultGraphModel.test.js dist/test/resultTableModel.test.js dist/test/entityMutation.test.js dist/test/resultDocument.test.js dist/test/moduleManifest.test.js dist/test/workspaceScript.test.js dist/test/importData.test.js",
"test:unit": "npm run compile && mocha --ui tdd dist/test/visualizationInstanceRegistry.test.js dist/test/projectDetector.test.js dist/test/environmentReport.test.js dist/test/uuid.test.js dist/test/arrowCodec.test.js dist/test/runtimeSelection.test.js dist/test/errorPresentation.test.js dist/test/projectKind.test.js dist/test/pythonInstallCommand.test.js dist/test/engineVersion.test.js dist/test/versionDiscovery.test.js dist/test/pythonProbe.test.js dist/test/pythonBridge.test.js dist/test/settingsSchema.test.js dist/test/getStartedContent.test.js dist/test/viewContributions.test.js dist/test/quickstartSample.test.js dist/test/projectArtifacts.test.js dist/test/visualizationRegistry.test.js dist/test/visualizationDocumentState.test.js dist/test/visualizationLoadingModel.test.js dist/test/geospatialModel.test.js dist/test/agentContext.test.js dist/test/knowledgeStatus.test.js dist/test/pickerCopy.test.js dist/test/paletteTitles.test.js dist/test/figureFromResult.test.js dist/test/figureSchema.test.js dist/test/resultGraphModel.test.js dist/test/resultTableModel.test.js dist/test/entityMutation.test.js dist/test/resultDocument.test.js dist/test/moduleManifest.test.js dist/test/workspaceScript.test.js dist/test/importData.test.js",
"compile:bench": "vite build --mode bench",
"bench": "npm run compile:bench && npm run bench:run",
"bench:run": "node dist/bench/index.mjs",
Expand Down
13 changes: 11 additions & 2 deletions src/commands/projectArtifacts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import {
type ResultGraphLifecycleMessage,
} from "../webview/resultGraphPanel";
import type { ResultGraphViewOptions } from "../webview/resultGraphModel";
import { visualizationInstanceId } from "../webview/visualizationInstanceRegistry";

function requireResultFields(
result: { columns: string[] },
Expand Down Expand Up @@ -160,6 +161,7 @@ type ArtifactPathInput = string | vscode.Uri | ArtifactPathArgs;
function waitForResultGraphLifecycle(
renderer: "g6" | "cytoscape" | "sigma",
timeoutMs: number,
instanceId: string,
): { promise: Promise<ResultGraphLifecycleMessage>; dispose: () => void } {
let disposable: vscode.Disposable | undefined;
let timer: NodeJS.Timeout | undefined;
Expand All @@ -183,6 +185,7 @@ function waitForResultGraphLifecycle(
disposable = ResultGraphPanel.onDidLifecycle((message) => {
if (
message.renderer === renderer &&
message.instanceId === instanceId &&
(message.type === "graphforge/renderReady" ||
message.type === "graphforge/renderFailed")
) {
Expand Down Expand Up @@ -668,23 +671,29 @@ export function registerProjectArtifacts(
throw new Error("Visualization timeoutMs must be an integer from 1000 through 60000.");
}
const timeoutMs = requestedTimeout ?? 30_000;
const graphInstanceId = visualizationInstanceId("graph", projectRoot, relativePath);
const lifecycle = waitForReady
? waitForResultGraphLifecycle(renderer, timeoutMs)
? waitForResultGraphLifecycle(renderer, timeoutMs, graphInstanceId)
: undefined;
try {
const outcome = await vscode.commands.executeCommand<Record<string, unknown>>(
"graphforge.showResultGraph",
{
title: spec.name,
payload: graphPayload,
instanceId: graphInstanceId,
...options,
},
);
if (outcome?.panel === "cancelled") {
return { path: relativePath, absolutePath, kind: spec.kind, spec, ...outcome };
}
if (spec.format === "graphforge.visualization/v2") {
ResultGraphPanel.current?.attachArtifact(
const instanceId = typeof outcome?.instanceId === "string" ? outcome.instanceId : undefined;
const graphPanel = instanceId
? ResultGraphPanel.instances().find((candidate) => candidate.instanceId === instanceId)
: undefined;
graphPanel?.attachArtifact(
projectRoot,
relativePath,
spec,
Expand Down
6 changes: 6 additions & 0 deletions src/commands/visualize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import type { ResultGraphViewOptions } from "../webview/resultGraphModel";
export interface ShowResultGraphArgs extends ResultGraphViewOptions {
title?: string;
payload?: GraphPayload;
instanceId?: string;
coordinationGroup?: string;
}

export function registerVisualizationCommands(
Expand All @@ -30,9 +32,13 @@ export function registerVisualizationCommands(
visualDensity: args?.visualDensity,
labels: args?.labels,
timebar: args?.timebar,
}, {
instanceId: args?.instanceId,
coordinationGroup: args?.coordinationGroup,
});
return {
panel: shown.status,
instanceId: shown.panel.instanceId,
nodes: payload.nodes.length,
edges: payload.edges.length,
styleMode: payload.styleMode,
Expand Down
3 changes: 2 additions & 1 deletion src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import { OntologyTreeProvider } from "./views/ontologyTree";
import { ProjectExplorerProvider } from "./views/projectExplorer";
import { EntityInspectPanel } from "./webview/entityInspectPanel";
import { ModuleManagerPanel } from "./webview/moduleManagerPanel";
import { visualizationInstances } from "./webview/visualizationInstanceRegistry";
import {
RESULTS_VIEW_ID,
ResultTableViewProvider,
Expand All @@ -33,7 +34,7 @@ export async function activate(
context: vscode.ExtensionContext,
): Promise<void> {
const session = new GraphForgeSession();
context.subscriptions.push(session);
context.subscriptions.push(session, { dispose: () => visualizationInstances.dispose() });

const projects = new ProjectExplorerProvider(session);
const ontology = new OntologyTreeProvider(session);
Expand Down
20 changes: 20 additions & 0 deletions src/test/extension.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -368,6 +368,26 @@ suite("GraphForge agent interop — safe commands (no binding, no project)", ()
assert.ok(result.panel === "opened" || result.panel === "updated");
});

test("keeps independently opened unsaved figures alive (#81)", async () => {
const countFigures = () => vscode.window.tabGroups.all.flatMap((group) => group.tabs)
.filter((tab) => tab.input instanceof vscode.TabInputWebview)
.filter((tab) => (tab.input as vscode.TabInputWebview).viewType.replace(/^mainThreadWebview-/, "") === "graphforge.figure")
.length;
const before = countFigures();
const first = await vscode.commands.executeCommand<{ panel?: string }>("graphforge.showFigure", {
figure: { data: [{ type: "bar", x: ["a"], y: [1] }], layout: { title: "Independent A" } },
});
const second = await vscode.commands.executeCommand<{ panel?: string }>("graphforge.showFigure", {
figure: { data: [{ type: "bar", x: ["b"], y: [2] }], layout: { title: "Independent B" } },
});
assert.equal(first?.panel, "opened");
assert.equal(second?.panel, "opened");
for (let attempt = 0; attempt < 50 && countFigures() < before + 2; attempt++) {
await new Promise((resolve) => setTimeout(resolve, 20));
}
assert.ok(countFigures() >= before + 2);
});

test("showFigure without figure returns FIGURE_REQUIRED (#62)", async () => {
const result = await vscode.commands.executeCommand<{
error?: string;
Expand Down
72 changes: 72 additions & 0 deletions src/test/visualizationInstanceRegistry.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import * as assert from "node:assert/strict";
import { VisualizationInstanceLifecycle, VisualizationInstanceRegistry, visualizationInstanceId, type VisualizationController, type VisualizationKind } from "../webview/visualizationInstanceRegistry";

function controller(instanceId: string, kind: VisualizationKind, coordinationGroup?: string): VisualizationController & { disposed: boolean } {
return { instanceId, kind, coordinationGroup, renderGeneration: 1, disposed: false, reveal() {}, dispose() { this.disposed = true; } };
}

suite("visualization instance registry (#81)", () => {
test("keeps multiple visualization kinds independent", () => {
const registry = new VisualizationInstanceRegistry();
const graph = registry.register(controller("graph:a", "graph"));
const map = registry.register(controller("geospatial:b", "geospatial"));
assert.equal(registry.get("graph:a"), graph);
assert.equal(registry.get("geospatial:b"), map);
assert.equal(registry.values().length, 2);
});
test("activation and removal never replace another instance", () => {
const registry = new VisualizationInstanceRegistry();
const first = registry.register(controller("graph:a", "graph"));
const second = registry.register(controller("graph:b", "graph"));
assert.equal(registry.active("graph"), second);
registry.activate(first.instanceId);
assert.equal(registry.active("graph"), first);
registry.remove(first.instanceId);
assert.equal(registry.active("graph"), second);
});
test("coordination is explicit and disposal is deterministic", () => {
const registry = new VisualizationInstanceRegistry();
const graph = registry.register(controller("graph:a", "graph", "analysis:1"));
const timeline = registry.register(controller("temporal:b", "temporal", "analysis:1"));
registry.register(controller("chart:c", "chart"));
assert.deepEqual(registry.coordinated("analysis:1"), [graph, timeline]);
registry.dispose();
assert.equal(graph.disposed, true);
assert.equal(timeline.disposed, true);
});
test("saved identities are stable and private; unsaved identities are unique", () => {
const saved = visualizationInstanceId("graph", "/secret/project", "visualizations/a.json");
assert.equal(saved, visualizationInstanceId("graph", "/secret/project", "visualizations/a.json"));
assert.notEqual(visualizationInstanceId("graph"), visualizationInstanceId("graph"));
assert.doesNotMatch(saved, /secret|visualizations/);
});
test("cancels superseded work and rejects late generations", () => {
const lifecycle = new VisualizationInstanceLifecycle("graph:a");
const first = lifecycle.beginRender();
const second = lifecycle.beginRender();
assert.equal(first.signal.aborted, true);
assert.equal(second.signal.aborted, false);
assert.equal(lifecycle.accepts(first.context), false);
assert.equal(lifecycle.accepts(second.context), true);
lifecycle.dispose();
assert.equal(second.signal.aborted, true);
assert.equal(lifecycle.accepts(second.context), false);
});
test("routes coordination only to an explicit group and rejects stale sources", () => {
const registry = new VisualizationInstanceRegistry();
registry.register(controller("graph:a", "graph", "analysis:1"));
registry.register(controller("temporal:b", "temporal", "analysis:1"));
registry.register(controller("chart:c", "chart"));
const received: unknown[] = [];
registry.subscribeCoordination("temporal:b", (event) => received.push(event.payload));
registry.publishCoordination({
group: "analysis:1", sourceInstanceId: "graph:a", instanceId: "graph:a",
renderGeneration: 1, type: "selection", payload: { ids: ["n1"] },
});
registry.publishCoordination({
group: "analysis:1", sourceInstanceId: "graph:a", instanceId: "graph:a",
renderGeneration: 0, type: "selection", payload: { ids: ["stale"] },
});
assert.deepEqual(received, [{ ids: ["n1"] }]);
});
});
5 changes: 3 additions & 2 deletions src/views/resultTableView.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,8 @@ export class ResultTableViewProvider

private selectResult(rowIndex: number, column?: string): void {
if (!this.state) return;
const highlight = ResultGraphPanel.current?.highlightFromResult(
const graphPanel = ResultGraphPanel.active();
const highlight = graphPanel?.highlightFromResult(
this.state.result,
rowIndex,
column,
Expand All @@ -123,7 +124,7 @@ export class ResultTableViewProvider
this.post({
type: "graphforge/resultSelection",
linked: count > 0,
message: !ResultGraphPanel.current
message: !graphPanel
? "Open Result Graph to link this selection."
: count > 0
? `Highlighted ${highlight?.nodeIds.length ?? 0} node(s) and ${highlight?.edgeIds.length ?? 0} edge(s).`
Expand Down
Loading