Сайт

Нодовый редактор SDK

Нодовый редактор доступен в двух пространствах.

  • sdk.project.nodeEditor — холст проекта. Он существует в единственном экземпляре: ядро создаёт его вместе с проектом и вы не открываете его самостоятельно.
  • sdk.nodeEditor — собственные нодовые редакторы расширения. Их создаёт код расширения, они открываются в виде вкладок в любой панели и уничтожаются также кодом.

Интерфейс графов один и тот же: всё, что описано ниже про чтение, изменение, порты и сообщения, одинаково работает и на sdk.project.nodeEditor, и на дескрипторе из sdk.nodeEditor.create.

Собственные редакторы: sdk.nodeEditor

create(id: string, options?: CreateNodeEditorOptions): Promise<NodeEditorHandle>
get(id: string): Promise<NodeEditorHandle | undefined>
list(): Promise<NodeEditorInfo[]>

list возвращает NodeEditorInfo — { id: NodeEditorId; displayName: string; visible: boolean }, где visible показывает, открыт ли холст хотя бы в одной вкладке.

id — локальный идентификатор в пределах расширения: 1–64 символа из [a-zA-Z0-9._-]. Глобальный идентификатор редактора — <extensionId>.<id>, именно он лежит в handle.id и из него выводятся идентификаторы команд редактора. get принимает и локальный, и глобальный идентификаторы, но видит только редакторы вызывающего расширения — как и list.

Опции create:

| Опция | По умолчанию | Что делает | | --- | --- | --- | | displayName | глобальный id | Заголовок вкладки и категория команд редактора. | | icon | — | Имя иконки lucide для вкладки. | | inspector | true | Открывать инспектор ноды по двойному клику. detailed устарел, но продолжает работать. | | statusbar | false | Регистрировать запись в статусбаре со счётчиками нод и рёбер. | | workspace | — | Рабочая область ('project', 'team', 'extensionWorkshop', 'home'), в которой доступны команды и статусбар редактора. Не задан — доступны во всех. | | when | — | Дополнительное when-выражение поверх рабочей области. |

NodeEditorHandle

Дескриптор реализует весь контракт нодового редактора из разделов ниже — те же события, чтение и мутации — плюс:

| Элемент | Описание | | --- | --- | | id | Глобальный id редактора. | | show(options?) | Открыть холст вкладкой: { panel?, activate?, pinned?, title? }. Без panel — project:panel.browser. Повторный вызов активирует уже открытую вкладку. | | getSnapshot() | Снимок графа { nodes, edges }. | | setSnapshot(snapshot) | Восстановить граф из снимка. | | undo() / redo() | Promise<boolean> — получилось ли откатить/повторить. Стек истории у редактора свой, проектный холст не затрагивается. | | clearHistory() | Очистить стек истории редактора. | | onDidDispose | Редактор уничтожен. | | dispose() | Закрыть вкладки, снять команды, запись статусбара и детальный вид. |

Где открывается

panel может быть любым PanelId из реестра. Холст не привязан к рабочей области проекта. Панели всех рабочих областей регистрируются при запуске IDE, поэтому вкладку можно открыть заранее — пользователь увидит её, переключившись на нужную рабочую область. Наборы id экспортируются из @fragmenta/extension: ProjectPanelIds, TeamPanelIds, WorkshopExtensionPanelIds, HomePanelIds. Также подойдёт и id панели, добавленной другим расширением.

await graph.show({ panel: TeamPanelIds.Right, title: 'Flow (team)' })
await graph.show({ panel: WorkshopExtensionPanelIds.Bottom })

Один и тот же редактор можно открыть сразу в нескольких панелях — граф у всех вкладок общий.

const graph = await sdk.nodeEditor.create('graph', {
  displayName: 'Flow',
  icon: 'Workflow',
  statusbar: true,
})

const nodeId = await graph.createNodeFromAsset(context.extension.id, 'my-node')
await graph.show({ panel: sdk.project.panel.bottom.panelId })

context.subscriptions.push(graph)

Ядро не сохраняет граф SDK-редактора: после перезагрузки IDE ни редактор, ни его содержимое не восстанавливаются. Сохранение реализовано на расширении, через getSnapshot и setSnapshot.

