import { Constructor } from "@decaf-ts/decoration";
import { Model } from "@decaf-ts/decorator-validation";
import {
GraphPortDefinition,
GraphWorkflowDefinition,
GraphWorkflowMetadata,
GraphWorkflowNodeMetadata,
GraphWorkflowRelationMetadata,
} from "./constants";
import { graphLeafPortsOf, graphWorkflowDefinitionOf } from "./reader";
import type {
GraphWorkflowDocument,
GraphWorkflowPortInstance,
} from "./document/GraphWorkflowDocument";
import type { GraphNodeInstance } from "./document/GraphNodeInstance";
import type { GraphEdgeInstance } from "./document/GraphEdgeInstance";
import type {
GraphEndpoint,
GraphWorkflowEndpoint,
} from "./document/GraphEndpoint";
import type { GraphInputBinding } from "./document/GraphNodeBinding";
import type { GraphJsonValue } from "./document/GraphJsonValue";
import { isGraphJsonSafeValue } from "./document/GraphJsonValue";
import type { GraphNodeUiState, GraphWorkflowUiState } from "./document/GraphWorkflowUiState";
import {
graphValueSchemaFromValidation,
type GraphValidationRecord,
} from "./catalog/GraphValueSchemaDerivation";
const GRAPH_LEGACY_BOUNDARY_ALIASES = ["$workflow", "workflow", "graph"];
const GRAPH_CONSTRUCTOR_DATA_KEYS = [
"node",
"modelClass",
"sourceClass",
"constructor",
"definition",
"executor",
];
type GraphModelLike<M extends Model = Model> = Constructor<M> | M;
export const GRAPH_WORKFLOW_SNAPSHOT_VERSION = 1 as const;
export type GraphWorkflowSnapshotReference = {
id?: string;
kind?: string;
tag?: string;
path?: string;
property?: string;
};
export type GraphWorkflowSnapshotPosition = {
x: number;
y: number;
};
export type GraphWorkflowSnapshotSize = {
width?: number;
height?: number;
};
export type GraphWorkflowSnapshotPortState = {
expanded?: boolean;
value?: unknown;
mode?: "port" | "value";
metadata?: Record<string, unknown>;
};
export type GraphWorkflowSnapshotValue = {
path: string;
value?: unknown;
label?: string;
type?: string;
required?: boolean;
model?: string;
metadata?: Record<string, unknown>;
};
export type GraphWorkflowSnapshotNode = {
id: string;
ref?: GraphWorkflowSnapshotReference;
label?: string;
kind?: string;
position?: GraphWorkflowSnapshotPosition;
size?: GraphWorkflowSnapshotSize;
expanded?: boolean;
ports?: Record<string, GraphWorkflowSnapshotPortState>;
data?: Record<string, unknown>;
metadata?: Record<string, unknown>;
};
export type GraphWorkflowSnapshotEdge = {
id: string;
source: string;
sourceRef?: string;
sourcePort?: string;
target: string;
targetRef?: string;
targetPort?: string;
label?: string;
metadata?: Record<string, unknown>;
};
export type GraphWorkflowSnapshotDefinition = {
name: string;
tag: string;
kind: string;
category?: string;
color?: string;
group?: string;
height?: number;
icon?: string;
labels: string[];
maxChildren?: number;
minWidth?: number;
width?: number;
metadata?: GraphWorkflowMetadata;
inputs: GraphPortDefinition[];
outputs: GraphPortDefinition[];
nodes: GraphWorkflowNodeMetadata[];
relations: GraphWorkflowRelationMetadata[];
};
export type GraphWorkflowSnapshotState = {
inputs: GraphWorkflowSnapshotValue[];
outputs: GraphWorkflowSnapshotValue[];
nodes: GraphWorkflowSnapshotNode[];
edges: GraphWorkflowSnapshotEdge[];
ui: Record<string, unknown>;
metadata: Record<string, unknown>;
};
/** Snapshot schema version literal type ({@link GRAPH_WORKFLOW_SNAPSHOT_VERSION}). */
export type GraphWorkflowSnapshotVersion = typeof GRAPH_WORKFLOW_SNAPSHOT_VERSION;
/**
* Legacy persisted snapshot (`{ version, definition, state }`). Kept only for
* loading/compatibility of persisted data; the canonical snapshot is
* {@link GraphWorkflowSnapshot} (`{ document, editor?, metadata? }`).
* Convert with {@link graphWorkflowSnapshotFromLegacy} /
* {@link graphWorkflowSnapshotToLegacy}.
*/
export type LegacyGraphWorkflowSnapshot = {
version: GraphWorkflowSnapshotVersion;
definition: GraphWorkflowSnapshotDefinition;
state: GraphWorkflowSnapshotState;
};
export type GraphWorkflowSnapshotInput = {
definition?: Partial<GraphWorkflowSnapshotDefinition>;
inputs?: GraphWorkflowSnapshotValue[] | Record<string, unknown>;
outputs?: GraphWorkflowSnapshotValue[] | Record<string, unknown>;
nodes?: GraphWorkflowSnapshotNode[];
edges?: GraphWorkflowSnapshotEdge[];
ui?: Record<string, unknown>;
metadata?: Record<string, unknown>;
};
function isPlainObject(value: unknown): value is Record<string, unknown> {
return (
!!value &&
typeof value === "object" &&
!Array.isArray(value) &&
!(value instanceof Date)
);
}
function cloneValue<T>(value: T): T {
if (Array.isArray(value)) {
return value.map((entry) => cloneValue(entry)) as T;
}
if (value instanceof Date) {
return new Date(value.getTime()) as T;
}
if (isPlainObject(value)) {
const cloned: Record<string, unknown> = {};
for (const [key, entry] of Object.entries(value)) {
cloned[key] = cloneValue(entry);
}
return cloned as T;
}
return value;
}
function asString(value: unknown): string | undefined {
if (value === undefined || value === null) return undefined;
return typeof value === "string" ? value : String(value);
}
function isWorkflowDefinitionLike(
value: unknown
): value is GraphWorkflowDefinition | GraphWorkflowSnapshotDefinition {
return (
isPlainObject(value) &&
typeof value.name === "string" &&
typeof value.tag === "string" &&
typeof value.kind === "string" &&
Array.isArray(value.inputs) &&
Array.isArray(value.outputs) &&
Array.isArray(value.nodes) &&
Array.isArray(value.relations)
);
}
function snapshotNodeKey(node: Partial<GraphWorkflowSnapshotNode>): string | undefined {
return node.ref?.id ?? node.id ?? node.ref?.path ?? node.ref?.property;
}
function snapshotEdgeKey(edge: Partial<GraphWorkflowSnapshotEdge>): string | undefined {
return (
edge.id ??
[edge.sourceRef ?? edge.source, edge.sourcePort, edge.targetRef ?? edge.target, edge.targetPort]
.filter(Boolean)
.join("::")
);
}
function normalizeValueEntries(
entries: GraphWorkflowSnapshotValue[] | Record<string, unknown> | undefined,
definitions: GraphPortDefinition[]
): GraphWorkflowSnapshotValue[] {
const byPath = new Map<string, GraphWorkflowSnapshotValue>();
if (Array.isArray(entries)) {
for (const entry of entries) {
if (!entry?.path) continue;
byPath.set(entry.path, cloneValue(entry));
}
} else if (isPlainObject(entries)) {
for (const [path, value] of Object.entries(entries)) {
byPath.set(path, { path, value: cloneValue(value) });
}
}
const normalized: GraphWorkflowSnapshotValue[] = definitions.map((definition) => {
const path = definition.path ?? definition.property;
const current = byPath.get(path);
const entry: GraphWorkflowSnapshotValue = {
label: definition.label,
type: definition.type,
required: definition.required,
model: definition.model,
metadata: cloneValue(
definition.graph?.metadata ??
definition.element?.props ??
definition.prop?.props ??
{}
),
...current,
path,
};
if (!Object.keys(entry.metadata || {}).length) {
delete entry.metadata;
}
return entry;
});
for (const entry of byPath.values()) {
if (normalized.some((current) => current.path === entry.path)) continue;
normalized.push(cloneValue(entry));
}
return normalized;
}
function normalizeNodes(
nodes: GraphWorkflowSnapshotNode[] | undefined,
definitions: GraphWorkflowNodeMetadata[]
): GraphWorkflowSnapshotNode[] {
const byKey = new Map<string, GraphWorkflowSnapshotNode>();
const result: GraphWorkflowSnapshotNode[] = [];
for (const definition of definitions) {
const node: GraphWorkflowSnapshotNode = {
id: definition.id,
ref: {
id: definition.id,
kind: definition.kind,
},
label: definition.label,
kind: definition.kind,
data: definition.node !== undefined ? { node: cloneValue(definition.node) } : undefined,
metadata: cloneValue(definition.metadata),
};
result.push(node);
byKey.set(definition.id, node);
}
for (const entry of nodes || []) {
const key = snapshotNodeKey(entry);
const current = key ? byKey.get(key) : undefined;
if (current) {
const merged: GraphWorkflowSnapshotNode = {
...current,
...cloneValue(entry),
id: entry.id || current.id,
ref: {
...current.ref,
...entry.ref,
id: entry.ref?.id ?? current.ref?.id ?? current.id,
},
position: entry.position ? cloneValue(entry.position) : current.position,
size: entry.size ? cloneValue(entry.size) : current.size,
ports: {
...(current.ports || {}),
...(entry.ports || {}),
},
data: {
...(current.data || {}),
...(entry.data || {}),
},
metadata: {
...(current.metadata || {}),
...(entry.metadata || {}),
},
};
if (key) byKey.set(key, merged);
const index = result.findIndex((value) => value.id === current.id);
if (index >= 0) result[index] = merged;
continue;
}
const cloned = cloneValue(entry);
result.push(cloned);
if (key) byKey.set(key, cloned);
}
return result;
}
function normalizeEdges(
edges: GraphWorkflowSnapshotEdge[] | undefined,
relations: GraphWorkflowRelationMetadata[]
): GraphWorkflowSnapshotEdge[] {
const byKey = new Map<string, GraphWorkflowSnapshotEdge>();
const result: GraphWorkflowSnapshotEdge[] = [];
for (const [index, relation] of relations.entries()) {
const source = asString(relation.source) ?? `source-${index}`;
const target = asString(relation.target) ?? `target-${index}`;
const edge: GraphWorkflowSnapshotEdge = {
id: relation.label || `${source}:${relation.sourcePort ?? "*"}->${target}:${relation.targetPort ?? "*"}`,
source,
sourceRef: source,
sourcePort: relation.sourcePort,
target,
targetRef: target,
targetPort: relation.targetPort,
label: relation.label,
metadata: cloneValue(relation.metadata),
};
result.push(edge);
byKey.set(edge.id, edge);
}
for (const entry of edges || []) {
const key = snapshotEdgeKey(entry);
const current = key ? byKey.get(key) : undefined;
if (current) {
const merged: GraphWorkflowSnapshotEdge = {
...current,
...cloneValue(entry),
id: entry.id || current.id,
source: entry.source || current.source,
sourceRef: entry.sourceRef ?? current.sourceRef ?? entry.source ?? current.source,
target: entry.target || current.target,
targetRef: entry.targetRef ?? current.targetRef ?? entry.target ?? current.target,
// Labels are document-carried display semantics (§4.4.7): a row merged
// from a canvas clone that never carried its own label must not erase
// the relation row's label.
label: entry.label ?? current.label,
metadata: {
...(current.metadata || {}),
...(entry.metadata || {}),
},
};
if (key) byKey.set(key, merged);
const index = result.findIndex((value) => value.id === current.id);
if (index >= 0) result[index] = merged;
continue;
}
const cloned = cloneValue(entry);
result.push(cloned);
if (key) byKey.set(key, cloned);
}
return result;
}
function normalizeWorkflowDefinition(
definition: GraphWorkflowDefinition | GraphWorkflowSnapshotDefinition,
overrides: Partial<GraphWorkflowSnapshotDefinition> = {}
): GraphWorkflowSnapshotDefinition {
return {
name: overrides.name ?? definition.name,
tag: overrides.tag ?? definition.tag,
kind: overrides.kind ?? definition.kind,
category: overrides.category ?? definition.category,
color: overrides.color ?? definition.color,
group: overrides.group ?? definition.group,
height: overrides.height ?? definition.height,
icon: overrides.icon ?? definition.icon,
labels: cloneValue(overrides.labels ?? definition.labels ?? []),
maxChildren: overrides.maxChildren ?? definition.maxChildren,
minWidth: overrides.minWidth ?? definition.minWidth,
width: overrides.width ?? definition.width,
metadata: cloneValue(
overrides.metadata ??
("workflow" in definition ? definition.workflow : definition.metadata)
),
inputs: cloneValue(overrides.inputs ?? definition.inputs ?? []),
outputs: cloneValue(overrides.outputs ?? definition.outputs ?? []),
nodes: cloneValue(overrides.nodes ?? definition.nodes ?? []),
relations: cloneValue(overrides.relations ?? definition.relations ?? []),
};
}
export function graphWorkflowSnapshotDefinitionOf<M extends Model>(
model: GraphModelLike<M> | GraphWorkflowDefinition | GraphWorkflowSnapshotDefinition
): GraphWorkflowSnapshotDefinition {
const definition = isWorkflowDefinitionLike(model)
? model
: graphWorkflowDefinitionOf(model as GraphModelLike<M>);
return normalizeWorkflowDefinition(definition);
}
export function graphWorkflowSnapshotOf<M extends Model>(
model: GraphModelLike<M> | GraphWorkflowDefinition | GraphWorkflowSnapshotDefinition,
input?: GraphWorkflowSnapshotInput
): LegacyGraphWorkflowSnapshot {
const normalizedDefinition = normalizeWorkflowDefinition(
graphWorkflowSnapshotDefinitionOf(model as any),
input?.definition
);
const inputPorts = graphLeafPortsOf(normalizedDefinition.inputs);
const outputPorts = graphLeafPortsOf(normalizedDefinition.outputs);
return {
version: GRAPH_WORKFLOW_SNAPSHOT_VERSION,
definition: normalizedDefinition,
state: {
inputs: normalizeValueEntries(input?.inputs, inputPorts),
outputs: normalizeValueEntries(input?.outputs, outputPorts),
nodes: normalizeNodes(input?.nodes, normalizedDefinition.nodes),
edges: normalizeEdges(input?.edges, normalizedDefinition.relations),
ui: cloneValue(input?.ui || {}),
metadata: cloneValue(input?.metadata || {}),
},
};
}
export function graphWorkflowSnapshotRestore<M extends Model>(
snapshot: LegacyGraphWorkflowSnapshot | GraphWorkflowSnapshotInput,
model?: GraphModelLike<M> | GraphWorkflowDefinition | GraphWorkflowSnapshotDefinition
): LegacyGraphWorkflowSnapshot {
if (!("version" in snapshot)) {
return graphWorkflowSnapshotOf(
model ??
(snapshot.definition as
| GraphWorkflowDefinition
| GraphWorkflowSnapshotDefinition
| undefined) ??
({} as GraphWorkflowDefinition),
snapshot
);
}
const fullSnapshot = snapshot as LegacyGraphWorkflowSnapshot;
const definition = model
? graphWorkflowSnapshotDefinitionOf(model)
: fullSnapshot.definition;
return graphWorkflowSnapshotOf(definition, {
inputs: fullSnapshot.state.inputs,
outputs: fullSnapshot.state.outputs,
nodes: fullSnapshot.state.nodes,
edges: fullSnapshot.state.edges,
ui: fullSnapshot.state.ui,
metadata: fullSnapshot.state.metadata,
});
}
export function graphWorkflowSnapshotToJSON(
snapshot: LegacyGraphWorkflowSnapshot,
space?: number
): string {
return JSON.stringify(snapshot, undefined, space);
}
export function graphWorkflowSnapshotFromJSON(
input: string | LegacyGraphWorkflowSnapshot,
model?: GraphModelLike | GraphWorkflowDefinition | GraphWorkflowSnapshotDefinition
): LegacyGraphWorkflowSnapshot {
const snapshot =
typeof input === "string" ? (JSON.parse(input) as LegacyGraphWorkflowSnapshot) : input;
return graphWorkflowSnapshotRestore(snapshot, model);
}
export function graphWorkflowSnapshotInputValuesOf(
snapshot: LegacyGraphWorkflowSnapshot
): Record<string, unknown> {
return Object.fromEntries(
snapshot.state.inputs.map((entry) => [entry.path, cloneValue(entry.value)])
);
}
export function graphWorkflowSnapshotOutputValuesOf(
snapshot: LegacyGraphWorkflowSnapshot
): Record<string, unknown> {
return Object.fromEntries(
snapshot.state.outputs.map((entry) => [entry.path, cloneValue(entry.value)])
);
}
/**
* Editor-only state that the canonical {@link GraphWorkflowDocument} cannot
* express (layout beyond node positions, config-store values, per-port modes,
* workflow boundary values, and duplicate counts). Stored as JSON-safe values
* only; constructors and `modelClass`/`sourceClass` references are dropped.
*/
export type GraphSnapshotEditorState = {
viewport?: { x: number; y: number; scale: number };
duplicateCounts?: Record<string, number>;
boundaryInputValues?: Record<string, GraphJsonValue>;
boundaryOutputValues?: Record<string, GraphJsonValue>;
nodePorts?: Record<string, Record<string, GraphJsonValue>>;
nodeConfigs?: Record<string, GraphJsonValue>;
diagramMetadata?: Record<string, GraphJsonValue>;
definitionDisplay?: Record<string, GraphJsonValue>;
legacy?: GraphJsonValue;
} & { [key: string]: GraphJsonValue | undefined };
/**
* Canonical document-first snapshot. `document` is the executable semantic
* truth; `editor` preserves legacy editor-only state for lossless round trips.
*/
export type GraphWorkflowSnapshot = {
document: GraphWorkflowDocument;
editor?: GraphSnapshotEditorState;
metadata?: Record<string, GraphJsonValue>;
};
export type GraphWorkflowSnapshotLike =
| LegacyGraphWorkflowSnapshot
| GraphWorkflowSnapshot;
function dropUnsafeEntries(
source: Record<string, unknown>
): Record<string, GraphJsonValue> {
const result: Record<string, GraphJsonValue> = {};
for (const [key, value] of Object.entries(source)) {
if (GRAPH_CONSTRUCTOR_DATA_KEYS.includes(key)) continue;
const sanitized = sanitizeToGraphJsonValue(value);
if (sanitized !== undefined) result[key] = sanitized;
}
return result;
}
/**
* Sanitizes a legacy snapshot into a JSON-safe payload while preserving its
* structural keys (`version`, `definition`, `state`). Constructor-key stripping
* still applies to the payload contents inside, so the canonical→legacy
* round trip stays lossless for persisted data.
*/
function sanitizeLegacySnapshotPayload(
snapshot: LegacyGraphWorkflowSnapshot
): GraphJsonValue {
return {
version: snapshot.version,
definition: dropUnsafeEntries(
(snapshot.definition ?? {}) as unknown as Record<string, unknown>
),
state: dropUnsafeEntries(
(snapshot.state ?? {}) as unknown as Record<string, unknown>
),
};
}
function sanitizeToGraphJsonValue(value: unknown): GraphJsonValue | undefined {
if (value === null) return null;
const type = typeof value;
if (type === "string" || type === "boolean") return value as GraphJsonValue;
if (type === "number") return Number.isFinite(value as number) ? (value as number) : undefined;
if (Array.isArray(value)) {
const items: GraphJsonValue[] = [];
for (const item of value) {
const sanitized = sanitizeToGraphJsonValue(item);
if (sanitized !== undefined) items.push(sanitized);
}
return items;
}
if (type === "object") {
return dropUnsafeEntries(value as Record<string, unknown>);
}
return undefined;
}
function boundaryAliasesFor(definition: GraphWorkflowSnapshotDefinition): Set<string> {
const aliases = new Set<string>(GRAPH_LEGACY_BOUNDARY_ALIASES);
if (definition.tag) aliases.add(definition.tag);
if (definition.name) aliases.add(definition.name);
return aliases;
}
function legacyEndpoint(
refId: string | undefined,
port: string | undefined,
aliases: Set<string>,
boundaryPortIds?: ReadonlyMap<string, string>
): GraphEndpoint {
const resolvedPort = port ?? "";
if (!refId || aliases.has(refId)) {
return { scope: "workflow", port: resolvedPort } satisfies GraphWorkflowEndpoint;
}
// The legacy decorated-root canvas rendered workflow input ports as draggable
// boundary badges (`input-{port}`, port handle `value`). Those bindings stay
// lossless only when the badge id folds back onto the workflow port it was
// drawn for (DECAF-50 §4.11); otherwise a workflow port would collapse into
// one shared boundary endpoint and drop real edges (§4.4.6 canonical form).
if (refId.startsWith("input-")) {
const workflowPortId = boundaryPortIds?.get(refId.slice("input-".length) ?? "");
if (workflowPortId) {
return {
scope: "workflow",
port: workflowPortId,
} satisfies GraphWorkflowEndpoint;
}
}
return { scope: "node", nodeId: refId, port: resolvedPort } satisfies GraphEndpoint;
}
/**
* Maps workflow input-property names to their legacy boundary-badge ids
* (`input-{property}`), so badges drawn on the canvas resolve back to the
* workflow port they represent (§4.11).
*/
function boundaryPortIdsFor(snapshot: LegacyGraphWorkflowSnapshot): ReadonlyMap<string, string> {
const map = new Map<string, string>();
for (const port of snapshot.definition.inputs) {
map.set(`input-${port.property}`, port.property);
}
return map;
}
/**
* Resolves the decorated definition's own edge labels by canonical endpoint
* pair, so converted legacy edges that never carried a label on the edge row
* (canvas-drawn clones merged over the relation rows) still restore the
* document-carried {@link GraphEdgeInstance.label} (§4.4.7) instead of losing
* the display affordance on save/reload.
*/
function legacyRelationLabelByEndpoint(
snapshot: LegacyGraphWorkflowSnapshot,
aliases: Set<string>,
boundaryPortIds: ReadonlyMap<string, string>
): ReadonlyMap<string, string> {
const map = new Map<string, string>();
for (const relation of snapshot.definition.relations) {
const label = typeof relation.label === "string" ? relation.label : undefined;
if (!label) continue;
const sourceRef = typeof relation.source === "string" ? relation.source : undefined;
const targetRef = typeof relation.target === "string" ? relation.target : undefined;
map.set(
edgePairKeyOf({
source: legacyEndpoint(sourceRef, relation.sourcePort, aliases, boundaryPortIds),
target: legacyEndpoint(targetRef, relation.targetPort, aliases, boundaryPortIds),
}),
label
);
}
return map;
}
function endpointToLegacyRef(endpoint: GraphEndpoint): { refId: string; port: string } {
if (endpoint.scope === "workflow") {
return { refId: "$workflow", port: endpoint.port };
}
return { refId: endpoint.nodeId, port: endpoint.port };
}
function portInstanceFromDefinition(
port: GraphPortDefinition,
value?: GraphJsonValue
): GraphWorkflowPortInstance {
const instance: GraphWorkflowPortInstance = {
id: port.property,
label: port.label,
};
instance.schema = graphValueSchemaFromValidation(
port.validation as GraphValidationRecord | undefined,
port.type,
port.model
);
if (port.required) instance.required = true;
if (value !== undefined) instance.defaultValue = value;
const metadata = sanitizeToGraphJsonValue(port.graph ?? {}) as Record<string, GraphJsonValue> | undefined;
if (metadata && Object.keys(metadata).length) instance.metadata = metadata;
return instance;
}
function inputBindingsFromPorts(
ports: Record<string, GraphWorkflowSnapshotPortState> | undefined
): Record<string, GraphInputBinding> | undefined {
if (!ports) return undefined;
const bindings: Record<string, GraphInputBinding> = {};
for (const [property, port] of Object.entries(ports)) {
if (port?.mode === "port") {
bindings[property] = { mode: "edge" };
} else if ("value" in (port ?? {})) {
const value = sanitizeToGraphJsonValue(port.value);
bindings[property] = { mode: "literal", value: value ?? null };
}
}
return Object.keys(bindings).length ? bindings : undefined;
}
function nodeInstanceFromLegacy(node: GraphWorkflowSnapshotNode): GraphNodeInstance {
const instance: GraphNodeInstance = {
id: node.id,
kind: node.kind ?? node.ref?.kind ?? node.id,
parameters: dropUnsafeEntries(node.data ?? {}),
};
if (node.label) instance.label = node.label;
const bindings = inputBindingsFromPorts(node.ports);
if (bindings) instance.inputBindings = bindings;
const metadata = sanitizeToGraphJsonValue(node.metadata ?? {}) as Record<string, GraphJsonValue> | undefined;
if (metadata && Object.keys(metadata).length) instance.metadata = metadata;
if (node.position || node.size || node.expanded !== undefined) {
const ui: GraphNodeUiState = {
position: node.position ? { x: node.position.x, y: node.position.y } : { x: 0, y: 0 },
};
if (node.size) ui.size = { width: node.size.width, height: node.size.height };
if (node.expanded !== undefined) ui.expanded = node.expanded;
instance.ui = ui;
}
return instance;
}
function edgeInstanceFromLegacy(
edge: GraphWorkflowSnapshotEdge,
aliases: Set<string>,
boundaryPortIds?: ReadonlyMap<string, string>,
relationLabelByPair?: ReadonlyMap<string, string>
): GraphEdgeInstance {
const source = legacyEndpoint(
edge.sourceRef ?? edge.source,
edge.sourcePort,
aliases,
boundaryPortIds
);
const target = legacyEndpoint(
edge.targetRef ?? edge.target,
edge.targetPort,
aliases,
boundaryPortIds
);
const instance: GraphEdgeInstance = {
id: edge.id,
type: "data",
source,
target,
};
const label = edge.label ?? relationLabelByPair?.get(edgePairKeyOf({ source, target }));
if (label) instance.label = label;
const metadata = sanitizeToGraphJsonValue(edge.metadata ?? {}) as Record<string, GraphJsonValue> | undefined;
if (metadata && Object.keys(metadata).length) instance.metadata = metadata;
return instance;
}
/** Endpoint-pair key for edge identity (canonical `GraphEndpoint` shape). */
function edgePairKeyOf(edge: { source: GraphEndpoint; target: GraphEndpoint }): string {
const keyOf = (endpoint: GraphEndpoint): string =>
endpoint.scope === "node"
? `node:${endpoint.nodeId}:${endpoint.port}`
: `workflow:${endpoint.port}`;
return `${keyOf(edge.source)}->${keyOf(edge.target)}`;
}
/**
* Maps a legacy (pre-P7) canvas snapshot to the canonical
* {@link GraphWorkflowDocument} (DECAF-50 §4.4.6): legacy `node.data` becomes
* instance `parameters`, legacy `node.metadata` becomes instance `metadata`,
* workflow boundary badges (`input-{port}`) fold back onto their workflow
* ports, and edge labels missing from cloned edge rows are restored from the
* decorated definition's relation labels (§4.4.7).
*/
export function graphWorkflowDocumentFromLegacySnapshot(
snapshot: LegacyGraphWorkflowSnapshot
): GraphWorkflowDocument {
const aliases = boundaryAliasesFor(snapshot.definition);
const boundaryPortIds = boundaryPortIdsFor(snapshot);
const relationLabelByPair = legacyRelationLabelByEndpoint(snapshot, aliases, boundaryPortIds);
const inputs = Object.fromEntries(
snapshot.state.inputs.map((entry) => [entry.path, sanitizeToGraphJsonValue(entry.value)])
);
const outputs = Object.fromEntries(
snapshot.state.outputs.map((entry) => [entry.path, sanitizeToGraphJsonValue(entry.value)])
);
const document: GraphWorkflowDocument = {
id: snapshot.definition.tag || snapshot.definition.name,
name: snapshot.definition.name,
inputs: snapshot.definition.inputs.map((port) =>
portInstanceFromDefinition(port, inputs[port.property])
),
outputs: snapshot.definition.outputs.map((port) =>
portInstanceFromDefinition(port, outputs[port.property])
),
nodes: snapshot.state.nodes.map(nodeInstanceFromLegacy),
edges: snapshot.state.edges.map((edge) =>
edgeInstanceFromLegacy(edge, aliases, boundaryPortIds, relationLabelByPair)
),
};
if (snapshot.definition.metadata) {
const metadata = sanitizeToGraphJsonValue(
snapshot.definition.metadata
) as Record<string, GraphJsonValue> | undefined;
if (metadata && Object.keys(metadata).length) document.metadata = metadata;
}
const ui: GraphWorkflowUiState = {};
const viewport = snapshot.state.ui?.["diagramMetadata"] as
| Record<string, unknown>
| undefined;
const parsedViewport = viewport?.["viewport"];
if (parsedViewport && typeof parsedViewport === "object") {
const v = parsedViewport as Record<string, unknown>;
const x = typeof v["x"] === "number" ? v["x"] : Number(v["x"]) || 0;
const y = typeof v["y"] === "number" ? v["y"] : Number(v["y"]) || 0;
const zoom = typeof v["scale"] === "number" ? v["scale"] : Number(v["scale"]) || 1;
ui.viewport = { x, y, zoom };
}
if (ui.viewport) document.ui = ui;
return document;
}
/**
* Converts a legacy persisted snapshot into the canonical wrapper. The canonical
* `document` carries executable semantics; `editor.legacy` keeps the sanitized
* (constructor-free) legacy payload so a canonical→legacy round trip is lossless
* for persisted data.
*/
export function graphWorkflowSnapshotFromLegacy(
snapshot: LegacyGraphWorkflowSnapshot
): GraphWorkflowSnapshot {
const document = graphWorkflowDocumentFromLegacySnapshot(snapshot);
const editor: GraphSnapshotEditorState = {};
const sanitizedLegacy = sanitizeLegacySnapshotPayload(snapshot);
if (sanitizedLegacy && isGraphJsonSafeValue(sanitizedLegacy)) editor.legacy = sanitizedLegacy;
const ui = snapshot.state.ui;
if (ui) {
const duplicateCounts = ui["duplicateCounts"];
if (duplicateCounts && typeof duplicateCounts === "object") {
editor.duplicateCounts = duplicateCounts as Record<string, number>;
}
const diagramMetadata = ui["diagramMetadata"];
if (diagramMetadata && typeof diagramMetadata === "object") {
editor.diagramMetadata = sanitizeToGraphJsonValue(
diagramMetadata as Record<string, unknown>
) as Record<string, GraphJsonValue>;
}
const nodeConfigs = ui["nodeConfigs"];
if (nodeConfigs && typeof nodeConfigs === "object") {
editor.nodeConfigs = sanitizeToGraphJsonValue(
nodeConfigs as Record<string, unknown>
) as Record<string, GraphJsonValue>;
}
}
editor.nodePorts = {};
for (const node of snapshot.state.nodes) {
if (node.ports) {
editor.nodePorts[node.id] = sanitizeToGraphJsonValue(
node.ports as unknown as Record<string, unknown>
) as Record<string, GraphJsonValue>;
}
}
if (!Object.keys(editor.nodePorts).length) delete editor.nodePorts;
const definitionDisplay = dropUnsafeEntries(
snapshot.definition as unknown as Record<string, unknown>
);
delete definitionDisplay["inputs"];
delete definitionDisplay["outputs"];
delete definitionDisplay["nodes"];
delete definitionDisplay["relations"];
editor.definitionDisplay = definitionDisplay;
const metadata = sanitizeToGraphJsonValue(
snapshot.state.metadata ?? {}
) as Record<string, GraphJsonValue>;
return { document, editor, metadata: Object.keys(metadata).length ? metadata : undefined };
}
/**
* Reconstructs a legacy snapshot from a canonical wrapper. Uses the
* `editor.legacy` payload when present for exact round trips; otherwise it
* rebuilds a best-effort legacy snapshot from the document + editor state
* (constructors are never restored).
*/
export function graphWorkflowSnapshotToLegacy(
snapshot: GraphWorkflowSnapshot
): LegacyGraphWorkflowSnapshot {
if (snapshot.editor?.legacy && isGraphJsonSafeValue(snapshot.editor.legacy)) {
return cloneValue(snapshot.editor.legacy) as unknown as LegacyGraphWorkflowSnapshot;
}
const { document, editor } = snapshot;
const aliases = new Set<string>(GRAPH_LEGACY_BOUNDARY_ALIASES);
if (document.id) aliases.add(document.id);
if (document.name) aliases.add(document.name);
const display = (editor?.definitionDisplay ?? {}) as Record<string, unknown>;
const definition = {
...cloneValue(display),
name: document.name,
tag: document.id || (display["tag"] as string | undefined) || document.name,
inputs: document.inputs.map((port) => portToLegacyDefinition(port)),
outputs: document.outputs.map((port) => portToLegacyDefinition(port)),
nodes: document.nodes.map((node) => ({ id: node.id, kind: node.kind })),
relations: document.edges.map((edge) => ({
id: edge.id,
source: endpointToLegacyRef(edge.source).refId,
sourcePort: edge.source.port,
target: endpointToLegacyRef(edge.target).refId,
targetPort: edge.target.port,
label: edge.label,
})),
} as unknown as LegacyGraphWorkflowSnapshot["definition"];
const state: LegacyGraphWorkflowSnapshot["state"] = {
inputs: document.inputs.map((port) => ({
path: port.id,
value: cloneValue(port.defaultValue) as never,
label: port.label,
required: port.required,
})),
outputs: document.outputs.map((port) => ({
path: port.id,
value: cloneValue(port.defaultValue) as never,
label: port.label,
required: port.required,
})),
nodes: document.nodes.map((node) => ({
id: node.id,
ref: { id: node.id, kind: node.kind },
label: node.label,
kind: node.kind,
position: node.ui?.position ? { x: node.ui.position.x, y: node.ui.position.y } : undefined,
size: node.ui?.size ? { width: node.ui.size.width, height: node.ui.size.height } : undefined,
expanded: node.ui?.expanded,
ports: (editor?.nodePorts?.[node.id] as never) ?? undefined,
data: cloneValue(node.parameters ?? {}) as never,
metadata: cloneValue(node.metadata ?? {}) as never,
})),
edges: document.edges.map((edge) => {
const source = endpointToLegacyRef(edge.source);
const target = endpointToLegacyRef(edge.target);
const isBoundary = source.refId === "$workflow" || target.refId === "$workflow";
const isConnection = edge.type === "connection";
return {
id: edge.id,
source: source.refId,
sourceRef: source.refId,
sourcePort: source.port,
target: target.refId,
targetRef: target.refId,
targetPort: target.port,
label: edge.label,
type: isConnection ? "connection" : isBoundary ? "output" : "data",
metadata: cloneValue(edge.metadata ?? {}) as never,
} as never;
}),
ui: cloneValue({
...(editor?.duplicateCounts ? { duplicateCounts: editor.duplicateCounts } : {}),
...(editor?.diagramMetadata ? { diagramMetadata: editor.diagramMetadata } : {}),
...(editor?.nodeConfigs ? { nodeConfigs: editor.nodeConfigs } : {}),
}) as never,
metadata: cloneValue(snapshot.metadata ?? {}) as never,
};
return {
version: GRAPH_WORKFLOW_SNAPSHOT_VERSION,
definition,
state,
};
}
function portToLegacyDefinition(port: GraphWorkflowPortInstance): GraphPortDefinition {
return {
property: port.id,
label: port.label,
required: port.required,
metadata: cloneValue(port.metadata ?? {}) as never,
} as unknown as GraphPortDefinition;
}
/** Normalizes any accepted snapshot/document form to a canonical wrapper. */
export function graphWorkflowSnapshotLikeToCanonical(
value: GraphWorkflowSnapshotLike | GraphWorkflowDocument
): GraphWorkflowSnapshot {
if ("document" in value) {
return value as GraphWorkflowSnapshot;
}
if ("state" in value || "definition" in value) {
return graphWorkflowSnapshotFromLegacy(value as LegacyGraphWorkflowSnapshot);
}
return { document: value as GraphWorkflowDocument };
}
Source