При деактивации или удалении расширения все его редакторы уничтожаются автоматически, вызывать dispose вручную не обязательно.

Ошибки

| Код | Когда | | --- | --- | | NODE_EDITOR_INVALID_ID | Пустой id, длиннее 64 символов или символы вне [a-zA-Z0-9._-]. | | NODE_EDITOR_ALREADY_EXISTS | create с id, уже занятым этим расширением. | | NODE_EDITOR_DISPOSED | Вызов на уничтоженном дескрипторе. | | NODE_EDITOR_NOT_FOUND | Операция по id, которого нет у этого расширения. | | PANEL_NOT_FOUND | show с идентификатором панели, которой нет в реестре. |

Методы не отклоняют Promise при ошибках графа. Если ничего не найдено, чтения возвращают undefined, null или []. Мутации успешно завершаются, даже если изменение отклонено , например из-за неизвестного id или проверки capabilities, при этом ошибка только пишется в лог. Чтобы проверить, применилось ли изменение, перечитайте граф.

События

| Событие | Payload | Когда срабатывает | | --- | --- | --- | | onNodeAdded | NodeId | Добавлена одна нода, например через createNodeFromAsset. | | onNodesAdded | readonly NodeId[] | Ноды добавлены одной пачкой, например при загрузке проекта. | | onNodeRemoved | NodeId | Нода удалена — по разу на каждую, включая удалённых вместе с ней потомков. | | onNodeUpdated | NodeId | Что-либо в ноде изменилось — см. ниже. | | onNodeDataUpdated | NodeId | data ноды заменена (срабатывает и onNodeUpdated). | | onNodePositionChanged | NodeId | Нода перемещена через moveNode или перетащена пользователем. | | onNodeParentChanged | NodeId | У ноды сменился родитель при группировке, разгруппировке или удалении её группы. | | onEdgeAdded / onEdgesAdded | EdgeId / readonly EdgeId[] | Добавлена одна связь/ связи добавлены одной пачкой. | | onEdgeRemoved | EdgeId | Связь удалена, в том числе связи удалённой ноды. | | onEdgeUpdated | EdgeId | Изменились подпись, цвет, анимация или данные у связи. | | onEdgeDataUpdated | EdgeId | data у связи заменена (срабатывает и onEdgeUpdated). | | onCleared | — | Весь граф очищен. | | onSelectionChanged | — | Изменилось выделение; прочитать его — getSelectedNodeIds / getSelectedEdgeIds. | | onNavigationChanged | NavigationState | Пользователь открыл вложенный граф или вернулся наверх. | | onOpenInspector | { nodeId: NodeId; node: NodeSnapshot } | Пользователь кликнул по ноде, и открылся её инспектор. onOpenDetail устарел, но продолжает работать. | | onNodeDoubleClick | { nodeId: NodeId; node: NodeSnapshot } | Пользователь дважды кликнул по ноде. | | onNodeMessage | NodeMessage | UI ноды отправил сообщение — см. «Обмен сообщениями с нодами» ниже. |

События срабатывают на любое изменение — от пользователя, от вашего расширения или от другого расширения.

  • onNodeUpdated срабатывает, когда меняются имя, цвет, данные, порты, поля updateNode или состояние выполнения ноды, при вызове setPortValue и когда setNodeOutputs действительно меняет значение.
  • Загрузка проекта пересобирает граф целиком. Сначала срабатывает onEdgeRemoved для каждой существующей связи, затем onCleared, после чего приходят пакеты onNodesAdded и onEdgesAdded. Отдельных событий onNodeRemoved / onNodeAdded для каждой ноды нет. Если вы держите зеркало графа, обрабатывайте onCleared и пакетные события.
  • updateEdgeType заменяет связь: подписчики получают onEdgeRemoved, затем onEdgeAdded с тем же id, а не onEdgeUpdated.

Чтение графа

getNode(nodeId: NodeId): Promise<NodeSnapshot | undefined>
getNodes(): Promise<NodeSnapshot[]>
getNormalizedNodes(): Promise<NodeSnapshot[]>
getNodesByAssetId(assetId: AssetId): Promise<NodeSnapshot[]>

getEdge(edgeId: EdgeId): Promise<EdgeSnapshot | undefined>
getEdges(): Promise<EdgeSnapshot[]>
getNormalizedEdges(): Promise<EdgeSnapshot[]>
getEdgesBetween(sourceNodeId: NodeId, targetNodeId: NodeId): Promise<EdgeSnapshot[]>
  • getNodesByAssetId сравнивает полный asset id из source.assetId ноды — node:<id расширения>.<type>, например node:acme.timers.TimerNode. Короткие id здесь не разрешаются.
  • getEdgesBetween учитывает направление: возвращает только связи, идущие из sourceNodeId в targetNodeId. Для обоих направлений вызовите дважды.

Normalized-варианты разворачивают группы. Именно эту форму обычно потребляют orchestrators при сборке DSL.

  • getNormalizedNodes выбрасывает групповые ноды; нода внутри группы получает parentId ближайшего не-group предка или остаётся без родителя. Ноды перечисляются в глубину, начиная с верхнего уровня; ноды, чей parentId указывает на несуществующую ноду, не попадают в результат.
  • getNormalizedEdges переписывает связи, подключённые к прокси-портам группы, так, чтобы они указывали на внутреннюю ноду и порт, которые этот прокси представляет. Связи, которые так разрешить нельзя, выбрасываются; остальные возвращаются как есть.

Иерархия и выделение:

getParent(nodeId: NodeId): Promise<NodeSnapshot | null>   // null для нод верхнего уровня
getChildren(nodeId: NodeId): Promise<NodeSnapshot[]>      // только прямые дети
getAncestors(nodeId: NodeId): Promise<NodeSnapshot[]>     // сначала родитель, дальше до верхнего уровня
getDescendants(nodeId: NodeId): Promise<NodeSnapshot[]>   // в ширину: дети, затем внуки, …
getRoots(): Promise<NodeSnapshot[]>                       // ноды без родителя

getSelectedNodeIds(): Promise<NodeId[]>
getSelectedEdgeIds(): Promise<EdgeId[]>

Навигация и используемые расширения:

getNavigationState(): Promise<NavigationState>
// { currentParentId, path, depth, breadcrumbs, isAtRoot }

getUsedNodeExtensions(): Promise<readonly ExtensionIdentity[]>
// из каких расширений пришли ноды на холсте — по одной записи на расширение
NavigationState и BreadcrumbItem
interface NavigationState {
  readonly currentParentId: NodeId | null   // нода, чей вложенный граф открыт; null на верхнем уровне
  readonly path: readonly NodeId[]          // открытые ноды, начиная с внешней
  readonly depth: number                    // path.length
  readonly breadcrumbs: readonly BreadcrumbItem[]
  readonly isAtRoot: boolean                // currentParentId === null
}

interface BreadcrumbItem {
  readonly nodeId: NodeId | null            // null у первого элемента — верхнего уровня с именем 'Root'
  readonly displayName: string
  readonly icon?: { name: string; color?: string }
  readonly color?: string                   // цвет из appearance ноды
  readonly depth: number                    // 0 для 'Root', дальше 1, 2, …
}
ExtensionIdentity
interface ExtensionIdentity {
  readonly id: ExtensionId                  // '<publisher>.<name>'
  readonly uuid: ExtensionUuid
  readonly versionId: ExtensionVersionId
  readonly version: string
}

Создание и изменение

createNodeFromAsset(assetId: string, position?: Position, parentId?: NodeId, options?: CreateNodeOptions): Promise<NodeId | undefined>
moveNode(nodeId: NodeId, position: Position, options?: NodeEditorHistoryOptions): Promise<void>

updateNode(nodeId: NodeId, params: UpdateNodeOptions, options?: NodeEditorHistoryOptions): Promise<void>
updateNodeDisplayName(nodeId: NodeId, displayName: string, options?: NodeEditorHistoryOptions): Promise<void>
updateNodeColor(nodeId: NodeId, color: string | undefined, options?: NodeEditorHistoryOptions): Promise<void>
updateNodeData(nodeId: NodeId, data: Dictionary, options?: NodeEditorHistoryOptions): Promise<void>
removeNode(nodeId: NodeId, options?: RemoveNodeOptions): Promise<void>

updateEdgeLabel(edgeId: EdgeId, label: string | undefined, options?: NodeEditorHistoryOptions): Promise<void>
updateEdgeColor(edgeId: EdgeId, color: string | undefined, options?: NodeEditorHistoryOptions): Promise<void>
updateEdgeAnimated(edgeId: EdgeId, animated: boolean | undefined, options?: NodeEditorHistoryOptions): Promise<void>
updateEdgeType(edgeId: EdgeId, assetId: AssetId, options?: NodeEditorHistoryOptions): Promise<void>
updateEdgeData(edgeId: EdgeId, data: Dictionary, options?: NodeEditorHistoryOptions): Promise<void>
removeEdge(edgeId: EdgeId, options?: NodeEditorHistoryOptions): Promise<void>
  • createNodeFromAsset создаёт ноду из набора asset и резолвится её id или undefined, если набор не найден или ноду не удалось добавить. assetId проверяется в таком порядке: полный набор id (node:acme.timers.TimerNode), квалифицированный тип (acme.timers.TimerNode), затем тип из вашего расширения (TimerNode). position по умолчанию { x: 0, y: 0 }; parentId помещает ноду внутрь другой ноды и должен быть id существующей ноды. Срабатывает onNodeAdded.
  • История. Методы с NodeEditorHistoryOptions по умолчанию записывают изменение в undo-историю (history: true); передайте { history: false }, чтобы применить его без шага отмены. Значения портов, outputs, параметры и состояние выполнения в историю не попадают никогда.
  • updateNodeData / updateEdgeData заменяют data целиком — без слияния. Если меняете один ключ, разверните текущее значение сами.
  • updateNodeColor, updateEdgeColor, updateEdgeLabel, updateEdgeAnimated — передайте undefined, чтобы сбросить значение.
  • updateNodeDisplayName (и updateNode с displayName) помечает имя как переопределённое: overrides.displayName становится true.
  • updateNode меняет несколько полей за один шаг отмены. Затрагиваются только переданные поля — undefined значит «оставить как есть», поэтому сбросить parentId, appearance или navigation через него нельзя. data, appearance и ports заменяют текущее значение (ports заменяет весь список портов); capabilities сливается с текущими capabilities.
  • removeNode удаляет ноду и подключённые к ней связи. Удаление обычной ноды удаляет и всех её потомков; удаление группы сохраняет наследников и переносит их к родителю группы. Нода с capabilities.deletable: false не удаляется, если не передать force: true.
  • moveNode не двигает ноду, у которой capabilities.draggable равно false. Срабатывает onNodePositionChanged.
  • updateEdgeType переключает связи на другой набор связей; assetId должен быть полным asset id edge — edge:<id расширения>.<type>. Связь пересоздаётся с тем же id и концами: label, animated и data берутся из нового набора, ранее заданный цвет сбрасывается.
const node = await sdk.project.nodeEditor.getNode(nodeId)
await sdk.project.nodeEditor.updateNodeData(nodeId, { ...node?.data, retries: 3 })
UpdateNodeOptions
interface UpdateNodeOptions {
  readonly position?: Position                          // { x: number; y: number }
  readonly displayName?: string                         // также выставляет overrides.displayName
  readonly icon?: { name: string; color?: string }
  readonly ports?: PortSnapshot[]                       // заменяет весь список портов
  readonly parentId?: NodeId
  readonly data?: Dictionary                            // заменяет data
  readonly appearance?: { color?: string }              // заменяет appearance
  readonly navigation?: boolean | { readonly notation?: ExtensionId }
  readonly capabilities?: Partial<{                     // сливается с текущими capabilities
    draggable: boolean
    selectable: boolean
    connectable: boolean
    deletable: boolean
  }>
}
NodeEditorHistoryOptions, RemoveNodeOptions, CreateNodeOptions
interface NodeEditorHistoryOptions {
  readonly history?: boolean      // по умолчанию true — записать изменение в undo-историю
}

interface RemoveNodeOptions extends NodeEditorHistoryOptions {
  readonly force?: boolean        // по умолчанию false — удалить, даже если capabilities.deletable равно false
}

interface CreateNodeOptions {
  readonly skipHistory?: boolean  // сейчас ни на что не влияет
}

Рёбра

canConnectEdge(sourceNodeId, sourcePortKey, targetNodeId, targetPortKey): Promise<boolean>
connectEdge(sourceNodeId, sourcePortKey, targetNodeId, targetPortKey): Promise<EdgeId | undefined>
createEdge(sourceNodeId, sourcePortKey, targetNodeId, targetPortKey): Promise<EdgeId | undefined>
createEdgeFromAsset(assetId, sourceNodeId, sourcePortKey, targetNodeId, targetPortKey): Promise<EdgeId | undefined>

Концы ребра задаются ключом порта (key из определения ноды), а не его id. canConnectEdge проверяет connectionRules портов, не меняя граф; connectEdge проверяет и соединяет; createEdge соединяет без проверки; createEdgeFromAsset берёт тип ребра из asset.

if (await sdk.project.nodeEditor.canConnectEdge(sourceId, 'out', targetId, 'in')) {
  const edgeId = await sdk.project.nodeEditor.connectEdge(sourceId, 'out', targetId, 'in')
}

Порты и состояние выполнения

setPortValue(nodeId: NodeId, portKey: string, value: unknown): Promise<void>
getPortValue<T = unknown>(nodeId: NodeId, portKey: string): Promise<T | undefined>
setParameterValue(nodeId: NodeId, key: string, value: unknown): Promise<void>
setParameterTemplate(nodeId: NodeId, key: string, template: string | undefined): Promise<void>
setPortTemplate(nodeId: NodeId, portKey: string, template: string | undefined): Promise<void>

setNodeOutputs(nodeId: NodeId, data: Record<string, any>): Promise<void>
setNodesOutputs(updates: readonly { nodeId: NodeId; data: Record<string, any> }[]): Promise<void>
setNodeInputsResolved(nodeId: NodeId, data: SdkNodeInputsResolved): Promise<void>

setNodeExecutionState(nodeId: NodeId, state: NodeState['execution']): Promise<void>
setNodesExecutionStates(updates: readonly { nodeId: NodeId; state: NodeState['execution'] }[]): Promise<void>
getNodeExecutionState(nodeId: NodeId): Promise<NodeState['execution'] | undefined>
  • Порты адресуются по key — ключу из определения ноды, — а не по id порта.
  • setPortValue / getPortValue работают с любым data-портом, входным или выходным; где лежит значение — в разделе Слоты значения. Неизвестные ноды, неизвестные ключи и signal-порты игнорируются; getPortValue для них резолвится в undefined.
  • setNodeOutputs задаёт по ключу значения нескольких выходных data-портов; ключи, не соответствующие выходным data-портам, пропускаются. setNodesOutputs делает то же для многих нод за один вызов.
  • setParameterValue работает только с class-нодами и существующими ключами параметров. Если определение ноды объявляет effect для этого значения параметра, порты ноды переключаются на порты этого effect.

Слоты значения

Data port и параметр class-node хранят до четырёх значений сразу, и они не затирают друг друга:

| Слот | Кто пишет | Уезжает в файл проекта | | --- | --- | --- | | defaultValue | автор ноды, в схеме данных | нет — приходит из схемы данных каждый раз | | value | человек, в inspector | да | | template | человек, в режиме выражения | да | | resolved | прогон | нет |

Чтение — одно правило: resolved → value → defaultValue, первое, что нашлось. getPortValue возвращает именно его.

  • setPortValue и setParameterValue пишут value и очищают resolved — значение, введённое руками, не будет перекрыто старым результатом прогона.
  • setNodeOutputs / setNodesOutputs пишут resolved выходных ports: прогон сообщает результат, не трогая авторский дефолт и не делая проект изменённым.
  • setParameterTemplate и setPortTemplate пишут template — то самое выражение, которое набрал бы человек. undefined убирает выражение и возвращает литерал. setPortTemplate адресует входной data port по ключу.
  • setNodeInputsResolved делает то же для входов — чем обернулось выражение, стоявшее в поле:
await sdk.project.nodeEditor.setNodeInputsResolved(nodeId, {
  parameters: { subject: 'Заказов: 2' },
  ports: { to: 'ivanov@example.com' },
})

Редактор показывает это под полем строкой на последнем прогоне: …, подставляя маску, если выражение читает секрет проекта. Ключ, отправленный как undefined, записывается со статусом missing: поле скажет, что выражение не разрешилось, вместо пустого значения.

Применяются только существующие ключи: неизвестный параметр, signal port или port не того направления просто игнорируются.

Состояние выполнения — union, различаемый по status; именно его редактор отображает на ноде. Под собственным именем тип не экспортируется — используйте NodeState['execution'].

| status | Дополнительные поля | | --- | --- | | 'idle' | — (состояние каждой новой ноды) | | 'pending' | queuePosition?: number | | 'running' | progress?: number (0..1), startedAt: number (epoch, мс) | | 'success' | result?: unknown, duration: number (мс) | | 'error' | error: BaseError, duration: number (мс) | | 'cancelled' | reason?: string |

  • Установка состояния, равного текущему, ничего не делает; любое другое изменение вызывает onNodeUpdated. Неизвестные идентификаторы ноды пропускаются.
  • Состояние выполнения не входит в NodeSnapshot, не сохраняется с проектом и не записывается в undo-историю. getNodeExecutionState резолвится в { status: 'idle' } для новой ноды и в undefined, только если нода не существует.
await sdk.project.nodeEditor.setNodeExecutionState(nodeId, {
  status: 'running',
  startedAt: Date.now(),
})
NodeState['execution']
type ExecutionState =
  | { readonly status: 'idle' }
  | { readonly status: 'pending'; readonly queuePosition?: number }
  | { readonly status: 'running'; readonly progress?: number; readonly startedAt: number }  // progress 0..1, startedAt — epoch в мс
  | { readonly status: 'success'; readonly result?: unknown; readonly duration: number }    // duration в мс
  | { readonly status: 'error'; readonly error: BaseError; readonly duration: number }      // duration в мс
  | { readonly status: 'cancelled'; readonly reason?: string }

interface BaseError extends Error {         // из @fragmenta/err
  readonly code: string
  readonly category: 'VALIDATION' | 'BUSINESS' | 'INFRASTRUCTURE' | 'SECURITY' | 'NOT_FOUND' | 'SYSTEM'
  readonly severity: 'LOW' | 'MEDIUM' | 'HIGH' | 'CRITICAL'
  readonly context: Dictionary
  readonly timestamp: Date
  readonly cause?: Error
}

Обмен сообщениями с нодами

Ноды с собственным UI (ui.path в определении node) могут обмениваться сообщениями с вашим расширением:

onNodeMessage: Event<NodeMessage>                                                // из UI ноды
postNodeMessage(nodeId: NodeId, event: string, payload?: unknown): Promise<void> // в UI ноды

NodeMessage: { nodeId: NodeId; assetId?: AssetId; event: string; payload?: unknown } — assetId — полный asset id ноды, к которой относится сообщение.

  • onNodeMessage получает сообщения от всех UI нод на холсте, включая ноды из других расширений. Фильтруйте по assetId перед обработкой.
  • postNodeMessage доходит только до UI этой ноды и только до её обработчиков этого event. assetId подставляется автоматически.
const TIMER_ASSET_ID: string = 'node:acme.timers.TimerNode'

context.subscriptions.push(
  sdk.project.nodeEditor.onNodeMessage(async (message) => {
    if (message.assetId !== TIMER_ASSET_ID) return
    if (message.event === 'timer.start') {
      await sdk.project.nodeEditor.postNodeMessage(message.nodeId, 'timer.tick', { value: 0 })
    }
  }),
)

Граф целиком

Кроме поштучных мутаций есть массовые — ими удобно загружать граф из своего формата и синхронизировать его.

getGraph(): Promise<NodeEditorSnapshot>
setGraph(snapshot): Promise<void>
clearGraph(): Promise<void>

setNodes(nodes): Promise<void>
setEdges(edges): Promise<void>

addNodes(nodes, options?): Promise<NodeId[]>
addEdges(edges, options?): Promise<EdgeId[]>
removeNodes(nodeIds, options?): Promise<void>
removeEdges(edgeIds, options?): Promise<void>

NodeEditorSnapshot — это { nodes: NodeSnapshot[]; edges: EdgeSnapshot[] }, тот же формат, что отдаёт getGraph. Round-trip getGraph → setGraph восстанавливает ноды, рёбра, порты и их значения.

Замена (setGraph, setNodes, setEdges, clearGraph) переписывает граф целиком и не пишется в историю. setNodeEditorSnapshot(snapshot) — прежнее имя setGraph, оставлено для совместимости; на дескрипторе то же самое доступно как setSnapshot / getSnapshot. setNodes сохраняет те рёбра, у которых оба конца остались в новом наборе нод; setEdges не трогает ноды.

Добавление и удаление (addNodes, addEdges, removeNodes, removeEdges) по умолчанию пишется в историю одной записью на вызов: один undo откатывает всю пачку. Передайте { history: false }, чтобы не трогать стек. removeNodes дополнительно принимает force?: boolean.

const graph = await sdk.project.nodeEditor.getGraph()

// перенести граф в свой холст и дополнить
await myEditor.setGraph(graph)
await myEditor.addNodes([{ ...nodeSnapshot, id: 'extra' }])

// одним undo вернуть обе добавленные ноды
await myEditor.undo()

Ноды и рёбра, которые не удалось создать из снимка, пропускаются с предупреждением в логе — остальные применяются.

Снимки

Снимки — это копии состояния графа, которые можно только прочитать: их изменение не меняет граф. Сужайте область поиска снимков нод по kind с помощью enum NodeKind из @fragmenta/extension; отдельные варианты по имени не экспортируются.

  • NodeSnapshot — id, type, kind ('base' | 'group' | 'class'), viewType?, source?, metadata, overrides?, position, dimensions?, ports, capabilities, parentId?, menu?, data?, appearance?, navigation?. Class-ноды добавляют parameters и effects?; group-ноды — childIds и proxyPortMappings.
  • EdgeSnapshot — id, type, kind ('base'), viewType?, source?, metadata, sourceEndpoint / targetEndpoint ({ nodeId, portId }), label?, animated?, data?.
  • PortSnapshot — DataPortSnapshot | SignalPortSnapshot: id, key, kind ('base'), nodeId, type ('signal' | 'data'), direction ('input' | 'output'), connectedEdges, displayName?, description?, required?, data?, connectionRules?, dynamic?; data-порты добавляют dataType и defaultValue?.
NodeSnapshot
type NodeSnapshot = BaseNodeSnapshot | GroupNodeSnapshot | ClassNodeSnapshot

interface CommonNodeFields {
  readonly id: NodeId
  readonly type: NodeType                   // '<id расширения>.<type>', например 'acme.timers.TimerNode'
  readonly kind: NodeKind                   // 'base' | 'group' | 'class'
  readonly viewType?: NodeType              // тип компонента, который отрисовывает ноду
  readonly source?: {
    readonly assetId?: AssetId              // полный asset id, например 'node:acme.timers.TimerNode'
    readonly extension?: ExtensionIdentity  // расширение, из которого получена нода
  }
  readonly metadata: {
    displayName: string
    description?: string
    icon?: { name: string; color?: string }
  }
  readonly overrides?: { displayName?: boolean } // true, если имя меняли на холсте или через SDK
  readonly position: Position               // { x: number; y: number }
  readonly dimensions?: { readonly width: number; readonly height: number }
  readonly ports: PortSnapshot[]
  readonly capabilities: {                  // каждое по умолчанию true
    readonly draggable: boolean
    readonly selectable: boolean
    readonly connectable: boolean
    readonly deletable: boolean
  }
  readonly parentId?: NodeId                // отсутствует у нод верхнего уровня
  readonly menu?: {                         // меню ноды, если его объявляет определение
    readonly id: string
    readonly display?: 'auto' | 'menu' | 'renderer'
    readonly items: readonly MenuItem[]
  }
  readonly data?: Dictionary
  readonly appearance?: { color?: string }
  readonly navigation?: boolean | { readonly notation?: ExtensionId } // нода открывается как вложенный граф; `notation` ограничивает это указанной нотацией
}

interface BaseNodeSnapshot extends CommonNodeFields {
  readonly kind: NodeKind.Base
}

interface GroupNodeSnapshot extends CommonNodeFields {
  readonly kind: NodeKind.Group
  readonly childIds: readonly NodeId[]
  readonly proxyPortMappings: readonly {
    readonly proxyPortId: PortId            // порт на group-ноде
    readonly originalPortId: PortId         // внутренний порт, который он представляет…
    readonly originalNodeId: NodeId         // …и внутренняя нода, которой принадлежит этот порт
  }[]
}

interface ClassNodeSnapshot extends CommonNodeFields {
  readonly kind: NodeKind.Class
  readonly parameters: Record<string, ClassParameter>
  readonly effects?: {
    parameters?: Record<string /* ключ параметра */, Record<string /* String(value) */, { ports: PortSnapshot[] }>>
  }
}
ClassParameter и NodeControl
interface ClassParameter {
  readonly key: string
  readonly displayName: string
  readonly description?: string
  readonly control: NodeControl
  readonly defaultValue?: any
  value?: any                               // текущее значение — задаётся через setParameterValue
  readonly parameters?: Record<string, ClassParameter> // вложенные параметры
}

type NodeControl =
  | { kind: 'text' }
  | { kind: 'number'; min?: number; max?: number; step?: number }
  | { kind: 'boolean' }
  | { kind: 'enum'; options: { value: string; label: string }[] }
  | { kind: 'object'; required?: boolean }
  | { kind: 'array' }
  | { kind: 'date' }
  | { kind: 'file'; accept?: string; multiple?: boolean; maxSize?: number }
EdgeSnapshot
interface EdgeSnapshot {
  readonly id: EdgeId
  readonly type: EdgeType                   // 'base' для простой связи, иначе '<id расширения>.<type>'
  readonly kind: 'base'
  readonly viewType?: EdgeType
  readonly source?: {
    readonly assetId?: AssetId              // полный asset id edge, например 'edge:acme.flows.DataEdge'
    readonly extension?: ExtensionIdentity
  }
  readonly metadata: { readonly displayName: string; readonly icon?: { name: string; color?: string } }
  readonly sourceEndpoint: { readonly nodeId: NodeId; readonly portId: PortId } // выходной порт
  readonly targetEndpoint: { readonly nodeId: NodeId; readonly portId: PortId } // входной порт
  readonly label?: string
  readonly animated?: boolean
  readonly data?: Dictionary
}
PortSnapshot
type PortSnapshot = DataPortSnapshot | SignalPortSnapshot

interface CommonPortFields {
  readonly id: PortId
  readonly key: string                      // ключ из определения ноды
  readonly kind: 'base'
  readonly nodeId: NodeId
  readonly type: PortType                   // 'signal' | 'data'
  readonly direction: PortDirection         // 'input' | 'output'
  readonly connectedEdges: readonly EdgeId[]
  readonly displayName?: string
  readonly description?: string
  readonly required?: boolean
  readonly data?: Dictionary
  readonly connectionRules?: {              // заполняется значениями по умолчанию ниже
    requireSameType?: boolean               // по умолчанию true
    requireSameDataType?: boolean           // по умолчанию false
    allowMultipleOutputs?: boolean          // входной порт принимает больше одной связи; по умолчанию: data false, signal true
    allowMultipleInputs?: boolean           // выходной порт передаёт данные больше, чем одной связи; по умолчанию: data true, signal false
  }
  readonly dynamic?: boolean                // true для портов, добавленных в рантайме, а не объявленных в определении
}

interface SignalPortSnapshot extends CommonPortFields {
  readonly type: PortType.Signal
}

interface DataPortSnapshot extends CommonPortFields {
  readonly type: PortType.Data
  readonly dataType: 'number' | 'string' | 'boolean' | 'array' | 'object' | 'date' | 'any' | 'file'
  defaultValue?: any                        // текущее значение — сюда пишут setPortValue и setNodeOutputs
}