From 2b8f1f26a5fe354653e2a087f90feaca8de3fdc6 Mon Sep 17 00:00:00 2001 From: Maksym Yezhov Date: Mon, 17 Nov 2025 22:50:42 -0800 Subject: [PATCH 001/225] poc: component spec object model --- package-lock.json | 33 +- package.json | 3 +- react-compiler.config.js | 2 + src/providers/ComponentSpec/annotations.ts | 66 + src/providers/ComponentSpec/componentSpec.ts | 67 + src/providers/ComponentSpec/context.ts | 229 +++ .../ComponentSpec/graphImplementation.ts | 202 +++ src/providers/ComponentSpec/inputs.ts | 64 + src/providers/ComponentSpec/outputs.ts | 55 + src/providers/ComponentSpec/types.ts | 34 + src/providers/ComponentSpec/yamlLoader.ts | 174 +++ src/routes/EditorV2/EditorV2.tsx | 182 +++ src/routes/EditorV2/assets/test-spec.yaml | 1291 +++++++++++++++++ src/routes/EditorV2/components/TaskNode.tsx | 15 + .../EditorV2/components/TaskNodeCard.tsx | 39 + src/routes/router.ts | 9 + src/utils/componentSpec.ts | 6 +- 17 files changed, 2466 insertions(+), 5 deletions(-) create mode 100644 src/providers/ComponentSpec/annotations.ts create mode 100644 src/providers/ComponentSpec/componentSpec.ts create mode 100644 src/providers/ComponentSpec/context.ts create mode 100644 src/providers/ComponentSpec/graphImplementation.ts create mode 100644 src/providers/ComponentSpec/inputs.ts create mode 100644 src/providers/ComponentSpec/outputs.ts create mode 100644 src/providers/ComponentSpec/types.ts create mode 100644 src/providers/ComponentSpec/yamlLoader.ts create mode 100644 src/routes/EditorV2/EditorV2.tsx create mode 100644 src/routes/EditorV2/assets/test-spec.yaml create mode 100644 src/routes/EditorV2/components/TaskNode.tsx create mode 100644 src/routes/EditorV2/components/TaskNodeCard.tsx diff --git a/package-lock.json b/package-lock.json index e8e327fc1..3adaf0423 100644 --- a/package-lock.json +++ b/package-lock.json @@ -70,7 +70,8 @@ "react-toastify": "^11.0.5", "tailwind-merge": "^3.5.0", "tailwindcss": "^4.1.16", - "tailwindcss-animate": "^1.0.7" + "tailwindcss-animate": "^1.0.7", + "valtio": "^2.2.0" }, "devDependencies": { "@eslint/js": "^9.39.2", @@ -10323,6 +10324,12 @@ "dev": true, "license": "MIT" }, + "node_modules/proxy-compare": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/proxy-compare/-/proxy-compare-3.0.1.tgz", + "integrity": "sha512-V9plBAt3qjMlS1+nC8771KNf6oJ12gExvaxnNzN/9yVRLdTv/lc+oJlnSzrdYDAvBfTStPCoiaCOTmTs0adv7Q==", + "license": "MIT" + }, "node_modules/pump": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", @@ -12469,6 +12476,30 @@ "uuid": "dist/bin/uuid" } }, + "node_modules/valtio": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/valtio/-/valtio-2.2.0.tgz", + "integrity": "sha512-l/zzQahUIm+dfUUP9fIecNVEWJLea9shMC1Bb1aK+v4XNOEzoq796Qax+yzMemmqpltuxfH7kPJy62FVGJDEtw==", + "license": "MIT", + "dependencies": { + "proxy-compare": "^3.0.1" + }, + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "react": ">=18.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "react": { + "optional": true + } + } + }, "node_modules/vite": { "version": "7.3.1", "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", diff --git a/package.json b/package.json index dd87740f5..ea60ba271 100644 --- a/package.json +++ b/package.json @@ -110,7 +110,8 @@ "react-toastify": "^11.0.5", "tailwind-merge": "^3.5.0", "tailwindcss": "^4.1.16", - "tailwindcss-animate": "^1.0.7" + "tailwindcss-animate": "^1.0.7", + "valtio": "^2.2.0" }, "peerDependencies": { "monaco-editor": "^0.54.0" diff --git a/react-compiler.config.js b/react-compiler.config.js index a4b3e53f8..f59de5cdb 100644 --- a/react-compiler.config.js +++ b/react-compiler.config.js @@ -64,6 +64,8 @@ export const REACT_COMPILER_ENABLED_DIRS = [ "src/components/shared/AnnouncementBanners.tsx", "src/components/shared/ReactFlow/FlowCanvas/TaskNode/TaskOverview/IOSection", + "src/routes/EditorV2", + // 11-20 useCallback/useMemo // "src/components/ui", // 12 // "src/components/PipelineRun", // 14 diff --git a/src/providers/ComponentSpec/annotations.ts b/src/providers/ComponentSpec/annotations.ts new file mode 100644 index 000000000..04f4691b4 --- /dev/null +++ b/src/providers/ComponentSpec/annotations.ts @@ -0,0 +1,66 @@ +import { BaseCollection, type Context } from "./context"; +import type { + BaseEntity, + RequiredProperties, + SerializableEntity, +} from "./types"; + +interface AnnotationScalarInterface { + key: string; + value?: unknown; +} + +export class AnnotationEntity + implements SerializableEntity, BaseEntity +{ + $indexed: never[]; + + key: string; + value?: unknown; + + constructor( + public readonly $id: string, + required: RequiredProperties, + ) { + this.$id = $id; + this.$indexed = []; + + this.key = required.key; + } + + populate(scalar: AnnotationScalarInterface) { + this.key = scalar.key; + this.value = scalar.value; + + return this; + } + + toJson() { + return JSON.stringify(this.value); + } +} + +export class AnnotationsCollection + extends BaseCollection + implements SerializableEntity +{ + constructor(parent: Context) { + super("annotations", parent); + } + + createEntity(spec: AnnotationScalarInterface): AnnotationEntity { + return new AnnotationEntity(this.generateId(), spec); + } + + toJson(): string { + return JSON.stringify( + this.getAll().reduce( + (acc, annotation) => { + acc[annotation.key] = annotation.toJson(); + return acc; + }, + {} as Record, + ), + ); + } +} diff --git a/src/providers/ComponentSpec/componentSpec.ts b/src/providers/ComponentSpec/componentSpec.ts new file mode 100644 index 000000000..e66faf855 --- /dev/null +++ b/src/providers/ComponentSpec/componentSpec.ts @@ -0,0 +1,67 @@ +import type { ComponentSpec } from "@/utils/componentSpec"; + +import { BaseNestedContext, type Context } from "./context"; +import type { GraphImplementation } from "./graphImplementation"; +import { InputsCollection } from "./inputs"; +import { OutputsCollection } from "./outputs"; +import type { + BaseEntity, + RequiredProperties, + SerializableEntity, +} from "./types"; + +export type ComponentSpecScalarInterface = Pick< + ComponentSpec, + "description" +> & { name: string }; + +export class ComponentSpecEntity + extends BaseNestedContext + implements BaseEntity, SerializableEntity +{ + readonly $indexed = ["name" as const]; + + name: string; + description?: string; + + implementation?: GraphImplementation; + + readonly inputs: InputsCollection; + readonly outputs: OutputsCollection; + + constructor( + public readonly $id: string, + parent: Context, + required: RequiredProperties, + ) { + super(required.name, parent); + + this.name = required.name; + + this.inputs = new InputsCollection(this); + this.outputs = new OutputsCollection(this); + } + + findComponentSpecEntity(name: string): ComponentSpecEntity | undefined { + return this.entities.findByIndex("name", name)[0] as + | ComponentSpecEntity + | undefined; + } + + populate(scalar: ComponentSpecScalarInterface) { + this.name = scalar.name; + this.description = scalar.description; + + return this; + } + + toJson() { + return { + name: this.name, + description: this.description, + implementation: this.implementation?.toJson(), + inputs: this.inputs.toJson(), + outputs: this.outputs.toJson(), + }; + } +} diff --git a/src/providers/ComponentSpec/context.ts b/src/providers/ComponentSpec/context.ts new file mode 100644 index 000000000..e75991ecd --- /dev/null +++ b/src/providers/ComponentSpec/context.ts @@ -0,0 +1,229 @@ +import type { BaseEntity } from "./types"; + +export type EntityId = string; + +export interface IdGenerator { + generateId(): EntityId; +} + +export interface Context { + $name: string; + generateId(): EntityId; + registerEntity>(entity: TEntity): void; +} + +export interface NestedContext extends Context { + // todo: parent relationship is not yet implemented + readonly $parent: Context; +} + +export class AutoincrementIdGenerator implements IdGenerator { + private counter = 0; + + constructor(private readonly prefix: string) {} + + generateId(): EntityId { + return `${this.prefix}_${++this.counter}`; + } +} + +class IndexByKey { + private readonly fieldValueToEntityId: Map< + string | number | symbol, + Map> + > = new Map(); + + add>(entity: TEntity) { + for (const index of entity.$indexed) { + const fieldValue = entity[index]; + if (!this.fieldValueToEntityId.has(index)) { + this.fieldValueToEntityId.set( + index, + new Map([[fieldValue, new Set([entity.$id])]]), + ); + continue; + } + + const valueToEntityId = this.fieldValueToEntityId.get(index)!; + + if (!valueToEntityId.has(fieldValue)) { + valueToEntityId.set(fieldValue, new Set([entity.$id])); + continue; + } + + valueToEntityId.get(fieldValue)!.add(entity.$id); + } + } + + remove>(entity: TEntity) { + for (const index of entity.$indexed) { + const valueToEntityId = this.fieldValueToEntityId.get(index); + if (!valueToEntityId) { + continue; + } + const entityIds = valueToEntityId.get(entity[index]); + if (!entityIds) { + continue; + } + entityIds.delete(entity.$id); + } + } + + findByIndex, TKey extends keyof TEntity>( + searchTerm: TKey, + value: TEntity[TKey], + ): EntityId[] { + const valueToEntityId = this.fieldValueToEntityId.get(searchTerm); + if (!valueToEntityId) { + return []; + } + + const entityIds = valueToEntityId.get(value); + if (!entityIds) { + return []; + } + return Array.from(entityIds); + } +} + +export class EntityIndex> { + private readonly entities: Map = new Map(); + private readonly indexByKey = new IndexByKey(); + + getAll(): TEntity[] { + return Array.from(this.entities.values()); + } + + has(id: EntityId): boolean { + return this.entities.has(id); + } + + add(entity: TEntity) { + this.entities.set(entity.$id, entity); + this.indexByKey.add(entity); + } + + remove(entity: TEntity) { + this.removeById(entity.$id); + } + + removeById(id: EntityId) { + const entity = this.entities.get(id); + if (!entity) { + return false; + } + + this.indexByKey.remove(entity); + this.entities.delete(id); + + return true; + } + + findById(id: EntityId): TEntity | undefined { + return this.entities.get(id); + } + + findByIndex( + index: TKey, + value: TEntity[TKey], + ): TEntity[] { + const ids = this.indexByKey.findByIndex(index, value); + return ids.map((id) => this.entities.get(id)!); + } +} + +export abstract class BaseCollection< + TScalar, + TEntity extends BaseEntity, + > + extends EntityIndex + implements NestedContext +{ + private readonly context: BaseNestedContext; + + constructor(contextName: string, parent: Context) { + super(); + this.context = new BaseNestedContext(contextName, parent); + } + + add(spec: TScalar) { + const entity = this.createEntity(spec); + this.context.registerEntity(entity); + + super.add(entity); + + return entity; + } + + abstract createEntity(spec: TScalar): TEntity; + + get $name(): string { + return this.context.$name; + } + + get $parent(): Context { + return this.context.$parent; + } + + generateId(): EntityId { + return this.context.generateId(); + } + + registerEntity>(entity: TEntity): void { + this.context.registerEntity(entity); + } +} + +export class BaseNestedContext implements NestedContext { + private readonly idGenerator: IdGenerator; + + protected readonly entities: EntityIndex> = new EntityIndex(); + + constructor( + private readonly contextName: string, + public readonly $parent: Context, + ) { + this.idGenerator = new AutoincrementIdGenerator(this.$name); + } + + generateId(): EntityId { + return this.idGenerator.generateId(); + } + + get $name(): string { + return `${this.$parent.$name}.${this.contextName}`; + } + + registerEntity>(entity: TEntity): void { + this.entities.add(entity); + } +} + +export class RootContext implements Context { + readonly $name = "root"; + private readonly entities: EntityIndex> = new EntityIndex(); + + private readonly idGenerator: IdGenerator = new AutoincrementIdGenerator( + "root", + ); + + generateId(): EntityId { + return this.idGenerator.generateId(); + } + + registerEntity>(entity: TEntity): void { + this.entities.add(entity); + } + + removeEntity>(entity: TEntity): void { + this.entities.remove(entity); + } +} + +/** + * + * const rootContext = new RootContext(); + * + * + * + */ diff --git a/src/providers/ComponentSpec/graphImplementation.ts b/src/providers/ComponentSpec/graphImplementation.ts new file mode 100644 index 000000000..ac8a61255 --- /dev/null +++ b/src/providers/ComponentSpec/graphImplementation.ts @@ -0,0 +1,202 @@ +import type { + ComponentReference, + ExecutionOptionsSpec, + PredicateType, + TaskSpec, +} from "@/utils/componentSpec"; + +import { BaseCollection, type Context, type NestedContext } from "./context"; +import { InputEntity } from "./inputs"; +import type { OutputEntity } from "./outputs"; +import type { + BaseEntity, + RequiredProperties, + ScalarType, + SerializableEntity, +} from "./types"; + +export class GraphImplementation implements SerializableEntity { + readonly tasks: TasksCollection; + + constructor(private readonly context: Context) { + this.tasks = new TasksCollection(this.context); + } + + toJson() { + return { + graph: { + tasks: this.tasks.toJson(), + }, + }; + } +} + +type TaskScalarInterface = Pick & { + name: string; + componentRef: ComponentReference; +}; + +export class TaskEntity + implements BaseEntity, SerializableEntity +{ + readonly $indexed = ["name" as const]; + + name: string; + componentRef: ComponentReference; + + isEnabled?: PredicateType; + executionOptions?: ExecutionOptionsSpec; + + readonly arguments: ArgumentsCollection; + + constructor( + readonly $id: string, + private readonly context: Context, + required: RequiredProperties, + ) { + this.name = required.name; + this.componentRef = required.componentRef; + + this.arguments = new ArgumentsCollection(this.context); + } + + populate(scalar: TaskScalarInterface) { + this.name = scalar.name; + this.isEnabled = scalar.isEnabled; + this.executionOptions = scalar.executionOptions; + this.componentRef = scalar.componentRef; + + return this; + } + + toJson() { + return { + taskId: this.name, + componentRef: this.componentRef, + isEnabled: this.isEnabled, + executionOptions: this.executionOptions, + arguments: this.arguments.toJson(), + }; + } +} + +export class TasksCollection + extends BaseCollection + implements SerializableEntity, NestedContext +{ + constructor(parent: Context) { + super("tasks", parent); + } + + createEntity(spec: TaskScalarInterface): TaskEntity { + return new TaskEntity(this.generateId(), this, spec).populate(spec); + } + + toJson() { + return this.getAll().reduce( + (acc, task) => { + acc[task.name] = task.toJson(); + return acc; + }, + {} as Record, + ); + } +} + +interface ArgumentScalarInterface { + // type: "graphInput" | "taskOutput" | "literal"; + name: string; +} + +type ScalarValue = string | number | boolean | null | undefined; + +export class ArgumentEntity + implements BaseEntity, SerializableEntity +{ + readonly $indexed = ["name" as const]; + + name: string; + + private _type: "graphInput" | "taskOutput" | "literal" = "literal"; + private _source: InputEntity | OutputEntity | undefined; + + private _value: ScalarValue | undefined; + + constructor( + readonly $id: string, + required: RequiredProperties, + ) { + this.name = required.name; + } + + populate(scalar: ArgumentScalarInterface) { + this.name = scalar.name; + + return this; + } + + connectTo(output: OutputEntity): void; + connectTo(input: InputEntity): void; + connectTo(source: InputEntity | OutputEntity): void { + if (source instanceof InputEntity) { + this._type = "graphInput"; + } else { + this._type = "taskOutput"; + } + this._source = source; + this._value = undefined; + } + + get value(): ScalarValue { + if (this._type === "literal") { + return this._value; + } + + // todo: return the value of the source? + // return this._source?.value; + return undefined; + } + + set value(value: ScalarValue) { + this._type = "literal"; + this._value = value; + } + + get type(): "graphInput" | "taskOutput" | "literal" { + return this._type; + } + + toJson() { + // todo: fix to return according to Spec + return { + __argument: { + name: this.name, + type: this.type, + value: this.value, + }, + }; + } +} + +export class ArgumentsCollection + extends BaseCollection + implements SerializableEntity +{ + constructor(parent: Context) { + super("arguments", parent); + } + + createEntity(spec: ArgumentScalarInterface): ArgumentEntity { + return new ArgumentEntity(this.generateId(), spec).populate(spec); + } + + toJson() { + return this.getAll().reduce( + (acc, argument) => { + acc[argument.name] = argument.toJson(); + return acc; + }, + {} as Record, + ); + } +} diff --git a/src/providers/ComponentSpec/inputs.ts b/src/providers/ComponentSpec/inputs.ts new file mode 100644 index 000000000..6b7fba9b2 --- /dev/null +++ b/src/providers/ComponentSpec/inputs.ts @@ -0,0 +1,64 @@ +import type { InputSpec, TypeSpecType } from "@/utils/componentSpec"; + +import { BaseCollection, type Context } from "./context"; +import type { BaseEntity, SerializableEntity } from "./types"; + +export type InputScalarInterface = Pick< + InputSpec, + "name" | "type" | "description" | "default" | "optional" | "value" +>; + +export class InputEntity + implements BaseEntity, SerializableEntity +{ + readonly $indexed = ["name" as const]; + + name: string = ""; + + type?: TypeSpecType; + description?: string; + default?: string; + optional?: boolean; + value?: string; + + constructor(readonly $id: string) {} + + populate(spec: InputScalarInterface) { + this.name = spec.name; + this.type = spec.type; + this.description = spec.description; + this.default = spec.default; + this.optional = spec.optional; + this.value = spec.value; + + return this; + } + + toJson() { + return { + name: this.name, + type: this.type, + description: this.description, + default: this.default, + optional: this.optional, + value: this.value, + }; + } +} + +export class InputsCollection + extends BaseCollection + implements SerializableEntity +{ + constructor(parent: Context) { + super("inputs", parent); + } + + createEntity(spec: InputScalarInterface): InputEntity { + return new InputEntity(this.generateId()).populate(spec); + } + + toJson() { + return this.getAll().map((input) => input.toJson()); + } +} diff --git a/src/providers/ComponentSpec/outputs.ts b/src/providers/ComponentSpec/outputs.ts new file mode 100644 index 000000000..0f8f5257b --- /dev/null +++ b/src/providers/ComponentSpec/outputs.ts @@ -0,0 +1,55 @@ +import type { OutputSpec, TypeSpecType } from "@/utils/componentSpec"; + +import { BaseCollection, type Context } from "./context"; +import type { BaseEntity, SerializableEntity } from "./types"; + +export type OutputScalarInterface = Pick< + OutputSpec, + "name" | "type" | "description" +>; + +export class OutputEntity + implements BaseEntity, SerializableEntity +{ + readonly $indexed = ["name" as const]; + + name: string = ""; + + type?: TypeSpecType; + description?: string; + + constructor(readonly $id: string) {} + + populate(spec: OutputScalarInterface) { + this.name = spec.name; + this.type = spec.type; + this.description = spec.description; + + return this; + } + + toJson() { + return { + name: this.name, + type: this.type, + description: this.description, + }; + } +} + +export class OutputsCollection + extends BaseCollection + implements SerializableEntity +{ + constructor(parent: Context) { + super("outputs", parent); + } + + createEntity(spec: OutputScalarInterface): OutputEntity { + return new OutputEntity(this.generateId()).populate(spec); + } + + toJson() { + return this.getAll().map((output) => output.toJson()); + } +} diff --git a/src/providers/ComponentSpec/types.ts b/src/providers/ComponentSpec/types.ts new file mode 100644 index 000000000..20696ec27 --- /dev/null +++ b/src/providers/ComponentSpec/types.ts @@ -0,0 +1,34 @@ +export type BaseEntity< + TScalar = {}, + TKey extends keyof TScalar = keyof TScalar, +> = { + readonly $id: string; + readonly $indexed: TKey[]; + + populate(scalar: TScalar): BaseEntity; +} & { + [K in TKey]: TScalar[K]; +}; + +export type ScalarType = undefined | null | string | number | boolean; + +export interface SerializableEntity { + toJson(): object | ScalarType; +} + +export function isSerializableEntity( + entity: unknown, +): entity is SerializableEntity { + return ( + !!entity && + typeof entity === "object" && + "toJson" in entity && + typeof entity.toJson === "function" + ); +} + +export type RequiredKeys = { + [K in keyof T]-?: {} extends Pick ? never : K; +}[keyof T]; + +export type RequiredProperties = Pick>; diff --git a/src/providers/ComponentSpec/yamlLoader.ts b/src/providers/ComponentSpec/yamlLoader.ts new file mode 100644 index 000000000..ba5c2812f --- /dev/null +++ b/src/providers/ComponentSpec/yamlLoader.ts @@ -0,0 +1,174 @@ +import { + hydrateComponentReference, + parseComponentData, +} from "@/services/componentService"; +import { + type ComponentSpec, + isGraphImplementation, + isGraphInputArgument, + isTaskOutputArgument, + type TaskSpec, +} from "@/utils/componentSpec"; + +import { ComponentSpecEntity } from "./componentSpec"; +import { type Context, RootContext } from "./context"; +import { GraphImplementation, TaskEntity } from "./graphImplementation"; + +export class YamlLoader { + private readonly rootContext: Context; + + constructor() { + this.rootContext = new RootContext(); + } + + async loadFromText(text: string): Promise { + const loadedSpec = parseComponentData(text); + if (!loadedSpec) { + throw new Error("Failed to load component data"); + } + + return this.load( + loadedSpec, + loadedSpec.name ?? "Root Spec", + this.rootContext, + ); + } + + async load( + loadedSpec: ComponentSpec, + name: string, + parentContext: Context, + ): Promise { + const rootSpecEntity = new ComponentSpecEntity( + parentContext.generateId(), + parentContext, + { name }, + ).populate({ + name, + description: loadedSpec.description, + }); + + parentContext.registerEntity(rootSpecEntity); + + for (const input of loadedSpec.inputs ?? []) { + rootSpecEntity.inputs.add({ + name: input.name, + type: input.type, + description: input.description, + }); + } + + for (const output of loadedSpec.outputs ?? []) { + rootSpecEntity.outputs.add({ + name: output.name, + type: output.type, + description: output.description, + }); + } + + if (isGraphImplementation(loadedSpec.implementation)) { + const queue: { + taskEntity: TaskEntity; + taskId: string; + taskSpec: TaskSpec; + }[] = []; + + // todo: use context + const graphImplementation = new GraphImplementation(rootSpecEntity); + rootSpecEntity.implementation = graphImplementation; + + for (const [taskId, task] of Object.entries( + loadedSpec.implementation.graph.tasks, + )) { + const hydratedComponentRef = await hydrateComponentReference( + task.componentRef, + ); + + if (!hydratedComponentRef) { + throw new Error( + `Failed to hydrate component reference for task ${taskId}`, + ); + } + + const taskEntity = graphImplementation.tasks.add({ + name: taskId, + componentRef: hydratedComponentRef, + }); + + queue.push({ taskEntity, taskId, taskSpec: task }); + + await this.load(hydratedComponentRef.spec, taskId, rootSpecEntity); + } + + // dequeue tasks and create connections + while (queue.length > 0) { + const { taskEntity, taskId, taskSpec } = queue.shift()!; + + // options + taskEntity.populate({ + ...taskSpec, + name: taskId, + }); + + for (const [argumentName, argumentValue] of Object.entries( + taskSpec.arguments ?? {}, + )) { + const argumentEntity = taskEntity.arguments.add({ + name: argumentName, + }); + + if (isGraphInputArgument(argumentValue)) { + const inputResult = rootSpecEntity.inputs.findByIndex( + "name", + argumentValue.graphInput.inputName, + ); + if (inputResult.length !== 1) { + throw new Error( + `Multiple inputs found for ${argumentValue.graphInput.inputName}`, + ); + } + argumentEntity.connectTo(inputResult[0]); + } else if (isTaskOutputArgument(argumentValue)) { + const taskResult = graphImplementation.tasks.findByIndex( + "name", + argumentValue.taskOutput.taskId, + ); + if (taskResult.length !== 1) { + throw new Error( + `Multiple tasks found for ${argumentValue.taskOutput.taskId}`, + ); + } + + const sourceComponentSpec = rootSpecEntity.findComponentSpecEntity( + argumentValue.taskOutput.taskId, + ); + if (!sourceComponentSpec) { + throw new Error( + `Source component spec entity not found for ${argumentValue.taskOutput.taskId}`, + ); + } + + const outputResult = sourceComponentSpec.outputs.findByIndex( + "name", + argumentValue.taskOutput.outputName, + ); + + if (outputResult.length !== 1) { + throw new Error( + `Multiple outputs found for ${argumentValue.taskOutput.outputName}`, + ); + } + + argumentEntity.connectTo(outputResult[0]); + } else { + argumentEntity.value = argumentValue; + } + } + } + } else { + // todo: handle other implementation types + } + + return rootSpecEntity; + } +} diff --git a/src/routes/EditorV2/EditorV2.tsx b/src/routes/EditorV2/EditorV2.tsx new file mode 100644 index 000000000..0f8bce7a4 --- /dev/null +++ b/src/routes/EditorV2/EditorV2.tsx @@ -0,0 +1,182 @@ +import { DndContext } from "@dnd-kit/core"; +import { useSuspenseQuery } from "@tanstack/react-query"; +import { + Background, + MiniMap, + ReactFlow, + type ReactFlowProps, + ReactFlowProvider, +} from "@xyflow/react"; +import { type ComponentType, useState } from "react"; +import { proxy, useSnapshot } from "valtio"; + +import { withSuspenseWrapper } from "@/components/shared/SuspenseWrapper"; +import { BlockStack, InlineStack } from "@/components/ui/layout"; +import { Heading, Paragraph, Text } from "@/components/ui/typography"; +import { cn } from "@/lib/utils"; +import type { ComponentSpecEntity } from "@/providers/ComponentSpec/componentSpec"; +import { + GraphImplementation, + type TasksCollection, +} from "@/providers/ComponentSpec/graphImplementation"; +import { YamlLoader } from "@/providers/ComponentSpec/yamlLoader"; + +import { TaskNode } from "./components/TaskNode"; + +const GRID_SIZE = 10; + +const availableTemplates = import.meta.glob("./assets/*.yaml", { + query: "?raw", + import: "default", +}); + +async function getSpecByName(name: "test-spec") { + return availableTemplates[`./assets/${name}.yaml`](); +} + +function useExperimentalFlow() { + const [yamlLoader] = useState(() => new YamlLoader()); + + /** + * Load the test spec from the assets folder. + */ + const { data: testSpecText } = useSuspenseQuery({ + queryKey: ["test-spec"], + queryFn: () => getSpecByName("test-spec"), + staleTime: Infinity, + retry: false, + }); + + const { data: testSpec } = useSuspenseQuery({ + queryKey: ["test-spec-entity"], + queryFn: () => yamlLoader.loadFromText(testSpecText), + staleTime: Infinity, + retry: false, + }); + + return proxy(testSpec); +} + +function isGraphImplementation( + implementation: ComponentSpecEntity["implementation"], +): implementation is GraphImplementation { + return ( + implementation !== undefined && + implementation !== null && + implementation instanceof GraphImplementation + ); +} + +const PipelineEditorCanvas = withSuspenseWrapper(() => { + const experimentalFlow = useExperimentalFlow(); + + const [flowConfig] = useState({ + snapGrid: [GRID_SIZE, GRID_SIZE], + snapToGrid: true, + panOnDrag: true, + selectionOnDrag: false, + nodesDraggable: true, + }); + + // todo: convert experimentalFlow to nodes and edges + console.log(experimentalFlow); + + if (!isGraphImplementation(experimentalFlow.implementation)) { + return null; + } + + return ( + + + + + + Debug info here + + + ); +}); + +export function EditorV2() { + return ( + + Editor V2 + This is the new editor. It is still in development. +
+ + + + + +
+
+ ); +} + +const nodeTypes: Record> = { + task: TaskNode, +}; + +function FlowCanvas({ + children, + nodes, + edges, + tasks, + ...rest +}: ReactFlowProps & { tasks: TasksCollection }) { + const tasksSnapshot = useSnapshot(tasks); + + const allNodes = [ + ...(tasksSnapshot.getAll().map((task, index) => ({ + id: task.$id, + type: "task", + position: { x: index * 100, y: index * 100 }, + data: { + name: task.name, + description: task.componentRef.spec?.description ?? "", + }, + })) ?? []), + ]; + + return ( + + + {children} + + + + + + ); +} diff --git a/src/routes/EditorV2/assets/test-spec.yaml b/src/routes/EditorV2/assets/test-spec.yaml new file mode 100644 index 000000000..85626ffc5 --- /dev/null +++ b/src/routes/EditorV2/assets/test-spec.yaml @@ -0,0 +1,1291 @@ +name: Max Pipeline (2025-10-03T02:39:08.402Z) +metadata: + annotations: + sdk: https://cloud-pipelines.net/pipeline-editor/ + editor.flow-direction: left-to-right +implementation: + graph: + tasks: + Xgboost predict on CSV: + componentRef: + digest: 6fd7196d2061e6f49ec98459c90f4b1e4e63bca21170c70b23f7679921ce01d7 + url: >- + https://raw.githubusercontent.com/Ark-kun/pipeline_components/96a177dd71d54c98573c4101d9a05ac801f1fa54/components/XGBoost/Predict/component.yaml + spec: + name: Xgboost predict on CSV + description: Makes predictions using a trained XGBoost model. + metadata: + annotations: + author: Alexey Volkov + canonical_location: >- + https://raw.githubusercontent.com/Ark-kun/pipeline_components/master/components/XGBoost/Predict/component.yaml + inputs: + - name: data + type: CSV + description: Feature data in Apache Parquet format. + - name: model + type: XGBoostModel + description: Trained model in binary XGBoost format. + - name: label_column_name + type: String + description: >- + Optional. Name of the column containing the label data that is + excluded during the prediction. + optional: true + outputs: + - name: predictions + description: Model predictions. + implementation: + container: + image: python:3.10 + command: + - sh + - '-c' + - >- + (PIP_DISABLE_PIP_VERSION_CHECK=1 python3 -m pip install + --quiet --no-warn-script-location 'xgboost==1.6.1' + 'pandas==1.4.3' 'numpy<2' || PIP_DISABLE_PIP_VERSION_CHECK=1 + python3 -m pip install --quiet --no-warn-script-location + 'xgboost==1.6.1' 'pandas==1.4.3' 'numpy<2' --user) && "$0" + "$@" + - sh + - '-ec' + - | + program_path=$(mktemp) + printf "%s" "$0" > "$program_path" + python3 -u "$program_path" "$@" + - > + def _make_parent_dirs_and_return_path(file_path: str): + import os + os.makedirs(os.path.dirname(file_path), exist_ok=True) + return file_path + + def xgboost_predict_on_CSV( + data_path, + model_path, + predictions_path, + label_column_name = None, + ): + """Makes predictions using a trained XGBoost model. + + Args: + data_path: Feature data in Apache Parquet format. + model_path: Trained model in binary XGBoost format. + predictions_path: Model predictions. + label_column_name: Optional. Name of the column containing the label data that is excluded during the prediction. + + Annotations: + author: Alexey Volkov + """ + from pathlib import Path + + import numpy + import pandas + import xgboost + + df = pandas.read_csv( + data_path, + ).convert_dtypes() + print("Evaluation data information:") + df.info(verbose=True) + # Converting column types that XGBoost does not support + for column_name, dtype in df.dtypes.items(): + if dtype in ["string", "object"]: + print(f"Treating the {dtype.name} column '{column_name}' as categorical.") + df[column_name] = df[column_name].astype("category") + print(f"Inferred {len(df[column_name].cat.categories)} categories for the '{column_name}' column.") + # Working around the XGBoost issue with nullable floats: https://github.com/dmlc/xgboost/issues/8213 + if pandas.api.types.is_float_dtype(dtype): + # Converting from "Float64" to "float64" + df[column_name] = df[column_name].astype(dtype.name.lower()) + print("Final evaluation data information:") + df.info(verbose=True) + + if label_column_name is not None: + df = df.drop(columns=[label_column_name]) + + testing_data = xgboost.DMatrix( + data=df, + enable_categorical=True, + ) + + model = xgboost.Booster(model_file=model_path) + + predictions = model.predict(testing_data) + + Path(predictions_path).parent.mkdir(parents=True, exist_ok=True) + numpy.savetxt(predictions_path, predictions) + + import argparse + + _parser = argparse.ArgumentParser(prog='Xgboost predict on + CSV', description='Makes predictions using a trained XGBoost + model.') + + _parser.add_argument("--data", dest="data_path", type=str, + required=True, default=argparse.SUPPRESS) + + _parser.add_argument("--model", dest="model_path", type=str, + required=True, default=argparse.SUPPRESS) + + _parser.add_argument("--label-column-name", + dest="label_column_name", type=str, required=False, + default=argparse.SUPPRESS) + + _parser.add_argument("--predictions", + dest="predictions_path", + type=_make_parent_dirs_and_return_path, required=True, + default=argparse.SUPPRESS) + + _parsed_args = vars(_parser.parse_args()) + + + _outputs = xgboost_predict_on_CSV(**_parsed_args) + args: + - '--data' + - inputPath: data + - '--model' + - inputPath: model + - if: + cond: + isPresent: label_column_name + then: + - '--label-column-name' + - inputValue: label_column_name + - '--predictions' + - outputPath: predictions + text: > + name: Xgboost predict on CSV + + description: Makes predictions using a trained XGBoost model. + + metadata: + annotations: {author: Alexey Volkov , canonical_location: 'https://raw.githubusercontent.com/Ark-kun/pipeline_components/master/components/XGBoost/Predict/component.yaml'} + inputs: + + - {name: data, type: CSV, description: Feature data in Apache + Parquet format.} + + - {name: model, type: XGBoostModel, description: Trained model in + binary XGBoost format.} + + - {name: label_column_name, type: String, description: Optional. + Name of the column + containing the label data that is excluded during the prediction., optional: true} + outputs: + + - {name: predictions, description: Model predictions.} + + implementation: + container: + image: python:3.10 + command: + - sh + - -c + - (PIP_DISABLE_PIP_VERSION_CHECK=1 python3 -m pip install --quiet --no-warn-script-location + 'xgboost==1.6.1' 'pandas==1.4.3' 'numpy<2' || PIP_DISABLE_PIP_VERSION_CHECK=1 python3 + -m pip install --quiet --no-warn-script-location 'xgboost==1.6.1' 'pandas==1.4.3' 'numpy<2' + --user) && "$0" "$@" + - sh + - -ec + - | + program_path=$(mktemp) + printf "%s" "$0" > "$program_path" + python3 -u "$program_path" "$@" + - | + def _make_parent_dirs_and_return_path(file_path: str): + import os + os.makedirs(os.path.dirname(file_path), exist_ok=True) + return file_path + + def xgboost_predict_on_CSV( + data_path, + model_path, + predictions_path, + label_column_name = None, + ): + """Makes predictions using a trained XGBoost model. + + Args: + data_path: Feature data in Apache Parquet format. + model_path: Trained model in binary XGBoost format. + predictions_path: Model predictions. + label_column_name: Optional. Name of the column containing the label data that is excluded during the prediction. + + Annotations: + author: Alexey Volkov + """ + from pathlib import Path + + import numpy + import pandas + import xgboost + + df = pandas.read_csv( + data_path, + ).convert_dtypes() + print("Evaluation data information:") + df.info(verbose=True) + # Converting column types that XGBoost does not support + for column_name, dtype in df.dtypes.items(): + if dtype in ["string", "object"]: + print(f"Treating the {dtype.name} column '{column_name}' as categorical.") + df[column_name] = df[column_name].astype("category") + print(f"Inferred {len(df[column_name].cat.categories)} categories for the '{column_name}' column.") + # Working around the XGBoost issue with nullable floats: https://github.com/dmlc/xgboost/issues/8213 + if pandas.api.types.is_float_dtype(dtype): + # Converting from "Float64" to "float64" + df[column_name] = df[column_name].astype(dtype.name.lower()) + print("Final evaluation data information:") + df.info(verbose=True) + + if label_column_name is not None: + df = df.drop(columns=[label_column_name]) + + testing_data = xgboost.DMatrix( + data=df, + enable_categorical=True, + ) + + model = xgboost.Booster(model_file=model_path) + + predictions = model.predict(testing_data) + + Path(predictions_path).parent.mkdir(parents=True, exist_ok=True) + numpy.savetxt(predictions_path, predictions) + + import argparse + _parser = argparse.ArgumentParser(prog='Xgboost predict on CSV', description='Makes predictions using a trained XGBoost model.') + _parser.add_argument("--data", dest="data_path", type=str, required=True, default=argparse.SUPPRESS) + _parser.add_argument("--model", dest="model_path", type=str, required=True, default=argparse.SUPPRESS) + _parser.add_argument("--label-column-name", dest="label_column_name", type=str, required=False, default=argparse.SUPPRESS) + _parser.add_argument("--predictions", dest="predictions_path", type=_make_parent_dirs_and_return_path, required=True, default=argparse.SUPPRESS) + _parsed_args = vars(_parser.parse_args()) + + _outputs = xgboost_predict_on_CSV(**_parsed_args) + args: + - --data + - {inputPath: data} + - --model + - {inputPath: model} + - if: + cond: {isPresent: label_column_name} + then: + - --label-column-name + - {inputValue: label_column_name} + - --predictions + - {outputPath: predictions} + arguments: + data: + taskOutput: + outputName: transformed_table + taskId: Fill all missing values using Pandas on CSV data + model: + taskOutput: + outputName: model + taskId: Train XGBoost model on CSV + label_column_name: tips + annotations: + editor.position: '{"x":1010,"y":60}' + Chicago Taxi Trips dataset: + componentRef: + digest: 71eab890457fefb2256a98c38553210eff481cf2d4900e37a8a084505158c286 + url: >- + https://raw.githubusercontent.com/Ark-kun/pipelines/2463ecda532517462590d75e6e14a8af6b55869a/components/datasets/Chicago_Taxi_Trips/component.yaml + spec: + name: Chicago Taxi Trips dataset + description: > + City of Chicago Taxi Trips dataset: + https://data.cityofchicago.org/Transportation/Taxi-Trips/wrvz-psew + + + The input parameters configure the SQL query to the database. + + The dataset is pretty big, so limit the number of results using + the `Limit` or `Where` parameters. + + Read [Socrata dev](https://dev.socrata.com/docs/queries/) for the + advanced query syntax + metadata: + annotations: + author: Alexey Volkov + inputs: + - name: Where + type: String + default: >- + trip_start_timestamp>="1900-01-01" AND + trip_start_timestamp<"2100-01-01" + - name: Limit + type: Integer + description: Number of rows to return. The rows are randomly sampled. + default: '1000' + - name: Select + type: String + default: >- + tips,trip_seconds,trip_miles,pickup_community_area,dropoff_community_area,fare,tolls,extras,trip_total + - name: Format + type: String + description: Output data format. Suports csv,tsv,cml,rdf,json + default: csv + outputs: + - name: Table + description: Result type depends on format. CSV and TSV have header. + implementation: + container: + image: >- + byrnedo/alpine-curl@sha256:548379d0a4a0c08b9e55d9d87a592b7d35d9ab3037f4936f5ccd09d0b625a342 + command: + - sh + - '-c' + - > + set -e -x -o pipefail + + output_path="$0" + + select="$1" + + where="$2" + + limit="$3" + + format="$4" + + mkdir -p "$(dirname "$output_path")" + + curl --get + 'https://data.cityofchicago.org/resource/wrvz-psew.'"${format}" + \ + --data-urlencode '$limit='"${limit}" \ + --data-urlencode '$where='"${where}" \ + --data-urlencode '$select='"${select}" \ + | tr -d '"' > "$output_path" # Removing unneeded quotes around all numbers + - outputPath: Table + - inputValue: Select + - inputValue: Where + - inputValue: Limit + - inputValue: Format + text: > + name: Chicago Taxi Trips dataset + + description: | + City of Chicago Taxi Trips dataset: https://data.cityofchicago.org/Transportation/Taxi-Trips/wrvz-psew + + The input parameters configure the SQL query to the database. + The dataset is pretty big, so limit the number of results using the `Limit` or `Where` parameters. + Read [Socrata dev](https://dev.socrata.com/docs/queries/) for the advanced query syntax + metadata: + annotations: + author: Alexey Volkov + inputs: + + - {name: Where, type: String, default: + 'trip_start_timestamp>="1900-01-01" AND + trip_start_timestamp<"2100-01-01"'} + + - {name: Limit, type: Integer, default: '1000', description: 'Number + of rows to return. The rows are randomly sampled.'} + + - {name: Select, type: String, default: + 'tips,trip_seconds,trip_miles,pickup_community_area,dropoff_community_area,fare,tolls,extras,trip_total'} + + - {name: Format, type: String, default: 'csv', description: 'Output + data format. Suports csv,tsv,cml,rdf,json'} + + outputs: + + - {name: Table, description: 'Result type depends on format. CSV and + TSV have header.'} + + implementation: + container: + # image: curlimages/curl # Sets a non-root user which cannot write to mounted volumes. See https://github.com/curl/curl-docker/issues/22 + image: byrnedo/alpine-curl@sha256:548379d0a4a0c08b9e55d9d87a592b7d35d9ab3037f4936f5ccd09d0b625a342 + command: + - sh + - -c + - | + set -e -x -o pipefail + output_path="$0" + select="$1" + where="$2" + limit="$3" + format="$4" + mkdir -p "$(dirname "$output_path")" + curl --get 'https://data.cityofchicago.org/resource/wrvz-psew.'"${format}" \ + --data-urlencode '$limit='"${limit}" \ + --data-urlencode '$where='"${where}" \ + --data-urlencode '$select='"${select}" \ + | tr -d '"' > "$output_path" # Removing unneeded quotes around all numbers + - {outputPath: Table} + - {inputValue: Select} + - {inputValue: Where} + - {inputValue: Limit} + - {inputValue: Format} + arguments: + Limit: '1000' + Where: >- + trip_start_timestamp>="1900-01-01" AND + trip_start_timestamp<"2100-01-01" + Format: csv + Select: >- + tips,trip_seconds,trip_miles,pickup_community_area,dropoff_community_area,fare,tolls,extras,trip_total + annotations: + editor.position: '{"x":-230,"y":150}' + Train XGBoost model on CSV: + componentRef: + digest: 5b8bec6716337d7bd8ecafd6dd46bc44527783bc05950cfe03a60206b43c7654 + url: >- + https://raw.githubusercontent.com/Ark-kun/pipeline_components/96a177dd71d54c98573c4101d9a05ac801f1fa54/components/XGBoost/Train/component.yaml + spec: + name: Train XGBoost model on CSV + description: Trains an XGBoost model. + metadata: + annotations: + author: Alexey Volkov + canonical_location: >- + https://raw.githubusercontent.com/Ark-kun/pipeline_components/master/components/XGBoost/Train/component.yaml + inputs: + - name: training_data + type: CSV + description: Training data in CSV format. + - name: label_column_name + type: String + description: Name of the column containing the label data. + - name: starting_model + type: XGBoostModel + description: >- + Existing trained model to start from (in the binary XGBoost + format). + optional: true + - name: num_iterations + type: Integer + description: Number of boosting iterations. + default: '10' + optional: true + - name: objective + type: String + description: >- + The learning task and the corresponding learning objective. + + See + https://xgboost.readthedocs.io/en/latest/parameter.html#learning-task-parameters + + The most common values are: + + "reg:squarederror" - Regression with squared loss (default). + + "reg:logistic" - Logistic regression. + + "binary:logistic" - Logistic regression for binary + classification, output probability. + + "binary:logitraw" - Logistic regression for binary + classification, output score before logistic transformation + + "rank:pairwise" - Use LambdaMART to perform pairwise ranking + where the pairwise loss is minimized + + "rank:ndcg" - Use LambdaMART to perform list-wise ranking + where Normalized Discounted Cumulative Gain (NDCG) is + maximized + default: reg:squarederror + optional: true + - name: booster + type: String + description: >- + The booster to use. Can be `gbtree`, `gblinear` or `dart`; + `gbtree` and `dart` use tree based models while `gblinear` + uses linear functions. + default: gbtree + optional: true + - name: learning_rate + type: Float + description: >- + Step size shrinkage used in update to prevents overfitting. + Range: [0,1]. + default: '0.3' + optional: true + - name: min_split_loss + type: Float + description: >- + Minimum loss reduction required to make a further partition on + a leaf node of the tree. + + The larger `min_split_loss` is, the more conservative the + algorithm will be. Range: [0,Inf]. + default: '0' + optional: true + - name: max_depth + type: Integer + description: >- + Maximum depth of a tree. Increasing this value will make the + model more complex and more likely to overfit. + + 0 indicates no limit on depth. Range: [0,Inf]. + default: '6' + optional: true + - name: booster_params + type: JsonObject + description: >- + Parameters for the booster. See + https://xgboost.readthedocs.io/en/latest/parameter.html + optional: true + outputs: + - name: model + type: XGBoostModel + description: Trained model in the binary XGBoost format. + - name: model_config + type: XGBoostModelConfig + description: >- + The internal parameter configuration of Booster as a JSON + string. + implementation: + container: + image: python:3.10 + command: + - sh + - '-c' + - >- + (PIP_DISABLE_PIP_VERSION_CHECK=1 python3 -m pip install + --quiet --no-warn-script-location 'xgboost==1.6.1' + 'pandas==1.4.3' 'numpy<2' || PIP_DISABLE_PIP_VERSION_CHECK=1 + python3 -m pip install --quiet --no-warn-script-location + 'xgboost==1.6.1' 'pandas==1.4.3' 'numpy<2' --user) && "$0" + "$@" + - sh + - '-ec' + - | + program_path=$(mktemp) + printf "%s" "$0" > "$program_path" + python3 -u "$program_path" "$@" + - > + def _make_parent_dirs_and_return_path(file_path: str): + import os + os.makedirs(os.path.dirname(file_path), exist_ok=True) + return file_path + + def train_XGBoost_model_on_CSV( + training_data_path, + model_path, + model_config_path, + label_column_name, + starting_model_path = None, + num_iterations = 10, + # Booster parameters + objective = "reg:squarederror", + booster = "gbtree", + learning_rate = 0.3, + min_split_loss = 0, + max_depth = 6, + booster_params = None, + ): + """Trains an XGBoost model. + + Args: + training_data_path: Training data in CSV format. + model_path: Trained model in the binary XGBoost format. + model_config_path: The internal parameter configuration of Booster as a JSON string. + starting_model_path: Existing trained model to start from (in the binary XGBoost format). + label_column_name: Name of the column containing the label data. + num_iterations: Number of boosting iterations. + booster_params: Parameters for the booster. See https://xgboost.readthedocs.io/en/latest/parameter.html + objective: The learning task and the corresponding learning objective. + See https://xgboost.readthedocs.io/en/latest/parameter.html#learning-task-parameters + The most common values are: + "reg:squarederror" - Regression with squared loss (default). + "reg:logistic" - Logistic regression. + "binary:logistic" - Logistic regression for binary classification, output probability. + "binary:logitraw" - Logistic regression for binary classification, output score before logistic transformation + "rank:pairwise" - Use LambdaMART to perform pairwise ranking where the pairwise loss is minimized + "rank:ndcg" - Use LambdaMART to perform list-wise ranking where Normalized Discounted Cumulative Gain (NDCG) is maximized + booster: The booster to use. Can be `gbtree`, `gblinear` or `dart`; `gbtree` and `dart` use tree based models while `gblinear` uses linear functions. + learning_rate: Step size shrinkage used in update to prevents overfitting. Range: [0,1]. + min_split_loss: Minimum loss reduction required to make a further partition on a leaf node of the tree. + The larger `min_split_loss` is, the more conservative the algorithm will be. Range: [0,Inf]. + max_depth: Maximum depth of a tree. Increasing this value will make the model more complex and more likely to overfit. + 0 indicates no limit on depth. Range: [0,Inf]. + + Annotations: + author: Alexey Volkov + """ + import pandas + import xgboost + + df = pandas.read_csv( + training_data_path, + ).convert_dtypes() + print("Training data information:") + df.info(verbose=True) + # Converting column types that XGBoost does not support + for column_name, dtype in df.dtypes.items(): + if dtype in ["string", "object"]: + print(f"Treating the {dtype.name} column '{column_name}' as categorical.") + df[column_name] = df[column_name].astype("category") + print(f"Inferred {len(df[column_name].cat.categories)} categories for the '{column_name}' column.") + # Working around the XGBoost issue with nullable floats: https://github.com/dmlc/xgboost/issues/8213 + if pandas.api.types.is_float_dtype(dtype): + # Converting from "Float64" to "float64" + df[column_name] = df[column_name].astype(dtype.name.lower()) + print() + print("Final training data information:") + df.info(verbose=True) + + training_data = xgboost.DMatrix( + data=df.drop(columns=[label_column_name]), + label=df[[label_column_name]], + enable_categorical=True, + ) + + booster_params = booster_params or {} + booster_params.setdefault("objective", objective) + booster_params.setdefault("booster", booster) + booster_params.setdefault("learning_rate", learning_rate) + booster_params.setdefault("min_split_loss", min_split_loss) + booster_params.setdefault("max_depth", max_depth) + + starting_model = None + if starting_model_path: + starting_model = xgboost.Booster(model_file=starting_model_path) + + print() + print("Training the model:") + model = xgboost.train( + params=booster_params, + dtrain=training_data, + num_boost_round=num_iterations, + xgb_model=starting_model, + evals=[(training_data, "training_data")], + ) + + # Saving the model in binary format + model.save_model(model_path) + + model_config_str = model.save_config() + with open(model_config_path, "w") as model_config_file: + model_config_file.write(model_config_str) + + import json + + import argparse + + _parser = argparse.ArgumentParser(prog='Train XGBoost model + on CSV', description='Trains an XGBoost model.') + + _parser.add_argument("--training-data", + dest="training_data_path", type=str, required=True, + default=argparse.SUPPRESS) + + _parser.add_argument("--label-column-name", + dest="label_column_name", type=str, required=True, + default=argparse.SUPPRESS) + + _parser.add_argument("--starting-model", + dest="starting_model_path", type=str, required=False, + default=argparse.SUPPRESS) + + _parser.add_argument("--num-iterations", + dest="num_iterations", type=int, required=False, + default=argparse.SUPPRESS) + + _parser.add_argument("--objective", dest="objective", + type=str, required=False, default=argparse.SUPPRESS) + + _parser.add_argument("--booster", dest="booster", type=str, + required=False, default=argparse.SUPPRESS) + + _parser.add_argument("--learning-rate", + dest="learning_rate", type=float, required=False, + default=argparse.SUPPRESS) + + _parser.add_argument("--min-split-loss", + dest="min_split_loss", type=float, required=False, + default=argparse.SUPPRESS) + + _parser.add_argument("--max-depth", dest="max_depth", + type=int, required=False, default=argparse.SUPPRESS) + + _parser.add_argument("--booster-params", + dest="booster_params", type=json.loads, required=False, + default=argparse.SUPPRESS) + + _parser.add_argument("--model", dest="model_path", + type=_make_parent_dirs_and_return_path, required=True, + default=argparse.SUPPRESS) + + _parser.add_argument("--model-config", + dest="model_config_path", + type=_make_parent_dirs_and_return_path, required=True, + default=argparse.SUPPRESS) + + _parsed_args = vars(_parser.parse_args()) + + + _outputs = train_XGBoost_model_on_CSV(**_parsed_args) + args: + - '--training-data' + - inputPath: training_data + - '--label-column-name' + - inputValue: label_column_name + - if: + cond: + isPresent: starting_model + then: + - '--starting-model' + - inputPath: starting_model + - if: + cond: + isPresent: num_iterations + then: + - '--num-iterations' + - inputValue: num_iterations + - if: + cond: + isPresent: objective + then: + - '--objective' + - inputValue: objective + - if: + cond: + isPresent: booster + then: + - '--booster' + - inputValue: booster + - if: + cond: + isPresent: learning_rate + then: + - '--learning-rate' + - inputValue: learning_rate + - if: + cond: + isPresent: min_split_loss + then: + - '--min-split-loss' + - inputValue: min_split_loss + - if: + cond: + isPresent: max_depth + then: + - '--max-depth' + - inputValue: max_depth + - if: + cond: + isPresent: booster_params + then: + - '--booster-params' + - inputValue: booster_params + - '--model' + - outputPath: model + - '--model-config' + - outputPath: model_config + text: > + name: Train XGBoost model on CSV + + description: Trains an XGBoost model. + + metadata: + annotations: {author: Alexey Volkov , canonical_location: 'https://raw.githubusercontent.com/Ark-kun/pipeline_components/master/components/XGBoost/Train/component.yaml'} + inputs: + + - {name: training_data, type: CSV, description: Training data in CSV + format.} + + - {name: label_column_name, type: String, description: Name of the + column containing + the label data.} + - {name: starting_model, type: XGBoostModel, description: Existing + trained model to + start from (in the binary XGBoost format)., optional: true} + - {name: num_iterations, type: Integer, description: Number of + boosting iterations., + default: '10', optional: true} + - name: objective + type: String + description: |- + The learning task and the corresponding learning objective. + See https://xgboost.readthedocs.io/en/latest/parameter.html#learning-task-parameters + The most common values are: + "reg:squarederror" - Regression with squared loss (default). + "reg:logistic" - Logistic regression. + "binary:logistic" - Logistic regression for binary classification, output probability. + "binary:logitraw" - Logistic regression for binary classification, output score before logistic transformation + "rank:pairwise" - Use LambdaMART to perform pairwise ranking where the pairwise loss is minimized + "rank:ndcg" - Use LambdaMART to perform list-wise ranking where Normalized Discounted Cumulative Gain (NDCG) is maximized + default: reg:squarederror + optional: true + - {name: booster, type: String, description: 'The booster to use. + Can be `gbtree`, + `gblinear` or `dart`; `gbtree` and `dart` use tree based models while `gblinear` + uses linear functions.', default: gbtree, optional: true} + - {name: learning_rate, type: Float, description: 'Step size + shrinkage used in update + to prevents overfitting. Range: [0,1].', default: '0.3', optional: true} + - name: min_split_loss + type: Float + description: |- + Minimum loss reduction required to make a further partition on a leaf node of the tree. + The larger `min_split_loss` is, the more conservative the algorithm will be. Range: [0,Inf]. + default: '0' + optional: true + - name: max_depth + type: Integer + description: |- + Maximum depth of a tree. Increasing this value will make the model more complex and more likely to overfit. + 0 indicates no limit on depth. Range: [0,Inf]. + default: '6' + optional: true + - {name: booster_params, type: JsonObject, description: 'Parameters + for the booster. + See https://xgboost.readthedocs.io/en/latest/parameter.html', optional: true} + outputs: + + - {name: model, type: XGBoostModel, description: Trained model in + the binary XGBoost + format.} + - {name: model_config, type: XGBoostModelConfig, description: The + internal parameter + configuration of Booster as a JSON string.} + implementation: + container: + image: python:3.10 + command: + - sh + - -c + - (PIP_DISABLE_PIP_VERSION_CHECK=1 python3 -m pip install --quiet --no-warn-script-location + 'xgboost==1.6.1' 'pandas==1.4.3' 'numpy<2' || PIP_DISABLE_PIP_VERSION_CHECK=1 python3 + -m pip install --quiet --no-warn-script-location 'xgboost==1.6.1' 'pandas==1.4.3' 'numpy<2' + --user) && "$0" "$@" + - sh + - -ec + - | + program_path=$(mktemp) + printf "%s" "$0" > "$program_path" + python3 -u "$program_path" "$@" + - | + def _make_parent_dirs_and_return_path(file_path: str): + import os + os.makedirs(os.path.dirname(file_path), exist_ok=True) + return file_path + + def train_XGBoost_model_on_CSV( + training_data_path, + model_path, + model_config_path, + label_column_name, + starting_model_path = None, + num_iterations = 10, + # Booster parameters + objective = "reg:squarederror", + booster = "gbtree", + learning_rate = 0.3, + min_split_loss = 0, + max_depth = 6, + booster_params = None, + ): + """Trains an XGBoost model. + + Args: + training_data_path: Training data in CSV format. + model_path: Trained model in the binary XGBoost format. + model_config_path: The internal parameter configuration of Booster as a JSON string. + starting_model_path: Existing trained model to start from (in the binary XGBoost format). + label_column_name: Name of the column containing the label data. + num_iterations: Number of boosting iterations. + booster_params: Parameters for the booster. See https://xgboost.readthedocs.io/en/latest/parameter.html + objective: The learning task and the corresponding learning objective. + See https://xgboost.readthedocs.io/en/latest/parameter.html#learning-task-parameters + The most common values are: + "reg:squarederror" - Regression with squared loss (default). + "reg:logistic" - Logistic regression. + "binary:logistic" - Logistic regression for binary classification, output probability. + "binary:logitraw" - Logistic regression for binary classification, output score before logistic transformation + "rank:pairwise" - Use LambdaMART to perform pairwise ranking where the pairwise loss is minimized + "rank:ndcg" - Use LambdaMART to perform list-wise ranking where Normalized Discounted Cumulative Gain (NDCG) is maximized + booster: The booster to use. Can be `gbtree`, `gblinear` or `dart`; `gbtree` and `dart` use tree based models while `gblinear` uses linear functions. + learning_rate: Step size shrinkage used in update to prevents overfitting. Range: [0,1]. + min_split_loss: Minimum loss reduction required to make a further partition on a leaf node of the tree. + The larger `min_split_loss` is, the more conservative the algorithm will be. Range: [0,Inf]. + max_depth: Maximum depth of a tree. Increasing this value will make the model more complex and more likely to overfit. + 0 indicates no limit on depth. Range: [0,Inf]. + + Annotations: + author: Alexey Volkov + """ + import pandas + import xgboost + + df = pandas.read_csv( + training_data_path, + ).convert_dtypes() + print("Training data information:") + df.info(verbose=True) + # Converting column types that XGBoost does not support + for column_name, dtype in df.dtypes.items(): + if dtype in ["string", "object"]: + print(f"Treating the {dtype.name} column '{column_name}' as categorical.") + df[column_name] = df[column_name].astype("category") + print(f"Inferred {len(df[column_name].cat.categories)} categories for the '{column_name}' column.") + # Working around the XGBoost issue with nullable floats: https://github.com/dmlc/xgboost/issues/8213 + if pandas.api.types.is_float_dtype(dtype): + # Converting from "Float64" to "float64" + df[column_name] = df[column_name].astype(dtype.name.lower()) + print() + print("Final training data information:") + df.info(verbose=True) + + training_data = xgboost.DMatrix( + data=df.drop(columns=[label_column_name]), + label=df[[label_column_name]], + enable_categorical=True, + ) + + booster_params = booster_params or {} + booster_params.setdefault("objective", objective) + booster_params.setdefault("booster", booster) + booster_params.setdefault("learning_rate", learning_rate) + booster_params.setdefault("min_split_loss", min_split_loss) + booster_params.setdefault("max_depth", max_depth) + + starting_model = None + if starting_model_path: + starting_model = xgboost.Booster(model_file=starting_model_path) + + print() + print("Training the model:") + model = xgboost.train( + params=booster_params, + dtrain=training_data, + num_boost_round=num_iterations, + xgb_model=starting_model, + evals=[(training_data, "training_data")], + ) + + # Saving the model in binary format + model.save_model(model_path) + + model_config_str = model.save_config() + with open(model_config_path, "w") as model_config_file: + model_config_file.write(model_config_str) + + import json + import argparse + _parser = argparse.ArgumentParser(prog='Train XGBoost model on CSV', description='Trains an XGBoost model.') + _parser.add_argument("--training-data", dest="training_data_path", type=str, required=True, default=argparse.SUPPRESS) + _parser.add_argument("--label-column-name", dest="label_column_name", type=str, required=True, default=argparse.SUPPRESS) + _parser.add_argument("--starting-model", dest="starting_model_path", type=str, required=False, default=argparse.SUPPRESS) + _parser.add_argument("--num-iterations", dest="num_iterations", type=int, required=False, default=argparse.SUPPRESS) + _parser.add_argument("--objective", dest="objective", type=str, required=False, default=argparse.SUPPRESS) + _parser.add_argument("--booster", dest="booster", type=str, required=False, default=argparse.SUPPRESS) + _parser.add_argument("--learning-rate", dest="learning_rate", type=float, required=False, default=argparse.SUPPRESS) + _parser.add_argument("--min-split-loss", dest="min_split_loss", type=float, required=False, default=argparse.SUPPRESS) + _parser.add_argument("--max-depth", dest="max_depth", type=int, required=False, default=argparse.SUPPRESS) + _parser.add_argument("--booster-params", dest="booster_params", type=json.loads, required=False, default=argparse.SUPPRESS) + _parser.add_argument("--model", dest="model_path", type=_make_parent_dirs_and_return_path, required=True, default=argparse.SUPPRESS) + _parser.add_argument("--model-config", dest="model_config_path", type=_make_parent_dirs_and_return_path, required=True, default=argparse.SUPPRESS) + _parsed_args = vars(_parser.parse_args()) + + _outputs = train_XGBoost_model_on_CSV(**_parsed_args) + args: + - --training-data + - {inputPath: training_data} + - --label-column-name + - {inputValue: label_column_name} + - if: + cond: {isPresent: starting_model} + then: + - --starting-model + - {inputPath: starting_model} + - if: + cond: {isPresent: num_iterations} + then: + - --num-iterations + - {inputValue: num_iterations} + - if: + cond: {isPresent: objective} + then: + - --objective + - {inputValue: objective} + - if: + cond: {isPresent: booster} + then: + - --booster + - {inputValue: booster} + - if: + cond: {isPresent: learning_rate} + then: + - --learning-rate + - {inputValue: learning_rate} + - if: + cond: {isPresent: min_split_loss} + then: + - --min-split-loss + - {inputValue: min_split_loss} + - if: + cond: {isPresent: max_depth} + then: + - --max-depth + - {inputValue: max_depth} + - if: + cond: {isPresent: booster_params} + then: + - --booster-params + - {inputValue: booster_params} + - --model + - {outputPath: model} + - --model-config + - {outputPath: model_config} + arguments: + booster: gbtree + max_depth: '6' + objective: reg:squarederror + learning_rate: '0.3' + training_data: + taskOutput: + outputName: transformed_table + taskId: Fill all missing values using Pandas on CSV data + min_split_loss: '0' + num_iterations: '10' + label_column_name: tips + annotations: + editor.position: '{"x":650,"y":430}' + Fill all missing values using Pandas on CSV data: + componentRef: + name: Fill all missing values using Pandas on CSV data + digest: c873de1f3a95bb7fb17efbbc5a72c4a13a1ef581dc162054707441ddce2c06d2 + url: >- + https://raw.githubusercontent.com/Ark-kun/pipeline_components/96a177dd71d54c98573c4101d9a05ac801f1fa54/components/pandas/Fill_all_missing_values/in_CSV_format/component.yaml + spec: + name: Fill all missing values using Pandas on CSV data + description: >- + Fills the missing column items with the specified replacement + value. + metadata: + annotations: + author: Alexey Volkov + canonical_location: >- + https://raw.githubusercontent.com/Ark-kun/pipeline_components/master/components/pandas/Fill_all_missing_values/in_CSV_format/component.yaml + inputs: + - name: table + type: CSV + description: Input data table. + - name: replacement_value + type: String + description: The value to use when replacing the missing items. + default: '0' + optional: true + - name: column_names + type: JsonArray + description: Names of the columns where to perform the replacement. + optional: true + outputs: + - name: transformed_table + type: CSV + description: Transformed data table where missing values are filed. + implementation: + container: + image: python:3.9 + command: + - sh + - '-c' + - >- + (PIP_DISABLE_PIP_VERSION_CHECK=1 python3 -m pip install + --quiet --no-warn-script-location 'pandas==1.4.1' 'numpy<2' + || PIP_DISABLE_PIP_VERSION_CHECK=1 python3 -m pip install + --quiet --no-warn-script-location 'pandas==1.4.1' 'numpy<2' + --user) && "$0" "$@" + - sh + - '-ec' + - | + program_path=$(mktemp) + printf "%s" "$0" > "$program_path" + python3 -u "$program_path" "$@" + - > + def _make_parent_dirs_and_return_path(file_path: str): + import os + os.makedirs(os.path.dirname(file_path), exist_ok=True) + return file_path + + def fill_all_missing_values_using_Pandas_on_CSV_data( + table_path, + transformed_table_path, + replacement_value = "0", + column_names = None, + ): + """Fills the missing column items with the specified replacement value. + + Args: + table_path: Input data table. + transformed_table_path: Transformed data table where missing values are filed. + replacement_value: The value to use when replacing the missing items. + column_names: Names of the columns where to perform the replacement. + """ + import pandas + + df = pandas.read_csv( + table_path, + dtype="string", + ) + + for column_name in column_names or df.columns: + df[column_name] = df[column_name].fillna(value=replacement_value) + + df.to_csv( + transformed_table_path, index=False, + ) + + import json + + import argparse + + _parser = argparse.ArgumentParser(prog='Fill all missing + values using Pandas on CSV data', description='Fills the + missing column items with the specified replacement value.') + + _parser.add_argument("--table", dest="table_path", type=str, + required=True, default=argparse.SUPPRESS) + + _parser.add_argument("--replacement-value", + dest="replacement_value", type=str, required=False, + default=argparse.SUPPRESS) + + _parser.add_argument("--column-names", dest="column_names", + type=json.loads, required=False, default=argparse.SUPPRESS) + + _parser.add_argument("--transformed-table", + dest="transformed_table_path", + type=_make_parent_dirs_and_return_path, required=True, + default=argparse.SUPPRESS) + + _parsed_args = vars(_parser.parse_args()) + + + _outputs = + fill_all_missing_values_using_Pandas_on_CSV_data(**_parsed_args) + args: + - '--table' + - inputPath: table + - if: + cond: + isPresent: replacement_value + then: + - '--replacement-value' + - inputValue: replacement_value + - if: + cond: + isPresent: column_names + then: + - '--column-names' + - inputValue: column_names + - '--transformed-table' + - outputPath: transformed_table + text: > + name: Fill all missing values using Pandas on CSV data + + description: Fills the missing column items with the specified + replacement value. + + metadata: + annotations: {author: Alexey Volkov , canonical_location: 'https://raw.githubusercontent.com/Ark-kun/pipeline_components/master/components/pandas/Fill_all_missing_values/in_CSV_format/component.yaml'} + inputs: + + - {name: table, type: CSV, description: Input data table.} + + - {name: replacement_value, type: String, description: The value to + use when replacing + the missing items., default: '0', optional: true} + - {name: column_names, type: JsonArray, description: Names of the + columns where to + perform the replacement., optional: true} + outputs: + + - {name: transformed_table, type: CSV, description: Transformed data + table where missing + values are filed.} + implementation: + container: + image: python:3.9 + command: + - sh + - -c + - (PIP_DISABLE_PIP_VERSION_CHECK=1 python3 -m pip install --quiet --no-warn-script-location + 'pandas==1.4.1' 'numpy<2' || PIP_DISABLE_PIP_VERSION_CHECK=1 python3 -m pip install --quiet + --no-warn-script-location 'pandas==1.4.1' 'numpy<2' --user) && "$0" "$@" + - sh + - -ec + - | + program_path=$(mktemp) + printf "%s" "$0" > "$program_path" + python3 -u "$program_path" "$@" + - | + def _make_parent_dirs_and_return_path(file_path: str): + import os + os.makedirs(os.path.dirname(file_path), exist_ok=True) + return file_path + + def fill_all_missing_values_using_Pandas_on_CSV_data( + table_path, + transformed_table_path, + replacement_value = "0", + column_names = None, + ): + """Fills the missing column items with the specified replacement value. + + Args: + table_path: Input data table. + transformed_table_path: Transformed data table where missing values are filed. + replacement_value: The value to use when replacing the missing items. + column_names: Names of the columns where to perform the replacement. + """ + import pandas + + df = pandas.read_csv( + table_path, + dtype="string", + ) + + for column_name in column_names or df.columns: + df[column_name] = df[column_name].fillna(value=replacement_value) + + df.to_csv( + transformed_table_path, index=False, + ) + + import json + import argparse + _parser = argparse.ArgumentParser(prog='Fill all missing values using Pandas on CSV data', description='Fills the missing column items with the specified replacement value.') + _parser.add_argument("--table", dest="table_path", type=str, required=True, default=argparse.SUPPRESS) + _parser.add_argument("--replacement-value", dest="replacement_value", type=str, required=False, default=argparse.SUPPRESS) + _parser.add_argument("--column-names", dest="column_names", type=json.loads, required=False, default=argparse.SUPPRESS) + _parser.add_argument("--transformed-table", dest="transformed_table_path", type=_make_parent_dirs_and_return_path, required=True, default=argparse.SUPPRESS) + _parsed_args = vars(_parser.parse_args()) + + _outputs = fill_all_missing_values_using_Pandas_on_CSV_data(**_parsed_args) + args: + - --table + - {inputPath: table} + - if: + cond: {isPresent: replacement_value} + then: + - --replacement-value + - {inputValue: replacement_value} + - if: + cond: {isPresent: column_names} + then: + - --column-names + - {inputValue: column_names} + - --transformed-table + - {outputPath: transformed_table} + arguments: + table: + taskOutput: + outputName: Table + taskId: Chicago Taxi Trips dataset + replacement_value: '0' + annotations: + editor.position: '{"x":170,"y":160}' + a: '1' + b: '2' + c: '3' + cloud-pipelines.net/launchers/generic/resources.cpu: '1' + executionOptions: {} + outputValues: {} diff --git a/src/routes/EditorV2/components/TaskNode.tsx b/src/routes/EditorV2/components/TaskNode.tsx new file mode 100644 index 000000000..a5ac0a58e --- /dev/null +++ b/src/routes/EditorV2/components/TaskNode.tsx @@ -0,0 +1,15 @@ +import type { NodeProps } from "@xyflow/react"; + +import { TaskNodeCard } from "./TaskNodeCard"; + +interface TaskNodeProps extends NodeProps { + data: { + name: string; + description: string; + } +} + +export function TaskNode({ data, selected }: TaskNodeProps) { + console.log(`TaskNode:`, data); + return ; +} diff --git a/src/routes/EditorV2/components/TaskNodeCard.tsx b/src/routes/EditorV2/components/TaskNodeCard.tsx new file mode 100644 index 000000000..13f0c6049 --- /dev/null +++ b/src/routes/EditorV2/components/TaskNodeCard.tsx @@ -0,0 +1,39 @@ +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Icon } from "@/components/ui/icon"; +import { BlockStack, InlineStack } from "@/components/ui/layout"; +import { Text } from "@/components/ui/typography"; +import { cn } from "@/lib/utils"; + +export function TaskNodeCard({ + name, + description, +}: { + name: string; + description: string; +}) { + return ( + + + + + + + {name} + + + + {description} + + + + +
Inputs / Outputs
+
+
+ ); +} diff --git a/src/routes/router.ts b/src/routes/router.ts index d7fe0cded..ead8f467f 100644 --- a/src/routes/router.ts +++ b/src/routes/router.ts @@ -26,6 +26,7 @@ import { DashboardPipelinesView } from "./Dashboard/DashboardPipelinesView"; import { DashboardRecentlyViewedView } from "./Dashboard/DashboardRecentlyViewedView"; import { DashboardRunsView } from "./Dashboard/DashboardRunsView"; import Editor from "./Editor"; +import { EditorV2 } from "./EditorV2/EditorV2"; import Home from "./Home"; import { ImportPage } from "./Import"; import NotFoundPage from "./NotFoundPage"; @@ -71,6 +72,7 @@ export const APP_ROUTES = { SETTINGS_SECRETS_REPLACE: `${SETTINGS_PATH}/secrets/$secretId/replace`, GITHUB_AUTH_CALLBACK: "/authorize/github", HUGGINGFACE_AUTH_CALLBACK: "/authorize/huggingface", + EDITOR_V2: "/editor-v2", } as const; const rootRoute = createRootRoute({ @@ -252,6 +254,12 @@ const settingsRouteTree = settingsLayoutRoute.addChildren([ secretsRouteTree, ]); +const editorV2Route = createRoute({ + getParentRoute: () => mainLayout, + path: APP_ROUTES.EDITOR_V2, + component: EditorV2, +}); + const dashboardRouteTree = dashboardRoute.addChildren([ dashboardIndexRoute, dashboardRunsRoute, @@ -269,6 +277,7 @@ const appRouteTree = mainLayout.addChildren([ editorRoute, runDetailRoute, runDetailWithSubgraphRoute, + editorV2Route, ]); const rootRouteTree = rootRoute.addChildren([ diff --git a/src/utils/componentSpec.ts b/src/utils/componentSpec.ts index 967a2a964..aaa7ed768 100644 --- a/src/utils/componentSpec.ts +++ b/src/utils/componentSpec.ts @@ -457,7 +457,7 @@ interface TwoLogicalOperands { /** * Optional configuration that specifies how the task should be executed. Can be used to set some platform-specific options. */ -type PredicateType = +export type PredicateType = | { "==": TwoArgumentOperands; } @@ -495,11 +495,11 @@ interface RetryStrategySpec { /** * Optional configuration that specifies how the task execution may be skipped if the output data exist in cache. */ -interface CachingStrategySpec { +export interface CachingStrategySpec { maxCacheStaleness?: string; } -interface ExecutionOptionsSpec { +export interface ExecutionOptionsSpec { retryStrategy?: RetryStrategySpec; cachingStrategy?: CachingStrategySpec; } From aab4c8807949447fe385bcc0b324ccd84e4d88df Mon Sep 17 00:00:00 2001 From: Maksym Yezhov Date: Wed, 11 Feb 2026 19:50:08 -0800 Subject: [PATCH 002/225] - csom to correspond to componentspec schema --- src/providers/ComponentSpec/OBJECT_MODEL.md | 267 ++++++ src/providers/ComponentSpec/annotations.ts | 28 +- src/providers/ComponentSpec/componentSpec.ts | 49 +- .../ComponentSpec/graphImplementation.ts | 206 ++++- src/providers/ComponentSpec/inputs.ts | 119 ++- src/providers/ComponentSpec/outputs.ts | 118 ++- .../ComponentSpec/tests/fixtures/index.ts | 309 +++++++ .../ComponentSpec/tests/schemaValidator.ts | 103 +++ .../ComponentSpec/tests/toJson.test.ts | 787 ++++++++++++++++++ 9 files changed, 1893 insertions(+), 93 deletions(-) create mode 100644 src/providers/ComponentSpec/OBJECT_MODEL.md create mode 100644 src/providers/ComponentSpec/tests/fixtures/index.ts create mode 100644 src/providers/ComponentSpec/tests/schemaValidator.ts create mode 100644 src/providers/ComponentSpec/tests/toJson.test.ts diff --git a/src/providers/ComponentSpec/OBJECT_MODEL.md b/src/providers/ComponentSpec/OBJECT_MODEL.md new file mode 100644 index 000000000..bb9cc7d6b --- /dev/null +++ b/src/providers/ComponentSpec/OBJECT_MODEL.md @@ -0,0 +1,267 @@ +# Component Spec Object Model + +This document describes the architecture and principles of the Component Spec Object Model used to represent ML pipeline components in memory. + +## Overview + +The Component Spec Object Model is a **hierarchical, entity-based system** for representing ML pipeline component specifications. It provides: + +- **Identity**: Every object has a unique `$id` +- **Indexing**: Fast lookups by indexed fields +- **Serialization**: Bidirectional conversion to/from YAML spec format +- **Relationships**: Explicit connections between entities (inputs, outputs, task arguments) + +--- + +## Core Concepts + +### Entities + +Every domain object is an **Entity** implementing `BaseEntity`: + +```typescript +type BaseEntity = { + readonly $id: string; // Unique identifier + readonly $indexed: (keyof TScalar)[]; // Fields to index for fast lookup + populate(scalar: TScalar): this; // Hydrate with data +}; +``` + +Entities also implement `SerializableEntity` for JSON export: + +```typescript +interface SerializableEntity { + toJson(): object | ScalarType; +} +``` + +### Scalars vs Entities + +- **Scalar**: Plain TypeScript interface representing raw data shape (e.g., `InputScalarInterface`) +- **Entity**: Class instance with identity, behavior, and relationships (e.g., `InputEntity`) + +This separation enables: +1. Loading YAML → scalars → entities via `populate()` +2. Saving entities → JSON via `toJson()` → YAML + +### Collections + +Every entity type has a corresponding **Collection** class extending `BaseCollection`: + +```typescript +abstract class BaseCollection { + add(spec: TScalar): TEntity; // Create and register entity + abstract createEntity(spec: TScalar): TEntity; // Factory method + getAll(): TEntity[]; + findById(id: EntityId): TEntity | undefined; + findByIndex(index: K, value: TEntity[K]): TEntity[]; +} +``` + +### Contexts + +Contexts form a **tree structure** providing: + +- **ID Generation**: Hierarchical prefixes (e.g., `root.component.inputs_1`) +- **Entity Registration**: Entities register in their parent context +- **Scoping**: Nested components have isolated namespaces + +``` +RootContext ($name: "root") +└── ComponentSpecEntity ($name: "root.MyComponent") + ├── InputsCollection ($name: "root.MyComponent.inputs") + └── OutputsCollection ($name: "root.MyComponent.outputs") +``` + +--- + +## Object Hierarchy + +``` +RootContext +└── ComponentSpecEntity + ├── inputs: InputsCollection + │ └── InputEntity[] + │ - name, type, description, default, optional, value + │ + ├── outputs: OutputsCollection + │ └── OutputEntity[] + │ - name, type, description + │ + └── implementation?: GraphImplementation + └── tasks: TasksCollection + └── TaskEntity[] + ├── name, componentRef, isEnabled, executionOptions + └── arguments: ArgumentsCollection + └── ArgumentEntity[] + - type: "graphInput" | "taskOutput" | "literal" + - connectTo(InputEntity | OutputEntity) +``` + +--- + +## Key Classes + +| Class | Purpose | Location | +|-------|---------|----------| +| `RootContext` | Top-level context for ID generation | `context.ts` | +| `ComponentSpecEntity` | Main component representation | `componentSpec.ts` | +| `InputEntity` / `InputsCollection` | Component inputs | `inputs.ts` | +| `OutputEntity` / `OutputsCollection` | Component outputs | `outputs.ts` | +| `GraphImplementation` | Graph-based implementation | `graphImplementation.ts` | +| `TaskEntity` / `TasksCollection` | Tasks within a graph | `graphImplementation.ts` | +| `ArgumentEntity` / `ArgumentsCollection` | Task argument bindings | `graphImplementation.ts` | +| `AnnotationEntity` / `AnnotationsCollection` | Key-value annotations | `annotations.ts` | +| `YamlLoader` | Loads YAML into object model | `yamlLoader.ts` | + +--- + +## Data Flow Connections + +`ArgumentEntity` represents how data flows between tasks: + +```typescript +class ArgumentEntity { + name: string; + + // Connection type + private _type: "graphInput" | "taskOutput" | "literal"; + + // Connect to a source + connectTo(source: InputEntity | OutputEntity): void; + + // Or set a literal value + set value(value: ScalarValue); +} +``` + +**Types:** +- `graphInput`: Argument bound to a graph-level input +- `taskOutput`: Argument bound to another task's output +- `literal`: Static value + +--- + +## Indexing System + +Entities declare indexed fields via `$indexed`: + +```typescript +class InputEntity { + readonly $indexed = ["name" as const]; + name: string; + // ... +} +``` + +Collections can then perform fast lookups: + +```typescript +const input = inputs.findByIndex("name", "myInputName")[0]; +``` + +The `IndexByKey` class maintains a `Map>>` structure for O(1) lookups. + +--- + +## Creating New Entity Types + +To add a new entity type: + +1. **Define the scalar interface:** +```typescript +interface MyScalarInterface { + name: string; + // ... other fields +} +``` + +2. **Create the entity class:** +```typescript +class MyEntity implements BaseEntity, SerializableEntity { + readonly $indexed = ["name" as const]; + name: string = ""; + + constructor(readonly $id: string) {} + + populate(spec: MyScalarInterface) { + this.name = spec.name; + return this; + } + + toJson() { + return { name: this.name }; + } +} +``` + +3. **Create the collection class:** +```typescript +class MyCollection extends BaseCollection { + constructor(parent: Context) { + super("myEntities", parent); + } + + createEntity(spec: MyScalarInterface): MyEntity { + return new MyEntity(this.generateId()).populate(spec); + } +} +``` + +--- + +## Loading from YAML + +The `YamlLoader` class handles YAML → Object Model conversion: + +```typescript +const loader = new YamlLoader(); +const componentSpec = await loader.loadFromText(yamlString); +``` + +Key loading steps: +1. Parse YAML to raw spec object +2. Create `ComponentSpecEntity` with `RootContext` +3. Populate inputs and outputs +4. If graph implementation: create tasks and resolve argument connections + +--- + +## Serialization + +Call `toJson()` on any entity to get its spec representation: + +```typescript +const json = componentSpec.toJson(); +// { +// name: "...", +// description: "...", +// inputs: [...], +// outputs: [...], +// implementation: { graph: { tasks: {...} } } +// } +``` + +--- + +## Important Notes + +### Mutation Model +Entities are **mutable**. After creation, fields can be modified directly. However: +- Index updates are NOT automatic — if you change an indexed field after creation, the index may become stale +- For React integration, trigger re-renders manually after mutations + +### Nested Components +When a graph task references a component, `YamlLoader` recursively creates a nested `ComponentSpecEntity`: + +```typescript +await this.load(hydratedComponentRef.spec, taskId, rootSpecEntity); +``` + +This creates the full component tree in memory. + +### ID Format +IDs follow the pattern: `{contextPath}_{counter}` +- Example: `root.MyPipeline.tasks_1` +- Useful for debugging and tracing entity origins + diff --git a/src/providers/ComponentSpec/annotations.ts b/src/providers/ComponentSpec/annotations.ts index 04f4691b4..a74ff741f 100644 --- a/src/providers/ComponentSpec/annotations.ts +++ b/src/providers/ComponentSpec/annotations.ts @@ -35,8 +35,11 @@ export class AnnotationEntity return this; } - toJson() { - return JSON.stringify(this.value); + /** + * Returns the annotation value directly (not stringified). + */ + toJson(): object | string | number | boolean | null | undefined { + return this.value as object | string | number | boolean | null | undefined; } } @@ -49,18 +52,19 @@ export class AnnotationsCollection } createEntity(spec: AnnotationScalarInterface): AnnotationEntity { - return new AnnotationEntity(this.generateId(), spec); + return new AnnotationEntity(this.generateId(), spec).populate(spec); } - toJson(): string { - return JSON.stringify( - this.getAll().reduce( - (acc, annotation) => { - acc[annotation.key] = annotation.toJson(); - return acc; - }, - {} as Record, - ), + /** + * Returns annotations as a plain object (not stringified). + */ + toJson(): Record { + return this.getAll().reduce( + (acc, annotation) => { + acc[annotation.key] = annotation.toJson(); + return acc; + }, + {} as Record, ); } } diff --git a/src/providers/ComponentSpec/componentSpec.ts b/src/providers/ComponentSpec/componentSpec.ts index e66faf855..1dd1f20a3 100644 --- a/src/providers/ComponentSpec/componentSpec.ts +++ b/src/providers/ComponentSpec/componentSpec.ts @@ -1,4 +1,4 @@ -import type { ComponentSpec } from "@/utils/componentSpec"; +import type { ComponentSpec, MetadataSpec } from "@/utils/componentSpec"; import { BaseNestedContext, type Context } from "./context"; import type { GraphImplementation } from "./graphImplementation"; @@ -13,7 +13,10 @@ import type { export type ComponentSpecScalarInterface = Pick< ComponentSpec, "description" -> & { name: string }; +> & { + name: string; + metadata?: MetadataSpec; +}; export class ComponentSpecEntity extends BaseNestedContext @@ -23,6 +26,7 @@ export class ComponentSpecEntity name: string; description?: string; + metadata?: MetadataSpec; implementation?: GraphImplementation; @@ -51,17 +55,44 @@ export class ComponentSpecEntity populate(scalar: ComponentSpecScalarInterface) { this.name = scalar.name; this.description = scalar.description; + this.metadata = scalar.metadata; return this; } - toJson() { - return { - name: this.name, - description: this.description, - implementation: this.implementation?.toJson(), - inputs: this.inputs.toJson(), - outputs: this.outputs.toJson(), + /** + * Serializes to schema-compliant ComponentSpec format. + * Only includes defined properties to avoid undefined values in JSON. + */ + toJson(): ComponentSpec { + const json: ComponentSpec = { + implementation: this.implementation?.toJson() ?? { + graph: { tasks: {} }, + }, }; + + if (this.name) { + json.name = this.name; + } + + if (this.description !== undefined) { + json.description = this.description; + } + + if (this.metadata !== undefined) { + json.metadata = this.metadata; + } + + const inputsJson = this.inputs.toJson(); + if (inputsJson.length > 0) { + json.inputs = inputsJson; + } + + const outputsJson = this.outputs.toJson(); + if (outputsJson.length > 0) { + json.outputs = outputsJson; + } + + return json; } } diff --git a/src/providers/ComponentSpec/graphImplementation.ts b/src/providers/ComponentSpec/graphImplementation.ts index ac8a61255..78b019974 100644 --- a/src/providers/ComponentSpec/graphImplementation.ts +++ b/src/providers/ComponentSpec/graphImplementation.ts @@ -1,39 +1,102 @@ import type { + ArgumentType, ComponentReference, ExecutionOptionsSpec, + GraphImplementation as GraphImplementationType, + GraphInputArgument, PredicateType, + TaskOutputArgument, TaskSpec, } from "@/utils/componentSpec"; import { BaseCollection, type Context, type NestedContext } from "./context"; import { InputEntity } from "./inputs"; -import type { OutputEntity } from "./outputs"; +import { OutputEntity } from "./outputs"; import type { BaseEntity, RequiredProperties, - ScalarType, SerializableEntity, } from "./types"; +interface OutputValueBinding { + outputName: string; + taskId: string; + taskOutputName: string; +} + export class GraphImplementation implements SerializableEntity { readonly tasks: TasksCollection; + /** + * Maps graph output names to task output sources. + * Used to expose task outputs as graph-level outputs. + */ + private readonly _outputValues: Map = new Map(); + constructor(private readonly context: Context) { this.tasks = new TasksCollection(this.context); } - toJson() { - return { + /** + * Sets a graph output value to reference a task output. + * @param graphOutputName - The name of the graph output + * @param taskId - The task ID that produces the output + * @param taskOutputName - The name of the task's output + */ + setOutputValue( + graphOutputName: string, + taskId: string, + taskOutputName: string, + ): void { + this._outputValues.set(graphOutputName, { + outputName: graphOutputName, + taskId, + taskOutputName, + }); + } + + /** + * Removes a graph output value binding. + */ + removeOutputValue(graphOutputName: string): void { + this._outputValues.delete(graphOutputName); + } + + /** + * Gets all output value bindings. + */ + getOutputValues(): OutputValueBinding[] { + return Array.from(this._outputValues.values()); + } + + toJson(): GraphImplementationType { + const json: GraphImplementationType = { graph: { tasks: this.tasks.toJson(), }, }; + + if (this._outputValues.size > 0) { + const outputValues: Record = {}; + for (const binding of this._outputValues.values()) { + outputValues[binding.outputName] = { + taskOutput: { + taskId: binding.taskId, + outputName: binding.taskOutputName, + }, + }; + } + json.graph.outputValues = outputValues; + } + + return json; } } type TaskScalarInterface = Pick & { name: string; componentRef: ComponentReference; + annotations?: Record; }; export class TaskEntity @@ -46,6 +109,7 @@ export class TaskEntity isEnabled?: PredicateType; executionOptions?: ExecutionOptionsSpec; + annotations?: Record; readonly arguments: ArgumentsCollection; @@ -65,18 +129,61 @@ export class TaskEntity this.isEnabled = scalar.isEnabled; this.executionOptions = scalar.executionOptions; this.componentRef = scalar.componentRef; + this.annotations = scalar.annotations; return this; } - toJson() { - return { - taskId: this.name, - componentRef: this.componentRef, - isEnabled: this.isEnabled, - executionOptions: this.executionOptions, - arguments: this.arguments.toJson(), + /** + * Serializes the task to schema-compliant TaskSpec format. + * Note: The task name is NOT included here - it's used as the key in the tasks map. + */ + toJson(): TaskSpec { + const json: TaskSpec = { + componentRef: this.serializeComponentRef(), }; + + const argsJson = this.arguments.toJson(); + if (Object.keys(argsJson).length > 0) { + json.arguments = argsJson; + } + + if (this.isEnabled !== undefined) { + json.isEnabled = this.isEnabled; + } + + if (this.executionOptions !== undefined) { + json.executionOptions = this.executionOptions; + } + + if (this.annotations !== undefined && Object.keys(this.annotations).length > 0) { + json.annotations = this.annotations; + } + + return json; + } + + /** + * Serializes the componentRef, excluding internal fields not in the schema. + */ + private serializeComponentRef(): ComponentReference { + const ref: ComponentReference = {}; + + if (this.componentRef.name) { + ref.name = this.componentRef.name; + } + if (this.componentRef.digest) { + ref.digest = this.componentRef.digest; + } + if (this.componentRef.tag) { + ref.tag = this.componentRef.tag; + } + if (this.componentRef.url) { + ref.url = this.componentRef.url; + } + // Note: spec and text are typically not serialized back to avoid duplication + + return ref; } } @@ -92,19 +199,18 @@ export class TasksCollection return new TaskEntity(this.generateId(), this, spec).populate(spec); } - toJson() { + toJson(): Record { return this.getAll().reduce( (acc, task) => { acc[task.name] = task.toJson(); return acc; }, - {} as Record, + {} as Record, ); } } interface ArgumentScalarInterface { - // type: "graphInput" | "taskOutput" | "literal"; name: string; } @@ -119,6 +225,7 @@ export class ArgumentEntity private _type: "graphInput" | "taskOutput" | "literal" = "literal"; private _source: InputEntity | OutputEntity | undefined; + private _sourceTaskId?: string; // For taskOutput: the task ID that owns the output private _value: ScalarValue | undefined; @@ -135,13 +242,23 @@ export class ArgumentEntity return this; } - connectTo(output: OutputEntity): void; + /** + * Connect this argument to a graph input. + */ connectTo(input: InputEntity): void; + /** + * Connect this argument to a task output. + * The taskId is inferred from the output's parent component spec. + */ + connectTo(output: OutputEntity): void; connectTo(source: InputEntity | OutputEntity): void { if (source instanceof InputEntity) { this._type = "graphInput"; + this._sourceTaskId = undefined; } else { this._type = "taskOutput"; + // Extract task ID from the output's parent component name + this._sourceTaskId = source.parentComponentName; } this._source = source; this._value = undefined; @@ -151,30 +268,61 @@ export class ArgumentEntity if (this._type === "literal") { return this._value; } - - // todo: return the value of the source? - // return this._source?.value; return undefined; } set value(value: ScalarValue) { this._type = "literal"; this._value = value; + this._source = undefined; + this._sourceTaskId = undefined; } get type(): "graphInput" | "taskOutput" | "literal" { return this._type; } - toJson() { - // todo: fix to return according to Spec - return { - __argument: { - name: this.name, - type: this.type, - value: this.value, - }, - }; + /** + * Returns the argument in the schema-compliant ArgumentType format: + * - Literal: returns the string/number/boolean value directly + * - GraphInput: returns { graphInput: { inputName: string } } + * - TaskOutput: returns { taskOutput: { taskId: string, outputName: string } } + */ + toJson(): ArgumentType { + switch (this._type) { + case "literal": + // Return the literal value directly (must be string per schema) + return String(this._value ?? ""); + + case "graphInput": { + if (!this._source) { + throw new Error( + `ArgumentEntity ${this.name}: graphInput source is not set`, + ); + } + const graphInputArg: GraphInputArgument = { + graphInput: { + inputName: this._source.name, + }, + }; + return graphInputArg; + } + + case "taskOutput": { + if (!this._source || !this._sourceTaskId) { + throw new Error( + `ArgumentEntity ${this.name}: taskOutput source or taskId is not set`, + ); + } + const taskOutputArg: TaskOutputArgument = { + taskOutput: { + taskId: this._sourceTaskId, + outputName: this._source.name, + }, + }; + return taskOutputArg; + } + } } } @@ -190,13 +338,13 @@ export class ArgumentsCollection return new ArgumentEntity(this.generateId(), spec).populate(spec); } - toJson() { + toJson(): Record { return this.getAll().reduce( (acc, argument) => { acc[argument.name] = argument.toJson(); return acc; }, - {} as Record, + {} as Record, ); } } diff --git a/src/providers/ComponentSpec/inputs.ts b/src/providers/ComponentSpec/inputs.ts index 6b7fba9b2..db79066fb 100644 --- a/src/providers/ComponentSpec/inputs.ts +++ b/src/providers/ComponentSpec/inputs.ts @@ -1,16 +1,32 @@ import type { InputSpec, TypeSpecType } from "@/utils/componentSpec"; -import { BaseCollection, type Context } from "./context"; -import type { BaseEntity, SerializableEntity } from "./types"; +import { AnnotationsCollection } from "./annotations"; +import { type Context, EntityIndex } from "./context"; +import type { SerializableEntity } from "./types"; +/** + * Scalar interface for InputEntity - represents the data used to populate an input. + * Note: `annotations` is handled separately by AnnotationsCollection on the entity. + */ export type InputScalarInterface = Pick< InputSpec, - "name" | "type" | "description" | "default" | "optional" | "value" ->; + "name" | "type" | "description" | "default" | "optional" +> & { + /** + * Internal value for runtime use. NOT part of the ComponentSpec schema. + */ + value?: string; +}; + +/** + * Interface for creating an InputEntity with annotations. + */ +export interface InputScalarWithAnnotations extends InputScalarInterface { + annotations?: Record; +} -export class InputEntity - implements BaseEntity, SerializableEntity -{ +export class InputEntity implements SerializableEntity { + readonly $id: string; readonly $indexed = ["name" as const]; name: string = ""; @@ -19,11 +35,20 @@ export class InputEntity description?: string; default?: string; optional?: boolean; + + /** + * Internal runtime value. NOT serialized to JSON (not part of schema). + */ value?: string; - constructor(readonly $id: string) {} + readonly annotations: AnnotationsCollection; - populate(spec: InputScalarInterface) { + constructor($id: string, parent: Context) { + this.$id = $id; + this.annotations = new AnnotationsCollection(parent); + } + + populate(spec: InputScalarWithAnnotations) { this.name = spec.name; this.type = spec.type; this.description = spec.description; @@ -31,34 +56,80 @@ export class InputEntity this.optional = spec.optional; this.value = spec.value; + if (spec.annotations) { + for (const [key, value] of Object.entries(spec.annotations)) { + this.annotations.add({ key, value }); + } + } + return this; } - toJson() { - return { + /** + * Serializes to schema-compliant InputSpec format. + * Note: `value` is intentionally excluded as it's not part of the schema. + */ + toJson(): InputSpec { + const json: InputSpec = { name: this.name, - type: this.type, - description: this.description, - default: this.default, - optional: this.optional, - value: this.value, }; + + if (this.type !== undefined) { + json.type = this.type; + } + if (this.description !== undefined) { + json.description = this.description; + } + if (this.default !== undefined) { + json.default = this.default; + } + if (this.optional !== undefined) { + json.optional = this.optional; + } + + const annotationsJson = this.annotations.toJson(); + if (Object.keys(annotationsJson).length > 0) { + json.annotations = annotationsJson; + } + + return json; } } -export class InputsCollection - extends BaseCollection - implements SerializableEntity -{ +export class InputsCollection implements SerializableEntity { + private readonly index = new EntityIndex(); + private readonly context: { $name: string; generateId(): string }; + constructor(parent: Context) { - super("inputs", parent); + const $name = `${parent.$name}.inputs`; + let counter = 0; + this.context = { + $name, + generateId: () => `${$name}_${++counter}`, + }; + } + + add(spec: InputScalarWithAnnotations): InputEntity { + const entity = new InputEntity( + this.context.generateId(), + this.context as Context, + ).populate(spec); + this.index.add(entity); + return entity; + } + + getAll(): InputEntity[] { + return this.index.getAll(); } - createEntity(spec: InputScalarInterface): InputEntity { - return new InputEntity(this.generateId()).populate(spec); + findByIndex( + indexKey: K, + value: InputEntity[K], + ): InputEntity[] { + return this.index.findByIndex(indexKey, value); } - toJson() { + toJson(): InputSpec[] { return this.getAll().map((input) => input.toJson()); } } diff --git a/src/providers/ComponentSpec/outputs.ts b/src/providers/ComponentSpec/outputs.ts index 0f8f5257b..688ede624 100644 --- a/src/providers/ComponentSpec/outputs.ts +++ b/src/providers/ComponentSpec/outputs.ts @@ -1,16 +1,27 @@ import type { OutputSpec, TypeSpecType } from "@/utils/componentSpec"; -import { BaseCollection, type Context } from "./context"; -import type { BaseEntity, SerializableEntity } from "./types"; +import { AnnotationsCollection } from "./annotations"; +import { type Context, EntityIndex } from "./context"; +import type { SerializableEntity } from "./types"; +/** + * Scalar interface for OutputEntity - represents the data used to populate an output. + * Note: `annotations` is handled separately by AnnotationsCollection on the entity. + */ export type OutputScalarInterface = Pick< OutputSpec, "name" | "type" | "description" >; -export class OutputEntity - implements BaseEntity, SerializableEntity -{ +/** + * Interface for creating an OutputEntity with annotations. + */ +export interface OutputScalarWithAnnotations extends OutputScalarInterface { + annotations?: Record; +} + +export class OutputEntity implements SerializableEntity { + readonly $id: string; readonly $indexed = ["name" as const]; name: string = ""; @@ -18,38 +29,107 @@ export class OutputEntity type?: TypeSpecType; description?: string; - constructor(readonly $id: string) {} + readonly annotations: AnnotationsCollection; + + /** + * The name of the parent ComponentSpec that owns this output. + * This is the task ID when the output belongs to a task's component. + */ + private _parentComponentName?: string; + + constructor($id: string, parent: Context) { + this.$id = $id; + this.annotations = new AnnotationsCollection(parent); + } + + /** + * Sets the parent component name (task ID in graph context). + * Called by OutputsCollection when creating the entity. + */ + setParentComponentName(name: string) { + this._parentComponentName = name; + } - populate(spec: OutputScalarInterface) { + /** + * Gets the parent component name (task ID in graph context). + * Used by ArgumentEntity when serializing taskOutput arguments. + */ + get parentComponentName(): string | undefined { + return this._parentComponentName; + } + + populate(spec: OutputScalarWithAnnotations) { this.name = spec.name; this.type = spec.type; this.description = spec.description; + if (spec.annotations) { + for (const [key, value] of Object.entries(spec.annotations)) { + this.annotations.add({ key, value }); + } + } + return this; } - toJson() { - return { + toJson(): OutputSpec { + const json: OutputSpec = { name: this.name, - type: this.type, - description: this.description, }; + + if (this.type !== undefined) { + json.type = this.type; + } + if (this.description !== undefined) { + json.description = this.description; + } + + const annotationsJson = this.annotations.toJson(); + if (Object.keys(annotationsJson).length > 0) { + json.annotations = annotationsJson; + } + + return json; } } -export class OutputsCollection - extends BaseCollection - implements SerializableEntity -{ +export class OutputsCollection implements SerializableEntity { + private readonly index = new EntityIndex(); + private readonly parentComponentName: string; + private readonly context: { $name: string; generateId(): string }; + constructor(parent: Context) { - super("outputs", parent); + this.parentComponentName = parent.$name.split(".").pop() || ""; + const $name = `${parent.$name}.outputs`; + let counter = 0; + this.context = { + $name, + generateId: () => `${$name}_${++counter}`, + }; + } + + add(spec: OutputScalarWithAnnotations): OutputEntity { + const entity = new OutputEntity( + this.context.generateId(), + this.context as Context, + ).populate(spec); + entity.setParentComponentName(this.parentComponentName); + this.index.add(entity); + return entity; + } + + getAll(): OutputEntity[] { + return this.index.getAll(); } - createEntity(spec: OutputScalarInterface): OutputEntity { - return new OutputEntity(this.generateId()).populate(spec); + findByIndex( + indexKey: K, + value: OutputEntity[K], + ): OutputEntity[] { + return this.index.findByIndex(indexKey, value); } - toJson() { + toJson(): OutputSpec[] { return this.getAll().map((output) => output.toJson()); } } diff --git a/src/providers/ComponentSpec/tests/fixtures/index.ts b/src/providers/ComponentSpec/tests/fixtures/index.ts new file mode 100644 index 000000000..79667b6fc --- /dev/null +++ b/src/providers/ComponentSpec/tests/fixtures/index.ts @@ -0,0 +1,309 @@ +import type { + ComponentReference, + ComponentSpec, + GraphImplementation, +} from "@/utils/componentSpec"; + +/** + * A simple container component for use as task componentRef + */ +export const simpleContainerComponent: ComponentSpec = { + name: "Simple Container", + description: "A simple container component", + inputs: [{ name: "input_data", type: "String", description: "Input data" }], + outputs: [ + { name: "output_data", type: "String", description: "Output data" }, + ], + implementation: { + container: { + image: "alpine:latest", + command: ["echo"], + args: [{ inputValue: "input_data" }], + }, + }, +}; + +export const simpleContainerComponentRef: ComponentReference = { + name: "Simple Container", + digest: "sha256:abc123", + spec: simpleContainerComponent, + text: "name: Simple Container\nimplementation:\n container:\n image: alpine:latest", +}; + +/** + * Expected output for a simple pipeline with one task using literal arguments + */ +export const expectedSimplePipeline: ComponentSpec = { + name: "Simple Pipeline", + description: "A simple pipeline with one task", + inputs: [], + outputs: [], + implementation: { + graph: { + tasks: { + task1: { + componentRef: { + name: "Simple Container", + digest: "sha256:abc123", + }, + arguments: { + input_data: "hello world", + }, + }, + }, + }, + } as GraphImplementation, +}; + +/** + * Expected output for a pipeline with graph input arguments + */ +export const expectedPipelineWithGraphInputs: ComponentSpec = { + name: "Pipeline with Graph Inputs", + description: "Pipeline that passes graph inputs to tasks", + inputs: [ + { name: "pipeline_input", type: "String", description: "Pipeline input" }, + ], + outputs: [], + implementation: { + graph: { + tasks: { + process_task: { + componentRef: { + name: "Simple Container", + digest: "sha256:abc123", + }, + arguments: { + input_data: { + graphInput: { + inputName: "pipeline_input", + }, + }, + }, + }, + }, + }, + } as GraphImplementation, +}; + +/** + * Expected output for a pipeline with task output arguments + */ +export const expectedPipelineWithTaskOutputs: ComponentSpec = { + name: "Pipeline with Task Outputs", + description: "Pipeline with chained tasks", + inputs: [], + outputs: [], + implementation: { + graph: { + tasks: { + first_task: { + componentRef: { + name: "Simple Container", + digest: "sha256:abc123", + }, + arguments: { + input_data: "initial data", + }, + }, + second_task: { + componentRef: { + name: "Simple Container", + digest: "sha256:abc123", + }, + arguments: { + input_data: { + taskOutput: { + taskId: "first_task", + outputName: "output_data", + }, + }, + }, + }, + }, + }, + } as GraphImplementation, +}; + +/** + * Expected output for a pipeline with outputValues + */ +export const expectedPipelineWithOutputValues: ComponentSpec = { + name: "Pipeline with Output Values", + description: "Pipeline that exposes task outputs as graph outputs", + inputs: [], + outputs: [ + { + name: "pipeline_output", + type: "String", + description: "Pipeline output", + }, + ], + implementation: { + graph: { + tasks: { + process_task: { + componentRef: { + name: "Simple Container", + digest: "sha256:abc123", + }, + arguments: { + input_data: "data", + }, + }, + }, + outputValues: { + pipeline_output: { + taskOutput: { + taskId: "process_task", + outputName: "output_data", + }, + }, + }, + }, + } as GraphImplementation, +}; + +/** + * Expected output for a pipeline with metadata and annotations + */ +export const expectedPipelineWithMetadataAndAnnotations: ComponentSpec = { + name: "Pipeline with Metadata", + description: "Pipeline demonstrating metadata and annotations", + metadata: { + annotations: { + author: "test-author", + version: "1.0.0", + }, + }, + inputs: [ + { + name: "annotated_input", + type: "String", + description: "An input with annotations", + annotations: { + ui_hint: "text_area", + required_level: "high", + }, + }, + ], + outputs: [ + { + name: "annotated_output", + type: "String", + description: "An output with annotations", + annotations: { + format: "json", + }, + }, + ], + implementation: { + graph: { + tasks: { + task_with_annotations: { + componentRef: { + name: "Simple Container", + digest: "sha256:abc123", + }, + arguments: { + input_data: { + graphInput: { + inputName: "annotated_input", + }, + }, + }, + annotations: { + task_priority: "high", + }, + }, + }, + }, + } as GraphImplementation, +}; + +/** + * Expected output for a full pipeline with execution options + */ +export const expectedFullPipeline: ComponentSpec = { + name: "Full Pipeline", + description: "A comprehensive pipeline with all features", + metadata: { + annotations: { + canonical_location: "https://example.com/pipelines/full", + author: "test-author", + }, + }, + inputs: [ + { + name: "required_input", + type: "String", + description: "A required input", + }, + { + name: "optional_input", + type: "Integer", + description: "An optional input", + optional: true, + default: "42", + }, + ], + outputs: [ + { + name: "final_output", + type: "String", + description: "The final output", + }, + ], + implementation: { + graph: { + tasks: { + first_task: { + componentRef: { + name: "Simple Container", + digest: "sha256:abc123", + }, + arguments: { + input_data: { + graphInput: { + inputName: "required_input", + }, + }, + }, + executionOptions: { + cachingStrategy: { + maxCacheStaleness: "P1D", + }, + }, + }, + second_task: { + componentRef: { + name: "Simple Container", + digest: "sha256:abc123", + }, + arguments: { + input_data: { + taskOutput: { + taskId: "first_task", + outputName: "output_data", + }, + }, + }, + executionOptions: { + retryStrategy: { + maxRetries: 3, + }, + }, + }, + }, + outputValues: { + final_output: { + taskOutput: { + taskId: "second_task", + outputName: "output_data", + }, + }, + }, + }, + } as GraphImplementation, +}; + diff --git a/src/providers/ComponentSpec/tests/schemaValidator.ts b/src/providers/ComponentSpec/tests/schemaValidator.ts new file mode 100644 index 000000000..5bee6ef45 --- /dev/null +++ b/src/providers/ComponentSpec/tests/schemaValidator.ts @@ -0,0 +1,103 @@ +import { addMediaTypePlugin } from "@hyperjump/browser"; +import { type OutputUnit, validate } from "@hyperjump/json-schema/draft-06"; +import { buildSchemaDocument } from "@hyperjump/json-schema/experimental"; + +const COMPONENT_SPEC_SCHEMA_URL = + "https://raw.githubusercontent.com/Cloud-Pipelines/component_spec_schema/refs/heads/master/component_spec.json_schema.json"; + +// Register media type plugin for the schema file +addMediaTypePlugin("text/plain", { + parse: async (response) => + buildSchemaDocument(JSON.parse(await response.text())), + fileMatcher: async (path) => path.endsWith(".json_schema.json"), +}); + +export interface ValidationResult { + valid: boolean; + errors?: string[]; +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type JsonCompatible = Record | any[] | string | number | boolean | null; + +let cachedValidator: ((value: JsonCompatible) => ValidationResult) | null = null; + +/** + * Validates a ComponentSpec JSON object against the official schema. + * Uses cached validator for performance. + */ +export async function validateComponentSpec( + json: JsonCompatible, +): Promise { + if (!cachedValidator) { + const validator = await validate(COMPONENT_SPEC_SCHEMA_URL); + + cachedValidator = (value: JsonCompatible): ValidationResult => { + const result = validator(value, { outputFormat: "BASIC" }); + + if (!result.valid && result.errors) { + return { + valid: false, + errors: formatValidationErrors(result.errors), + }; + } + + return { valid: result.valid }; + }; + } + + return cachedValidator(json); +} + +function formatValidationErrors(errors: OutputUnit[]): string[] { + const formattedErrors: string[] = []; + + for (const error of errors) { + const location = error.instanceLocation || "#"; + const keyword = extractKeyword(error.keyword); + + // Extract the field path from the instance location + const fieldPath = location.replace("#/", "").replace(/\//g, "."); + + let message: string; + + if (keyword === "required") { + const parentPath = fieldPath || "root"; + message = `Missing required field in ${parentPath}`; + } else if (keyword === "additionalProperties") { + const parts = fieldPath.split("."); + const invalidField = parts[parts.length - 1]; + const parentPath = parts.slice(0, -1).join(".") || "root"; + message = `Unknown property "${invalidField}" in ${parentPath}`; + } else if (keyword === "type") { + message = `Invalid type at ${fieldPath || "root"}`; + } else if (keyword === "enum") { + message = `Invalid value at ${fieldPath || "root"} - must be one of the allowed values`; + } else if (keyword === "oneOf" || keyword === "anyOf") { + message = `Value at ${fieldPath || "root"} doesn't match any of the expected formats`; + } else if (keyword === "validate") { + if (error.absoluteKeywordLocation?.includes("additionalProperties")) { + const parts = fieldPath.split("."); + const invalidField = parts[parts.length - 1]; + const parentPath = parts.slice(0, -1).join(".") || "root"; + message = `Unknown property "${invalidField}" in ${parentPath}`; + } else { + message = `Validation failed at ${fieldPath || "root"}`; + } + } else { + message = `${keyword} validation failed at ${fieldPath || "root"}`; + } + + if (!formattedErrors.includes(message)) { + formattedErrors.push(message); + } + } + + return formattedErrors; +} + +function extractKeyword(keywordUrl: string): string { + const parts = keywordUrl.split("/"); + return parts[parts.length - 1] || "unknown"; +} + diff --git a/src/providers/ComponentSpec/tests/toJson.test.ts b/src/providers/ComponentSpec/tests/toJson.test.ts new file mode 100644 index 000000000..0d8253b56 --- /dev/null +++ b/src/providers/ComponentSpec/tests/toJson.test.ts @@ -0,0 +1,787 @@ +import { beforeAll, describe, expect, it } from "vitest"; + +import { isGraphImplementation } from "@/utils/componentSpec"; + +import { ComponentSpecEntity } from "../componentSpec"; +import { RootContext } from "../context"; +import { GraphImplementation } from "../graphImplementation"; +import { InputEntity } from "../inputs"; +import { OutputEntity } from "../outputs"; +import { simpleContainerComponentRef } from "./fixtures"; +import { validateComponentSpec } from "./schemaValidator"; + +describe("ComponentSpec Object Model toJson()", () => { + let rootContext: RootContext; + + beforeAll(() => { + rootContext = new RootContext(); + }); + + describe("Schema Validation", () => { + it("should reject a component without implementation", async () => { + const invalidSpec = { + name: "Invalid Component", + description: "Missing implementation", + inputs: [], + outputs: [], + }; + + const result = await validateComponentSpec(invalidSpec); + expect(result.valid).toBe(false); + expect(result.errors).toBeDefined(); + }); + + it("should accept a valid component with graph implementation", async () => { + const validSpec = { + name: "Valid Pipeline", + implementation: { + graph: { + tasks: { + task1: { + componentRef: { + name: "Test", + digest: "sha256:abc", + }, + }, + }, + }, + }, + }; + + const result = await validateComponentSpec(validSpec); + expect(result.valid).toBe(true); + }); + }); + + describe("InputEntity.toJson()", () => { + it("should serialize input without value field", () => { + const input = new InputEntity("test-input-1", rootContext); + input.populate({ + name: "test_input", + type: "String", + description: "A test input", + default: "default_value", + optional: true, + }); + + const json = input.toJson(); + + expect(json).toEqual({ + name: "test_input", + type: "String", + description: "A test input", + default: "default_value", + optional: true, + }); + // Should NOT have 'value' field + expect(json).not.toHaveProperty("value"); + }); + + it("should include annotations when present", () => { + const input = new InputEntity("test-input-2", rootContext); + input.populate({ + name: "annotated_input", + type: "String", + description: "Input with annotations", + }); + // Now we can add annotations + input.annotations.add({ key: "ui_hint", value: "text_area" }); + + const json = input.toJson(); + + expect(json.name).toBe("annotated_input"); + expect(json.annotations).toEqual({ ui_hint: "text_area" }); + }); + + it("should omit undefined optional fields", () => { + const input = new InputEntity("test-input-3", rootContext); + input.populate({ + name: "minimal_input", + }); + + const json = input.toJson(); + + expect(json.name).toBe("minimal_input"); + // Optional fields should be undefined (and filtered in final output) + expect(json.type).toBeUndefined(); + expect(json.description).toBeUndefined(); + expect(json.default).toBeUndefined(); + expect(json.optional).toBeUndefined(); + }); + }); + + describe("OutputEntity.toJson()", () => { + it("should serialize output correctly", () => { + const output = new OutputEntity("test-output-1", rootContext); + output.populate({ + name: "test_output", + type: "String", + description: "A test output", + }); + + const json = output.toJson(); + + expect(json).toEqual({ + name: "test_output", + type: "String", + description: "A test output", + }); + }); + + it("should include annotations when present", () => { + const output = new OutputEntity("test-output-2", rootContext); + output.populate({ + name: "annotated_output", + type: "String", + description: "Output with annotations", + }); + // Now we can add annotations + output.annotations.add({ key: "format", value: "json" }); + + const json = output.toJson(); + + expect(json.name).toBe("annotated_output"); + expect(json.annotations).toEqual({ format: "json" }); + }); + }); + + describe("ArgumentEntity.toJson()", () => { + it("should serialize literal argument as string value", () => { + const context = new RootContext(); + const componentSpec = new ComponentSpecEntity( + context.generateId(), + context, + { name: "Test" }, + ); + const graphImpl = new GraphImplementation(componentSpec); + const task = graphImpl.tasks.add({ + name: "test_task", + componentRef: simpleContainerComponentRef, + }); + + const argument = task.arguments.add({ name: "input_data" }); + argument.value = "literal value"; + + const json = argument.toJson(); + + // Should return just the string value for literal arguments + expect(json).toBe("literal value"); + }); + + it("should serialize graphInput argument correctly", () => { + const context = new RootContext(); + const componentSpec = new ComponentSpecEntity( + context.generateId(), + context, + { name: "Test" }, + ); + + // Add a graph input + const graphInput = componentSpec.inputs.add({ + name: "pipeline_input", + type: "String", + }); + + const graphImpl = new GraphImplementation(componentSpec); + componentSpec.implementation = graphImpl; + + const task = graphImpl.tasks.add({ + name: "test_task", + componentRef: simpleContainerComponentRef, + }); + + const argument = task.arguments.add({ name: "input_data" }); + argument.connectTo(graphInput); + + const json = argument.toJson(); + + // Should return GraphInputArgument format + expect(json).toEqual({ + graphInput: { + inputName: "pipeline_input", + }, + }); + }); + + it("should serialize taskOutput argument correctly", () => { + const context = new RootContext(); + const componentSpec = new ComponentSpecEntity( + context.generateId(), + context, + { name: "Test" }, + ); + + const graphImpl = new GraphImplementation(componentSpec); + componentSpec.implementation = graphImpl; + + // Create first task with an output + graphImpl.tasks.add({ + name: "first_task", + componentRef: simpleContainerComponentRef, + }); + + // We need to create a nested component spec to get the output + const nestedComponentSpec = new ComponentSpecEntity( + componentSpec.generateId(), + componentSpec, + { name: "first_task" }, + ); + componentSpec.registerEntity(nestedComponentSpec); + + const taskOutput = nestedComponentSpec.outputs.add({ + name: "output_data", + type: "String", + }); + + // Create second task that uses first task's output + const secondTask = graphImpl.tasks.add({ + name: "second_task", + componentRef: simpleContainerComponentRef, + }); + + const argument = secondTask.arguments.add({ name: "input_data" }); + argument.connectTo(taskOutput); + + const json = argument.toJson(); + + // Should return TaskOutputArgument format + expect(json).toEqual({ + taskOutput: { + taskId: "first_task", + outputName: "output_data", + }, + }); + }); + }); + + describe("TaskEntity.toJson()", () => { + it("should NOT include taskId in output (key is the task name)", () => { + const context = new RootContext(); + const componentSpec = new ComponentSpecEntity( + context.generateId(), + context, + { name: "Test" }, + ); + const graphImpl = new GraphImplementation(componentSpec); + + const task = graphImpl.tasks.add({ + name: "my_task", + componentRef: simpleContainerComponentRef, + }); + + const json = task.toJson(); + + // taskId should NOT be in the output - the task name is the key in the tasks map + expect(json).not.toHaveProperty("taskId"); + expect(json).toHaveProperty("componentRef"); + }); + + it("should include componentRef with correct format", () => { + const context = new RootContext(); + const componentSpec = new ComponentSpecEntity( + context.generateId(), + context, + { name: "Test" }, + ); + const graphImpl = new GraphImplementation(componentSpec); + + const task = graphImpl.tasks.add({ + name: "my_task", + componentRef: { + name: "Test Component", + digest: "sha256:abc123", + url: "https://example.com/component.yaml", + }, + }); + + const json = task.toJson(); + + expect(json.componentRef).toEqual({ + name: "Test Component", + digest: "sha256:abc123", + url: "https://example.com/component.yaml", + }); + }); + + it("should include executionOptions when present", () => { + const context = new RootContext(); + const componentSpec = new ComponentSpecEntity( + context.generateId(), + context, + { name: "Test" }, + ); + const graphImpl = new GraphImplementation(componentSpec); + + const task = graphImpl.tasks.add({ + name: "my_task", + componentRef: simpleContainerComponentRef, + }); + task.populate({ + name: "my_task", + componentRef: simpleContainerComponentRef, + executionOptions: { + retryStrategy: { maxRetries: 3 }, + cachingStrategy: { maxCacheStaleness: "P1D" }, + }, + }); + + const json = task.toJson(); + + expect(json.executionOptions).toEqual({ + retryStrategy: { maxRetries: 3 }, + cachingStrategy: { maxCacheStaleness: "P1D" }, + }); + }); + + it("should include annotations when present", () => { + const context = new RootContext(); + const componentSpec = new ComponentSpecEntity( + context.generateId(), + context, + { name: "Test" }, + ); + const graphImpl = new GraphImplementation(componentSpec); + + const task = graphImpl.tasks.add({ + name: "my_task", + componentRef: simpleContainerComponentRef, + }); + // After fix: task.annotations.add({ key: "priority", value: "high" }); + + const json = task.toJson(); + + // After fix: expect(json.annotations).toEqual({ priority: "high" }); + expect(json.componentRef).toBeDefined(); + }); + }); + + describe("GraphImplementation.toJson()", () => { + it("should serialize tasks as a map keyed by task name", () => { + const context = new RootContext(); + const componentSpec = new ComponentSpecEntity( + context.generateId(), + context, + { name: "Test" }, + ); + const graphImpl = new GraphImplementation(componentSpec); + + graphImpl.tasks.add({ + name: "task_a", + componentRef: simpleContainerComponentRef, + }); + graphImpl.tasks.add({ + name: "task_b", + componentRef: simpleContainerComponentRef, + }); + + const json = graphImpl.toJson(); + + expect(json).toHaveProperty("graph"); + expect(json.graph).toHaveProperty("tasks"); + expect(Object.keys(json.graph.tasks)).toEqual(["task_a", "task_b"]); + }); + + it("should include outputValues when present", () => { + const context = new RootContext(); + const componentSpec = new ComponentSpecEntity( + context.generateId(), + context, + { name: "Test" }, + ); + + // Add a graph output + componentSpec.outputs.add({ + name: "pipeline_output", + type: "String", + }); + + const graphImpl = new GraphImplementation(componentSpec); + componentSpec.implementation = graphImpl; + + graphImpl.tasks.add({ + name: "process_task", + componentRef: simpleContainerComponentRef, + }); + + // After fix: graphImpl.setOutputValue("pipeline_output", "process_task", "output_data"); + + const json = graphImpl.toJson(); + + expect(json).toHaveProperty("graph"); + // After fix: expect(json.graph.outputValues).toBeDefined(); + }); + }); + + describe("ComponentSpecEntity.toJson()", () => { + it("should produce valid schema-compliant output for simple pipeline", async () => { + const context = new RootContext(); + const componentSpec = new ComponentSpecEntity( + context.generateId(), + context, + { name: "Simple Pipeline" }, + ); + componentSpec.populate({ + name: "Simple Pipeline", + description: "A simple pipeline with one task", + }); + + const graphImpl = new GraphImplementation(componentSpec); + componentSpec.implementation = graphImpl; + + const task = graphImpl.tasks.add({ + name: "task1", + componentRef: { + name: "Simple Container", + digest: "sha256:abc123", + }, + }); + task.arguments.add({ name: "input_data" }).value = "hello world"; + + const json = componentSpec.toJson(); + + // Validate against schema + const validationResult = await validateComponentSpec(json); + expect(validationResult.valid).toBe(true); + if (!validationResult.valid) { + console.error("Validation errors:", validationResult.errors); + } + }); + + it("should produce valid schema-compliant output for pipeline with graph inputs", async () => { + const context = new RootContext(); + const componentSpec = new ComponentSpecEntity( + context.generateId(), + context, + { name: "Pipeline with Graph Inputs" }, + ); + componentSpec.populate({ + name: "Pipeline with Graph Inputs", + description: "Pipeline that passes graph inputs to tasks", + }); + + const graphInput = componentSpec.inputs.add({ + name: "pipeline_input", + type: "String", + description: "Pipeline input", + }); + + const graphImpl = new GraphImplementation(componentSpec); + componentSpec.implementation = graphImpl; + + const task = graphImpl.tasks.add({ + name: "process_task", + componentRef: { + name: "Simple Container", + digest: "sha256:abc123", + }, + }); + + const argument = task.arguments.add({ name: "input_data" }); + argument.connectTo(graphInput); + + const json = componentSpec.toJson(); + + // Validate against schema + const validationResult = await validateComponentSpec(json); + expect(validationResult.valid).toBe(true); + if (!validationResult.valid) { + console.error("Validation errors:", validationResult.errors); + } + + // Verify the argument format + expect(isGraphImplementation(json.implementation)).toBe(true); + if (isGraphImplementation(json.implementation)) { + const taskJson = json.implementation.graph.tasks["process_task"]; + expect(taskJson?.arguments?.["input_data"]).toEqual({ + graphInput: { + inputName: "pipeline_input", + }, + }); + } + }); + + it("should produce valid schema-compliant output for pipeline with task outputs", async () => { + const context = new RootContext(); + const componentSpec = new ComponentSpecEntity( + context.generateId(), + context, + { name: "Pipeline with Task Outputs" }, + ); + componentSpec.populate({ + name: "Pipeline with Task Outputs", + description: "Pipeline with chained tasks", + }); + + const graphImpl = new GraphImplementation(componentSpec); + componentSpec.implementation = graphImpl; + + // First task + const firstTask = graphImpl.tasks.add({ + name: "first_task", + componentRef: { + name: "Simple Container", + digest: "sha256:abc123", + }, + }); + firstTask.arguments.add({ name: "input_data" }).value = "initial data"; + + // Create nested component spec for first task to get its output + const firstTaskComponentSpec = new ComponentSpecEntity( + componentSpec.generateId(), + componentSpec, + { name: "first_task" }, + ); + componentSpec.registerEntity(firstTaskComponentSpec); + const firstTaskOutput = firstTaskComponentSpec.outputs.add({ + name: "output_data", + type: "String", + }); + + // Second task uses first task's output + const secondTask = graphImpl.tasks.add({ + name: "second_task", + componentRef: { + name: "Simple Container", + digest: "sha256:abc123", + }, + }); + + const secondTaskArg = secondTask.arguments.add({ name: "input_data" }); + secondTaskArg.connectTo(firstTaskOutput); + + const json = componentSpec.toJson(); + + // Validate against schema + const validationResult = await validateComponentSpec(json); + expect(validationResult.valid).toBe(true); + if (!validationResult.valid) { + console.error("Validation errors:", validationResult.errors); + } + + // Verify the argument format + expect(isGraphImplementation(json.implementation)).toBe(true); + if (isGraphImplementation(json.implementation)) { + const secondTaskJson = json.implementation.graph.tasks["second_task"]; + expect(secondTaskJson?.arguments?.["input_data"]).toEqual({ + taskOutput: { + taskId: "first_task", + outputName: "output_data", + }, + }); + } + }); + + it("should include metadata when present", async () => { + const context = new RootContext(); + const componentSpec = new ComponentSpecEntity( + context.generateId(), + context, + { name: "Pipeline with Metadata" }, + ); + componentSpec.populate({ + name: "Pipeline with Metadata", + description: "Pipeline with metadata", + }); + + // After fix: componentSpec.metadata = { annotations: { author: "test" } }; + + const graphImpl = new GraphImplementation(componentSpec); + componentSpec.implementation = graphImpl; + + graphImpl.tasks.add({ + name: "task1", + componentRef: { + name: "Simple Container", + digest: "sha256:abc123", + }, + }); + + const json = componentSpec.toJson(); + + // After fix: expect(json.metadata).toEqual({ annotations: { author: "test" } }); + expect(json.name).toBe("Pipeline with Metadata"); + }); + + it("should include input and output annotations", async () => { + const context = new RootContext(); + const componentSpec = new ComponentSpecEntity( + context.generateId(), + context, + { name: "Pipeline with Annotations" }, + ); + componentSpec.populate({ + name: "Pipeline with Annotations", + description: "Pipeline demonstrating annotations", + }); + + componentSpec.inputs.add({ + name: "annotated_input", + type: "String", + description: "Input with annotations", + // After fix: annotations: { ui_hint: "text_area" } + }); + + componentSpec.outputs.add({ + name: "annotated_output", + type: "String", + description: "Output with annotations", + // After fix: annotations: { format: "json" } + }); + + const graphImpl = new GraphImplementation(componentSpec); + componentSpec.implementation = graphImpl; + + graphImpl.tasks.add({ + name: "task1", + componentRef: { + name: "Simple Container", + digest: "sha256:abc123", + }, + }); + + const json = componentSpec.toJson(); + + expect(json.inputs?.[0]?.name).toBe("annotated_input"); + expect(json.outputs?.[0]?.name).toBe("annotated_output"); + }); + + it("should produce valid schema-compliant output for full pipeline with all features", async () => { + const context = new RootContext(); + const componentSpec = new ComponentSpecEntity( + context.generateId(), + context, + { name: "Full Pipeline" }, + ); + componentSpec.populate({ + name: "Full Pipeline", + description: "A comprehensive pipeline with all features", + }); + + // Add inputs + const requiredInput = componentSpec.inputs.add({ + name: "required_input", + type: "String", + description: "A required input", + }); + + componentSpec.inputs.add({ + name: "optional_input", + type: "Integer", + description: "An optional input", + optional: true, + default: "42", + }); + + // Add outputs + componentSpec.outputs.add({ + name: "final_output", + type: "String", + description: "The final output", + }); + + const graphImpl = new GraphImplementation(componentSpec); + componentSpec.implementation = graphImpl; + + // First task with caching + const firstTask = graphImpl.tasks.add({ + name: "first_task", + componentRef: { + name: "Simple Container", + digest: "sha256:abc123", + }, + }); + firstTask.populate({ + name: "first_task", + componentRef: { + name: "Simple Container", + digest: "sha256:abc123", + }, + executionOptions: { + cachingStrategy: { maxCacheStaleness: "P1D" }, + }, + }); + + const firstTaskArg = firstTask.arguments.add({ name: "input_data" }); + firstTaskArg.connectTo(requiredInput); + + // Create nested component spec for first task + const firstTaskComponentSpec = new ComponentSpecEntity( + componentSpec.generateId(), + componentSpec, + { name: "first_task" }, + ); + componentSpec.registerEntity(firstTaskComponentSpec); + const firstTaskOutput = firstTaskComponentSpec.outputs.add({ + name: "output_data", + type: "String", + }); + + // Second task with retry strategy + const secondTask = graphImpl.tasks.add({ + name: "second_task", + componentRef: { + name: "Simple Container", + digest: "sha256:abc123", + }, + }); + secondTask.populate({ + name: "second_task", + componentRef: { + name: "Simple Container", + digest: "sha256:abc123", + }, + executionOptions: { + retryStrategy: { maxRetries: 3 }, + }, + }); + + const secondTaskArg = secondTask.arguments.add({ name: "input_data" }); + secondTaskArg.connectTo(firstTaskOutput); + + const json = componentSpec.toJson(); + + // Validate against schema + const validationResult = await validateComponentSpec(json); + expect(validationResult.valid).toBe(true); + if (!validationResult.valid) { + console.error("Validation errors:", validationResult.errors); + } + + // Verify structure + expect(json.name).toBe("Full Pipeline"); + expect(json.inputs).toHaveLength(2); + expect(json.outputs).toHaveLength(1); + expect(isGraphImplementation(json.implementation)).toBe(true); + if (isGraphImplementation(json.implementation)) { + expect(Object.keys(json.implementation.graph.tasks)).toEqual([ + "first_task", + "second_task", + ]); + } + }); + }); + + describe("AnnotationsCollection.toJson()", () => { + it("should return an object, not a stringified JSON", () => { + // This test will verify that annotations are returned as objects + // Currently AnnotationsCollection.toJson() returns JSON.stringify() + + const context = new RootContext(); + const componentSpec = new ComponentSpecEntity( + context.generateId(), + context, + { name: "Test" }, + ); + + // After we add annotations support to inputs/outputs + // const input = componentSpec.inputs.add({ name: "test" }); + // input.annotations.add({ key: "hint", value: "text" }); + // const json = input.toJson(); + // expect(typeof json.annotations).toBe("object"); + // expect(json.annotations).not.toBe('"text"'); + + expect(componentSpec.name).toBe("Test"); + }); + }); +}); + From cb72bce1361fed80d7f32c39b351fc93bc6ae12b Mon Sep 17 00:00:00 2001 From: Maksym Yezhov Date: Thu, 12 Feb 2026 11:13:36 -0800 Subject: [PATCH 003/225] - pre-valtio poc --- src/providers/ComponentSpec/OBJECT_MODEL.md | 50 +- src/providers/ComponentSpec/context.ts | 78 +- .../ComponentSpec/graphImplementation.ts | 47 +- src/providers/ComponentSpec/inputs.ts | 8 + src/providers/ComponentSpec/outputs.ts | 8 + .../ComponentSpec/tests/fixtures/index.ts | 1 - .../ComponentSpec/tests/schemaValidator.ts | 16 +- .../ComponentSpec/tests/toJson.test.ts | 1 - src/routes/EditorV2/EditorV2.tsx | 187 +--- .../EditorV2/components/ContextPanel.tsx | 339 ++++++++ src/routes/EditorV2/components/FlowCanvas.tsx | 206 +++++ src/routes/EditorV2/components/IONode.tsx | 118 +++ .../EditorV2/components/SelectionToolbar.tsx | 179 ++++ src/routes/EditorV2/components/Sidebar.tsx | 238 +++++ src/routes/EditorV2/components/TaskNode.tsx | 171 +++- .../EditorV2/hooks/useSpecToNodesEdges.ts | 225 +++++ src/routes/EditorV2/store/actions.ts | 815 ++++++++++++++++++ src/routes/EditorV2/store/editorStore.ts | 44 + 18 files changed, 2504 insertions(+), 227 deletions(-) create mode 100644 src/routes/EditorV2/components/ContextPanel.tsx create mode 100644 src/routes/EditorV2/components/FlowCanvas.tsx create mode 100644 src/routes/EditorV2/components/IONode.tsx create mode 100644 src/routes/EditorV2/components/SelectionToolbar.tsx create mode 100644 src/routes/EditorV2/components/Sidebar.tsx create mode 100644 src/routes/EditorV2/hooks/useSpecToNodesEdges.ts create mode 100644 src/routes/EditorV2/store/actions.ts create mode 100644 src/routes/EditorV2/store/editorStore.ts diff --git a/src/providers/ComponentSpec/OBJECT_MODEL.md b/src/providers/ComponentSpec/OBJECT_MODEL.md index bb9cc7d6b..e59fab983 100644 --- a/src/providers/ComponentSpec/OBJECT_MODEL.md +++ b/src/providers/ComponentSpec/OBJECT_MODEL.md @@ -21,9 +21,9 @@ Every domain object is an **Entity** implementing `BaseEntity`: ```typescript type BaseEntity = { - readonly $id: string; // Unique identifier - readonly $indexed: (keyof TScalar)[]; // Fields to index for fast lookup - populate(scalar: TScalar): this; // Hydrate with data + readonly $id: string; // Unique identifier + readonly $indexed: (keyof TScalar)[]; // Fields to index for fast lookup + populate(scalar: TScalar): this; // Hydrate with data }; ``` @@ -41,6 +41,7 @@ interface SerializableEntity { - **Entity**: Class instance with identity, behavior, and relationships (e.g., `InputEntity`) This separation enables: + 1. Loading YAML → scalars → entities via `populate()` 2. Saving entities → JSON via `toJson()` → YAML @@ -50,8 +51,8 @@ Every entity type has a corresponding **Collection** class extending `BaseCollec ```typescript abstract class BaseCollection { - add(spec: TScalar): TEntity; // Create and register entity - abstract createEntity(spec: TScalar): TEntity; // Factory method + add(spec: TScalar): TEntity; // Create and register entity + abstract createEntity(spec: TScalar): TEntity; // Factory method getAll(): TEntity[]; findById(id: EntityId): TEntity | undefined; findByIndex(index: K, value: TEntity[K]): TEntity[]; @@ -102,17 +103,17 @@ RootContext ## Key Classes -| Class | Purpose | Location | -|-------|---------|----------| -| `RootContext` | Top-level context for ID generation | `context.ts` | -| `ComponentSpecEntity` | Main component representation | `componentSpec.ts` | -| `InputEntity` / `InputsCollection` | Component inputs | `inputs.ts` | -| `OutputEntity` / `OutputsCollection` | Component outputs | `outputs.ts` | -| `GraphImplementation` | Graph-based implementation | `graphImplementation.ts` | -| `TaskEntity` / `TasksCollection` | Tasks within a graph | `graphImplementation.ts` | -| `ArgumentEntity` / `ArgumentsCollection` | Task argument bindings | `graphImplementation.ts` | -| `AnnotationEntity` / `AnnotationsCollection` | Key-value annotations | `annotations.ts` | -| `YamlLoader` | Loads YAML into object model | `yamlLoader.ts` | +| Class | Purpose | Location | +| -------------------------------------------- | ----------------------------------- | ------------------------ | +| `RootContext` | Top-level context for ID generation | `context.ts` | +| `ComponentSpecEntity` | Main component representation | `componentSpec.ts` | +| `InputEntity` / `InputsCollection` | Component inputs | `inputs.ts` | +| `OutputEntity` / `OutputsCollection` | Component outputs | `outputs.ts` | +| `GraphImplementation` | Graph-based implementation | `graphImplementation.ts` | +| `TaskEntity` / `TasksCollection` | Tasks within a graph | `graphImplementation.ts` | +| `ArgumentEntity` / `ArgumentsCollection` | Task argument bindings | `graphImplementation.ts` | +| `AnnotationEntity` / `AnnotationsCollection` | Key-value annotations | `annotations.ts` | +| `YamlLoader` | Loads YAML into object model | `yamlLoader.ts` | --- @@ -123,19 +124,20 @@ RootContext ```typescript class ArgumentEntity { name: string; - + // Connection type private _type: "graphInput" | "taskOutput" | "literal"; - + // Connect to a source connectTo(source: InputEntity | OutputEntity): void; - + // Or set a literal value set value(value: ScalarValue); } ``` **Types:** + - `graphInput`: Argument bound to a graph-level input - `taskOutput`: Argument bound to another task's output - `literal`: Static value @@ -169,6 +171,7 @@ The `IndexByKey` class maintains a `Map To add a new entity type: 1. **Define the scalar interface:** + ```typescript interface MyScalarInterface { name: string; @@ -177,6 +180,7 @@ interface MyScalarInterface { ``` 2. **Create the entity class:** + ```typescript class MyEntity implements BaseEntity, SerializableEntity { readonly $indexed = ["name" as const]; @@ -196,6 +200,7 @@ class MyEntity implements BaseEntity, SerializableEntity { ``` 3. **Create the collection class:** + ```typescript class MyCollection extends BaseCollection { constructor(parent: Context) { @@ -220,6 +225,7 @@ const componentSpec = await loader.loadFromText(yamlString); ``` Key loading steps: + 1. Parse YAML to raw spec object 2. Create `ComponentSpecEntity` with `RootContext` 3. Populate inputs and outputs @@ -247,11 +253,14 @@ const json = componentSpec.toJson(); ## Important Notes ### Mutation Model + Entities are **mutable**. After creation, fields can be modified directly. However: + - Index updates are NOT automatic — if you change an indexed field after creation, the index may become stale - For React integration, trigger re-renders manually after mutations ### Nested Components + When a graph task references a component, `YamlLoader` recursively creates a nested `ComponentSpecEntity`: ```typescript @@ -261,7 +270,8 @@ await this.load(hydratedComponentRef.spec, taskId, rootSpecEntity); This creates the full component tree in memory. ### ID Format + IDs follow the pattern: `{contextPath}_{counter}` + - Example: `root.MyPipeline.tasks_1` - Useful for debugging and tracing entity origins - diff --git a/src/providers/ComponentSpec/context.ts b/src/providers/ComponentSpec/context.ts index e75991ecd..12a12a3cc 100644 --- a/src/providers/ComponentSpec/context.ts +++ b/src/providers/ComponentSpec/context.ts @@ -1,3 +1,5 @@ +import { proxy } from "valtio"; + import type { BaseEntity } from "./types"; export type EntityId = string; @@ -28,44 +30,45 @@ export class AutoincrementIdGenerator implements IdGenerator { } class IndexByKey { - private readonly fieldValueToEntityId: Map< + /** + * Index structure using plain objects for valtio compatibility. + * Structure: { [fieldName]: { [fieldValue]: { [entityId]: true } } } + */ + private readonly fieldValueToEntityId: Record< string | number | symbol, - Map> - > = new Map(); + Record> + > = {}; add>(entity: TEntity) { for (const index of entity.$indexed) { - const fieldValue = entity[index]; - if (!this.fieldValueToEntityId.has(index)) { - this.fieldValueToEntityId.set( - index, - new Map([[fieldValue, new Set([entity.$id])]]), - ); - continue; + const fieldValue = String(entity[index]); + + if (!this.fieldValueToEntityId[index]) { + this.fieldValueToEntityId[index] = {}; } - const valueToEntityId = this.fieldValueToEntityId.get(index)!; + const valueToEntityId = this.fieldValueToEntityId[index]; - if (!valueToEntityId.has(fieldValue)) { - valueToEntityId.set(fieldValue, new Set([entity.$id])); - continue; + if (!valueToEntityId[fieldValue]) { + valueToEntityId[fieldValue] = {}; } - valueToEntityId.get(fieldValue)!.add(entity.$id); + valueToEntityId[fieldValue][entity.$id] = true; } } remove>(entity: TEntity) { for (const index of entity.$indexed) { - const valueToEntityId = this.fieldValueToEntityId.get(index); + const valueToEntityId = this.fieldValueToEntityId[index]; if (!valueToEntityId) { continue; } - const entityIds = valueToEntityId.get(entity[index]); + const fieldValue = String(entity[index]); + const entityIds = valueToEntityId[fieldValue]; if (!entityIds) { continue; } - entityIds.delete(entity.$id); + delete entityIds[entity.$id]; } } @@ -73,34 +76,40 @@ class IndexByKey { searchTerm: TKey, value: TEntity[TKey], ): EntityId[] { - const valueToEntityId = this.fieldValueToEntityId.get(searchTerm); + const valueToEntityId = this.fieldValueToEntityId[searchTerm as string]; if (!valueToEntityId) { return []; } - const entityIds = valueToEntityId.get(value); + const entityIds = valueToEntityId[String(value)]; if (!entityIds) { return []; } - return Array.from(entityIds); + return Object.keys(entityIds); } } export class EntityIndex> { - private readonly entities: Map = new Map(); + /** + * Plain object for entity storage - valtio tracks property access natively. + * Keys are entity IDs, values are entities. + */ + readonly entities: Record = {}; private readonly indexByKey = new IndexByKey(); getAll(): TEntity[] { - return Array.from(this.entities.values()); + return Object.values(this.entities); } has(id: EntityId): boolean { - return this.entities.has(id); + return id in this.entities; } add(entity: TEntity) { - this.entities.set(entity.$id, entity); - this.indexByKey.add(entity); + // Wrap entity in valtio proxy to make mutations reactive + const proxiedEntity = proxy(entity) as TEntity; + this.entities[proxiedEntity.$id] = proxiedEntity; + this.indexByKey.add(proxiedEntity); } remove(entity: TEntity) { @@ -108,19 +117,19 @@ export class EntityIndex> { } removeById(id: EntityId) { - const entity = this.entities.get(id); + const entity = this.entities[id]; if (!entity) { return false; } this.indexByKey.remove(entity); - this.entities.delete(id); + delete this.entities[id]; return true; } findById(id: EntityId): TEntity | undefined { - return this.entities.get(id); + return this.entities[id]; } findByIndex( @@ -128,14 +137,14 @@ export class EntityIndex> { value: TEntity[TKey], ): TEntity[] { const ids = this.indexByKey.findByIndex(index, value); - return ids.map((id) => this.entities.get(id)!); + return ids.map((id) => this.entities[id]).filter(Boolean) as TEntity[]; } } export abstract class BaseCollection< - TScalar, - TEntity extends BaseEntity, - > + TScalar, + TEntity extends BaseEntity, +> extends EntityIndex implements NestedContext { @@ -152,7 +161,8 @@ export abstract class BaseCollection< super.add(entity); - return entity; + // Return the proxied entity from the store + return this.findById(entity.$id)!; } abstract createEntity(spec: TScalar): TEntity; diff --git a/src/providers/ComponentSpec/graphImplementation.ts b/src/providers/ComponentSpec/graphImplementation.ts index 78b019974..4c92886c3 100644 --- a/src/providers/ComponentSpec/graphImplementation.ts +++ b/src/providers/ComponentSpec/graphImplementation.ts @@ -9,6 +9,7 @@ import type { TaskSpec, } from "@/utils/componentSpec"; +import { AnnotationsCollection } from "./annotations"; import { BaseCollection, type Context, type NestedContext } from "./context"; import { InputEntity } from "./inputs"; import { OutputEntity } from "./outputs"; @@ -96,6 +97,13 @@ export class GraphImplementation implements SerializableEntity { type TaskScalarInterface = Pick & { name: string; componentRef: ComponentReference; +}; + +/** + * Input type for populating a TaskEntity from raw spec data. + * Extends the scalar interface with annotations in their raw format. + */ +type TaskPopulateInput = TaskScalarInterface & { annotations?: Record; }; @@ -109,8 +117,8 @@ export class TaskEntity isEnabled?: PredicateType; executionOptions?: ExecutionOptionsSpec; - annotations?: Record; + readonly annotations: AnnotationsCollection; readonly arguments: ArgumentsCollection; constructor( @@ -121,15 +129,22 @@ export class TaskEntity this.name = required.name; this.componentRef = required.componentRef; + this.annotations = new AnnotationsCollection(this.context); this.arguments = new ArgumentsCollection(this.context); } - populate(scalar: TaskScalarInterface) { - this.name = scalar.name; - this.isEnabled = scalar.isEnabled; - this.executionOptions = scalar.executionOptions; - this.componentRef = scalar.componentRef; - this.annotations = scalar.annotations; + populate(input: TaskPopulateInput) { + this.name = input.name; + this.isEnabled = input.isEnabled; + this.executionOptions = input.executionOptions; + this.componentRef = input.componentRef; + + // Populate annotations collection from input Record + if (input.annotations) { + for (const [key, value] of Object.entries(input.annotations)) { + this.annotations.add({ key, value }); + } + } return this; } @@ -156,8 +171,9 @@ export class TaskEntity json.executionOptions = this.executionOptions; } - if (this.annotations !== undefined && Object.keys(this.annotations).length > 0) { - json.annotations = this.annotations; + const annotationsJson = this.annotations.toJson(); + if (Object.keys(annotationsJson).length > 0) { + json.annotations = annotationsJson; } return json; @@ -195,8 +211,19 @@ export class TasksCollection super("tasks", parent); } + /** + * Override add to accept TaskPopulateInput which includes annotations. + * Uses type assertion since parent expects TaskScalarInterface but we accept extended type. + */ + add(spec: TaskPopulateInput): TaskEntity { + return super.add(spec as TaskScalarInterface); + } + createEntity(spec: TaskScalarInterface): TaskEntity { - return new TaskEntity(this.generateId(), this, spec).populate(spec); + // Cast back to TaskPopulateInput since we know it may include annotations + return new TaskEntity(this.generateId(), this, spec).populate( + spec as TaskPopulateInput, + ); } toJson(): Record { diff --git a/src/providers/ComponentSpec/inputs.ts b/src/providers/ComponentSpec/inputs.ts index db79066fb..ea77c8367 100644 --- a/src/providers/ComponentSpec/inputs.ts +++ b/src/providers/ComponentSpec/inputs.ts @@ -100,6 +100,14 @@ export class InputsCollection implements SerializableEntity { private readonly index = new EntityIndex(); private readonly context: { $name: string; generateId(): string }; + /** + * Direct access to entities for valtio reactivity. + * Access this property to ensure valtio tracks entity changes. + */ + get entities() { + return this.index.entities; + } + constructor(parent: Context) { const $name = `${parent.$name}.inputs`; let counter = 0; diff --git a/src/providers/ComponentSpec/outputs.ts b/src/providers/ComponentSpec/outputs.ts index 688ede624..7d8acf894 100644 --- a/src/providers/ComponentSpec/outputs.ts +++ b/src/providers/ComponentSpec/outputs.ts @@ -98,6 +98,14 @@ export class OutputsCollection implements SerializableEntity { private readonly parentComponentName: string; private readonly context: { $name: string; generateId(): string }; + /** + * Direct access to entities for valtio reactivity. + * Access this property to ensure valtio tracks entity changes. + */ + get entities() { + return this.index.entities; + } + constructor(parent: Context) { this.parentComponentName = parent.$name.split(".").pop() || ""; const $name = `${parent.$name}.outputs`; diff --git a/src/providers/ComponentSpec/tests/fixtures/index.ts b/src/providers/ComponentSpec/tests/fixtures/index.ts index 79667b6fc..b4556bfe9 100644 --- a/src/providers/ComponentSpec/tests/fixtures/index.ts +++ b/src/providers/ComponentSpec/tests/fixtures/index.ts @@ -306,4 +306,3 @@ export const expectedFullPipeline: ComponentSpec = { }, } as GraphImplementation, }; - diff --git a/src/providers/ComponentSpec/tests/schemaValidator.ts b/src/providers/ComponentSpec/tests/schemaValidator.ts index 5bee6ef45..569c292f0 100644 --- a/src/providers/ComponentSpec/tests/schemaValidator.ts +++ b/src/providers/ComponentSpec/tests/schemaValidator.ts @@ -17,10 +17,17 @@ export interface ValidationResult { errors?: string[]; } -// eslint-disable-next-line @typescript-eslint/no-explicit-any -type JsonCompatible = Record | any[] | string | number | boolean | null; - -let cachedValidator: ((value: JsonCompatible) => ValidationResult) | null = null; + +type JsonCompatible = + | Record + | any[] + | string + | number + | boolean + | null; + +let cachedValidator: ((value: JsonCompatible) => ValidationResult) | null = + null; /** * Validates a ComponentSpec JSON object against the official schema. @@ -100,4 +107,3 @@ function extractKeyword(keywordUrl: string): string { const parts = keywordUrl.split("/"); return parts[parts.length - 1] || "unknown"; } - diff --git a/src/providers/ComponentSpec/tests/toJson.test.ts b/src/providers/ComponentSpec/tests/toJson.test.ts index 0d8253b56..2ad2e542a 100644 --- a/src/providers/ComponentSpec/tests/toJson.test.ts +++ b/src/providers/ComponentSpec/tests/toJson.test.ts @@ -784,4 +784,3 @@ describe("ComponentSpec Object Model toJson()", () => { }); }); }); - diff --git a/src/routes/EditorV2/EditorV2.tsx b/src/routes/EditorV2/EditorV2.tsx index 0f8bce7a4..5eb8bb179 100644 --- a/src/routes/EditorV2/EditorV2.tsx +++ b/src/routes/EditorV2/EditorV2.tsx @@ -1,29 +1,17 @@ -import { DndContext } from "@dnd-kit/core"; +import "@xyflow/react/dist/style.css"; + import { useSuspenseQuery } from "@tanstack/react-query"; -import { - Background, - MiniMap, - ReactFlow, - type ReactFlowProps, - ReactFlowProvider, -} from "@xyflow/react"; -import { type ComponentType, useState } from "react"; -import { proxy, useSnapshot } from "valtio"; +import { ReactFlowProvider } from "@xyflow/react"; +import { useEffect, useState } from "react"; import { withSuspenseWrapper } from "@/components/shared/SuspenseWrapper"; -import { BlockStack, InlineStack } from "@/components/ui/layout"; -import { Heading, Paragraph, Text } from "@/components/ui/typography"; -import { cn } from "@/lib/utils"; -import type { ComponentSpecEntity } from "@/providers/ComponentSpec/componentSpec"; -import { - GraphImplementation, - type TasksCollection, -} from "@/providers/ComponentSpec/graphImplementation"; +import { InlineStack } from "@/components/ui/layout"; import { YamlLoader } from "@/providers/ComponentSpec/yamlLoader"; -import { TaskNode } from "./components/TaskNode"; - -const GRID_SIZE = 10; +import { ContextPanel } from "./components/ContextPanel"; +import { FlowCanvas } from "./components/FlowCanvas"; +import { Sidebar } from "./components/Sidebar"; +import { editorStore, initializeStore } from "./store/editorStore"; const availableTemplates = import.meta.glob("./assets/*.yaml", { query: "?raw", @@ -34,149 +22,56 @@ async function getSpecByName(name: "test-spec") { return availableTemplates[`./assets/${name}.yaml`](); } -function useExperimentalFlow() { +function useLoadSpec() { const [yamlLoader] = useState(() => new YamlLoader()); - /** - * Load the test spec from the assets folder. - */ - const { data: testSpecText } = useSuspenseQuery({ - queryKey: ["test-spec"], - queryFn: () => getSpecByName("test-spec"), - staleTime: Infinity, - retry: false, - }); - + // Parse the YAML into a ComponentSpecEntity const { data: testSpec } = useSuspenseQuery({ - queryKey: ["test-spec-entity"], - queryFn: () => yamlLoader.loadFromText(testSpecText), + queryKey: ["test-spec-entity-v2"], + queryFn: async () => { + const testSpecText = await getSpecByName("test-spec"); + return yamlLoader.loadFromText(testSpecText); + }, staleTime: Infinity, retry: false, }); - return proxy(testSpec); -} - -function isGraphImplementation( - implementation: ComponentSpecEntity["implementation"], -): implementation is GraphImplementation { - return ( - implementation !== undefined && - implementation !== null && - implementation instanceof GraphImplementation - ); + return testSpec; } -const PipelineEditorCanvas = withSuspenseWrapper(() => { - const experimentalFlow = useExperimentalFlow(); +const PipelineEditor = withSuspenseWrapper(() => { + const spec = useLoadSpec(); - const [flowConfig] = useState({ - snapGrid: [GRID_SIZE, GRID_SIZE], - snapToGrid: true, - panOnDrag: true, - selectionOnDrag: false, - nodesDraggable: true, - }); - - // todo: convert experimentalFlow to nodes and edges - console.log(experimentalFlow); + // Initialize the valtio store with the loaded spec + // Entities are already proxied when added to collections, no need to wrap again + useEffect(() => { + if (spec) { + initializeStore(spec); + } - if (!isGraphImplementation(experimentalFlow.implementation)) { - return null; - } + return () => { + // Clear store on unmount + editorStore.spec = null; + editorStore.selectedNodeId = null; + editorStore.selectedNodeType = null; + }; + }, [spec]); return ( - - - - - - Debug info here - + + + + ); }); export function EditorV2() { return ( - - Editor V2 - This is the new editor. It is still in development. -
- - - - - -
-
- ); -} - -const nodeTypes: Record> = { - task: TaskNode, -}; - -function FlowCanvas({ - children, - nodes, - edges, - tasks, - ...rest -}: ReactFlowProps & { tasks: TasksCollection }) { - const tasksSnapshot = useSnapshot(tasks); - - const allNodes = [ - ...(tasksSnapshot.getAll().map((task, index) => ({ - id: task.$id, - type: "task", - position: { x: index * 100, y: index * 100 }, - data: { - name: task.name, - description: task.componentRef.spec?.description ?? "", - }, - })) ?? []), - ]; - - return ( - - - {children} - - - - - +
+ + + +
); } diff --git a/src/routes/EditorV2/components/ContextPanel.tsx b/src/routes/EditorV2/components/ContextPanel.tsx new file mode 100644 index 000000000..961f363cb --- /dev/null +++ b/src/routes/EditorV2/components/ContextPanel.tsx @@ -0,0 +1,339 @@ +import type { ChangeEvent } from "react"; +import { useSnapshot } from "valtio"; + +import { Icon } from "@/components/ui/icon"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { BlockStack, InlineStack } from "@/components/ui/layout"; +import { Separator } from "@/components/ui/separator"; +import { Text } from "@/components/ui/typography"; +import { cn } from "@/lib/utils"; +import { GraphImplementation } from "@/providers/ComponentSpec/graphImplementation"; + +import { renameInput, renameOutput, renameTask } from "../store/actions"; +import { editorStore } from "../store/editorStore"; + +/** + * Panel that displays details about the selected node + * and allows editing of node properties via direct mutation. + */ +export function ContextPanel() { + const snapshot = useSnapshot(editorStore); + const { selectedNodeId, selectedNodeType, spec } = snapshot; + + if (!selectedNodeId || !selectedNodeType || !spec) { + return ; + } + + return ( + + {selectedNodeType === "task" && ( + + )} + {selectedNodeType === "input" && ( + + )} + {selectedNodeType === "output" && ( + + )} + + ); +} + +function EmptyState() { + return ( + + + + Select a node to view details + + + ); +} + +interface TaskDetailsProps { + entityId: string; +} + +function TaskDetails({ entityId }: TaskDetailsProps) { + const snapshot = useSnapshot(editorStore); + const spec = snapshot.spec; + + if ( + !spec?.implementation || + !(spec.implementation instanceof GraphImplementation) + ) { + return null; + } + + // Get task directly from entities using $id + const task = spec.implementation.tasks.entities[entityId]; + if (!task) { + return null; + } + + const componentSpec = task.componentRef.spec; + + const handleNameChange = (event: ChangeEvent) => { + const newName = event.target.value; + if (newName && newName !== task.name) { + renameTask(entityId, newName); + } + }; + + return ( + + + + + + + + + + {componentSpec?.description && ( + + + + {componentSpec.description} + + + )} + + + + {componentSpec?.inputs && componentSpec.inputs.length > 0 && ( + + + + {componentSpec.inputs.map((input) => ( + + + {input.name} + + {input.type && ( + + : {String(input.type)} + + )} + {input.optional && ( + + (optional) + + )} + + ))} + + + )} + + {componentSpec?.outputs && componentSpec.outputs.length > 0 && ( + + + + {componentSpec.outputs.map((output) => ( + + + {output.name} + + {output.type && ( + + : {String(output.type)} + + )} + + ))} + + + )} + + + ); +} + +interface InputDetailsProps { + entityId: string; +} + +function InputDetails({ entityId }: InputDetailsProps) { + const snapshot = useSnapshot(editorStore); + const spec = snapshot.spec; + if (!spec) return null; + + // Get input directly from entities using $id + const input = spec.inputs.entities[entityId]; + if (!input) return null; + + const handleNameChange = (event: ChangeEvent) => { + const newName = event.target.value; + if (newName && newName !== input.name) { + renameInput(entityId, newName); + } + }; + + return ( + + + + + + + + + + {input.type && ( + + + + {String(input.type)} + + + )} + + {input.description && ( + + + + {input.description} + + + )} + + {input.default !== undefined && ( + + + + {input.default} + + + )} + + + + Optional: + + + {input.optional ? "Yes" : "No"} + + + + + ); +} + +interface OutputDetailsProps { + entityId: string; +} + +function OutputDetails({ entityId }: OutputDetailsProps) { + const snapshot = useSnapshot(editorStore); + const spec = snapshot.spec; + if (!spec) return null; + + // Get output directly from entities using $id + const output = spec.outputs.entities[entityId]; + if (!output) return null; + + const handleNameChange = (event: ChangeEvent) => { + const newName = event.target.value; + if (newName && newName !== output.name) { + renameOutput(entityId, newName); + } + }; + + return ( + + + + + + + + + + {output.type && ( + + + + {String(output.type)} + + + )} + + {output.description && ( + + + + {output.description} + + + )} + + + ); +} + +interface PanelHeaderProps { + icon: string; + iconClassName?: string; + title: string; +} + +function PanelHeader({ icon, iconClassName, title }: PanelHeaderProps) { + return ( + + + + {title} + + + ); +} diff --git a/src/routes/EditorV2/components/FlowCanvas.tsx b/src/routes/EditorV2/components/FlowCanvas.tsx new file mode 100644 index 000000000..3ec9fe5f3 --- /dev/null +++ b/src/routes/EditorV2/components/FlowCanvas.tsx @@ -0,0 +1,206 @@ +import { + Background, + type Connection, + Controls, + MiniMap, + type Node, + type NodeChange, + type OnConnect, + type OnSelectionChangeParams, + ReactFlow, + type ReactFlowInstance, + SelectionMode, + useEdgesState, + useNodesState, +} from "@xyflow/react"; +import type { ComponentType, DragEvent } from "react"; +import { useEffect, useRef, useState } from "react"; + +import { BlockStack } from "@/components/ui/layout"; +import { cn } from "@/lib/utils"; +import { hydrateComponentReference } from "@/services/componentService"; +import type { + HydratedComponentReference, + TaskSpec, +} from "@/utils/componentSpec"; + +import { useSpecToNodesEdges } from "../hooks/useSpecToNodesEdges"; +import { + addInput, + addOutput, + addTask, + connectNodes, + updateNodePosition, +} from "../store/actions"; +import { clearSelection } from "../store/editorStore"; +import { IONode } from "./IONode"; +import { SelectionToolbar } from "./SelectionToolbar"; +import { TaskNode } from "./TaskNode"; + +const GRID_SIZE = 10; + +const nodeTypes: Record> = { + task: TaskNode, + io: IONode, +}; + +interface FlowCanvasProps { + className?: string; +} + +export function FlowCanvas({ className }: FlowCanvasProps) { + const [reactFlowInstance, setReactFlowInstance] = + useState(null); + const [selectedNodes, setSelectedNodes] = useState([]); + const containerRef = useRef(null); + + // Get nodes and edges from the spec via valtio + const { nodes: specNodes, edges: specEdges } = useSpecToNodesEdges(); + + // Use ReactFlow's state management for nodes and edges + const [nodes, setNodes, onNodesChange] = useNodesState(specNodes); + const [edges, setEdges, onEdgesChange] = useEdgesState(specEdges); + + // Sync spec changes to ReactFlow state + useEffect(() => { + setNodes(specNodes); + setEdges(specEdges); + }, [specNodes, specEdges, setNodes, setEdges]); + + const handleSelectionChange = ({ + nodes: selected, + }: OnSelectionChangeParams) => { + setSelectedNodes(selected); + }; + + const handleSubgraphCreated = () => { + // Clear selection after creating subgraph + setSelectedNodes([]); + }; + + const handleNodesChange = (changes: NodeChange[]) => { + // Handle position changes to update the spec + const positionChanges = changes.filter( + (change) => change.type === "position" && change.dragging === false, + ); + + for (const change of positionChanges) { + if ("id" in change && "position" in change && change.position) { + updateNodePosition(change.id, change.position); + } + } + + onNodesChange(changes); + }; + + const handleConnect: OnConnect = (connection: Connection) => { + if ( + !connection.source || + !connection.target || + !connection.sourceHandle || + !connection.targetHandle + ) { + return; + } + + // Don't allow self-connections + if (connection.source === connection.target) { + return; + } + + connectNodes({ + sourceNodeId: connection.source, + sourceHandleId: connection.sourceHandle, + targetNodeId: connection.target, + targetHandleId: connection.targetHandle, + }); + }; + + const handleDragOver = (event: DragEvent) => { + event.preventDefault(); + event.dataTransfer.dropEffect = "move"; + }; + + const handleDrop = async (event: DragEvent) => { + event.preventDefault(); + + if (!reactFlowInstance) { + return; + } + + const droppedData = event.dataTransfer.getData("application/reactflow"); + if (!droppedData) { + return; + } + + // Calculate drop position + const position = reactFlowInstance.screenToFlowPosition({ + x: event.clientX, + y: event.clientY, + }); + + try { + const parsedData = JSON.parse(droppedData); + + if (parsedData.task) { + // Dropped a task + const taskSpec = parsedData.task as TaskSpec; + const componentRef: HydratedComponentReference | null = + await hydrateComponentReference(taskSpec.componentRef); + + if (componentRef) { + addTask(componentRef, position); + } + } else if (parsedData.input !== undefined) { + // Dropped an input node + addInput(position); + } else if (parsedData.output !== undefined) { + // Dropped an output node + addOutput(position); + } + } catch (err) { + console.error("Failed to parse dropped data:", err); + } + }; + + const handlePaneClick = () => { + clearSelection(); + }; + + return ( + + + + + + + + + ); +} diff --git a/src/routes/EditorV2/components/IONode.tsx b/src/routes/EditorV2/components/IONode.tsx new file mode 100644 index 000000000..ba67da1e6 --- /dev/null +++ b/src/routes/EditorV2/components/IONode.tsx @@ -0,0 +1,118 @@ +import { Handle, type Node, type NodeProps, Position } from "@xyflow/react"; +import { useSnapshot } from "valtio"; + +import { Card, CardHeader, CardTitle } from "@/components/ui/card"; +import { Icon } from "@/components/ui/icon"; +import { InlineStack } from "@/components/ui/layout"; +import { Text } from "@/components/ui/typography"; +import { cn } from "@/lib/utils"; + +import type { IONodeData } from "../hooks/useSpecToNodesEdges"; +import { editorStore, selectNode } from "../store/editorStore"; + +type IONodeType = Node; +type IONodeProps = NodeProps; + +/** + * Convert type to string for display. + */ +function typeToString(type: unknown): string | undefined { + if (type === undefined || type === null) return undefined; + if (typeof type === "string") return type; + return JSON.stringify(type); +} + +export function IONode({ id, data, selected }: IONodeProps) { + const { entityId, ioType } = data; + + // Access the store directly to get the entity + // This ensures valtio reactivity works - when entity properties change, + // this component re-renders + const snapshot = useSnapshot(editorStore); + const spec = snapshot.spec; + + const isInput = ioType === "input"; + + // Find the entity by its stable $id + const entity = isInput + ? spec?.inputs.entities[entityId] + : spec?.outputs.entities[entityId]; + + const handleClick = () => { + selectNode(id, ioType); + }; + + if (!entity) { + return ( + + + {isInput ? "Input" : "Output"} not found: {entityId} + + + ); + } + + const name = entity.name; + const type = typeToString(entity.type); + const description = entity.description; + + return ( + + + + + + {name} + + + + {(type || description) && ( + + {type ?? description} + + )} + + + {/* Input nodes have an output handle (they provide data) */} + {isInput && ( + + )} + + {/* Output nodes have an input handle (they receive data) */} + {!isInput && ( + + )} + + ); +} diff --git a/src/routes/EditorV2/components/SelectionToolbar.tsx b/src/routes/EditorV2/components/SelectionToolbar.tsx new file mode 100644 index 000000000..3465d1a41 --- /dev/null +++ b/src/routes/EditorV2/components/SelectionToolbar.tsx @@ -0,0 +1,179 @@ +import type { Node } from "@xyflow/react"; +import { useState } from "react"; +import { useSnapshot } from "valtio"; + +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Icon } from "@/components/ui/icon"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { BlockStack, InlineStack } from "@/components/ui/layout"; +import { Text } from "@/components/ui/typography"; +import { GraphImplementation } from "@/providers/ComponentSpec/graphImplementation"; + +import { createSubgraph } from "../store/actions"; +import { editorStore } from "../store/editorStore"; + +interface SelectionToolbarProps { + selectedNodes: Node[]; + onSubgraphCreated?: () => void; +} + +export function SelectionToolbar({ + selectedNodes, + onSubgraphCreated, +}: SelectionToolbarProps) { + const [isDialogOpen, setIsDialogOpen] = useState(false); + const [subgraphName, setSubgraphName] = useState(""); + + const snapshot = useSnapshot(editorStore); + const spec = snapshot.spec; + + // Filter to only task nodes + const selectedTaskNodes = selectedNodes.filter( + (node) => node.type === "task", + ); + + // Only show toolbar if multiple task nodes are selected + if (selectedTaskNodes.length < 2) { + return null; + } + + // Get task names from entity IDs (for display and createSubgraph) + const getTaskName = (entityId: string): string => { + if ( + !spec?.implementation || + !(spec.implementation instanceof GraphImplementation) + ) { + return entityId; + } + const task = spec.implementation.tasks.entities[entityId]; + return task?.name ?? entityId; + }; + + const handleOpenDialog = () => { + setSubgraphName(`Subgraph (${selectedTaskNodes.length} tasks)`); + setIsDialogOpen(true); + }; + + const handleCreateSubgraph = () => { + if (!subgraphName.trim()) return; + + // Get task names from entity IDs + const taskNames = selectedTaskNodes.map((node) => getTaskName(node.id)); + + // Calculate center position of selected nodes + const positions = selectedTaskNodes.map((node) => node.position); + const centerX = + positions.reduce((sum, pos) => sum + pos.x, 0) / positions.length; + const centerY = + positions.reduce((sum, pos) => sum + pos.y, 0) / positions.length; + + const result = createSubgraph(taskNames, subgraphName.trim(), { + x: centerX, + y: centerY, + }); + + if (result) { + setIsDialogOpen(false); + setSubgraphName(""); + onSubgraphCreated?.(); + } + }; + + return ( + <> +
+ + + + + {selectedTaskNodes.length} tasks selected + + + +
+ + + +
+ + + + + Create Subgraph + + Group {selectedTaskNodes.length} selected tasks into a reusable + subgraph component. + + + + + + + setSubgraphName(e.target.value)} + placeholder="Enter subgraph name..." + autoFocus + /> + + + + + + {selectedTaskNodes.map((node) => ( + + + + {getTaskName(node.id)} + + + ))} + + + + + + + + + + + + ); +} diff --git a/src/routes/EditorV2/components/Sidebar.tsx b/src/routes/EditorV2/components/Sidebar.tsx new file mode 100644 index 000000000..cb04ba211 --- /dev/null +++ b/src/routes/EditorV2/components/Sidebar.tsx @@ -0,0 +1,238 @@ +import type { DragEvent } from "react"; + +import { Icon } from "@/components/ui/icon"; +import { BlockStack, InlineStack } from "@/components/ui/layout"; +import { Separator } from "@/components/ui/separator"; +import { Text } from "@/components/ui/typography"; +import { cn } from "@/lib/utils"; +import type { ComponentReference, TaskSpec } from "@/utils/componentSpec"; + +/** + * Hardcoded sample components for the MVP. + * In a real implementation, these would come from a component library. + */ +const SAMPLE_COMPONENTS: ComponentReference[] = [ + { + name: "Data Loader", + digest: "sample-data-loader", + spec: { + name: "Data Loader", + description: "Loads data from a source", + inputs: [ + { + name: "source_url", + type: "String", + description: "URL of the data source", + }, + { name: "format", type: "String", default: "csv", optional: true }, + ], + outputs: [{ name: "data", type: "Dataset", description: "Loaded data" }], + implementation: { + container: { + image: "python:3.10", + command: ["python", "-c", "print('Loading data...')"], + }, + }, + }, + }, + { + name: "Data Transform", + digest: "sample-data-transform", + spec: { + name: "Data Transform", + description: "Transforms input data", + inputs: [ + { name: "input_data", type: "Dataset" }, + { name: "transform_type", type: "String", default: "normalize" }, + ], + outputs: [{ name: "transformed_data", type: "Dataset" }], + implementation: { + container: { + image: "python:3.10", + command: ["python", "-c", "print('Transforming...')"], + }, + }, + }, + }, + { + name: "Model Trainer", + digest: "sample-model-trainer", + spec: { + name: "Model Trainer", + description: "Trains a machine learning model", + inputs: [ + { name: "training_data", type: "Dataset" }, + { name: "model_type", type: "String", default: "linear" }, + { name: "epochs", type: "Integer", default: "10", optional: true }, + ], + outputs: [ + { name: "model", type: "Model" }, + { name: "metrics", type: "Metrics" }, + ], + implementation: { + container: { + image: "python:3.10", + command: ["python", "-c", "print('Training...')"], + }, + }, + }, + }, + { + name: "Model Predictor", + digest: "sample-model-predictor", + spec: { + name: "Model Predictor", + description: "Makes predictions using a trained model", + inputs: [ + { name: "model", type: "Model" }, + { name: "input_data", type: "Dataset" }, + ], + outputs: [{ name: "predictions", type: "Dataset" }], + implementation: { + container: { + image: "python:3.10", + command: ["python", "-c", "print('Predicting...')"], + }, + }, + }, + }, +]; + +interface DraggableItemProps { + children: React.ReactNode; + onDragStart: (event: DragEvent) => void; + className?: string; +} + +function DraggableItem({ + children, + onDragStart, + className, +}: DraggableItemProps) { + return ( +
+ {children} +
+ ); +} + +interface ComponentItemProps { + component: ComponentReference; +} + +function ComponentItem({ component }: ComponentItemProps) { + const handleDragStart = (event: DragEvent) => { + const taskSpec: TaskSpec = { + componentRef: component, + }; + + event.dataTransfer.setData( + "application/reactflow", + JSON.stringify({ task: taskSpec }), + ); + event.dataTransfer.effectAllowed = "move"; + }; + + return ( + + + + + + {component.name ?? component.spec?.name ?? "Unknown"} + + {component.spec?.description && ( + + {component.spec.description} + + )} + + + + ); +} + +interface IOItemProps { + type: "input" | "output"; +} + +function IOItem({ type }: IOItemProps) { + const handleDragStart = (event: DragEvent) => { + event.dataTransfer.setData( + "application/reactflow", + JSON.stringify({ [type]: null }), + ); + event.dataTransfer.effectAllowed = "move"; + }; + + const isInput = type === "input"; + + return ( + + + + + {isInput ? "Graph Input" : "Graph Output"} + + + + ); +} + +export function Sidebar() { + return ( + + + + Inputs & Outputs + + + + + + + + + + + + + Sample Components + + + Drag components to the canvas + + + + + {SAMPLE_COMPONENTS.map((component) => ( + + ))} + + + ); +} diff --git a/src/routes/EditorV2/components/TaskNode.tsx b/src/routes/EditorV2/components/TaskNode.tsx index a5ac0a58e..7bb395428 100644 --- a/src/routes/EditorV2/components/TaskNode.tsx +++ b/src/routes/EditorV2/components/TaskNode.tsx @@ -1,15 +1,166 @@ -import type { NodeProps } from "@xyflow/react"; +import { Handle, type Node, type NodeProps, Position } from "@xyflow/react"; +import { useSnapshot } from "valtio"; -import { TaskNodeCard } from "./TaskNodeCard"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Icon } from "@/components/ui/icon"; +import { BlockStack, InlineStack } from "@/components/ui/layout"; +import { Text } from "@/components/ui/typography"; +import { cn } from "@/lib/utils"; +import { GraphImplementation } from "@/providers/ComponentSpec/graphImplementation"; -interface TaskNodeProps extends NodeProps { - data: { - name: string; - description: string; - } +import type { TaskNodeData } from "../hooks/useSpecToNodesEdges"; +import { editorStore, selectNode } from "../store/editorStore"; + +type TaskNodeType = Node; +type TaskNodeProps = NodeProps; + +/** + * Check if spec has a graph implementation. + */ +function hasGraphImplementation( + spec: unknown, +): spec is { implementation: GraphImplementation } { + if (!spec || typeof spec !== "object") return false; + const s = spec as { implementation?: unknown }; + return s.implementation instanceof GraphImplementation; } -export function TaskNode({ data, selected }: TaskNodeProps) { - console.log(`TaskNode:`, data); - return ; +export function TaskNode({ id, data, selected }: TaskNodeProps) { + const { entityId } = data; + + // Access the store directly to get the task entity + // This ensures valtio reactivity works - when task properties change, + // this component re-renders + const snapshot = useSnapshot(editorStore); + const spec = snapshot.spec; + + // Find the task entity by its stable $id + const task = + spec && hasGraphImplementation(spec) + ? spec.implementation.tasks.findById(entityId) + : null; + + const handleClick = () => { + selectNode(id, "task"); + }; + + if (!task) { + return ( + + + Task not found: {entityId} + + + ); + } + + // Get inputs and outputs from the component spec + const componentSpec = task.componentRef.spec; + const inputs = componentSpec?.inputs ?? []; + const outputs = componentSpec?.outputs ?? []; + const description = componentSpec?.description ?? ""; + + return ( + + + + + + {task.name} + + + {description && ( + + {description} + + )} + + + + + {/* Inputs */} + + {inputs.length > 0 ? ( + inputs.map((input) => ( +
+ + + {input.name} + +
+ )) + ) : ( + + No inputs + + )} +
+ + {/* Outputs */} + + {outputs.length > 0 ? ( + outputs.map((output) => ( +
+ + {output.name} + + +
+ )) + ) : ( + + No outputs + + )} +
+
+
+
+ ); } diff --git a/src/routes/EditorV2/hooks/useSpecToNodesEdges.ts b/src/routes/EditorV2/hooks/useSpecToNodesEdges.ts new file mode 100644 index 000000000..a88c0af06 --- /dev/null +++ b/src/routes/EditorV2/hooks/useSpecToNodesEdges.ts @@ -0,0 +1,225 @@ +import type { Edge, Node } from "@xyflow/react"; +import { useSnapshot } from "valtio"; + +import { GraphImplementation } from "@/providers/ComponentSpec/graphImplementation"; +import { EDITOR_POSITION_ANNOTATION } from "@/utils/annotations"; + +import { editorStore } from "../store/editorStore"; + +interface NodePosition { + x: number; + y: number; +} + +const DEFAULT_POSITION: NodePosition = { x: 0, y: 0 }; +const TASK_OFFSET = 200; +const IO_OFFSET = 150; + +/** + * Parse position from annotations. + */ +function getPositionFromAnnotations( + annotations?: Record, +): NodePosition { + if (!annotations?.[EDITOR_POSITION_ANNOTATION]) { + return DEFAULT_POSITION; + } + + try { + const posStr = annotations[EDITOR_POSITION_ANNOTATION]; + if (typeof posStr === "string") { + return JSON.parse(posStr) as NodePosition; + } + } catch { + // Ignore parse errors + } + + return DEFAULT_POSITION; +} + +/** + * Check if spec has a graph implementation. + * Works with both raw spec and valtio snapshots. + */ +function hasGraphImplementation( + spec: unknown, +): spec is { implementation: GraphImplementation } { + if (!spec || typeof spec !== "object") return false; + const s = spec as { implementation?: unknown }; + return s.implementation instanceof GraphImplementation; +} + +/** + * Node data contains only stable entity $id. + * Node components fetch actual data from the store using this id. + */ +export interface TaskNodeData extends Record { + entityId: string; +} + +export interface IONodeData extends Record { + entityId: string; + ioType: "input" | "output"; +} + +/** + * Hook to convert ComponentSpec to ReactFlow nodes and edges. + * + * Node data only contains stable entity $id references. + * Node components fetch actual data from the store using these ids, + * which ensures valtio reactivity works correctly. + */ +export function useSpecToNodesEdges() { + const snapshot = useSnapshot(editorStore); + const spec = snapshot.spec; + + const nodes: Node[] = []; + const edges: Edge[] = []; + + if (!spec) { + return { nodes, edges }; + } + + // Create nodes for graph inputs - use $id for stable node IDs + const inputs = spec.inputs.getAll(); + inputs.forEach((input, index) => { + const position = getPositionFromAnnotations( + input.annotations?.toJson?.() ?? {}, + ); + + nodes.push({ + id: input.$id, + type: "io", + position: + position.x === 0 && position.y === 0 + ? { x: -200, y: index * IO_OFFSET } + : position, + data: { + entityId: input.$id, + ioType: "input", + } satisfies IONodeData, + }); + }); + + // Create nodes for graph outputs - use $id for stable node IDs + const outputs = spec.outputs.getAll(); + outputs.forEach((output, index) => { + const position = getPositionFromAnnotations( + output.annotations?.toJson?.() ?? {}, + ); + + nodes.push({ + id: output.$id, + type: "io", + position: + position.x === 0 && position.y === 0 + ? { x: 800, y: index * IO_OFFSET } + : position, + data: { + entityId: output.$id, + ioType: "output", + } satisfies IONodeData, + }); + }); + + // Create nodes for tasks - use $id for stable node IDs + if (hasGraphImplementation(spec)) { + const tasks = spec.implementation.tasks.getAll(); + + tasks.forEach((task, index) => { + const position = getPositionFromAnnotations(task.annotations.toJson()); + + nodes.push({ + id: task.$id, + type: "task", + position: + position.x === 0 && position.y === 0 + ? { + x: 200 + (index % 3) * TASK_OFFSET, + y: Math.floor(index / 3) * TASK_OFFSET, + } + : position, + data: { + entityId: task.$id, + } satisfies TaskNodeData, + }); + + // Create edges from task arguments + const args = task.arguments.getAll(); + for (const arg of args) { + const argType = arg.type; + + if (argType === "graphInput") { + // Edge from graph input to task + const argJson = arg.toJson(); + if (typeof argJson === "object" && "graphInput" in argJson) { + const sourceInputName = argJson.graphInput.inputName; + // Find input entity by name to get its $id + const sourceInput = spec.inputs.findByIndex( + "name", + sourceInputName, + )[0]; + if (sourceInput) { + edges.push({ + id: `edge_${sourceInput.$id}_to_${task.$id}_${arg.name}`, + source: sourceInput.$id, + sourceHandle: `output_${sourceInputName}`, + target: task.$id, + targetHandle: `input_${arg.name}`, + type: "default", + }); + } + } + } else if (argType === "taskOutput") { + // Edge from another task's output + const argJson = arg.toJson(); + if (typeof argJson === "object" && "taskOutput" in argJson) { + const { taskId: sourceTaskName, outputName } = argJson.taskOutput; + // Find source task by name to get its $id + const sourceTask = spec.implementation.tasks.findByIndex( + "name", + sourceTaskName, + )[0]; + if (sourceTask) { + edges.push({ + id: `edge_${sourceTask.$id}_${outputName}_to_${task.$id}_${arg.name}`, + source: sourceTask.$id, + sourceHandle: `output_${outputName}`, + target: task.$id, + targetHandle: `input_${arg.name}`, + type: "default", + }); + } + } + } + // Literal values don't create edges + } + }); + + // Create edges for graph output values + const outputValues = spec.implementation.getOutputValues(); + for (const binding of outputValues) { + // Find task and output entities by name to get their $ids + const sourceTask = spec.implementation.tasks.findByIndex( + "name", + binding.taskId, + )[0]; + const targetOutput = spec.outputs.findByIndex( + "name", + binding.outputName, + )[0]; + if (sourceTask && targetOutput) { + edges.push({ + id: `edge_${sourceTask.$id}_${binding.taskOutputName}_to_${targetOutput.$id}`, + source: sourceTask.$id, + sourceHandle: `output_${binding.taskOutputName}`, + target: targetOutput.$id, + targetHandle: `input_${binding.outputName}`, + type: "default", + }); + } + } + } + + return { nodes, edges }; +} diff --git a/src/routes/EditorV2/store/actions.ts b/src/routes/EditorV2/store/actions.ts new file mode 100644 index 000000000..9fd2cc595 --- /dev/null +++ b/src/routes/EditorV2/store/actions.ts @@ -0,0 +1,815 @@ +import type { XYPosition } from "@xyflow/react"; + +import type { ComponentSpecEntity } from "@/providers/ComponentSpec/componentSpec"; +import { GraphImplementation } from "@/providers/ComponentSpec/graphImplementation"; +import type { InputEntity } from "@/providers/ComponentSpec/inputs"; +import type { OutputEntity } from "@/providers/ComponentSpec/outputs"; +import { EDITOR_POSITION_ANNOTATION } from "@/utils/annotations"; +import type { ComponentReference } from "@/utils/componentSpec"; + +import { editorStore } from "./editorStore"; + +/** + * Check if the spec has a graph implementation. + */ +function hasGraphImplementation( + spec: ComponentSpecEntity | null, +): spec is ComponentSpecEntity & { implementation: GraphImplementation } { + return spec?.implementation instanceof GraphImplementation; +} + +/** + * Update the position of an entity (task, input, or output) by its $id. + */ +export function updateNodePosition(entityId: string, position: XYPosition) { + const { spec } = editorStore; + if (!spec) return; + + // Try to find as task + const task = spec.implementation?.tasks?.entities?.[entityId]; + if (task) { + // Find and remove existing position annotation, then add the new one + const existing = task.annotations + .getAll() + .find((a) => a.key === EDITOR_POSITION_ANNOTATION); + if (existing) { + existing.value = JSON.stringify(position); + } else { + task.annotations.add({ + key: EDITOR_POSITION_ANNOTATION, + value: JSON.stringify(position), + }); + } + return; + } + + // Try to find as input + const input = spec.inputs.entities[entityId]; + if (input) { + input.annotations.add({ + key: EDITOR_POSITION_ANNOTATION, + value: JSON.stringify(position), + }); + return; + } + + // Try to find as output + const output = spec.outputs.entities[entityId]; + if (output) { + output.annotations.add({ + key: EDITOR_POSITION_ANNOTATION, + value: JSON.stringify(position), + }); + } +} + +/** + * Generate a unique task name based on the component name. + */ +function generateUniqueTaskName( + spec: ComponentSpecEntity, + baseName: string, +): string { + if (!hasGraphImplementation(spec)) { + return baseName; + } + + const existingNames = new Set( + spec.implementation.tasks.getAll().map((t) => t.name), + ); + + if (!existingNames.has(baseName)) { + return baseName; + } + + let counter = 2; + while (existingNames.has(`${baseName} ${counter}`)) { + counter++; + } + return `${baseName} ${counter}`; +} + +/** + * Generate a unique input name. + */ +function generateUniqueInputName( + spec: ComponentSpecEntity, + baseName: string = "Input", +): string { + const existingNames = new Set(spec.inputs.getAll().map((i) => i.name)); + + if (!existingNames.has(baseName)) { + return baseName; + } + + let counter = 2; + while (existingNames.has(`${baseName} ${counter}`)) { + counter++; + } + return `${baseName} ${counter}`; +} + +/** + * Generate a unique output name. + */ +function generateUniqueOutputName( + spec: ComponentSpecEntity, + baseName: string = "Output", +): string { + const existingNames = new Set(spec.outputs.getAll().map((o) => o.name)); + + if (!existingNames.has(baseName)) { + return baseName; + } + + let counter = 2; + while (existingNames.has(`${baseName} ${counter}`)) { + counter++; + } + return `${baseName} ${counter}`; +} + +/** + * Add a new task to the graph. + */ +export function addTask( + componentRef: ComponentReference, + position: XYPosition, +) { + const { spec } = editorStore; + + if (!hasGraphImplementation(spec)) { + console.error("Cannot add task: spec has no graph implementation"); + return null; + } + + const componentName = componentRef.spec?.name ?? componentRef.name ?? "Task"; + const taskName = generateUniqueTaskName(spec, componentName); + + const taskEntity = spec.implementation.tasks.add({ + name: taskName, + componentRef, + annotations: { + [EDITOR_POSITION_ANNOTATION]: JSON.stringify({ + x: position.x, + y: position.y, + }), + }, + }); + + // Add default argument values from component spec inputs + const inputs = componentRef.spec?.inputs ?? []; + for (const input of inputs) { + if (input.default !== undefined) { + const arg = taskEntity.arguments.add({ name: input.name }); + arg.value = input.default; + } + } + + return taskEntity; +} + +/** + * Add a new input node to the graph. + */ +export function addInput(position: XYPosition, name?: string) { + const { spec } = editorStore; + + if (!spec) { + console.error("Cannot add input: no spec loaded"); + return null; + } + + const inputName = generateUniqueInputName(spec, name); + + const inputEntity = spec.inputs.add({ + name: inputName, + annotations: { + [EDITOR_POSITION_ANNOTATION]: JSON.stringify({ + x: position.x, + y: position.y, + }), + }, + }); + + return inputEntity; +} + +/** + * Add a new output node to the graph. + */ +export function addOutput(position: XYPosition, name?: string) { + const { spec } = editorStore; + + if (!spec) { + console.error("Cannot add output: no spec loaded"); + return null; + } + + const outputName = generateUniqueOutputName(spec, name); + + const outputEntity = spec.outputs.add({ + name: outputName, + annotations: { + [EDITOR_POSITION_ANNOTATION]: JSON.stringify({ + x: position.x, + y: position.y, + }), + }, + }); + + return outputEntity; +} + +/** + * Connection info parsed from ReactFlow handles. + */ +interface ConnectionInfo { + sourceNodeId: string; + sourceHandleId: string; + targetNodeId: string; + targetHandleId: string; +} + +/** + * Connect two nodes by creating an argument binding. + * + * Handles these connection types: + * - Task output → Task input (taskOutput argument) + * - Graph input → Task input (graphInput argument) + * - Task output → Graph output (outputValues binding) + */ +export function connectNodes(connection: ConnectionInfo) { + const { spec } = editorStore; + + if (!hasGraphImplementation(spec)) { + console.error("Cannot connect: spec has no graph implementation"); + return false; + } + + const { sourceNodeId, sourceHandleId, targetNodeId, targetHandleId } = + connection; + + // Parse handle IDs to get the actual names + // Handle format: "input_{inputName}" or "output_{outputName}" + const sourceOutputName = sourceHandleId.replace(/^output_/, ""); + const targetInputName = targetHandleId.replace(/^input_/, ""); + + // Check if source is a graph input + const isSourceGraphInput = sourceNodeId.startsWith("input_"); + // Check if target is a graph output + const isTargetGraphOutput = targetNodeId.startsWith("output_"); + + if (isSourceGraphInput && isTargetGraphOutput) { + // Cannot directly connect graph input to graph output + console.error("Cannot connect graph input directly to graph output"); + return false; + } + + if (isTargetGraphOutput) { + // Task output → Graph output + const sourceTaskName = sourceNodeId.replace(/^task_/, ""); + const graphOutputName = targetNodeId.replace(/^output_/, ""); + + spec.implementation.setOutputValue( + graphOutputName, + sourceTaskName, + sourceOutputName, + ); + return true; + } + + // Target is a task + const targetTaskName = targetNodeId.replace(/^task_/, ""); + const targetTask = spec.implementation.tasks.findByIndex( + "name", + targetTaskName, + )[0]; + + if (!targetTask) { + console.error(`Target task not found: ${targetTaskName}`); + return false; + } + + // Find or create the argument + let argument = targetTask.arguments.findByIndex("name", targetInputName)[0]; + if (!argument) { + argument = targetTask.arguments.add({ name: targetInputName }); + } + + if (isSourceGraphInput) { + // Graph input → Task input + const graphInputName = sourceNodeId.replace(/^input_/, ""); + const graphInput = spec.inputs.findByIndex("name", graphInputName)[0]; + + if (!graphInput) { + console.error(`Graph input not found: ${graphInputName}`); + return false; + } + + argument.connectTo(graphInput); + return true; + } + + // Task output → Task input + const sourceTaskName = sourceNodeId.replace(/^task_/, ""); + const sourceTask = spec.implementation.tasks.findByIndex( + "name", + sourceTaskName, + )[0]; + + if (!sourceTask) { + console.error(`Source task not found: ${sourceTaskName}`); + return false; + } + + // Find the output entity in the source task's component spec + const sourceComponentSpec = spec.findComponentSpecEntity(sourceTaskName); + if (!sourceComponentSpec) { + console.error( + `Source component spec not found for task: ${sourceTaskName}`, + ); + return false; + } + + const sourceOutput = sourceComponentSpec.outputs.findByIndex( + "name", + sourceOutputName, + )[0]; + + if (!sourceOutput) { + console.error(`Source output not found: ${sourceOutputName}`); + return false; + } + + argument.connectTo(sourceOutput); + return true; +} + +/** + * Remove a connection by resetting the argument to a literal value. + */ +export function removeConnection(taskName: string, argumentName: string) { + const { spec } = editorStore; + + if (!hasGraphImplementation(spec)) { + console.error("Cannot remove connection: spec has no graph implementation"); + return false; + } + + const task = spec.implementation.tasks.findByIndex("name", taskName)[0]; + + if (!task) { + console.error(`Task not found: ${taskName}`); + return false; + } + + const argument = task.arguments.findByIndex("name", argumentName)[0]; + + if (!argument) { + console.error(`Argument not found: ${argumentName}`); + return false; + } + + // Reset to empty literal value + argument.value = ""; + return true; +} + +/** + * Remove a graph output value binding. + */ +export function removeOutputConnection(graphOutputName: string) { + const { spec } = editorStore; + + if (!hasGraphImplementation(spec)) { + console.error("Cannot remove connection: spec has no graph implementation"); + return false; + } + + spec.implementation.removeOutputValue(graphOutputName); + return true; +} + +/** + * Rename a task by its entity $id. + */ +export function renameTask(entityId: string, newName: string) { + const { spec } = editorStore; + + if (!hasGraphImplementation(spec)) { + console.error("Cannot rename: spec has no graph implementation"); + return false; + } + + // Get task directly by $id + const task = spec.implementation.tasks.entities[entityId]; + + if (!task) { + console.error(`Task not found: ${entityId}`); + return false; + } + + // Check if new name is unique + const existingTask = spec.implementation.tasks.findByIndex( + "name", + newName, + )[0]; + + if (existingTask && existingTask.$id !== entityId) { + console.error(`Task name already exists: ${newName}`); + return false; + } + + task.name = newName; + return true; +} + +/** + * Rename an input by its entity $id. + */ +export function renameInput(entityId: string, newName: string) { + const { spec } = editorStore; + + if (!spec) { + console.error("Cannot rename: no spec loaded"); + return false; + } + + // Get input directly by $id + const input = spec.inputs.entities[entityId]; + + if (!input) { + console.error(`Input not found: ${entityId}`); + return false; + } + + // Check if new name is unique + const existingInput = spec.inputs.findByIndex("name", newName)[0]; + + if (existingInput && existingInput.$id !== entityId) { + console.error(`Input name already exists: ${newName}`); + return false; + } + + input.name = newName; + return true; +} + +/** + * Rename an output by its entity $id. + */ +export function renameOutput(entityId: string, newName: string) { + const { spec } = editorStore; + + if (!spec) { + console.error("Cannot rename: no spec loaded"); + return false; + } + + // Get output directly by $id + const output = spec.outputs.entities[entityId]; + + if (!output) { + console.error(`Output not found: ${entityId}`); + return false; + } + + // Check if new name is unique + const existingOutput = spec.outputs.findByIndex("name", newName)[0]; + + if (existingOutput && existingOutput.$id !== entityId) { + console.error(`Output name already exists: ${newName}`); + return false; + } + + output.name = newName; + return true; +} + +/** + * Get the selected entity based on current selection. + */ +export function getSelectedEntity(): + | InputEntity + | OutputEntity + | ReturnType + | null { + const { spec, selectedNodeId, selectedNodeType } = editorStore; + + if (!spec || !selectedNodeId || !selectedNodeType) { + return null; + } + + switch (selectedNodeType) { + case "task": { + const taskName = selectedNodeId.replace(/^task_/, ""); + return getTaskEntity(taskName); + } + case "input": { + const inputName = selectedNodeId.replace(/^input_/, ""); + return spec.inputs.findByIndex("name", inputName)[0] ?? null; + } + case "output": { + const outputName = selectedNodeId.replace(/^output_/, ""); + return spec.outputs.findByIndex("name", outputName)[0] ?? null; + } + default: + return null; + } +} + +/** + * Get a task entity by name. + */ +function getTaskEntity(taskName: string) { + const { spec } = editorStore; + + if (!hasGraphImplementation(spec)) { + return null; + } + + return spec.implementation.tasks.findByIndex("name", taskName)[0] ?? null; +} + +/** + * Create a subgraph from selected task names. + * + * This is an advanced feature that: + * 1. Creates a new ComponentSpec containing the selected tasks + * 2. Creates a new task that references this subgraph spec + * 3. Remaps external connections to/from the subgraph + * 4. Removes the original tasks + * + * @param taskNames - Array of task names to include in the subgraph + * @param subgraphName - Name for the new subgraph + * @param position - Position for the new subgraph task node + */ +export function createSubgraph( + taskNames: string[], + subgraphName: string, + position: XYPosition, +) { + const { spec } = editorStore; + + if (!hasGraphImplementation(spec)) { + console.error("Cannot create subgraph: spec has no graph implementation"); + return null; + } + + if (taskNames.length === 0) { + console.error("Cannot create subgraph: no tasks selected"); + return null; + } + + const uniqueSubgraphName = generateUniqueTaskName(spec, subgraphName); + const selectedTaskNames = new Set(taskNames); + + // Collect selected tasks + const selectedTasks = taskNames + .map((name) => spec.implementation.tasks.findByIndex("name", name)[0]) + .filter(Boolean); + + if (selectedTasks.length === 0) { + console.error("Cannot create subgraph: no valid tasks found"); + return null; + } + + // Build the subgraph spec by serializing selected tasks + const subgraphTasks: Record< + string, + ReturnType<(typeof selectedTasks)[0]["toJson"]> + > = {}; + const subgraphInputs: Array<{ name: string; type?: string }> = []; + const subgraphOutputs: Array<{ name: string; type?: string }> = []; + const subgraphOutputValues: Record< + string, + { taskOutput: { taskId: string; outputName: string } } + > = {}; + const subgraphArguments: Record = {}; + + for (const task of selectedTasks) { + // Serialize the task + subgraphTasks[task.name] = task.toJson(); + + // Check task arguments for external connections + const args = task.arguments.getAll(); + for (const arg of args) { + const argType = arg.type; + + if (argType === "taskOutput") { + // Check if source task is in the selection + const argJson = arg.toJson(); + if (typeof argJson === "object" && "taskOutput" in argJson) { + const sourceTaskId = argJson.taskOutput.taskId; + if (!selectedTaskNames.has(sourceTaskId)) { + // External connection - create subgraph input + const inputName = `${sourceTaskId}_${argJson.taskOutput.outputName}`; + if (!subgraphInputs.some((i) => i.name === inputName)) { + subgraphInputs.push({ name: inputName }); + // Forward the external connection as subgraph argument + subgraphArguments[inputName] = argJson; + } + // Update the task to use the new subgraph input + const taskSpec = subgraphTasks[task.name]; + if (taskSpec.arguments) { + taskSpec.arguments[arg.name] = { + graphInput: { inputName }, + }; + } + } + } + } else if (argType === "graphInput") { + // Graph input from parent - pass through to subgraph + const argJson = arg.toJson(); + if (typeof argJson === "object" && "graphInput" in argJson) { + const inputName = argJson.graphInput.inputName; + if (!subgraphInputs.some((i) => i.name === inputName)) { + subgraphInputs.push({ name: inputName }); + // Forward the graph input as subgraph argument + subgraphArguments[inputName] = argJson; + } + } + } + } + + // Check if task outputs are consumed by external tasks + const taskOutputs = task.componentRef.spec?.outputs ?? []; + for (const output of taskOutputs) { + // Check all other tasks in the parent graph for connections to this output + const allTasks = spec.implementation.tasks.getAll(); + for (const otherTask of allTasks) { + if (selectedTaskNames.has(otherTask.name)) continue; + + const otherArgs = otherTask.arguments.getAll(); + for (const arg of otherArgs) { + if (arg.type === "taskOutput") { + const argJson = arg.toJson(); + if ( + typeof argJson === "object" && + "taskOutput" in argJson && + argJson.taskOutput.taskId === task.name && + argJson.taskOutput.outputName === output.name + ) { + // External task consumes this output - create subgraph output + const outputName = `${task.name}_${output.name}`; + if (!subgraphOutputs.some((o) => o.name === outputName)) { + subgraphOutputs.push({ name: outputName }); + subgraphOutputValues[outputName] = { + taskOutput: { + taskId: task.name, + outputName: output.name, + }, + }; + } + } + } + } + } + + // Also check graph output values + const graphOutputValues = spec.implementation.getOutputValues(); + for (const binding of graphOutputValues) { + if ( + binding.taskId === task.name && + binding.taskOutputName === output.name + ) { + const outputName = `${task.name}_${output.name}`; + if (!subgraphOutputs.some((o) => o.name === outputName)) { + subgraphOutputs.push({ name: outputName }); + subgraphOutputValues[outputName] = { + taskOutput: { + taskId: task.name, + outputName: output.name, + }, + }; + } + } + } + } + } + + // Create the subgraph ComponentSpec + const subgraphSpec: ComponentReference["spec"] = { + name: uniqueSubgraphName, + description: `Subgraph containing: ${taskNames.join(", ")}`, + inputs: subgraphInputs.map((i) => ({ name: i.name, type: i.type })), + outputs: subgraphOutputs.map((o) => ({ name: o.name, type: o.type })), + implementation: { + graph: { + tasks: subgraphTasks, + outputValues: subgraphOutputValues, + }, + }, + }; + + // Create the subgraph task + const subgraphComponentRef: ComponentReference = { + name: uniqueSubgraphName, + spec: subgraphSpec, + }; + + const subgraphTask = addTask(subgraphComponentRef, position); + if (!subgraphTask) { + console.error("Failed to create subgraph task"); + return null; + } + + // Set the subgraph arguments (forwarding external connections) + for (const [argName, argValue] of Object.entries(subgraphArguments)) { + const arg = subgraphTask.arguments.add({ name: argName }); + + if (typeof argValue === "object" && argValue !== null) { + if ("taskOutput" in argValue) { + // Connect to external task output + const taskOutput = argValue as { + taskOutput: { taskId: string; outputName: string }; + }; + const sourceTask = spec.implementation.tasks.findByIndex( + "name", + taskOutput.taskOutput.taskId, + )[0]; + if (sourceTask) { + const sourceComponentSpec = spec.findComponentSpecEntity( + sourceTask.name, + ); + const sourceOutput = sourceComponentSpec?.outputs.findByIndex( + "name", + taskOutput.taskOutput.outputName, + )[0]; + if (sourceOutput) { + arg.connectTo(sourceOutput); + } + } + } else if ("graphInput" in argValue) { + // Connect to graph input + const graphInput = argValue as { graphInput: { inputName: string } }; + const inputEntity = spec.inputs.findByIndex( + "name", + graphInput.graphInput.inputName, + )[0]; + if (inputEntity) { + arg.connectTo(inputEntity); + } + } + } + } + + // Update external tasks to connect to subgraph outputs instead of original tasks + const allTasks = spec.implementation.tasks.getAll(); + for (const task of allTasks) { + if (selectedTaskNames.has(task.name) || task.name === subgraphTask.name) + continue; + + const args = task.arguments.getAll(); + for (const arg of args) { + if (arg.type === "taskOutput") { + const argJson = arg.toJson(); + if (typeof argJson === "object" && "taskOutput" in argJson) { + const sourceTaskId = argJson.taskOutput.taskId; + const sourceOutputName = argJson.taskOutput.outputName; + + if (selectedTaskNames.has(sourceTaskId)) { + // Find the corresponding subgraph output + const subgraphOutputName = `${sourceTaskId}_${sourceOutputName}`; + const subgraphComponentSpec = spec.findComponentSpecEntity( + subgraphTask.name, + ); + const subgraphOutput = subgraphComponentSpec?.outputs.findByIndex( + "name", + subgraphOutputName, + )[0]; + + if (subgraphOutput) { + arg.connectTo(subgraphOutput); + } + } + } + } + } + } + + // Update graph output values to point to subgraph + const graphOutputValues = spec.implementation.getOutputValues(); + for (const binding of graphOutputValues) { + if (selectedTaskNames.has(binding.taskId)) { + const subgraphOutputName = `${binding.taskId}_${binding.taskOutputName}`; + spec.implementation.setOutputValue( + binding.outputName, + subgraphTask.name, + subgraphOutputName, + ); + } + } + + // Remove original tasks (we can't actually remove from collections in this MVP, + // so we'll mark them as disabled or just leave them - full implementation would + // require collection removal methods) + // For MVP, we log a warning + console.warn( + "MVP limitation: Original tasks are not removed from the graph. " + + "Full implementation would require collection removal methods.", + ); + + return subgraphTask; +} diff --git a/src/routes/EditorV2/store/editorStore.ts b/src/routes/EditorV2/store/editorStore.ts new file mode 100644 index 000000000..151f9c3a7 --- /dev/null +++ b/src/routes/EditorV2/store/editorStore.ts @@ -0,0 +1,44 @@ +import { proxy } from "valtio"; + +import type { ComponentSpecEntity } from "@/providers/ComponentSpec/componentSpec"; + +export interface EditorStore { + spec: ComponentSpecEntity | null; + selectedNodeId: string | null; + selectedNodeType: "task" | "input" | "output" | null; +} + +export const editorStore = proxy({ + spec: null, + selectedNodeId: null, + selectedNodeType: null, +}); + +/** + * Initialize the editor store with a ComponentSpec. + * The spec is wrapped with valtio's proxy for reactivity. + */ +export function initializeStore(spec: ComponentSpecEntity) { + editorStore.spec = spec; + editorStore.selectedNodeId = null; + editorStore.selectedNodeType = null; +} + +/** + * Select a node by its ID and type. + */ +export function selectNode( + nodeId: string | null, + nodeType: EditorStore["selectedNodeType"] = null, +) { + editorStore.selectedNodeId = nodeId; + editorStore.selectedNodeType = nodeType; +} + +/** + * Clear the current selection. + */ +export function clearSelection() { + editorStore.selectedNodeId = null; + editorStore.selectedNodeType = null; +} From ca5cdce2c812f3cb8307e18a984d3d6aa3fdbe80 Mon Sep 17 00:00:00 2001 From: Maksym Yezhov Date: Thu, 12 Feb 2026 13:34:56 -0800 Subject: [PATCH 004/225] - version counter workaround --- src/providers/ComponentSpec/context.ts | 18 +- .../ComponentSpec/graphImplementation.ts | 17 +- src/providers/ComponentSpec/inputs.ts | 77 ++-- src/providers/ComponentSpec/outputs.ts | 78 ++-- .../ComponentSpec/tests/schemaValidator.ts | 1 - .../ComponentSpec/tests/toJson.test.ts | 20 +- .../tests/valtioReactivity.test.ts | 333 ++++++++++++++++++ src/routes/EditorV2/EditorV2.tsx | 39 +- .../EditorV2/components/ContextPanel.tsx | 15 +- src/routes/EditorV2/components/DebugPanel.tsx | 290 +++++++++++++++ src/routes/EditorV2/components/IONode.tsx | 3 + src/routes/EditorV2/components/TaskNode.tsx | 3 + .../EditorV2/hooks/useSpecToNodesEdges.ts | 5 + src/routes/EditorV2/store/actions.ts | 90 +++-- src/routes/EditorV2/store/editorStore.ts | 22 +- 15 files changed, 863 insertions(+), 148 deletions(-) create mode 100644 src/providers/ComponentSpec/tests/valtioReactivity.test.ts create mode 100644 src/routes/EditorV2/components/DebugPanel.tsx diff --git a/src/providers/ComponentSpec/context.ts b/src/providers/ComponentSpec/context.ts index 12a12a3cc..909de5e77 100644 --- a/src/providers/ComponentSpec/context.ts +++ b/src/providers/ComponentSpec/context.ts @@ -1,5 +1,3 @@ -import { proxy } from "valtio"; - import type { BaseEntity } from "./types"; export type EntityId = string; @@ -91,8 +89,12 @@ class IndexByKey { export class EntityIndex> { /** - * Plain object for entity storage - valtio tracks property access natively. - * Keys are entity IDs, values are entities. + * Plain object for entity storage. + * + * IMPORTANT: Do NOT wrap this in proxy() - Valtio handles wrapping + * automatically when this object is accessed through the store's proxy chain. + * Pre-creating proxies breaks Valtio's subscription system because nested + * proxies are separate from the parent proxy. */ readonly entities: Record = {}; private readonly indexByKey = new IndexByKey(); @@ -106,10 +108,10 @@ export class EntityIndex> { } add(entity: TEntity) { - // Wrap entity in valtio proxy to make mutations reactive - const proxiedEntity = proxy(entity) as TEntity; - this.entities[proxiedEntity.$id] = proxiedEntity; - this.indexByKey.add(proxiedEntity); + // Store entity directly - Valtio will wrap it when accessed through the store + // Do NOT pre-wrap with proxy() as it breaks the subscription chain + this.entities[entity.$id] = entity; + this.indexByKey.add(entity); } remove(entity: TEntity) { diff --git a/src/providers/ComponentSpec/graphImplementation.ts b/src/providers/ComponentSpec/graphImplementation.ts index 4c92886c3..ad0575139 100644 --- a/src/providers/ComponentSpec/graphImplementation.ts +++ b/src/providers/ComponentSpec/graphImplementation.ts @@ -31,8 +31,10 @@ export class GraphImplementation implements SerializableEntity { /** * Maps graph output names to task output sources. * Used to expose task outputs as graph-level outputs. + * + * Plain object - Valtio wraps it when accessed through the store's proxy. */ - private readonly _outputValues: Map = new Map(); + private readonly _outputValues: Record = {}; constructor(private readonly context: Context) { this.tasks = new TasksCollection(this.context); @@ -49,25 +51,25 @@ export class GraphImplementation implements SerializableEntity { taskId: string, taskOutputName: string, ): void { - this._outputValues.set(graphOutputName, { + this._outputValues[graphOutputName] = { outputName: graphOutputName, taskId, taskOutputName, - }); + }; } /** * Removes a graph output value binding. */ removeOutputValue(graphOutputName: string): void { - this._outputValues.delete(graphOutputName); + delete this._outputValues[graphOutputName]; } /** * Gets all output value bindings. */ getOutputValues(): OutputValueBinding[] { - return Array.from(this._outputValues.values()); + return Object.values(this._outputValues); } toJson(): GraphImplementationType { @@ -77,9 +79,10 @@ export class GraphImplementation implements SerializableEntity { }, }; - if (this._outputValues.size > 0) { + const outputValueKeys = Object.keys(this._outputValues); + if (outputValueKeys.length > 0) { const outputValues: Record = {}; - for (const binding of this._outputValues.values()) { + for (const binding of Object.values(this._outputValues)) { outputValues[binding.outputName] = { taskOutput: { taskId: binding.taskId, diff --git a/src/providers/ComponentSpec/inputs.ts b/src/providers/ComponentSpec/inputs.ts index ea77c8367..c0e496e27 100644 --- a/src/providers/ComponentSpec/inputs.ts +++ b/src/providers/ComponentSpec/inputs.ts @@ -1,8 +1,12 @@ import type { InputSpec, TypeSpecType } from "@/utils/componentSpec"; import { AnnotationsCollection } from "./annotations"; -import { type Context, EntityIndex } from "./context"; -import type { SerializableEntity } from "./types"; +import { BaseCollection, type Context } from "./context"; +import type { + BaseEntity, + RequiredProperties, + SerializableEntity, +} from "./types"; /** * Scalar interface for InputEntity - represents the data used to populate an input. @@ -25,11 +29,12 @@ export interface InputScalarWithAnnotations extends InputScalarInterface { annotations?: Record; } -export class InputEntity implements SerializableEntity { - readonly $id: string; +export class InputEntity + implements BaseEntity, SerializableEntity +{ readonly $indexed = ["name" as const]; - name: string = ""; + name: string; type?: TypeSpecType; description?: string; @@ -43,9 +48,13 @@ export class InputEntity implements SerializableEntity { readonly annotations: AnnotationsCollection; - constructor($id: string, parent: Context) { - this.$id = $id; - this.annotations = new AnnotationsCollection(parent); + constructor( + readonly $id: string, + private readonly context: Context, + required: RequiredProperties, + ) { + this.name = required.name; + this.annotations = new AnnotationsCollection(this.context); } populate(spec: InputScalarWithAnnotations) { @@ -96,45 +105,33 @@ export class InputEntity implements SerializableEntity { } } -export class InputsCollection implements SerializableEntity { - private readonly index = new EntityIndex(); - private readonly context: { $name: string; generateId(): string }; - - /** - * Direct access to entities for valtio reactivity. - * Access this property to ensure valtio tracks entity changes. - */ - get entities() { - return this.index.entities; - } +/** + * Input type for populating an InputEntity from raw spec data. + * Extends the scalar interface with annotations in their raw format. + */ +type InputPopulateInput = InputScalarInterface & { + annotations?: Record; +}; +export class InputsCollection + extends BaseCollection + implements SerializableEntity +{ constructor(parent: Context) { - const $name = `${parent.$name}.inputs`; - let counter = 0; - this.context = { - $name, - generateId: () => `${$name}_${++counter}`, - }; + super("inputs", parent); } + /** + * Override add to accept InputScalarWithAnnotations which includes annotations. + */ add(spec: InputScalarWithAnnotations): InputEntity { - const entity = new InputEntity( - this.context.generateId(), - this.context as Context, - ).populate(spec); - this.index.add(entity); - return entity; - } - - getAll(): InputEntity[] { - return this.index.getAll(); + return super.add(spec as InputScalarInterface); } - findByIndex( - indexKey: K, - value: InputEntity[K], - ): InputEntity[] { - return this.index.findByIndex(indexKey, value); + createEntity(spec: InputScalarInterface): InputEntity { + return new InputEntity(this.generateId(), this, spec).populate( + spec as InputPopulateInput, + ); } toJson(): InputSpec[] { diff --git a/src/providers/ComponentSpec/outputs.ts b/src/providers/ComponentSpec/outputs.ts index 7d8acf894..545aebcf5 100644 --- a/src/providers/ComponentSpec/outputs.ts +++ b/src/providers/ComponentSpec/outputs.ts @@ -1,8 +1,12 @@ import type { OutputSpec, TypeSpecType } from "@/utils/componentSpec"; import { AnnotationsCollection } from "./annotations"; -import { type Context, EntityIndex } from "./context"; -import type { SerializableEntity } from "./types"; +import { BaseCollection, type Context } from "./context"; +import type { + BaseEntity, + RequiredProperties, + SerializableEntity, +} from "./types"; /** * Scalar interface for OutputEntity - represents the data used to populate an output. @@ -20,11 +24,12 @@ export interface OutputScalarWithAnnotations extends OutputScalarInterface { annotations?: Record; } -export class OutputEntity implements SerializableEntity { - readonly $id: string; +export class OutputEntity + implements BaseEntity, SerializableEntity +{ readonly $indexed = ["name" as const]; - name: string = ""; + name: string; type?: TypeSpecType; description?: string; @@ -37,9 +42,13 @@ export class OutputEntity implements SerializableEntity { */ private _parentComponentName?: string; - constructor($id: string, parent: Context) { - this.$id = $id; - this.annotations = new AnnotationsCollection(parent); + constructor( + readonly $id: string, + private readonly context: Context, + required: RequiredProperties, + ) { + this.name = required.name; + this.annotations = new AnnotationsCollection(this.context); } /** @@ -93,48 +102,39 @@ export class OutputEntity implements SerializableEntity { } } -export class OutputsCollection implements SerializableEntity { - private readonly index = new EntityIndex(); - private readonly parentComponentName: string; - private readonly context: { $name: string; generateId(): string }; +/** + * Input type for populating an OutputEntity from raw spec data. + * Extends the scalar interface with annotations in their raw format. + */ +type OutputPopulateInput = OutputScalarInterface & { + annotations?: Record; +}; - /** - * Direct access to entities for valtio reactivity. - * Access this property to ensure valtio tracks entity changes. - */ - get entities() { - return this.index.entities; - } +export class OutputsCollection + extends BaseCollection + implements SerializableEntity +{ + private readonly parentComponentName: string; constructor(parent: Context) { + super("outputs", parent); this.parentComponentName = parent.$name.split(".").pop() || ""; - const $name = `${parent.$name}.outputs`; - let counter = 0; - this.context = { - $name, - generateId: () => `${$name}_${++counter}`, - }; } + /** + * Override add to accept OutputScalarWithAnnotations and set parent component name. + */ add(spec: OutputScalarWithAnnotations): OutputEntity { - const entity = new OutputEntity( - this.context.generateId(), - this.context as Context, - ).populate(spec); + const entity = super.add(spec as OutputScalarInterface); + // Set parent component name on the proxied entity returned from super.add() entity.setParentComponentName(this.parentComponentName); - this.index.add(entity); return entity; } - getAll(): OutputEntity[] { - return this.index.getAll(); - } - - findByIndex( - indexKey: K, - value: OutputEntity[K], - ): OutputEntity[] { - return this.index.findByIndex(indexKey, value); + createEntity(spec: OutputScalarInterface): OutputEntity { + return new OutputEntity(this.generateId(), this, spec).populate( + spec as OutputPopulateInput, + ); } toJson(): OutputSpec[] { diff --git a/src/providers/ComponentSpec/tests/schemaValidator.ts b/src/providers/ComponentSpec/tests/schemaValidator.ts index 569c292f0..a828dbbbb 100644 --- a/src/providers/ComponentSpec/tests/schemaValidator.ts +++ b/src/providers/ComponentSpec/tests/schemaValidator.ts @@ -17,7 +17,6 @@ export interface ValidationResult { errors?: string[]; } - type JsonCompatible = | Record | any[] diff --git a/src/providers/ComponentSpec/tests/toJson.test.ts b/src/providers/ComponentSpec/tests/toJson.test.ts index 2ad2e542a..ad6210a4d 100644 --- a/src/providers/ComponentSpec/tests/toJson.test.ts +++ b/src/providers/ComponentSpec/tests/toJson.test.ts @@ -55,7 +55,9 @@ describe("ComponentSpec Object Model toJson()", () => { describe("InputEntity.toJson()", () => { it("should serialize input without value field", () => { - const input = new InputEntity("test-input-1", rootContext); + const input = new InputEntity("test-input-1", rootContext, { + name: "test_input", + }); input.populate({ name: "test_input", type: "String", @@ -78,7 +80,9 @@ describe("ComponentSpec Object Model toJson()", () => { }); it("should include annotations when present", () => { - const input = new InputEntity("test-input-2", rootContext); + const input = new InputEntity("test-input-2", rootContext, { + name: "annotated_input", + }); input.populate({ name: "annotated_input", type: "String", @@ -94,7 +98,9 @@ describe("ComponentSpec Object Model toJson()", () => { }); it("should omit undefined optional fields", () => { - const input = new InputEntity("test-input-3", rootContext); + const input = new InputEntity("test-input-3", rootContext, { + name: "minimal_input", + }); input.populate({ name: "minimal_input", }); @@ -112,7 +118,9 @@ describe("ComponentSpec Object Model toJson()", () => { describe("OutputEntity.toJson()", () => { it("should serialize output correctly", () => { - const output = new OutputEntity("test-output-1", rootContext); + const output = new OutputEntity("test-output-1", rootContext, { + name: "test_output", + }); output.populate({ name: "test_output", type: "String", @@ -129,7 +137,9 @@ describe("ComponentSpec Object Model toJson()", () => { }); it("should include annotations when present", () => { - const output = new OutputEntity("test-output-2", rootContext); + const output = new OutputEntity("test-output-2", rootContext, { + name: "annotated_output", + }); output.populate({ name: "annotated_output", type: "String", diff --git a/src/providers/ComponentSpec/tests/valtioReactivity.test.ts b/src/providers/ComponentSpec/tests/valtioReactivity.test.ts new file mode 100644 index 000000000..2dc3bd122 --- /dev/null +++ b/src/providers/ComponentSpec/tests/valtioReactivity.test.ts @@ -0,0 +1,333 @@ +import { proxy, snapshot, subscribe } from "valtio"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { ComponentSpecEntity } from "../componentSpec"; +import { RootContext } from "../context"; +import { GraphImplementation } from "../graphImplementation"; +import { simpleContainerComponentRef } from "./fixtures"; + +describe("Valtio Reactivity for ComponentSpec Object Model", () => { + let rootContext: RootContext; + + beforeEach(() => { + rootContext = new RootContext(); + }); + + describe("Entity mutations trigger reactivity", () => { + it("should track changes to entity properties", async () => { + const componentSpec = new ComponentSpecEntity( + rootContext.generateId(), + rootContext, + { name: "Test" }, + ); + rootContext.registerEntity(componentSpec); + + const input = componentSpec.inputs.add({ + name: "test_input", + type: "String", + }); + + // Wrap in proxy to enable tracking + const proxiedInput = proxy(input); + const callback = vi.fn(); + + // Subscribe to changes + const unsubscribe = subscribe(proxiedInput, callback); + + // Mutate the entity + proxiedInput.name = "renamed_input"; + + // Wait for async notification + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(callback).toHaveBeenCalled(); + expect(proxiedInput.name).toBe("renamed_input"); + + unsubscribe(); + }); + + it("should track changes to task entity properties", async () => { + const componentSpec = new ComponentSpecEntity( + rootContext.generateId(), + rootContext, + { name: "Test" }, + ); + const graphImpl = new GraphImplementation(componentSpec); + componentSpec.implementation = graphImpl; + + const task = graphImpl.tasks.add({ + name: "original_task", + componentRef: simpleContainerComponentRef, + }); + + const callback = vi.fn(); + const unsubscribe = subscribe(task, callback); + + // Mutate the task name + task.name = "renamed_task"; + + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(callback).toHaveBeenCalled(); + expect(task.name).toBe("renamed_task"); + + unsubscribe(); + }); + }); + + describe("Collection operations trigger reactivity", () => { + it("should track adding entities to InputsCollection", async () => { + const componentSpec = new ComponentSpecEntity( + rootContext.generateId(), + rootContext, + { name: "Test" }, + ); + + // The entities object inside the collection is what we need to track + const proxiedEntities = proxy(componentSpec.inputs.entities); + const callback = vi.fn(); + const unsubscribe = subscribe(proxiedEntities, callback); + + // Add an entity + componentSpec.inputs.add({ + name: "new_input", + type: "String", + }); + + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(callback).toHaveBeenCalled(); + expect(Object.keys(proxiedEntities).length).toBe(1); + + unsubscribe(); + }); + + it("should track adding entities to TasksCollection", async () => { + const componentSpec = new ComponentSpecEntity( + rootContext.generateId(), + rootContext, + { name: "Test" }, + ); + const graphImpl = new GraphImplementation(componentSpec); + componentSpec.implementation = graphImpl; + + const proxiedEntities = proxy(graphImpl.tasks.entities); + const callback = vi.fn(); + const unsubscribe = subscribe(proxiedEntities, callback); + + // Add a task + graphImpl.tasks.add({ + name: "new_task", + componentRef: simpleContainerComponentRef, + }); + + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(callback).toHaveBeenCalled(); + expect(Object.keys(proxiedEntities).length).toBe(1); + + unsubscribe(); + }); + }); + + describe("GraphImplementation output values use Record (not Map)", () => { + it("should track setOutputValue changes via Record", async () => { + const componentSpec = new ComponentSpecEntity( + rootContext.generateId(), + rootContext, + { name: "Test" }, + ); + const graphImpl = new GraphImplementation(componentSpec); + + // Wrap the graph implementation in a proxy + const proxiedGraphImpl = proxy(graphImpl); + const callback = vi.fn(); + const unsubscribe = subscribe(proxiedGraphImpl, callback); + + // Set an output value + proxiedGraphImpl.setOutputValue("output1", "task1", "result"); + + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(callback).toHaveBeenCalled(); + + // Verify the value was set + const outputValues = proxiedGraphImpl.getOutputValues(); + expect(outputValues).toHaveLength(1); + expect(outputValues[0]).toEqual({ + outputName: "output1", + taskId: "task1", + taskOutputName: "result", + }); + + unsubscribe(); + }); + + it("should track removeOutputValue changes", async () => { + const componentSpec = new ComponentSpecEntity( + rootContext.generateId(), + rootContext, + { name: "Test" }, + ); + const graphImpl = new GraphImplementation(componentSpec); + + // Add an output value first + graphImpl.setOutputValue("output1", "task1", "result"); + + const proxiedGraphImpl = proxy(graphImpl); + const callback = vi.fn(); + const unsubscribe = subscribe(proxiedGraphImpl, callback); + + // Remove the output value + proxiedGraphImpl.removeOutputValue("output1"); + + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(callback).toHaveBeenCalled(); + expect(proxiedGraphImpl.getOutputValues()).toHaveLength(0); + + unsubscribe(); + }); + + it("should serialize output values correctly to JSON", () => { + const componentSpec = new ComponentSpecEntity( + rootContext.generateId(), + rootContext, + { name: "Test" }, + ); + const graphImpl = new GraphImplementation(componentSpec); + componentSpec.implementation = graphImpl; + + graphImpl.tasks.add({ + name: "task1", + componentRef: simpleContainerComponentRef, + }); + + // Set output value binding + graphImpl.setOutputValue("final_output", "task1", "task_result"); + + const json = graphImpl.toJson(); + + expect(json.graph.outputValues).toEqual({ + final_output: { + taskOutput: { + taskId: "task1", + outputName: "task_result", + }, + }, + }); + }); + }); + + describe("Nested collections are reactive", () => { + it("should track nested annotations collection changes", async () => { + const componentSpec = new ComponentSpecEntity( + rootContext.generateId(), + rootContext, + { name: "Test" }, + ); + + const input = componentSpec.inputs.add({ + name: "annotated_input", + type: "String", + }); + + // Proxy the input entity (which contains nested annotations collection) + const proxiedInput = proxy(input); + const callback = vi.fn(); + const unsubscribe = subscribe(proxiedInput, callback); + + // Add an annotation to the nested collection + proxiedInput.annotations.add({ key: "hint", value: "text_area" }); + + await new Promise((resolve) => setTimeout(resolve, 0)); + + // The callback should be triggered when nested collection changes + expect(callback).toHaveBeenCalled(); + + unsubscribe(); + }); + + it("should track nested arguments collection changes on TaskEntity", async () => { + const componentSpec = new ComponentSpecEntity( + rootContext.generateId(), + rootContext, + { name: "Test" }, + ); + const graphImpl = new GraphImplementation(componentSpec); + componentSpec.implementation = graphImpl; + + const task = graphImpl.tasks.add({ + name: "test_task", + componentRef: simpleContainerComponentRef, + }); + + const callback = vi.fn(); + const unsubscribe = subscribe(task, callback); + + // Add an argument to the nested collection + task.arguments.add({ name: "input_data" }); + + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(callback).toHaveBeenCalled(); + + unsubscribe(); + }); + }); + + describe("Snapshot creates immutable copies", () => { + it("should create immutable snapshot of entity", () => { + const componentSpec = new ComponentSpecEntity( + rootContext.generateId(), + rootContext, + { name: "Test" }, + ); + + const input = componentSpec.inputs.add({ + name: "test_input", + type: "String", + }); + + const proxiedInput = proxy(input); + const snap = snapshot(proxiedInput); + + // Snapshot should reflect current state + expect(snap.name).toBe("test_input"); + + // Mutating the proxy should not affect snapshot + proxiedInput.name = "changed"; + expect(snap.name).toBe("test_input"); + expect(proxiedInput.name).toBe("changed"); + }); + + it("should create immutable snapshot of collection entities", () => { + const componentSpec = new ComponentSpecEntity( + rootContext.generateId(), + rootContext, + { name: "Test" }, + ); + const graphImpl = new GraphImplementation(componentSpec); + componentSpec.implementation = graphImpl; + + graphImpl.tasks.add({ + name: "task1", + componentRef: simpleContainerComponentRef, + }); + + const proxiedEntities = proxy(graphImpl.tasks.entities); + const snap = snapshot(proxiedEntities); + + // Add another task + graphImpl.tasks.add({ + name: "task2", + componentRef: simpleContainerComponentRef, + }); + + // Snapshot should still have only 1 task + expect(Object.keys(snap).length).toBe(1); + expect(Object.keys(proxiedEntities).length).toBe(2); + }); + }); +}); + diff --git a/src/routes/EditorV2/EditorV2.tsx b/src/routes/EditorV2/EditorV2.tsx index 5eb8bb179..5a0243f97 100644 --- a/src/routes/EditorV2/EditorV2.tsx +++ b/src/routes/EditorV2/EditorV2.tsx @@ -3,12 +3,14 @@ import "@xyflow/react/dist/style.css"; import { useSuspenseQuery } from "@tanstack/react-query"; import { ReactFlowProvider } from "@xyflow/react"; import { useEffect, useState } from "react"; +import { subscribe } from "valtio"; import { withSuspenseWrapper } from "@/components/shared/SuspenseWrapper"; import { InlineStack } from "@/components/ui/layout"; import { YamlLoader } from "@/providers/ComponentSpec/yamlLoader"; import { ContextPanel } from "./components/ContextPanel"; +import { DebugPanel } from "./components/DebugPanel"; import { FlowCanvas } from "./components/FlowCanvas"; import { Sidebar } from "./components/Sidebar"; import { editorStore, initializeStore } from "./store/editorStore"; @@ -43,26 +45,37 @@ const PipelineEditor = withSuspenseWrapper(() => { const spec = useLoadSpec(); // Initialize the valtio store with the loaded spec - // Entities are already proxied when added to collections, no need to wrap again + // The spec is wrapped in proxy() inside initializeStore for deep reactivity useEffect(() => { if (spec) { initializeStore(spec); - } - return () => { - // Clear store on unmount - editorStore.spec = null; - editorStore.selectedNodeId = null; - editorStore.selectedNodeType = null; - }; + // Subscribe to the proxied spec AFTER initializeStore has wrapped it + // editorStore.spec is now the proxied version + const unsubscribe = subscribe(editorStore.spec!, (ops) => { + console.log(`%c Spec changed`, "color: orange; font-weight: bold;", ops); + }); + + return () => { + unsubscribe(); + // Clear store on unmount + editorStore.spec = null; + editorStore.selectedNodeId = null; + editorStore.selectedNodeType = null; + }; + } }, [spec]); + return ( - - - - - + <> + + + + + + + ); }); diff --git a/src/routes/EditorV2/components/ContextPanel.tsx b/src/routes/EditorV2/components/ContextPanel.tsx index 961f363cb..bb56d1751 100644 --- a/src/routes/EditorV2/components/ContextPanel.tsx +++ b/src/routes/EditorV2/components/ContextPanel.tsx @@ -21,15 +21,16 @@ export function ContextPanel() { const snapshot = useSnapshot(editorStore); const { selectedNodeId, selectedNodeType, spec } = snapshot; + // Access version to subscribe to spec mutations + void snapshot.version; + if (!selectedNodeId || !selectedNodeType || !spec) { return ; } return ( - {selectedNodeType === "task" && ( - - )} + {selectedNodeType === "task" && } {selectedNodeType === "input" && ( )} @@ -42,9 +43,7 @@ export function ContextPanel() { function EmptyState() { return ( - + Select a node to view details @@ -60,6 +59,7 @@ interface TaskDetailsProps { function TaskDetails({ entityId }: TaskDetailsProps) { const snapshot = useSnapshot(editorStore); const spec = snapshot.spec; + void snapshot.version; if ( !spec?.implementation || @@ -80,6 +80,7 @@ function TaskDetails({ entityId }: TaskDetailsProps) { const newName = event.target.value; if (newName && newName !== task.name) { renameTask(entityId, newName); + console.log("task renamed 1", newName); } }; @@ -178,6 +179,7 @@ interface InputDetailsProps { function InputDetails({ entityId }: InputDetailsProps) { const snapshot = useSnapshot(editorStore); const spec = snapshot.spec; + void snapshot.version; if (!spec) return null; // Get input directly from entities using $id @@ -258,6 +260,7 @@ interface OutputDetailsProps { function OutputDetails({ entityId }: OutputDetailsProps) { const snapshot = useSnapshot(editorStore); const spec = snapshot.spec; + void snapshot.version; if (!spec) return null; // Get output directly from entities using $id diff --git a/src/routes/EditorV2/components/DebugPanel.tsx b/src/routes/EditorV2/components/DebugPanel.tsx new file mode 100644 index 000000000..0fe3da3cb --- /dev/null +++ b/src/routes/EditorV2/components/DebugPanel.tsx @@ -0,0 +1,290 @@ +import { useRef, useState } from "react"; +import { useSnapshot } from "valtio"; +import { computed } from "valtio-reactive"; + +import CodeSyntaxHighlighter from "@/components/shared/CodeViewer/CodeSyntaxHighlighter"; +import { Button } from "@/components/ui/button"; +import { Icon } from "@/components/ui/icon"; +import { BlockStack, InlineStack } from "@/components/ui/layout"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Text } from "@/components/ui/typography"; +import { cn } from "@/lib/utils"; + +import { editorStore } from "../store/editorStore"; + +// Computed value that automatically updates when spec changes +const derivedState = computed({ + specJson: () => { + // Access version to track spec mutations + void editorStore.version; + const spec = editorStore.spec; + if (!spec) return "null"; + try { + return JSON.stringify(spec.toJson(), null, 2); + } catch { + return "Error serializing spec"; + } + }, +}); + +interface Position { + x: number; + y: number; +} + +interface Size { + width: number; + height: number; +} + +const MIN_WIDTH = 280; +const MIN_HEIGHT = 200; +const DEFAULT_WIDTH = 320; +const DEFAULT_HEIGHT = 420; + +interface StatItemProps { + label: string; + value: number | string; +} + +function StatItem({ label, value }: StatItemProps) { + return ( + + + {label} + + + {value} + + + ); +} + +interface StatGroupProps { + title: string; + children: React.ReactNode; +} + +function StatGroup({ title, children }: StatGroupProps) { + return ( + + + {title} + + {children} + + ); +} + +export function DebugPanel() { + const snap = useSnapshot(editorStore); + const derivedJson = useSnapshot(derivedState); + + // Access version to subscribe to spec changes + void snap.version; + + const [isMinimized, setIsMinimized] = useState(false); + const [position, setPosition] = useState({ x: 16, y: 16 }); + const [size, setSize] = useState({ width: DEFAULT_WIDTH, height: DEFAULT_HEIGHT }); + const [isDragging, setIsDragging] = useState(false); + const [isResizing, setIsResizing] = useState(false); + const dragOffset = useRef({ x: 0, y: 0 }); + const panelRef = useRef(null); + + const handleMouseDown = (e: React.MouseEvent) => { + if ((e.target as HTMLElement).closest("button")) return; + + setIsDragging(true); + dragOffset.current = { + x: e.clientX - position.x, + y: e.clientY - position.y, + }; + + const handleMouseMove = (e: MouseEvent) => { + setPosition({ + x: e.clientX - dragOffset.current.x, + y: e.clientY - dragOffset.current.y, + }); + }; + + const handleMouseUp = () => { + setIsDragging(false); + document.removeEventListener("mousemove", handleMouseMove); + document.removeEventListener("mouseup", handleMouseUp); + }; + + document.addEventListener("mousemove", handleMouseMove); + document.addEventListener("mouseup", handleMouseUp); + }; + + const handleResizeMouseDown = (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + + setIsResizing(true); + const startX = e.clientX; + const startY = e.clientY; + const startWidth = size.width; + const startHeight = size.height; + + const handleMouseMove = (e: MouseEvent) => { + const newWidth = Math.max(MIN_WIDTH, startWidth + (e.clientX - startX)); + const newHeight = Math.max(MIN_HEIGHT, startHeight + (e.clientY - startY)); + setSize({ width: newWidth, height: newHeight }); + }; + + const handleMouseUp = () => { + setIsResizing(false); + document.removeEventListener("mousemove", handleMouseMove); + document.removeEventListener("mouseup", handleMouseUp); + }; + + document.addEventListener("mousemove", handleMouseMove); + document.addEventListener("mouseup", handleMouseUp); + }; + + // Calculate content height (total height minus header) + const contentHeight = size.height - 44; // 44px is approximately the header height + + // Collect stats from the spec + const spec = snap.spec; + + const stats = { + name: spec?.name ?? "—", + inputs: spec?.inputs.getAll().length ?? 0, + outputs: spec?.outputs.getAll().length ?? 0, + tasks: spec?.implementation?.tasks.getAll().length ?? 0, + arguments: spec?.implementation?.tasks + .getAll() + .reduce((acc, task) => acc + task.arguments.getAll().length, 0) ?? 0, + annotations: spec?.implementation?.tasks + .getAll() + .reduce((acc, task) => acc + task.annotations.getAll().length, 0) ?? 0, + outputValues: spec?.implementation?.getOutputValues().length ?? 0, + }; + + const selectedInfo = snap.selectedNodeId + ? `${snap.selectedNodeType}: ${snap.selectedNodeId}` + : "None"; + + + return ( +
+ {/* Header - Draggable area */} +
+ + + + Debug Panel + + + +
+ + {/* Content */} + {!isMinimized && ( + + + Stats + JSON + + + +
+ + {/* Spec Info */} + + + + + {/* Selection Info */} + + + + + {/* Graph Counts */} + + + + + + + {/* Internal State */} + + + + + + + {/* Memory/Debug Info */} + + + + + +
+
+ + +
+ +
+
+
+ )} + + {/* Resize handle */} + {!isMinimized && ( +
+ + + +
+ )} +
+ ); +} + diff --git a/src/routes/EditorV2/components/IONode.tsx b/src/routes/EditorV2/components/IONode.tsx index ba67da1e6..27e0e237c 100644 --- a/src/routes/EditorV2/components/IONode.tsx +++ b/src/routes/EditorV2/components/IONode.tsx @@ -31,6 +31,9 @@ export function IONode({ id, data, selected }: IONodeProps) { const snapshot = useSnapshot(editorStore); const spec = snapshot.spec; + // Access version to subscribe to spec mutations + void snapshot.version; + const isInput = ioType === "input"; // Find the entity by its stable $id diff --git a/src/routes/EditorV2/components/TaskNode.tsx b/src/routes/EditorV2/components/TaskNode.tsx index 7bb395428..f874dadd4 100644 --- a/src/routes/EditorV2/components/TaskNode.tsx +++ b/src/routes/EditorV2/components/TaskNode.tsx @@ -34,6 +34,9 @@ export function TaskNode({ id, data, selected }: TaskNodeProps) { const snapshot = useSnapshot(editorStore); const spec = snapshot.spec; + // Access version to subscribe to spec mutations + void snapshot.version; + // Find the task entity by its stable $id const task = spec && hasGraphImplementation(spec) diff --git a/src/routes/EditorV2/hooks/useSpecToNodesEdges.ts b/src/routes/EditorV2/hooks/useSpecToNodesEdges.ts index a88c0af06..7e675ffff 100644 --- a/src/routes/EditorV2/hooks/useSpecToNodesEdges.ts +++ b/src/routes/EditorV2/hooks/useSpecToNodesEdges.ts @@ -73,6 +73,11 @@ export function useSpecToNodesEdges() { const snapshot = useSnapshot(editorStore); const spec = snapshot.spec; + // Access version to subscribe to spec changes. + // Class methods bypass Valtio's proxy, so we use a version counter + // that gets incremented after mutations to force re-renders. + void snapshot.version; + const nodes: Node[] = []; const edges: Edge[] = []; diff --git a/src/routes/EditorV2/store/actions.ts b/src/routes/EditorV2/store/actions.ts index 9fd2cc595..80daac696 100644 --- a/src/routes/EditorV2/store/actions.ts +++ b/src/routes/EditorV2/store/actions.ts @@ -7,7 +7,7 @@ import type { OutputEntity } from "@/providers/ComponentSpec/outputs"; import { EDITOR_POSITION_ANNOTATION } from "@/utils/annotations"; import type { ComponentReference } from "@/utils/componentSpec"; -import { editorStore } from "./editorStore"; +import { editorStore, notifySpecChanged } from "./editorStore"; /** * Check if the spec has a graph implementation. @@ -40,6 +40,7 @@ export function updateNodePosition(entityId: string, position: XYPosition) { value: JSON.stringify(position), }); } + notifySpecChanged(); return; } @@ -50,6 +51,7 @@ export function updateNodePosition(entityId: string, position: XYPosition) { key: EDITOR_POSITION_ANNOTATION, value: JSON.stringify(position), }); + notifySpecChanged(); return; } @@ -60,6 +62,7 @@ export function updateNodePosition(entityId: string, position: XYPosition) { key: EDITOR_POSITION_ANNOTATION, value: JSON.stringify(position), }); + notifySpecChanged(); } } @@ -166,6 +169,7 @@ export function addTask( } } + notifySpecChanged(); return taskEntity; } @@ -192,6 +196,7 @@ export function addInput(position: XYPosition, name?: string) { }, }); + notifySpecChanged(); return inputEntity; } @@ -218,6 +223,7 @@ export function addOutput(position: XYPosition, name?: string) { }, }); + notifySpecChanged(); return outputEntity; } @@ -231,6 +237,18 @@ interface ConnectionInfo { targetHandleId: string; } +/** + * Helper to determine node type from entity $id. + * Entity IDs follow the pattern: root.{specName}.{collection}_{number} + * e.g., "root.MyPipeline.inputs_1", "root.MyPipeline.outputs_2", "root.MyPipeline.tasks_3" + */ +function getNodeTypeFromId(nodeId: string): "input" | "output" | "task" | null { + if (nodeId.includes(".inputs_")) return "input"; + if (nodeId.includes(".outputs_")) return "output"; + if (nodeId.includes(".tasks_")) return "task"; + return null; +} + /** * Connect two nodes by creating an argument binding. * @@ -238,6 +256,8 @@ interface ConnectionInfo { * - Task output → Task input (taskOutput argument) * - Graph input → Task input (graphInput argument) * - Task output → Graph output (outputValues binding) + * + * Node IDs are entity $ids in the format: root.{specName}.{collection}_{number} */ export function connectNodes(connection: ConnectionInfo) { const { spec } = editorStore; @@ -255,10 +275,12 @@ export function connectNodes(connection: ConnectionInfo) { const sourceOutputName = sourceHandleId.replace(/^output_/, ""); const targetInputName = targetHandleId.replace(/^input_/, ""); - // Check if source is a graph input - const isSourceGraphInput = sourceNodeId.startsWith("input_"); - // Check if target is a graph output - const isTargetGraphOutput = targetNodeId.startsWith("output_"); + // Determine node types from entity $id format + const sourceType = getNodeTypeFromId(sourceNodeId); + const targetType = getNodeTypeFromId(targetNodeId); + + const isSourceGraphInput = sourceType === "input"; + const isTargetGraphOutput = targetType === "output"; if (isSourceGraphInput && isTargetGraphOutput) { // Cannot directly connect graph input to graph output @@ -268,26 +290,34 @@ export function connectNodes(connection: ConnectionInfo) { if (isTargetGraphOutput) { // Task output → Graph output - const sourceTaskName = sourceNodeId.replace(/^task_/, ""); - const graphOutputName = targetNodeId.replace(/^output_/, ""); + // Look up source task by $id to get its name + const sourceTask = spec.implementation.tasks.findById(sourceNodeId); + if (!sourceTask) { + console.error(`Source task not found: ${sourceNodeId}`); + return false; + } + + // Look up target output by $id to get its name + const targetOutput = spec.outputs.findById(targetNodeId); + if (!targetOutput) { + console.error(`Target output not found: ${targetNodeId}`); + return false; + } spec.implementation.setOutputValue( - graphOutputName, - sourceTaskName, + targetOutput.name, + sourceTask.name, sourceOutputName, ); + notifySpecChanged(); return true; } - // Target is a task - const targetTaskName = targetNodeId.replace(/^task_/, ""); - const targetTask = spec.implementation.tasks.findByIndex( - "name", - targetTaskName, - )[0]; + // Target is a task - look up by $id + const targetTask = spec.implementation.tasks.findById(targetNodeId); if (!targetTask) { - console.error(`Target task not found: ${targetTaskName}`); + console.error(`Target task not found: ${targetNodeId}`); return false; } @@ -299,35 +329,33 @@ export function connectNodes(connection: ConnectionInfo) { if (isSourceGraphInput) { // Graph input → Task input - const graphInputName = sourceNodeId.replace(/^input_/, ""); - const graphInput = spec.inputs.findByIndex("name", graphInputName)[0]; + // Look up graph input by $id + const graphInput = spec.inputs.findById(sourceNodeId); if (!graphInput) { - console.error(`Graph input not found: ${graphInputName}`); + console.error(`Graph input not found: ${sourceNodeId}`); return false; } argument.connectTo(graphInput); + notifySpecChanged(); return true; } // Task output → Task input - const sourceTaskName = sourceNodeId.replace(/^task_/, ""); - const sourceTask = spec.implementation.tasks.findByIndex( - "name", - sourceTaskName, - )[0]; + // Look up source task by $id + const sourceTask = spec.implementation.tasks.findById(sourceNodeId); if (!sourceTask) { - console.error(`Source task not found: ${sourceTaskName}`); + console.error(`Source task not found: ${sourceNodeId}`); return false; } // Find the output entity in the source task's component spec - const sourceComponentSpec = spec.findComponentSpecEntity(sourceTaskName); + const sourceComponentSpec = spec.findComponentSpecEntity(sourceTask.name); if (!sourceComponentSpec) { console.error( - `Source component spec not found for task: ${sourceTaskName}`, + `Source component spec not found for task: ${sourceTask.name}`, ); return false; } @@ -343,6 +371,7 @@ export function connectNodes(connection: ConnectionInfo) { } argument.connectTo(sourceOutput); + notifySpecChanged(); return true; } @@ -373,6 +402,7 @@ export function removeConnection(taskName: string, argumentName: string) { // Reset to empty literal value argument.value = ""; + notifySpecChanged(); return true; } @@ -388,6 +418,7 @@ export function removeOutputConnection(graphOutputName: string) { } spec.implementation.removeOutputValue(graphOutputName); + notifySpecChanged(); return true; } @@ -403,7 +434,7 @@ export function renameTask(entityId: string, newName: string) { } // Get task directly by $id - const task = spec.implementation.tasks.entities[entityId]; + const task = spec.implementation.tasks.findById(entityId); if (!task) { console.error(`Task not found: ${entityId}`); @@ -422,6 +453,7 @@ export function renameTask(entityId: string, newName: string) { } task.name = newName; + notifySpecChanged(); return true; } @@ -453,6 +485,7 @@ export function renameInput(entityId: string, newName: string) { } input.name = newName; + notifySpecChanged(); return true; } @@ -484,6 +517,7 @@ export function renameOutput(entityId: string, newName: string) { } output.name = newName; + notifySpecChanged(); return true; } diff --git a/src/routes/EditorV2/store/editorStore.ts b/src/routes/EditorV2/store/editorStore.ts index 151f9c3a7..b3252141b 100644 --- a/src/routes/EditorV2/store/editorStore.ts +++ b/src/routes/EditorV2/store/editorStore.ts @@ -6,19 +6,39 @@ export interface EditorStore { spec: ComponentSpecEntity | null; selectedNodeId: string | null; selectedNodeType: "task" | "input" | "output" | null; + /** + * Version counter incremented after mutations. + * Used to force React re-renders since class method mutations + * bypass Valtio's proxy tracking. + */ + version: number; } export const editorStore = proxy({ spec: null, selectedNodeId: null, selectedNodeType: null, + version: 0, }); +/** + * Increment version to trigger React re-renders. + * Call this after any mutation to the spec. + */ +export function notifySpecChanged() { + editorStore.version++; +} + /** * Initialize the editor store with a ComponentSpec. - * The spec is wrapped with valtio's proxy for reactivity. + * + * The editorStore is already a Valtio proxy, so assigning to it + * will auto-wrap the spec. The nested `entities` objects in collections + * are also proxies (created in EntityIndex), enabling deep reactivity. */ export function initializeStore(spec: ComponentSpecEntity) { + // Assign directly - editorStore is a proxy and will handle nested objects + // The entities objects in collections are already proxies for reactivity editorStore.spec = spec; editorStore.selectedNodeId = null; editorStore.selectedNodeType = null; From e0635f968dda81d658dd8a38781158d2acb1b567 Mon Sep 17 00:00:00 2001 From: Maksym Yezhov Date: Thu, 12 Feb 2026 15:29:08 -0800 Subject: [PATCH 005/225] - fix valtio in CSOM --- package-lock.json | 12 +++- package.json | 3 +- src/providers/ComponentSpec/componentSpec.ts | 7 ++- src/providers/ComponentSpec/context.ts | 16 +++-- .../ComponentSpec/graphImplementation.ts | 10 ++- src/providers/ComponentSpec/inputs.ts | 5 +- src/providers/ComponentSpec/outputs.ts | 5 +- .../tests/valtioReactivity.test.ts | 16 ++--- src/providers/ComponentSpec/yamlLoader.ts | 17 +++-- src/routes/EditorV2/EditorV2.tsx | 7 ++- .../EditorV2/components/ContextPanel.tsx | 6 -- src/routes/EditorV2/components/DebugPanel.tsx | 62 ++++++++++++------- src/routes/EditorV2/components/IONode.tsx | 6 +- src/routes/EditorV2/components/TaskNode.tsx | 6 +- .../EditorV2/hooks/useSpecToNodesEdges.ts | 5 -- src/routes/EditorV2/store/actions.ts | 16 +---- src/routes/EditorV2/store/editorStore.ts | 22 +------ 17 files changed, 110 insertions(+), 111 deletions(-) diff --git a/package-lock.json b/package-lock.json index 3adaf0423..68669b4b3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -71,7 +71,8 @@ "tailwind-merge": "^3.5.0", "tailwindcss": "^4.1.16", "tailwindcss-animate": "^1.0.7", - "valtio": "^2.2.0" + "valtio": "^2.2.0", + "valtio-reactive": "^0.2.0" }, "devDependencies": { "@eslint/js": "^9.39.2", @@ -12500,6 +12501,15 @@ } } }, + "node_modules/valtio-reactive": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/valtio-reactive/-/valtio-reactive-0.2.0.tgz", + "integrity": "sha512-Zl1vr0w2S33QP9wBRX60p1LczyHrX+AWLB3iRl1doCdUTKaCziSj9GZIyrEIMykfjd0b3t27+X44phKOplcyfg==", + "license": "MIT", + "peerDependencies": { + "valtio": ">=2.0.0" + } + }, "node_modules/vite": { "version": "7.3.1", "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", diff --git a/package.json b/package.json index ea60ba271..91d63c18b 100644 --- a/package.json +++ b/package.json @@ -111,7 +111,8 @@ "tailwind-merge": "^3.5.0", "tailwindcss": "^4.1.16", "tailwindcss-animate": "^1.0.7", - "valtio": "^2.2.0" + "valtio": "^2.2.0", + "valtio-reactive": "^0.2.0" }, "peerDependencies": { "monaco-editor": "^0.54.0" diff --git a/src/providers/ComponentSpec/componentSpec.ts b/src/providers/ComponentSpec/componentSpec.ts index 1dd1f20a3..2453718ec 100644 --- a/src/providers/ComponentSpec/componentSpec.ts +++ b/src/providers/ComponentSpec/componentSpec.ts @@ -1,3 +1,5 @@ +import { proxy } from "valtio"; + import type { ComponentSpec, MetadataSpec } from "@/utils/componentSpec"; import { BaseNestedContext, type Context } from "./context"; @@ -42,8 +44,9 @@ export class ComponentSpecEntity this.name = required.name; - this.inputs = new InputsCollection(this); - this.outputs = new OutputsCollection(this); + // Wrap collections with proxy() to ensure Valtio tracks mutations + this.inputs = proxy(new InputsCollection(this)); + this.outputs = proxy(new OutputsCollection(this)); } findComponentSpecEntity(name: string): ComponentSpecEntity | undefined { diff --git a/src/providers/ComponentSpec/context.ts b/src/providers/ComponentSpec/context.ts index 909de5e77..659add760 100644 --- a/src/providers/ComponentSpec/context.ts +++ b/src/providers/ComponentSpec/context.ts @@ -1,3 +1,5 @@ +import { proxy } from "valtio"; + import type { BaseEntity } from "./types"; export type EntityId = string; @@ -90,11 +92,7 @@ class IndexByKey { export class EntityIndex> { /** * Plain object for entity storage. - * - * IMPORTANT: Do NOT wrap this in proxy() - Valtio handles wrapping - * automatically when this object is accessed through the store's proxy chain. - * Pre-creating proxies breaks Valtio's subscription system because nested - * proxies are separate from the parent proxy. + * Entities are wrapped with proxy() when added to ensure Valtio reactivity. */ readonly entities: Record = {}; private readonly indexByKey = new IndexByKey(); @@ -108,10 +106,10 @@ export class EntityIndex> { } add(entity: TEntity) { - // Store entity directly - Valtio will wrap it when accessed through the store - // Do NOT pre-wrap with proxy() as it breaks the subscription chain - this.entities[entity.$id] = entity; - this.indexByKey.add(entity); + // Wrap entity in proxy before storing to ensure Valtio tracks mutations + const proxiedEntity = proxy(entity) as TEntity; + this.entities[proxiedEntity.$id] = proxiedEntity; + this.indexByKey.add(proxiedEntity); } remove(entity: TEntity) { diff --git a/src/providers/ComponentSpec/graphImplementation.ts b/src/providers/ComponentSpec/graphImplementation.ts index ad0575139..a2c5508f8 100644 --- a/src/providers/ComponentSpec/graphImplementation.ts +++ b/src/providers/ComponentSpec/graphImplementation.ts @@ -1,3 +1,5 @@ +import { proxy } from "valtio"; + import type { ArgumentType, ComponentReference, @@ -37,7 +39,8 @@ export class GraphImplementation implements SerializableEntity { private readonly _outputValues: Record = {}; constructor(private readonly context: Context) { - this.tasks = new TasksCollection(this.context); + // Wrap collection with proxy() to ensure Valtio tracks mutations + this.tasks = proxy(new TasksCollection(this.context)); } /** @@ -132,8 +135,9 @@ export class TaskEntity this.name = required.name; this.componentRef = required.componentRef; - this.annotations = new AnnotationsCollection(this.context); - this.arguments = new ArgumentsCollection(this.context); + // Wrap collections with proxy() to ensure Valtio tracks mutations + this.annotations = proxy(new AnnotationsCollection(this.context)); + this.arguments = proxy(new ArgumentsCollection(this.context)); } populate(input: TaskPopulateInput) { diff --git a/src/providers/ComponentSpec/inputs.ts b/src/providers/ComponentSpec/inputs.ts index c0e496e27..6d3e82898 100644 --- a/src/providers/ComponentSpec/inputs.ts +++ b/src/providers/ComponentSpec/inputs.ts @@ -1,3 +1,5 @@ +import { proxy } from "valtio"; + import type { InputSpec, TypeSpecType } from "@/utils/componentSpec"; import { AnnotationsCollection } from "./annotations"; @@ -54,7 +56,8 @@ export class InputEntity required: RequiredProperties, ) { this.name = required.name; - this.annotations = new AnnotationsCollection(this.context); + // Wrap collection with proxy() to ensure Valtio tracks mutations + this.annotations = proxy(new AnnotationsCollection(this.context)); } populate(spec: InputScalarWithAnnotations) { diff --git a/src/providers/ComponentSpec/outputs.ts b/src/providers/ComponentSpec/outputs.ts index 545aebcf5..70295aa23 100644 --- a/src/providers/ComponentSpec/outputs.ts +++ b/src/providers/ComponentSpec/outputs.ts @@ -1,3 +1,5 @@ +import { proxy } from "valtio"; + import type { OutputSpec, TypeSpecType } from "@/utils/componentSpec"; import { AnnotationsCollection } from "./annotations"; @@ -48,7 +50,8 @@ export class OutputEntity required: RequiredProperties, ) { this.name = required.name; - this.annotations = new AnnotationsCollection(this.context); + // Wrap collection with proxy() to ensure Valtio tracks mutations + this.annotations = proxy(new AnnotationsCollection(this.context)); } /** diff --git a/src/providers/ComponentSpec/tests/valtioReactivity.test.ts b/src/providers/ComponentSpec/tests/valtioReactivity.test.ts index 2dc3bd122..fc4abad2b 100644 --- a/src/providers/ComponentSpec/tests/valtioReactivity.test.ts +++ b/src/providers/ComponentSpec/tests/valtioReactivity.test.ts @@ -83,10 +83,10 @@ describe("Valtio Reactivity for ComponentSpec Object Model", () => { { name: "Test" }, ); - // The entities object inside the collection is what we need to track - const proxiedEntities = proxy(componentSpec.inputs.entities); + // Collections are already wrapped with proxy() in the constructor, + // so we subscribe directly to the collection const callback = vi.fn(); - const unsubscribe = subscribe(proxiedEntities, callback); + const unsubscribe = subscribe(componentSpec.inputs, callback); // Add an entity componentSpec.inputs.add({ @@ -97,7 +97,7 @@ describe("Valtio Reactivity for ComponentSpec Object Model", () => { await new Promise((resolve) => setTimeout(resolve, 0)); expect(callback).toHaveBeenCalled(); - expect(Object.keys(proxiedEntities).length).toBe(1); + expect(Object.keys(componentSpec.inputs.entities).length).toBe(1); unsubscribe(); }); @@ -111,9 +111,10 @@ describe("Valtio Reactivity for ComponentSpec Object Model", () => { const graphImpl = new GraphImplementation(componentSpec); componentSpec.implementation = graphImpl; - const proxiedEntities = proxy(graphImpl.tasks.entities); + // Collections are already wrapped with proxy() in the constructor, + // so we subscribe directly to the collection const callback = vi.fn(); - const unsubscribe = subscribe(proxiedEntities, callback); + const unsubscribe = subscribe(graphImpl.tasks, callback); // Add a task graphImpl.tasks.add({ @@ -124,7 +125,7 @@ describe("Valtio Reactivity for ComponentSpec Object Model", () => { await new Promise((resolve) => setTimeout(resolve, 0)); expect(callback).toHaveBeenCalled(); - expect(Object.keys(proxiedEntities).length).toBe(1); + expect(Object.keys(graphImpl.tasks.entities).length).toBe(1); unsubscribe(); }); @@ -330,4 +331,3 @@ describe("Valtio Reactivity for ComponentSpec Object Model", () => { }); }); }); - diff --git a/src/providers/ComponentSpec/yamlLoader.ts b/src/providers/ComponentSpec/yamlLoader.ts index ba5c2812f..a1b7ac076 100644 --- a/src/providers/ComponentSpec/yamlLoader.ts +++ b/src/providers/ComponentSpec/yamlLoader.ts @@ -1,3 +1,5 @@ +import { proxy } from "valtio"; + import { hydrateComponentReference, parseComponentData, @@ -39,10 +41,11 @@ export class YamlLoader { name: string, parentContext: Context, ): Promise { - const rootSpecEntity = new ComponentSpecEntity( - parentContext.generateId(), - parentContext, - { name }, + // Wrap root entity with proxy() to ensure Valtio tracks mutations + const rootSpecEntity = proxy( + new ComponentSpecEntity(parentContext.generateId(), parentContext, { + name, + }), ).populate({ name, description: loadedSpec.description, @@ -73,8 +76,10 @@ export class YamlLoader { taskSpec: TaskSpec; }[] = []; - // todo: use context - const graphImplementation = new GraphImplementation(rootSpecEntity); + // Wrap GraphImplementation with proxy() to ensure Valtio tracks mutations + const graphImplementation = proxy( + new GraphImplementation(rootSpecEntity), + ); rootSpecEntity.implementation = graphImplementation; for (const [taskId, task] of Object.entries( diff --git a/src/routes/EditorV2/EditorV2.tsx b/src/routes/EditorV2/EditorV2.tsx index 5a0243f97..0ce94c4f3 100644 --- a/src/routes/EditorV2/EditorV2.tsx +++ b/src/routes/EditorV2/EditorV2.tsx @@ -53,7 +53,11 @@ const PipelineEditor = withSuspenseWrapper(() => { // Subscribe to the proxied spec AFTER initializeStore has wrapped it // editorStore.spec is now the proxied version const unsubscribe = subscribe(editorStore.spec!, (ops) => { - console.log(`%c Spec changed`, "color: orange; font-weight: bold;", ops); + console.log( + `%c Spec changed`, + "color: orange; font-weight: bold;", + ops, + ); }); return () => { @@ -66,7 +70,6 @@ const PipelineEditor = withSuspenseWrapper(() => { } }, [spec]); - return ( <> diff --git a/src/routes/EditorV2/components/ContextPanel.tsx b/src/routes/EditorV2/components/ContextPanel.tsx index bb56d1751..e1ce51d9e 100644 --- a/src/routes/EditorV2/components/ContextPanel.tsx +++ b/src/routes/EditorV2/components/ContextPanel.tsx @@ -21,9 +21,6 @@ export function ContextPanel() { const snapshot = useSnapshot(editorStore); const { selectedNodeId, selectedNodeType, spec } = snapshot; - // Access version to subscribe to spec mutations - void snapshot.version; - if (!selectedNodeId || !selectedNodeType || !spec) { return ; } @@ -59,7 +56,6 @@ interface TaskDetailsProps { function TaskDetails({ entityId }: TaskDetailsProps) { const snapshot = useSnapshot(editorStore); const spec = snapshot.spec; - void snapshot.version; if ( !spec?.implementation || @@ -179,7 +175,6 @@ interface InputDetailsProps { function InputDetails({ entityId }: InputDetailsProps) { const snapshot = useSnapshot(editorStore); const spec = snapshot.spec; - void snapshot.version; if (!spec) return null; // Get input directly from entities using $id @@ -260,7 +255,6 @@ interface OutputDetailsProps { function OutputDetails({ entityId }: OutputDetailsProps) { const snapshot = useSnapshot(editorStore); const spec = snapshot.spec; - void snapshot.version; if (!spec) return null; // Get output directly from entities using $id diff --git a/src/routes/EditorV2/components/DebugPanel.tsx b/src/routes/EditorV2/components/DebugPanel.tsx index 0fe3da3cb..fd5e49e9f 100644 --- a/src/routes/EditorV2/components/DebugPanel.tsx +++ b/src/routes/EditorV2/components/DebugPanel.tsx @@ -15,8 +15,7 @@ import { editorStore } from "../store/editorStore"; // Computed value that automatically updates when spec changes const derivedState = computed({ specJson: () => { - // Access version to track spec mutations - void editorStore.version; + // Valtio tracks mutations automatically since entities are wrapped with proxy() const spec = editorStore.spec; if (!spec) return "null"; try { @@ -68,10 +67,16 @@ interface StatGroupProps { function StatGroup({ title, children }: StatGroupProps) { return ( - + {title} - {children} + + {children} + ); } @@ -80,12 +85,12 @@ export function DebugPanel() { const snap = useSnapshot(editorStore); const derivedJson = useSnapshot(derivedState); - // Access version to subscribe to spec changes - void snap.version; - const [isMinimized, setIsMinimized] = useState(false); const [position, setPosition] = useState({ x: 16, y: 16 }); - const [size, setSize] = useState({ width: DEFAULT_WIDTH, height: DEFAULT_HEIGHT }); + const [size, setSize] = useState({ + width: DEFAULT_WIDTH, + height: DEFAULT_HEIGHT, + }); const [isDragging, setIsDragging] = useState(false); const [isResizing, setIsResizing] = useState(false); const dragOffset = useRef({ x: 0, y: 0 }); @@ -129,7 +134,10 @@ export function DebugPanel() { const handleMouseMove = (e: MouseEvent) => { const newWidth = Math.max(MIN_WIDTH, startWidth + (e.clientX - startX)); - const newHeight = Math.max(MIN_HEIGHT, startHeight + (e.clientY - startY)); + const newHeight = Math.max( + MIN_HEIGHT, + startHeight + (e.clientY - startY), + ); setSize({ width: newWidth, height: newHeight }); }; @@ -154,12 +162,14 @@ export function DebugPanel() { inputs: spec?.inputs.getAll().length ?? 0, outputs: spec?.outputs.getAll().length ?? 0, tasks: spec?.implementation?.tasks.getAll().length ?? 0, - arguments: spec?.implementation?.tasks - .getAll() - .reduce((acc, task) => acc + task.arguments.getAll().length, 0) ?? 0, - annotations: spec?.implementation?.tasks - .getAll() - .reduce((acc, task) => acc + task.annotations.getAll().length, 0) ?? 0, + arguments: + spec?.implementation?.tasks + .getAll() + .reduce((acc, task) => acc + task.arguments.getAll().length, 0) ?? 0, + annotations: + spec?.implementation?.tasks + .getAll() + .reduce((acc, task) => acc + task.annotations.getAll().length, 0) ?? 0, outputValues: spec?.implementation?.getOutputValues().length ?? 0, }; @@ -167,7 +177,6 @@ export function DebugPanel() { ? `${snap.selectedNodeType}: ${snap.selectedNodeId}` : "None"; - return (
-
+
{/* Spec Info */} @@ -243,13 +255,19 @@ export function DebugPanel() { - + {/* Memory/Debug Info */} - +
@@ -260,7 +278,10 @@ export function DebugPanel() { className="m-2 rounded-md overflow-hidden bg-slate-900" style={{ height: contentHeight - 48 }} > - +
@@ -287,4 +308,3 @@ export function DebugPanel() {
); } - diff --git a/src/routes/EditorV2/components/IONode.tsx b/src/routes/EditorV2/components/IONode.tsx index 27e0e237c..5b69993d5 100644 --- a/src/routes/EditorV2/components/IONode.tsx +++ b/src/routes/EditorV2/components/IONode.tsx @@ -26,14 +26,10 @@ export function IONode({ id, data, selected }: IONodeProps) { const { entityId, ioType } = data; // Access the store directly to get the entity - // This ensures valtio reactivity works - when entity properties change, - // this component re-renders + // Valtio tracks mutations automatically since entities are wrapped with proxy() const snapshot = useSnapshot(editorStore); const spec = snapshot.spec; - // Access version to subscribe to spec mutations - void snapshot.version; - const isInput = ioType === "input"; // Find the entity by its stable $id diff --git a/src/routes/EditorV2/components/TaskNode.tsx b/src/routes/EditorV2/components/TaskNode.tsx index f874dadd4..cbc387cb8 100644 --- a/src/routes/EditorV2/components/TaskNode.tsx +++ b/src/routes/EditorV2/components/TaskNode.tsx @@ -29,14 +29,10 @@ export function TaskNode({ id, data, selected }: TaskNodeProps) { const { entityId } = data; // Access the store directly to get the task entity - // This ensures valtio reactivity works - when task properties change, - // this component re-renders + // Valtio tracks mutations automatically since entities are wrapped with proxy() const snapshot = useSnapshot(editorStore); const spec = snapshot.spec; - // Access version to subscribe to spec mutations - void snapshot.version; - // Find the task entity by its stable $id const task = spec && hasGraphImplementation(spec) diff --git a/src/routes/EditorV2/hooks/useSpecToNodesEdges.ts b/src/routes/EditorV2/hooks/useSpecToNodesEdges.ts index 7e675ffff..a88c0af06 100644 --- a/src/routes/EditorV2/hooks/useSpecToNodesEdges.ts +++ b/src/routes/EditorV2/hooks/useSpecToNodesEdges.ts @@ -73,11 +73,6 @@ export function useSpecToNodesEdges() { const snapshot = useSnapshot(editorStore); const spec = snapshot.spec; - // Access version to subscribe to spec changes. - // Class methods bypass Valtio's proxy, so we use a version counter - // that gets incremented after mutations to force re-renders. - void snapshot.version; - const nodes: Node[] = []; const edges: Edge[] = []; diff --git a/src/routes/EditorV2/store/actions.ts b/src/routes/EditorV2/store/actions.ts index 80daac696..f2b27f9ab 100644 --- a/src/routes/EditorV2/store/actions.ts +++ b/src/routes/EditorV2/store/actions.ts @@ -7,7 +7,7 @@ import type { OutputEntity } from "@/providers/ComponentSpec/outputs"; import { EDITOR_POSITION_ANNOTATION } from "@/utils/annotations"; import type { ComponentReference } from "@/utils/componentSpec"; -import { editorStore, notifySpecChanged } from "./editorStore"; +import { editorStore } from "./editorStore"; /** * Check if the spec has a graph implementation. @@ -40,7 +40,6 @@ export function updateNodePosition(entityId: string, position: XYPosition) { value: JSON.stringify(position), }); } - notifySpecChanged(); return; } @@ -51,7 +50,6 @@ export function updateNodePosition(entityId: string, position: XYPosition) { key: EDITOR_POSITION_ANNOTATION, value: JSON.stringify(position), }); - notifySpecChanged(); return; } @@ -62,7 +60,6 @@ export function updateNodePosition(entityId: string, position: XYPosition) { key: EDITOR_POSITION_ANNOTATION, value: JSON.stringify(position), }); - notifySpecChanged(); } } @@ -169,7 +166,6 @@ export function addTask( } } - notifySpecChanged(); return taskEntity; } @@ -196,7 +192,6 @@ export function addInput(position: XYPosition, name?: string) { }, }); - notifySpecChanged(); return inputEntity; } @@ -223,7 +218,6 @@ export function addOutput(position: XYPosition, name?: string) { }, }); - notifySpecChanged(); return outputEntity; } @@ -309,7 +303,6 @@ export function connectNodes(connection: ConnectionInfo) { sourceTask.name, sourceOutputName, ); - notifySpecChanged(); return true; } @@ -338,7 +331,6 @@ export function connectNodes(connection: ConnectionInfo) { } argument.connectTo(graphInput); - notifySpecChanged(); return true; } @@ -371,7 +363,6 @@ export function connectNodes(connection: ConnectionInfo) { } argument.connectTo(sourceOutput); - notifySpecChanged(); return true; } @@ -402,7 +393,6 @@ export function removeConnection(taskName: string, argumentName: string) { // Reset to empty literal value argument.value = ""; - notifySpecChanged(); return true; } @@ -418,7 +408,6 @@ export function removeOutputConnection(graphOutputName: string) { } spec.implementation.removeOutputValue(graphOutputName); - notifySpecChanged(); return true; } @@ -453,7 +442,6 @@ export function renameTask(entityId: string, newName: string) { } task.name = newName; - notifySpecChanged(); return true; } @@ -485,7 +473,6 @@ export function renameInput(entityId: string, newName: string) { } input.name = newName; - notifySpecChanged(); return true; } @@ -517,7 +504,6 @@ export function renameOutput(entityId: string, newName: string) { } output.name = newName; - notifySpecChanged(); return true; } diff --git a/src/routes/EditorV2/store/editorStore.ts b/src/routes/EditorV2/store/editorStore.ts index b3252141b..1019b5c4a 100644 --- a/src/routes/EditorV2/store/editorStore.ts +++ b/src/routes/EditorV2/store/editorStore.ts @@ -6,39 +6,21 @@ export interface EditorStore { spec: ComponentSpecEntity | null; selectedNodeId: string | null; selectedNodeType: "task" | "input" | "output" | null; - /** - * Version counter incremented after mutations. - * Used to force React re-renders since class method mutations - * bypass Valtio's proxy tracking. - */ - version: number; } export const editorStore = proxy({ spec: null, selectedNodeId: null, selectedNodeType: null, - version: 0, }); -/** - * Increment version to trigger React re-renders. - * Call this after any mutation to the spec. - */ -export function notifySpecChanged() { - editorStore.version++; -} - /** * Initialize the editor store with a ComponentSpec. * - * The editorStore is already a Valtio proxy, so assigning to it - * will auto-wrap the spec. The nested `entities` objects in collections - * are also proxies (created in EntityIndex), enabling deep reactivity. + * The spec is already proxied from YamlLoader, and all nested collections + * and entities are wrapped with proxy() for native Valtio reactivity. */ export function initializeStore(spec: ComponentSpecEntity) { - // Assign directly - editorStore is a proxy and will handle nested objects - // The entities objects in collections are already proxies for reactivity editorStore.spec = spec; editorStore.selectedNodeId = null; editorStore.selectedNodeType = null; From 4aa0348d2d910cccd0f3c27ef96c516b1e82155d Mon Sep 17 00:00:00 2001 From: Maksym Yezhov Date: Thu, 12 Feb 2026 17:16:34 -0800 Subject: [PATCH 006/225] - windows system --- src/routes/EditorV2/EditorV2.tsx | 41 ++- .../EditorV2/components/ContextPanel.tsx | 93 +++--- src/routes/EditorV2/components/DebugPanel.tsx | 310 +++++------------- src/routes/EditorV2/windows/TaskPanel.tsx | 69 ++++ src/routes/EditorV2/windows/Window.tsx | 237 +++++++++++++ .../EditorV2/windows/WindowContainer.tsx | 21 ++ src/routes/EditorV2/windows/types.ts | 67 ++++ src/routes/EditorV2/windows/useWindows.ts | 123 +++++++ src/routes/EditorV2/windows/windowStore.ts | 243 ++++++++++++++ 9 files changed, 946 insertions(+), 258 deletions(-) create mode 100644 src/routes/EditorV2/windows/TaskPanel.tsx create mode 100644 src/routes/EditorV2/windows/Window.tsx create mode 100644 src/routes/EditorV2/windows/WindowContainer.tsx create mode 100644 src/routes/EditorV2/windows/types.ts create mode 100644 src/routes/EditorV2/windows/useWindows.ts create mode 100644 src/routes/EditorV2/windows/windowStore.ts diff --git a/src/routes/EditorV2/EditorV2.tsx b/src/routes/EditorV2/EditorV2.tsx index 0ce94c4f3..ee1523d9b 100644 --- a/src/routes/EditorV2/EditorV2.tsx +++ b/src/routes/EditorV2/EditorV2.tsx @@ -9,11 +9,18 @@ import { withSuspenseWrapper } from "@/components/shared/SuspenseWrapper"; import { InlineStack } from "@/components/ui/layout"; import { YamlLoader } from "@/providers/ComponentSpec/yamlLoader"; -import { ContextPanel } from "./components/ContextPanel"; +import { ContextPanelContent } from "./components/ContextPanel"; import { DebugPanel } from "./components/DebugPanel"; import { FlowCanvas } from "./components/FlowCanvas"; import { Sidebar } from "./components/Sidebar"; import { editorStore, initializeStore } from "./store/editorStore"; +import { TaskPanel } from "./windows/TaskPanel"; +import { WindowContainer } from "./windows/WindowContainer"; +import { + getWindowById, + openWindow, + updateWindowContent, +} from "./windows/windowStore"; const availableTemplates = import.meta.glob("./assets/*.yaml", { query: "?raw", @@ -41,6 +48,8 @@ function useLoadSpec() { return testSpec; } +const CONTEXT_PANEL_WINDOW_ID = "context-panel"; + const PipelineEditor = withSuspenseWrapper(() => { const spec = useLoadSpec(); @@ -70,14 +79,42 @@ const PipelineEditor = withSuspenseWrapper(() => { } }, [spec]); + // Open/close context panel window based on node selection + useEffect(() => { + const unsubscribe = subscribe(editorStore, () => { + const { selectedNodeId, selectedNodeType } = editorStore; + + if (selectedNodeId && selectedNodeType) { + const existingWindow = getWindowById(CONTEXT_PANEL_WINDOW_ID); + const content = ; + + if (existingWindow) { + // Update content if window exists + updateWindowContent(CONTEXT_PANEL_WINDOW_ID, content); + } else { + // Open new window + openWindow(content, { + id: CONTEXT_PANEL_WINDOW_ID, + title: "Properties", + position: { x: window.innerWidth - 340, y: 80 }, + size: { width: 300, height: 400 }, + }); + } + } + }); + + return unsubscribe; + }, []); + return ( <> - + + ); }); diff --git a/src/routes/EditorV2/components/ContextPanel.tsx b/src/routes/EditorV2/components/ContextPanel.tsx index e1ce51d9e..87d3ad936 100644 --- a/src/routes/EditorV2/components/ContextPanel.tsx +++ b/src/routes/EditorV2/components/ContextPanel.tsx @@ -14,10 +14,11 @@ import { renameInput, renameOutput, renameTask } from "../store/actions"; import { editorStore } from "../store/editorStore"; /** - * Panel that displays details about the selected node - * and allows editing of node properties via direct mutation. + * Content for the Context Panel window. + * Displays details about the selected node and allows editing via direct mutation. + * Used within the Windows system. */ -export function ContextPanel() { +export function ContextPanelContent() { const snapshot = useSnapshot(editorStore); const { selectedNodeId, selectedNodeType, spec } = snapshot; @@ -26,7 +27,7 @@ export function ContextPanel() { } return ( - + {selectedNodeType === "task" && } {selectedNodeType === "input" && ( @@ -38,10 +39,22 @@ export function ContextPanel() { ); } +/** + * @deprecated Use ContextPanelContent within the Windows system instead. + * Kept for backwards compatibility. + */ +export function ContextPanel() { + return ( + + + + ); +} + function EmptyState() { return ( - - + + Select a node to view details @@ -88,9 +101,11 @@ function TaskDetails({ entityId }: TaskDetailsProps) { title="Task Node" /> - + - + - - + + {componentSpec.description} @@ -113,24 +128,24 @@ function TaskDetails({ entityId }: TaskDetailsProps) { {componentSpec?.inputs && componentSpec.inputs.length > 0 && ( - + {componentSpec.inputs.map((input) => ( - + {input.name} {input.type && ( - + : {String(input.type)} )} {input.optional && ( - + (optional) )} @@ -142,19 +157,19 @@ function TaskDetails({ entityId }: TaskDetailsProps) { {componentSpec?.outputs && componentSpec.outputs.length > 0 && ( - + {componentSpec.outputs.map((output) => ( - + {output.name} {output.type && ( - + : {String(output.type)} )} @@ -196,9 +211,11 @@ function InputDetails({ entityId }: InputDetailsProps) { title="Graph Input" /> - + - + - - + + {String(input.type)} @@ -219,8 +236,8 @@ function InputDetails({ entityId }: InputDetailsProps) { {input.description && ( - - + + {input.description} @@ -228,18 +245,18 @@ function InputDetails({ entityId }: InputDetailsProps) { {input.default !== undefined && ( - - + + {input.default} )} - + Optional: - + {input.optional ? "Yes" : "No"} @@ -276,9 +293,11 @@ function OutputDetails({ entityId }: OutputDetailsProps) { title="Graph Output" /> - + - + - - + + {String(output.type)} @@ -299,8 +318,8 @@ function OutputDetails({ entityId }: OutputDetailsProps) { {output.description && ( - - + + {output.description} @@ -321,14 +340,14 @@ function PanelHeader({ icon, iconClassName, title }: PanelHeaderProps) { - + {title} diff --git a/src/routes/EditorV2/components/DebugPanel.tsx b/src/routes/EditorV2/components/DebugPanel.tsx index fd5e49e9f..09ffe0e33 100644 --- a/src/routes/EditorV2/components/DebugPanel.tsx +++ b/src/routes/EditorV2/components/DebugPanel.tsx @@ -1,16 +1,20 @@ -import { useRef, useState } from "react"; +import { useEffect } from "react"; import { useSnapshot } from "valtio"; import { computed } from "valtio-reactive"; import CodeSyntaxHighlighter from "@/components/shared/CodeViewer/CodeSyntaxHighlighter"; -import { Button } from "@/components/ui/button"; -import { Icon } from "@/components/ui/icon"; import { BlockStack, InlineStack } from "@/components/ui/layout"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Text } from "@/components/ui/typography"; -import { cn } from "@/lib/utils"; import { editorStore } from "../store/editorStore"; +import { + closeWindow, + getWindowById, + openWindow, +} from "../windows/windowStore"; + +const DEBUG_PANEL_WINDOW_ID = "debug-panel"; // Computed value that automatically updates when spec changes const derivedState = computed({ @@ -26,21 +30,6 @@ const derivedState = computed({ }, }); -interface Position { - x: number; - y: number; -} - -interface Size { - width: number; - height: number; -} - -const MIN_WIDTH = 280; -const MIN_HEIGHT = 200; -const DEFAULT_WIDTH = 320; -const DEFAULT_HEIGHT = 420; - interface StatItemProps { label: string; value: number | string; @@ -49,10 +38,10 @@ interface StatItemProps { function StatItem({ label, value }: StatItemProps) { return ( - + {label} - + {value} @@ -70,90 +59,25 @@ function StatGroup({ title, children }: StatGroupProps) { {title} - + {children} ); } -export function DebugPanel() { +/** + * Debug panel content - displays stats and JSON representation of the spec. + * Used within the Windows system. + */ +export function DebugPanelContent() { const snap = useSnapshot(editorStore); const derivedJson = useSnapshot(derivedState); - const [isMinimized, setIsMinimized] = useState(false); - const [position, setPosition] = useState({ x: 16, y: 16 }); - const [size, setSize] = useState({ - width: DEFAULT_WIDTH, - height: DEFAULT_HEIGHT, - }); - const [isDragging, setIsDragging] = useState(false); - const [isResizing, setIsResizing] = useState(false); - const dragOffset = useRef({ x: 0, y: 0 }); - const panelRef = useRef(null); - - const handleMouseDown = (e: React.MouseEvent) => { - if ((e.target as HTMLElement).closest("button")) return; - - setIsDragging(true); - dragOffset.current = { - x: e.clientX - position.x, - y: e.clientY - position.y, - }; - - const handleMouseMove = (e: MouseEvent) => { - setPosition({ - x: e.clientX - dragOffset.current.x, - y: e.clientY - dragOffset.current.y, - }); - }; - - const handleMouseUp = () => { - setIsDragging(false); - document.removeEventListener("mousemove", handleMouseMove); - document.removeEventListener("mouseup", handleMouseUp); - }; - - document.addEventListener("mousemove", handleMouseMove); - document.addEventListener("mouseup", handleMouseUp); - }; - - const handleResizeMouseDown = (e: React.MouseEvent) => { - e.preventDefault(); - e.stopPropagation(); - - setIsResizing(true); - const startX = e.clientX; - const startY = e.clientY; - const startWidth = size.width; - const startHeight = size.height; - - const handleMouseMove = (e: MouseEvent) => { - const newWidth = Math.max(MIN_WIDTH, startWidth + (e.clientX - startX)); - const newHeight = Math.max( - MIN_HEIGHT, - startHeight + (e.clientY - startY), - ); - setSize({ width: newWidth, height: newHeight }); - }; - - const handleMouseUp = () => { - setIsResizing(false); - document.removeEventListener("mousemove", handleMouseMove); - document.removeEventListener("mouseup", handleMouseUp); - }; - - document.addEventListener("mousemove", handleMouseMove); - document.addEventListener("mouseup", handleMouseUp); - }; - - // Calculate content height (total height minus header) - const contentHeight = size.height - 44; // 44px is approximately the header height - // Collect stats from the spec const spec = snap.spec; @@ -178,133 +102,81 @@ export function DebugPanel() { : "None"; return ( -
- {/* Header - Draggable area */} -
- - - - Debug Panel - - - -
- - {/* Content */} - {!isMinimized && ( - - - Stats - JSON - - - -
- - {/* Spec Info */} - - - - - {/* Selection Info */} - - - - - {/* Graph Counts */} - - - - - - - {/* Internal State */} - - - - - + + + Stats + JSON + + + + + {/* Spec Info */} + + + + + {/* Selection Info */} + + + + + {/* Graph Counts */} + + + + + + + {/* Internal State */} + + + + + + + {/* Memory/Debug Info */} + + + + + + + + +
+ +
+
+
+ ); +} - {/* Memory/Debug Info */} - - - - -
-
-
+/** + * DebugPanel component that opens the debug panel as a window on mount. + * This component manages the window lifecycle. + */ +export function DebugPanel() { + useEffect(() => { + // Open the debug panel window if it doesn't exist + const existingWindow = getWindowById(DEBUG_PANEL_WINDOW_ID); + if (!existingWindow) { + openWindow(, { + id: DEBUG_PANEL_WINDOW_ID, + title: "Debug Panel", + position: { x: 16, y: 16 }, + size: { width: 320, height: 420 }, + }); + } - -
- -
-
-
- )} + // Cleanup: close window on unmount + return () => { + closeWindow(DEBUG_PANEL_WINDOW_ID); + }; + }, []); - {/* Resize handle */} - {!isMinimized && ( -
- - - -
- )} -
- ); + // This component doesn't render anything - it just manages the window + return null; } diff --git a/src/routes/EditorV2/windows/TaskPanel.tsx b/src/routes/EditorV2/windows/TaskPanel.tsx new file mode 100644 index 000000000..c85733c72 --- /dev/null +++ b/src/routes/EditorV2/windows/TaskPanel.tsx @@ -0,0 +1,69 @@ +import { useSnapshot } from "valtio"; + +import { Button } from "@/components/ui/button"; +import { Icon } from "@/components/ui/icon"; +import { InlineStack } from "@/components/ui/layout"; +import { Text } from "@/components/ui/typography"; + +import { closeWindow, restoreWindow, windowStore } from "./windowStore"; + +/** + * Fixed bottom panel that displays hidden windows. + * Only renders when there are hidden windows. + * Click on a window title restores it, click X closes it. + */ +export function TaskPanel() { + const snap = useSnapshot(windowStore); + + // Filter for hidden windows + const hiddenWindows = snap.windowOrder + .map((id) => snap.windows[id]) + .filter((w) => w?.state === "hidden"); + + // Don't render if no hidden windows + if (hiddenWindows.length === 0) { + return null; + } + + return ( +
+ + {hiddenWindows.map((window) => ( + + + + + ))} + +
+ ); +} + diff --git a/src/routes/EditorV2/windows/Window.tsx b/src/routes/EditorV2/windows/Window.tsx new file mode 100644 index 000000000..ec67a8e7e --- /dev/null +++ b/src/routes/EditorV2/windows/Window.tsx @@ -0,0 +1,237 @@ +import { useRef, useState } from "react"; +import { useSnapshot } from "valtio"; + +import { Button } from "@/components/ui/button"; +import { Icon } from "@/components/ui/icon"; +import { InlineStack } from "@/components/ui/layout"; +import { Text } from "@/components/ui/typography"; +import { cn } from "@/lib/utils"; + +import type { Position, WindowConfig } from "./types"; +import { + bringToFront, + closeWindow, + getWindowContent, + hideWindow, + toggleMaximize, + toggleMinimize, + updateWindowPosition, + updateWindowSize, + windowStore, +} from "./windowStore"; + +interface WindowProps { + windowId: string; +} + +export function Window({ windowId }: WindowProps) { + const snap = useSnapshot(windowStore); + const windowConfig = snap.windows[windowId] as WindowConfig | undefined; + const zIndex = snap.windowOrder.indexOf(windowId); + + const [isDragging, setIsDragging] = useState(false); + const [isResizing, setIsResizing] = useState(false); + const dragOffset = useRef({ x: 0, y: 0 }); + const panelRef = useRef(null); + + if (!windowConfig || windowConfig.state === "hidden") { + return null; + } + + const { title, state, position, size, minSize } = windowConfig; + // Get content from separate map (not stored in proxy to avoid React Compiler issues) + const content = getWindowContent(windowId); + const isMinimized = state === "minimized"; + const isMaximized = state === "maximized"; + + const handleMouseDown = () => { + bringToFront(windowId); + }; + + const handleHeaderMouseDown = (e: React.MouseEvent) => { + // Don't drag if clicking on buttons + if ((e.target as HTMLElement).closest("button")) return; + + bringToFront(windowId); + setIsDragging(true); + dragOffset.current = { + x: e.clientX - position.x, + y: e.clientY - position.y, + }; + + const handleMouseMove = (e: MouseEvent) => { + updateWindowPosition(windowId, { + x: e.clientX - dragOffset.current.x, + y: e.clientY - dragOffset.current.y, + }); + }; + + const handleMouseUp = () => { + setIsDragging(false); + document.removeEventListener("mousemove", handleMouseMove); + document.removeEventListener("mouseup", handleMouseUp); + }; + + document.addEventListener("mousemove", handleMouseMove); + document.addEventListener("mouseup", handleMouseUp); + }; + + const handleResizeMouseDown = (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + + setIsResizing(true); + const startX = e.clientX; + const startY = e.clientY; + const startWidth = size.width; + const startHeight = size.height; + + const handleMouseMove = (e: MouseEvent) => { + const newWidth = Math.max(minSize.width, startWidth + (e.clientX - startX)); + const newHeight = Math.max( + minSize.height, + startHeight + (e.clientY - startY), + ); + updateWindowSize(windowId, { width: newWidth, height: newHeight }); + }; + + const handleMouseUp = () => { + setIsResizing(false); + document.removeEventListener("mousemove", handleMouseMove); + document.removeEventListener("mouseup", handleMouseUp); + }; + + document.addEventListener("mousemove", handleMouseMove); + document.addEventListener("mouseup", handleMouseUp); + }; + + // Calculate content height (total height minus header ~44px) + const contentHeight = size.height - 44; + + // Maximized state: full viewport + const windowStyle = isMaximized + ? { + left: 0, + top: 0, + width: "100vw", + height: "100vh", + zIndex: 50 + zIndex, + } + : { + left: position.x, + top: position.y, + width: isMinimized ? "auto" : size.width, + height: isMinimized ? "auto" : size.height, + minWidth: minSize.width, + minHeight: isMinimized ? "auto" : minSize.height, + zIndex: 50 + zIndex, + }; + + return ( +
+ {/* Header - Draggable area */} +
+ + + {title} + + + + + {/* Minimize button (collapse to header) */} + + + {/* Maximize button */} + + + {/* Hide button (to task panel) */} + + + {/* Close button */} + + +
+ + {/* Content */} + {!isMinimized && ( +
+ {content} +
+ )} + + {/* Resize handle - only shown when not minimized/maximized */} + {!isMinimized && !isMaximized && ( +
+ + + +
+ )} +
+ ); +} + diff --git a/src/routes/EditorV2/windows/WindowContainer.tsx b/src/routes/EditorV2/windows/WindowContainer.tsx new file mode 100644 index 000000000..80ae6bbac --- /dev/null +++ b/src/routes/EditorV2/windows/WindowContainer.tsx @@ -0,0 +1,21 @@ +import { useSnapshot } from "valtio"; + +import { Window } from "./Window"; +import { windowStore } from "./windowStore"; + +/** + * Container component that renders all active windows from the store. + * Should be placed in the EditorV2 layout to enable the windows system. + */ +export function WindowContainer() { + const snap = useSnapshot(windowStore); + + return ( + <> + {snap.windowOrder.map((windowId) => ( + + ))} + + ); +} + diff --git a/src/routes/EditorV2/windows/types.ts b/src/routes/EditorV2/windows/types.ts new file mode 100644 index 000000000..b32494f67 --- /dev/null +++ b/src/routes/EditorV2/windows/types.ts @@ -0,0 +1,67 @@ +/** Window display state */ +export type WindowState = "normal" | "maximized" | "minimized" | "hidden"; + +/** Position coordinates */ +export interface Position { + x: number; + y: number; +} + +/** Size dimensions */ +export interface Size { + width: number; + height: number; +} + +/** Window configuration stored in the registry (no React elements) */ +export interface WindowConfig { + id: string; + title: string; + state: WindowState; + position: Position; + size: Size; + minSize: Size; + /** Stored for restore operations */ + previousPosition?: Position; + previousSize?: Size; + previousState?: WindowState; +} + +/** Reference returned from open() for controlling a window */ +export interface WindowRef { + id: string; + close: () => void; + minimize: () => void; + maximize: () => void; + hide: () => void; + restore: () => void; +} + +/** Options for opening a new window */ +export interface WindowOptions { + /** Explicit ID - auto-generated if not provided */ + id?: string; + /** Window title displayed in header */ + title: string; + /** Initial position - defaults to cascaded from last window */ + position?: Position; + /** Initial size - defaults to 320x420 */ + size?: Size; + /** Minimum size for resizing - defaults to 280x200 */ + minSize?: Size; +} + +/** Default window dimensions */ +export const DEFAULT_WINDOW_SIZE: Size = { + width: 320, + height: 420, +}; + +export const DEFAULT_MIN_SIZE: Size = { + width: 280, + height: 200, +}; + +/** Cascade offset for new windows */ +export const CASCADE_OFFSET = 24; + diff --git a/src/routes/EditorV2/windows/useWindows.ts b/src/routes/EditorV2/windows/useWindows.ts new file mode 100644 index 000000000..214cf279f --- /dev/null +++ b/src/routes/EditorV2/windows/useWindows.ts @@ -0,0 +1,123 @@ +import type { ReactNode } from "react"; +import { useSnapshot } from "valtio"; + +import type { WindowConfig, WindowOptions, WindowRef } from "./types"; +import { + bringToFront, + closeWindow, + getAllWindows, + getHiddenWindows, + getWindowById, + hideWindow, + maximizeWindow, + minimizeWindow, + openWindow, + restoreWindow, + updateWindowContent, + windowStore, +} from "./windowStore"; + +export interface UseWindowsReturn { + /** Open a new window or focus existing one with same ID */ + open: (content: ReactNode, options: WindowOptions) => WindowRef; + /** Get a window configuration by ID */ + getById: (id: string) => WindowConfig | undefined; + /** Get all windows */ + list: () => WindowConfig[]; + /** Get only hidden windows */ + listHidden: () => WindowConfig[]; + /** Close a window by ID */ + close: (id: string) => void; + /** Minimize a window by ID */ + minimize: (id: string) => void; + /** Maximize a window by ID */ + maximize: (id: string) => void; + /** Hide a window to task panel by ID */ + hide: (id: string) => void; + /** Restore a window from hidden/minimized/maximized state */ + restore: (id: string) => void; + /** Bring a window to front */ + focus: (id: string) => void; + /** Update window content */ + updateContent: (id: string, content: ReactNode) => void; +} + +/** + * Hook for managing windows in the EditorV2 windows system. + * + * @example + * ```tsx + * const { open, getById, list } = useWindows(); + * + * const componentMarkup = My Window Content; + * const options: WindowOptions = { title: "My Window", id: "my-window" }; + * + * const windowRef = open(componentMarkup, options); + * + * // Later: close the window + * windowRef.close(); + * ``` + */ +export function useWindows(): UseWindowsReturn { + // Subscribe to store changes for reactive updates + useSnapshot(windowStore); + + const open = (content: ReactNode, options: WindowOptions): WindowRef => { + return openWindow(content, options); + }; + + const getById = (id: string): WindowConfig | undefined => { + return getWindowById(id); + }; + + const list = (): WindowConfig[] => { + return getAllWindows(); + }; + + const listHidden = (): WindowConfig[] => { + return getHiddenWindows(); + }; + + const close = (id: string): void => { + closeWindow(id); + }; + + const minimize = (id: string): void => { + minimizeWindow(id); + }; + + const maximize = (id: string): void => { + maximizeWindow(id); + }; + + const hide = (id: string): void => { + hideWindow(id); + }; + + const restore = (id: string): void => { + restoreWindow(id); + }; + + const focus = (id: string): void => { + bringToFront(id); + }; + + const updateContent = (id: string, content: ReactNode): void => { + updateWindowContent(id, content); + }; + + return { + open, + getById, + list, + listHidden, + close, + minimize, + maximize, + hide, + restore, + focus, + updateContent, + }; +} + diff --git a/src/routes/EditorV2/windows/windowStore.ts b/src/routes/EditorV2/windows/windowStore.ts new file mode 100644 index 000000000..762e0aa38 --- /dev/null +++ b/src/routes/EditorV2/windows/windowStore.ts @@ -0,0 +1,243 @@ +import type { ReactNode } from "react"; +import { proxy } from "valtio"; + +import { + CASCADE_OFFSET, + DEFAULT_MIN_SIZE, + DEFAULT_WINDOW_SIZE, + type Position, + type Size, + type WindowConfig, + type WindowOptions, + type WindowRef, +} from "./types"; + +export interface WindowStore { + /** Map of window ID to window configuration */ + windows: Record; + /** Ordered list of window IDs for z-index stacking (last = top) */ + windowOrder: string[]; +} + +export const windowStore = proxy({ + windows: {}, + windowOrder: [], +}); + +/** + * Content storage - separate from Valtio proxy to avoid React Compiler issues. + * React elements should never be stored in a proxy. + */ +const windowContentMap = new Map(); + +/** Get window content by ID */ +export function getWindowContent(id: string): ReactNode | undefined { + return windowContentMap.get(id); +} + +/** Generate a unique window ID */ +function generateWindowId(): string { + return `window-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`; +} + +/** Calculate position for a new window (cascading from last window) */ +function calculateNewPosition(): Position { + const windowCount = windowStore.windowOrder.length; + const baseX = 100; + const baseY = 100; + + return { + x: baseX + windowCount * CASCADE_OFFSET, + y: baseY + windowCount * CASCADE_OFFSET, + }; +} + +/** Open a new window or focus existing window with same ID */ +export function openWindow( + content: ReactNode, + options: WindowOptions, +): WindowRef { + const id = options.id ?? generateWindowId(); + + // If window with this ID already exists, bring it to front and restore + const existingWindow = windowStore.windows[id]; + if (existingWindow) { + // Update content + windowContentMap.set(id, content); + bringToFront(id); + if ( + existingWindow.state === "hidden" || + existingWindow.state === "minimized" + ) { + restoreWindow(id); + } + return createWindowRef(id); + } + + // Store content separately (not in proxy) + windowContentMap.set(id, content); + + const config: WindowConfig = { + id, + title: options.title, + state: "normal", + position: options.position ?? calculateNewPosition(), + size: options.size ?? { ...DEFAULT_WINDOW_SIZE }, + minSize: options.minSize ?? { ...DEFAULT_MIN_SIZE }, + }; + + windowStore.windows[id] = config; + windowStore.windowOrder.push(id); + + return createWindowRef(id); +} + +/** Close a window (remove from registry) */ +export function closeWindow(id: string): void { + delete windowStore.windows[id]; + windowContentMap.delete(id); + const index = windowStore.windowOrder.indexOf(id); + if (index !== -1) { + windowStore.windowOrder.splice(index, 1); + } +} + +/** Minimize a window (collapse to header only) */ +export function minimizeWindow(id: string): void { + const window = windowStore.windows[id]; + if (!window || window.state === "minimized") return; + + window.previousState = window.state; + window.previousPosition = { ...window.position }; + window.previousSize = { ...window.size }; + window.state = "minimized"; +} + +/** Maximize a window (full screen) */ +export function maximizeWindow(id: string): void { + const window = windowStore.windows[id]; + if (!window || window.state === "maximized") return; + + window.previousState = window.state; + window.previousPosition = { ...window.position }; + window.previousSize = { ...window.size }; + window.state = "maximized"; +} + +/** Hide a window (move to task panel) */ +export function hideWindow(id: string): void { + const window = windowStore.windows[id]; + if (!window || window.state === "hidden") return; + + window.previousState = window.state; + window.previousPosition = { ...window.position }; + window.previousSize = { ...window.size }; + window.state = "hidden"; +} + +/** Restore a window to its previous state */ +export function restoreWindow(id: string): void { + const window = windowStore.windows[id]; + if (!window) return; + + // Restore to normal if no previous state or if coming from hidden/minimized + const targetState = window.previousState ?? "normal"; + + if (window.previousPosition) { + window.position = { ...window.previousPosition }; + } + if (window.previousSize) { + window.size = { ...window.previousSize }; + } + + // If restoring from maximized, go to normal + // If restoring from hidden/minimized, go to previous state (or normal) + window.state = targetState === "maximized" ? "normal" : targetState; + + // Clear previous state after restore + window.previousState = undefined; + window.previousPosition = undefined; + window.previousSize = undefined; + + bringToFront(id); +} + +/** Bring a window to the front (top of z-order) */ +export function bringToFront(id: string): void { + const index = windowStore.windowOrder.indexOf(id); + if (index !== -1 && index !== windowStore.windowOrder.length - 1) { + windowStore.windowOrder.splice(index, 1); + windowStore.windowOrder.push(id); + } +} + +/** Update window position */ +export function updateWindowPosition(id: string, position: Position): void { + const window = windowStore.windows[id]; + if (window) { + window.position = position; + } +} + +/** Update window size */ +export function updateWindowSize(id: string, size: Size): void { + const window = windowStore.windows[id]; + if (window) { + window.size = size; + } +} + +/** Get a window by ID */ +export function getWindowById(id: string): WindowConfig | undefined { + return windowStore.windows[id]; +} + +/** Get all windows as an array */ +export function getAllWindows(): WindowConfig[] { + return windowStore.windowOrder.map((id) => windowStore.windows[id]); +} + +/** Get hidden windows only */ +export function getHiddenWindows(): WindowConfig[] { + return getAllWindows().filter((w) => w.state === "hidden"); +} + +/** Create a WindowRef object for external control */ +function createWindowRef(id: string): WindowRef { + return { + id, + close: () => closeWindow(id), + minimize: () => minimizeWindow(id), + maximize: () => maximizeWindow(id), + hide: () => hideWindow(id), + restore: () => restoreWindow(id), + }; +} + +/** Update window content */ +export function updateWindowContent(id: string, content: ReactNode): void { + windowContentMap.set(id, content); +} + +/** Toggle window state - useful for minimize/maximize buttons */ +export function toggleMinimize(id: string): void { + const window = windowStore.windows[id]; + if (!window) return; + + if (window.state === "minimized") { + restoreWindow(id); + } else { + minimizeWindow(id); + } +} + +export function toggleMaximize(id: string): void { + const window = windowStore.windows[id]; + if (!window) return; + + if (window.state === "maximized") { + restoreWindow(id); + } else { + maximizeWindow(id); + } +} From dcaecd10f95aa09a5088c65dca2ba17aa2e3dbd7 Mon Sep 17 00:00:00 2001 From: Maksym Yezhov Date: Thu, 12 Feb 2026 17:30:12 -0800 Subject: [PATCH 007/225] - node selection tweaks --- src/routes/EditorV2/EditorV2.tsx | 112 +++++++++++- src/routes/EditorV2/components/IONode.tsx | 7 +- .../EditorV2/components/PinnedTaskContent.tsx | 173 ++++++++++++++++++ src/routes/EditorV2/components/TaskNode.tsx | 7 +- src/routes/EditorV2/store/editorStore.ts | 28 +++ src/routes/EditorV2/windows/types.ts | 4 + src/routes/EditorV2/windows/windowStore.ts | 11 ++ 7 files changed, 329 insertions(+), 13 deletions(-) create mode 100644 src/routes/EditorV2/components/PinnedTaskContent.tsx diff --git a/src/routes/EditorV2/EditorV2.tsx b/src/routes/EditorV2/EditorV2.tsx index ee1523d9b..09bbc095e 100644 --- a/src/routes/EditorV2/EditorV2.tsx +++ b/src/routes/EditorV2/EditorV2.tsx @@ -2,24 +2,29 @@ import "@xyflow/react/dist/style.css"; import { useSuspenseQuery } from "@tanstack/react-query"; import { ReactFlowProvider } from "@xyflow/react"; -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { subscribe } from "valtio"; import { withSuspenseWrapper } from "@/components/shared/SuspenseWrapper"; import { InlineStack } from "@/components/ui/layout"; +import { GraphImplementation } from "@/providers/ComponentSpec/graphImplementation"; import { YamlLoader } from "@/providers/ComponentSpec/yamlLoader"; import { ContextPanelContent } from "./components/ContextPanel"; import { DebugPanel } from "./components/DebugPanel"; import { FlowCanvas } from "./components/FlowCanvas"; +import { PinnedTaskContent } from "./components/PinnedTaskContent"; import { Sidebar } from "./components/Sidebar"; import { editorStore, initializeStore } from "./store/editorStore"; import { TaskPanel } from "./windows/TaskPanel"; import { WindowContainer } from "./windows/WindowContainer"; import { + closeWindow, + closeWindowsByLinkedEntity, + getAllWindows, getWindowById, openWindow, - updateWindowContent, + restoreWindow, } from "./windows/windowStore"; const availableTemplates = import.meta.glob("./assets/*.yaml", { @@ -50,8 +55,25 @@ function useLoadSpec() { const CONTEXT_PANEL_WINDOW_ID = "context-panel"; +/** Generate a unique ID for pinned windows */ +function generatePinnedWindowId(): string { + return `pinned-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`; +} + +/** Get task name from entity ID */ +function getTaskNameByEntityId(entityId: string): string | null { + const spec = editorStore.spec; + if (!spec?.implementation || !(spec.implementation instanceof GraphImplementation)) { + return null; + } + const task = spec.implementation.tasks.entities[entityId]; + return task?.name ?? null; +} + const PipelineEditor = withSuspenseWrapper(() => { const spec = useLoadSpec(); + // Track previous task entity IDs to detect deletions + const prevTaskEntityIdsRef = useRef>(new Set()); // Initialize the valtio store with the loaded spec // The spec is wrapped in proxy() inside initializeStore for deep reactivity @@ -67,8 +89,34 @@ const PipelineEditor = withSuspenseWrapper(() => { "color: orange; font-weight: bold;", ops, ); + + // Check for deleted tasks and close their windows + const currentSpec = editorStore.spec; + if (currentSpec?.implementation instanceof GraphImplementation) { + const currentTaskIds = new Set( + Object.keys(currentSpec.implementation.tasks.entities), + ); + + // Find deleted tasks (were in prev, not in current) + for (const prevId of prevTaskEntityIdsRef.current) { + if (!currentTaskIds.has(prevId)) { + // Task was deleted - close any windows linked to it + closeWindowsByLinkedEntity(prevId); + } + } + + // Update the ref with current task IDs + prevTaskEntityIdsRef.current = currentTaskIds; + } }); + // Initialize the task entity IDs ref + if (spec.implementation instanceof GraphImplementation) { + prevTaskEntityIdsRef.current = new Set( + Object.keys(spec.implementation.tasks.entities), + ); + } + return () => { unsubscribe(); // Clear store on unmount @@ -79,33 +127,79 @@ const PipelineEditor = withSuspenseWrapper(() => { } }, [spec]); - // Open/close context panel window based on node selection + // Handle node selection changes and window management useEffect(() => { const unsubscribe = subscribe(editorStore, () => { - const { selectedNodeId, selectedNodeType } = editorStore; + const { + selectedNodeId, + selectedNodeType, + lastSelectionWasShiftClick, + lastShiftClickEntityId, + } = editorStore; + + // Handle shift-click: create a pinned window for the task + if (lastSelectionWasShiftClick && lastShiftClickEntityId) { + const taskName = getTaskNameByEntityId(lastShiftClickEntityId); + if (taskName) { + openWindow(, { + id: generatePinnedWindowId(), + title: taskName, + linkedEntityId: lastShiftClickEntityId, + }); + } + // Clear the shift-click state after processing + editorStore.lastSelectionWasShiftClick = false; + editorStore.lastShiftClickEntityId = null; + return; + } + // Handle regular selection if (selectedNodeId && selectedNodeType) { const existingWindow = getWindowById(CONTEXT_PANEL_WINDOW_ID); - const content = ; if (existingWindow) { - // Update content if window exists - updateWindowContent(CONTEXT_PANEL_WINDOW_ID, content); + // If window exists but is hidden, restore it + if (existingWindow.state === "hidden") { + restoreWindow(CONTEXT_PANEL_WINDOW_ID); + } + // Window exists and is visible - no need to do anything + // ContextPanelContent reads from editorStore.selectedNodeId directly } else { - // Open new window - openWindow(content, { + // Open new properties window + openWindow(, { id: CONTEXT_PANEL_WINDOW_ID, title: "Properties", position: { x: window.innerWidth - 340, y: 80 }, size: { width: 300, height: 400 }, }); } + } else { + // No selection - close the properties window (not pinned windows) + const existingWindow = getWindowById(CONTEXT_PANEL_WINDOW_ID); + if (existingWindow) { + closeWindow(CONTEXT_PANEL_WINDOW_ID); + } } }); return unsubscribe; }, []); + // Cleanup pinned windows for deleted tasks + useEffect(() => { + // This effect runs once to set up cleanup logic + // The actual cleanup happens in the spec subscription above + return () => { + // On unmount, close all windows linked to entities + const windows = getAllWindows(); + for (const win of windows) { + if (win.linkedEntityId) { + closeWindow(win.id); + } + } + }; + }, []); + return ( <> diff --git a/src/routes/EditorV2/components/IONode.tsx b/src/routes/EditorV2/components/IONode.tsx index 5b69993d5..0f7d38fd9 100644 --- a/src/routes/EditorV2/components/IONode.tsx +++ b/src/routes/EditorV2/components/IONode.tsx @@ -37,8 +37,11 @@ export function IONode({ id, data, selected }: IONodeProps) { ? spec?.inputs.entities[entityId] : spec?.outputs.entities[entityId]; - const handleClick = () => { - selectNode(id, ioType); + const handleClick = (event: React.MouseEvent) => { + selectNode(id, ioType, { + shiftKey: event.shiftKey, + entityId, + }); }; if (!entity) { diff --git a/src/routes/EditorV2/components/PinnedTaskContent.tsx b/src/routes/EditorV2/components/PinnedTaskContent.tsx new file mode 100644 index 000000000..15431a913 --- /dev/null +++ b/src/routes/EditorV2/components/PinnedTaskContent.tsx @@ -0,0 +1,173 @@ +import type { ChangeEvent } from "react"; +import { useSnapshot } from "valtio"; + +import { Icon } from "@/components/ui/icon"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { BlockStack, InlineStack } from "@/components/ui/layout"; +import { Separator } from "@/components/ui/separator"; +import { Text } from "@/components/ui/typography"; +import { GraphImplementation } from "@/providers/ComponentSpec/graphImplementation"; + +import { renameTask } from "../store/actions"; +import { editorStore } from "../store/editorStore"; + +interface PinnedTaskContentProps { + /** The entity ID of the task to display */ + entityId: string; +} + +/** + * Content for a pinned task window. + * Shows details for a specific task, independent of the current selection. + * Used for shift-click "pinned" windows. + */ +export function PinnedTaskContent({ entityId }: PinnedTaskContentProps) { + const snapshot = useSnapshot(editorStore); + const spec = snapshot.spec; + + if ( + !spec?.implementation || + !(spec.implementation instanceof GraphImplementation) + ) { + return ; + } + + // Get task directly from entities using $id + const task = spec.implementation.tasks.entities[entityId]; + if (!task) { + return ; + } + + const componentSpec = task.componentRef.spec; + + const handleNameChange = (event: ChangeEvent) => { + const newName = event.target.value; + if (newName && newName !== task.name) { + renameTask(entityId, newName); + } + }; + + return ( + + + + + + + + + + {componentSpec?.description && ( + + + + {componentSpec.description} + + + )} + + + + {componentSpec?.inputs && componentSpec.inputs.length > 0 && ( + + + + {componentSpec.inputs.map((input) => ( + + + {input.name} + + {input.type && ( + + : {String(input.type)} + + )} + {input.optional && ( + + (optional) + + )} + + ))} + + + )} + + {componentSpec?.outputs && componentSpec.outputs.length > 0 && ( + + + + {componentSpec.outputs.map((output) => ( + + + {output.name} + + {output.type && ( + + : {String(output.type)} + + )} + + ))} + + + )} + + + ); +} + +interface PanelHeaderProps { + taskName: string; +} + +function PanelHeader({ taskName }: PanelHeaderProps) { + return ( + + + + {taskName} + + + ); +} + +interface NotFoundStateProps { + entityId: string; +} + +function NotFoundState({ entityId }: NotFoundStateProps) { + return ( + + + + Task not found + + + {entityId} + + + ); +} + diff --git a/src/routes/EditorV2/components/TaskNode.tsx b/src/routes/EditorV2/components/TaskNode.tsx index cbc387cb8..5e792f318 100644 --- a/src/routes/EditorV2/components/TaskNode.tsx +++ b/src/routes/EditorV2/components/TaskNode.tsx @@ -39,8 +39,11 @@ export function TaskNode({ id, data, selected }: TaskNodeProps) { ? spec.implementation.tasks.findById(entityId) : null; - const handleClick = () => { - selectNode(id, "task"); + const handleClick = (event: React.MouseEvent) => { + selectNode(id, "task", { + shiftKey: event.shiftKey, + entityId, + }); }; if (!task) { diff --git a/src/routes/EditorV2/store/editorStore.ts b/src/routes/EditorV2/store/editorStore.ts index 1019b5c4a..2e2faff53 100644 --- a/src/routes/EditorV2/store/editorStore.ts +++ b/src/routes/EditorV2/store/editorStore.ts @@ -6,12 +6,18 @@ export interface EditorStore { spec: ComponentSpecEntity | null; selectedNodeId: string | null; selectedNodeType: "task" | "input" | "output" | null; + /** Tracks if shift key was held during last selection (for pinned windows) */ + lastSelectionWasShiftClick: boolean; + /** Entity ID from the last shift-click (for creating pinned window) */ + lastShiftClickEntityId: string | null; } export const editorStore = proxy({ spec: null, selectedNodeId: null, selectedNodeType: null, + lastSelectionWasShiftClick: false, + lastShiftClickEntityId: null, }); /** @@ -24,15 +30,35 @@ export function initializeStore(spec: ComponentSpecEntity) { editorStore.spec = spec; editorStore.selectedNodeId = null; editorStore.selectedNodeType = null; + editorStore.lastSelectionWasShiftClick = false; + editorStore.lastShiftClickEntityId = null; } /** * Select a node by its ID and type. + * @param nodeId - The flow node ID + * @param nodeType - The type of node (task, input, output) + * @param options - Additional options + * @param options.shiftKey - If true, indicates this should create a pinned window + * @param options.entityId - The entity ID (different from nodeId for tasks) */ export function selectNode( nodeId: string | null, nodeType: EditorStore["selectedNodeType"] = null, + options?: { shiftKey?: boolean; entityId?: string }, ) { + const isShiftClick = options?.shiftKey ?? false; + + // Track shift-click for pinned window creation + editorStore.lastSelectionWasShiftClick = isShiftClick; + editorStore.lastShiftClickEntityId = isShiftClick ? (options?.entityId ?? null) : null; + + // If it's a shift-click, don't change the current selection + // The window system will handle creating a pinned window + if (isShiftClick) { + return; + } + editorStore.selectedNodeId = nodeId; editorStore.selectedNodeType = nodeType; } @@ -43,4 +69,6 @@ export function selectNode( export function clearSelection() { editorStore.selectedNodeId = null; editorStore.selectedNodeType = null; + editorStore.lastSelectionWasShiftClick = false; + editorStore.lastShiftClickEntityId = null; } diff --git a/src/routes/EditorV2/windows/types.ts b/src/routes/EditorV2/windows/types.ts index b32494f67..e3897e109 100644 --- a/src/routes/EditorV2/windows/types.ts +++ b/src/routes/EditorV2/windows/types.ts @@ -25,6 +25,8 @@ export interface WindowConfig { previousPosition?: Position; previousSize?: Size; previousState?: WindowState; + /** Optional entity ID this window is linked to (for auto-close on entity deletion) */ + linkedEntityId?: string; } /** Reference returned from open() for controlling a window */ @@ -49,6 +51,8 @@ export interface WindowOptions { size?: Size; /** Minimum size for resizing - defaults to 280x200 */ minSize?: Size; + /** Optional entity ID to link this window to (for auto-close on entity deletion) */ + linkedEntityId?: string; } /** Default window dimensions */ diff --git a/src/routes/EditorV2/windows/windowStore.ts b/src/routes/EditorV2/windows/windowStore.ts index 762e0aa38..09e167f57 100644 --- a/src/routes/EditorV2/windows/windowStore.ts +++ b/src/routes/EditorV2/windows/windowStore.ts @@ -84,6 +84,7 @@ export function openWindow( position: options.position ?? calculateNewPosition(), size: options.size ?? { ...DEFAULT_WINDOW_SIZE }, minSize: options.minSize ?? { ...DEFAULT_MIN_SIZE }, + linkedEntityId: options.linkedEntityId, }; windowStore.windows[id] = config; @@ -202,6 +203,16 @@ export function getHiddenWindows(): WindowConfig[] { return getAllWindows().filter((w) => w.state === "hidden"); } +/** Close all windows linked to a specific entity */ +export function closeWindowsByLinkedEntity(entityId: string): void { + const windowsToClose = windowStore.windowOrder.filter( + (id) => windowStore.windows[id]?.linkedEntityId === entityId, + ); + for (const id of windowsToClose) { + closeWindow(id); + } +} + /** Create a WindowRef object for external control */ function createWindowRef(id: string): WindowRef { return { From b2edfaeda08fdbd8640144dd97522a1ba2801123 Mon Sep 17 00:00:00 2001 From: Maksym Yezhov Date: Thu, 12 Feb 2026 17:35:03 -0800 Subject: [PATCH 008/225] - debug window is hidden by default --- src/routes/EditorV2/components/DebugPanel.tsx | 31 ++++++++++++++++--- 1 file changed, 26 insertions(+), 5 deletions(-) diff --git a/src/routes/EditorV2/components/DebugPanel.tsx b/src/routes/EditorV2/components/DebugPanel.tsx index 09ffe0e33..87aa34a69 100644 --- a/src/routes/EditorV2/components/DebugPanel.tsx +++ b/src/routes/EditorV2/components/DebugPanel.tsx @@ -11,10 +11,11 @@ import { editorStore } from "../store/editorStore"; import { closeWindow, getWindowById, + hideWindow, openWindow, } from "../windows/windowStore"; -const DEBUG_PANEL_WINDOW_ID = "debug-panel"; +export const DEBUG_PANEL_WINDOW_ID = "debug-panel"; // Computed value that automatically updates when spec changes const derivedState = computed({ @@ -155,12 +156,30 @@ export function DebugPanelContent() { } /** - * DebugPanel component that opens the debug panel as a window on mount. - * This component manages the window lifecycle. + * Toggle the debug panel window visibility. + */ +export function toggleDebugPanel() { + const existingWindow = getWindowById(DEBUG_PANEL_WINDOW_ID); + if (existingWindow) { + closeWindow(DEBUG_PANEL_WINDOW_ID); + } else { + openWindow(, { + id: DEBUG_PANEL_WINDOW_ID, + title: "Debug Panel", + position: { x: 16, y: 16 }, + size: { width: 320, height: 420 }, + }); + } +} + +/** + * DebugPanel component that manages the debug panel window lifecycle. + * The window is created but hidden by default - it appears in the TaskPanel + * and can be restored from there or toggled with toggleDebugPanel(). */ export function DebugPanel() { useEffect(() => { - // Open the debug panel window if it doesn't exist + // Create the window but immediately hide it so it appears in TaskPanel const existingWindow = getWindowById(DEBUG_PANEL_WINDOW_ID); if (!existingWindow) { openWindow(, { @@ -169,6 +188,8 @@ export function DebugPanel() { position: { x: 16, y: 16 }, size: { width: 320, height: 420 }, }); + // Hide it immediately so it starts in the TaskPanel + hideWindow(DEBUG_PANEL_WINDOW_ID); } // Cleanup: close window on unmount @@ -177,6 +198,6 @@ export function DebugPanel() { }; }, []); - // This component doesn't render anything - it just manages the window + // This component doesn't render anything - it just manages the window lifecycle return null; } From 68159af2cd5dd99274b38b191fd275c6cc31f906 Mon Sep 17 00:00:00 2001 From: Maksym Yezhov Date: Thu, 12 Feb 2026 17:48:23 -0800 Subject: [PATCH 009/225] - component library --- src/routes/EditorV2/EditorV2.tsx | 30 ++- .../components/ComponentLibraryContent.tsx | 16 ++ src/routes/EditorV2/components/Sidebar.tsx | 238 ------------------ 3 files changed, 39 insertions(+), 245 deletions(-) create mode 100644 src/routes/EditorV2/components/ComponentLibraryContent.tsx delete mode 100644 src/routes/EditorV2/components/Sidebar.tsx diff --git a/src/routes/EditorV2/EditorV2.tsx b/src/routes/EditorV2/EditorV2.tsx index 09bbc095e..eabe540cf 100644 --- a/src/routes/EditorV2/EditorV2.tsx +++ b/src/routes/EditorV2/EditorV2.tsx @@ -6,15 +6,16 @@ import { useEffect, useRef, useState } from "react"; import { subscribe } from "valtio"; import { withSuspenseWrapper } from "@/components/shared/SuspenseWrapper"; -import { InlineStack } from "@/components/ui/layout"; +import { ComponentLibraryProvider } from "@/providers/ComponentLibraryProvider"; +import { ForcedSearchProvider } from "@/providers/ComponentLibraryProvider/ForcedSearchProvider"; import { GraphImplementation } from "@/providers/ComponentSpec/graphImplementation"; import { YamlLoader } from "@/providers/ComponentSpec/yamlLoader"; +import { ComponentLibraryContent } from "./components/ComponentLibraryContent"; import { ContextPanelContent } from "./components/ContextPanel"; import { DebugPanel } from "./components/DebugPanel"; import { FlowCanvas } from "./components/FlowCanvas"; import { PinnedTaskContent } from "./components/PinnedTaskContent"; -import { Sidebar } from "./components/Sidebar"; import { editorStore, initializeStore } from "./store/editorStore"; import { TaskPanel } from "./windows/TaskPanel"; import { WindowContainer } from "./windows/WindowContainer"; @@ -54,6 +55,7 @@ function useLoadSpec() { } const CONTEXT_PANEL_WINDOW_ID = "context-panel"; +const COMPONENT_LIBRARY_WINDOW_ID = "component-library"; /** Generate a unique ID for pinned windows */ function generatePinnedWindowId(): string { @@ -200,13 +202,23 @@ const PipelineEditor = withSuspenseWrapper(() => { }; }, []); + // Open component library window on mount + useEffect(() => { + const existingWindow = getWindowById(COMPONENT_LIBRARY_WINDOW_ID); + if (!existingWindow) { + openWindow(, { + id: COMPONENT_LIBRARY_WINDOW_ID, + title: "Components", + position: { x: 20, y: 80 }, + size: { width: 280, height: 500 }, + }); + } + }, []); + return ( <> - - - - + @@ -217,7 +229,11 @@ export function EditorV2() { return (
- + + + + +
); diff --git a/src/routes/EditorV2/components/ComponentLibraryContent.tsx b/src/routes/EditorV2/components/ComponentLibraryContent.tsx new file mode 100644 index 000000000..e92ae6f36 --- /dev/null +++ b/src/routes/EditorV2/components/ComponentLibraryContent.tsx @@ -0,0 +1,16 @@ +import GraphComponents from "@/components/shared/ReactFlow/FlowSidebar/sections/GraphComponents"; +import { BlockStack } from "@/components/ui/layout"; + +/** + * Wrapper component that renders the component library inside a Window. + * GraphComponents is designed for the sidebar but works well in a window context + * when rendered in "open" (expanded) mode. + */ +export function ComponentLibraryContent() { + return ( + + + + ); +} + diff --git a/src/routes/EditorV2/components/Sidebar.tsx b/src/routes/EditorV2/components/Sidebar.tsx deleted file mode 100644 index cb04ba211..000000000 --- a/src/routes/EditorV2/components/Sidebar.tsx +++ /dev/null @@ -1,238 +0,0 @@ -import type { DragEvent } from "react"; - -import { Icon } from "@/components/ui/icon"; -import { BlockStack, InlineStack } from "@/components/ui/layout"; -import { Separator } from "@/components/ui/separator"; -import { Text } from "@/components/ui/typography"; -import { cn } from "@/lib/utils"; -import type { ComponentReference, TaskSpec } from "@/utils/componentSpec"; - -/** - * Hardcoded sample components for the MVP. - * In a real implementation, these would come from a component library. - */ -const SAMPLE_COMPONENTS: ComponentReference[] = [ - { - name: "Data Loader", - digest: "sample-data-loader", - spec: { - name: "Data Loader", - description: "Loads data from a source", - inputs: [ - { - name: "source_url", - type: "String", - description: "URL of the data source", - }, - { name: "format", type: "String", default: "csv", optional: true }, - ], - outputs: [{ name: "data", type: "Dataset", description: "Loaded data" }], - implementation: { - container: { - image: "python:3.10", - command: ["python", "-c", "print('Loading data...')"], - }, - }, - }, - }, - { - name: "Data Transform", - digest: "sample-data-transform", - spec: { - name: "Data Transform", - description: "Transforms input data", - inputs: [ - { name: "input_data", type: "Dataset" }, - { name: "transform_type", type: "String", default: "normalize" }, - ], - outputs: [{ name: "transformed_data", type: "Dataset" }], - implementation: { - container: { - image: "python:3.10", - command: ["python", "-c", "print('Transforming...')"], - }, - }, - }, - }, - { - name: "Model Trainer", - digest: "sample-model-trainer", - spec: { - name: "Model Trainer", - description: "Trains a machine learning model", - inputs: [ - { name: "training_data", type: "Dataset" }, - { name: "model_type", type: "String", default: "linear" }, - { name: "epochs", type: "Integer", default: "10", optional: true }, - ], - outputs: [ - { name: "model", type: "Model" }, - { name: "metrics", type: "Metrics" }, - ], - implementation: { - container: { - image: "python:3.10", - command: ["python", "-c", "print('Training...')"], - }, - }, - }, - }, - { - name: "Model Predictor", - digest: "sample-model-predictor", - spec: { - name: "Model Predictor", - description: "Makes predictions using a trained model", - inputs: [ - { name: "model", type: "Model" }, - { name: "input_data", type: "Dataset" }, - ], - outputs: [{ name: "predictions", type: "Dataset" }], - implementation: { - container: { - image: "python:3.10", - command: ["python", "-c", "print('Predicting...')"], - }, - }, - }, - }, -]; - -interface DraggableItemProps { - children: React.ReactNode; - onDragStart: (event: DragEvent) => void; - className?: string; -} - -function DraggableItem({ - children, - onDragStart, - className, -}: DraggableItemProps) { - return ( -
- {children} -
- ); -} - -interface ComponentItemProps { - component: ComponentReference; -} - -function ComponentItem({ component }: ComponentItemProps) { - const handleDragStart = (event: DragEvent) => { - const taskSpec: TaskSpec = { - componentRef: component, - }; - - event.dataTransfer.setData( - "application/reactflow", - JSON.stringify({ task: taskSpec }), - ); - event.dataTransfer.effectAllowed = "move"; - }; - - return ( - - - - - - {component.name ?? component.spec?.name ?? "Unknown"} - - {component.spec?.description && ( - - {component.spec.description} - - )} - - - - ); -} - -interface IOItemProps { - type: "input" | "output"; -} - -function IOItem({ type }: IOItemProps) { - const handleDragStart = (event: DragEvent) => { - event.dataTransfer.setData( - "application/reactflow", - JSON.stringify({ [type]: null }), - ); - event.dataTransfer.effectAllowed = "move"; - }; - - const isInput = type === "input"; - - return ( - - - - - {isInput ? "Graph Input" : "Graph Output"} - - - - ); -} - -export function Sidebar() { - return ( - - - - Inputs & Outputs - - - - - - - - - - - - - Sample Components - - - Drag components to the canvas - - - - - {SAMPLE_COMPONENTS.map((component) => ( - - ))} - - - ); -} From d1476d68140469d7276f5027cfd481c78960bfbe Mon Sep 17 00:00:00 2001 From: Maksym Yezhov Date: Thu, 12 Feb 2026 17:50:18 -0800 Subject: [PATCH 010/225] - windows: disable some actions like close --- src/routes/EditorV2/EditorV2.tsx | 3 +- src/routes/EditorV2/components/DebugPanel.tsx | 20 +---- src/routes/EditorV2/windows/TaskPanel.tsx | 69 ++++++++------- src/routes/EditorV2/windows/Window.tsx | 87 +++++++++++-------- src/routes/EditorV2/windows/types.ts | 7 ++ src/routes/EditorV2/windows/windowStore.ts | 1 + 6 files changed, 98 insertions(+), 89 deletions(-) diff --git a/src/routes/EditorV2/EditorV2.tsx b/src/routes/EditorV2/EditorV2.tsx index eabe540cf..7c62b45f0 100644 --- a/src/routes/EditorV2/EditorV2.tsx +++ b/src/routes/EditorV2/EditorV2.tsx @@ -209,8 +209,9 @@ const PipelineEditor = withSuspenseWrapper(() => { openWindow(, { id: COMPONENT_LIBRARY_WINDOW_ID, title: "Components", - position: { x: 20, y: 80 }, + position: { x: 0, y: 60 }, size: { width: 280, height: 500 }, + disabledActions: ["close"], }); } }, []); diff --git a/src/routes/EditorV2/components/DebugPanel.tsx b/src/routes/EditorV2/components/DebugPanel.tsx index 87aa34a69..2b0939d62 100644 --- a/src/routes/EditorV2/components/DebugPanel.tsx +++ b/src/routes/EditorV2/components/DebugPanel.tsx @@ -155,27 +155,10 @@ export function DebugPanelContent() { ); } -/** - * Toggle the debug panel window visibility. - */ -export function toggleDebugPanel() { - const existingWindow = getWindowById(DEBUG_PANEL_WINDOW_ID); - if (existingWindow) { - closeWindow(DEBUG_PANEL_WINDOW_ID); - } else { - openWindow(, { - id: DEBUG_PANEL_WINDOW_ID, - title: "Debug Panel", - position: { x: 16, y: 16 }, - size: { width: 320, height: 420 }, - }); - } -} - /** * DebugPanel component that manages the debug panel window lifecycle. * The window is created but hidden by default - it appears in the TaskPanel - * and can be restored from there or toggled with toggleDebugPanel(). + * and can be restored from there. */ export function DebugPanel() { useEffect(() => { @@ -187,6 +170,7 @@ export function DebugPanel() { title: "Debug Panel", position: { x: 16, y: 16 }, size: { width: 320, height: 420 }, + disabledActions: ["close"], }); // Hide it immediately so it starts in the TaskPanel hideWindow(DEBUG_PANEL_WINDOW_ID); diff --git a/src/routes/EditorV2/windows/TaskPanel.tsx b/src/routes/EditorV2/windows/TaskPanel.tsx index c85733c72..52e52d0f1 100644 --- a/src/routes/EditorV2/windows/TaskPanel.tsx +++ b/src/routes/EditorV2/windows/TaskPanel.tsx @@ -28,40 +28,45 @@ export function TaskPanel() { return (
- {hiddenWindows.map((window) => ( - - - - - ))} + + + {window.title} + + + {canClose && ( + + )} + + ); + })}
); diff --git a/src/routes/EditorV2/windows/Window.tsx b/src/routes/EditorV2/windows/Window.tsx index ec67a8e7e..91b0806cd 100644 --- a/src/routes/EditorV2/windows/Window.tsx +++ b/src/routes/EditorV2/windows/Window.tsx @@ -7,7 +7,7 @@ import { InlineStack } from "@/components/ui/layout"; import { Text } from "@/components/ui/typography"; import { cn } from "@/lib/utils"; -import type { Position, WindowConfig } from "./types"; +import type { Position, WindowAction, WindowConfig } from "./types"; import { bringToFront, closeWindow, @@ -38,12 +38,15 @@ export function Window({ windowId }: WindowProps) { return null; } - const { title, state, position, size, minSize } = windowConfig; + const { title, state, position, size, minSize, disabledActions } = windowConfig; // Get content from separate map (not stored in proxy to avoid React Compiler issues) const content = getWindowContent(windowId); const isMinimized = state === "minimized"; const isMaximized = state === "maximized"; + const isActionDisabled = (action: WindowAction) => + disabledActions?.includes(action) ?? false; + const handleMouseDown = () => { bringToFront(windowId); }; @@ -158,48 +161,56 @@ export function Window({ windowId }: WindowProps) { {/* Minimize button (collapse to header) */} - + {!isActionDisabled("minimize") && ( + + )} {/* Maximize button */} - + {!isActionDisabled("maximize") && ( + + )} {/* Hide button (to task panel) */} - + {!isActionDisabled("hide") && ( + + )} {/* Close button */} - + {!isActionDisabled("close") && ( + + )}
diff --git a/src/routes/EditorV2/windows/types.ts b/src/routes/EditorV2/windows/types.ts index e3897e109..6733669b8 100644 --- a/src/routes/EditorV2/windows/types.ts +++ b/src/routes/EditorV2/windows/types.ts @@ -1,6 +1,9 @@ /** Window display state */ export type WindowState = "normal" | "maximized" | "minimized" | "hidden"; +/** Actions that can be performed on a window */ +export type WindowAction = "close" | "minimize" | "maximize" | "hide"; + /** Position coordinates */ export interface Position { x: number; @@ -27,6 +30,8 @@ export interface WindowConfig { previousState?: WindowState; /** Optional entity ID this window is linked to (for auto-close on entity deletion) */ linkedEntityId?: string; + /** Actions that are disabled for this window */ + disabledActions?: WindowAction[]; } /** Reference returned from open() for controlling a window */ @@ -53,6 +58,8 @@ export interface WindowOptions { minSize?: Size; /** Optional entity ID to link this window to (for auto-close on entity deletion) */ linkedEntityId?: string; + /** Actions to disable for this window (e.g., ["close"] for non-closable windows) */ + disabledActions?: WindowAction[]; } /** Default window dimensions */ diff --git a/src/routes/EditorV2/windows/windowStore.ts b/src/routes/EditorV2/windows/windowStore.ts index 09e167f57..3a2e72c0f 100644 --- a/src/routes/EditorV2/windows/windowStore.ts +++ b/src/routes/EditorV2/windows/windowStore.ts @@ -85,6 +85,7 @@ export function openWindow( size: options.size ?? { ...DEFAULT_WINDOW_SIZE }, minSize: options.minSize ?? { ...DEFAULT_MIN_SIZE }, linkedEntityId: options.linkedEntityId, + disabledActions: options.disabledActions, }; windowStore.windows[id] = config; From ae5d2457545c0100bb6f2d25eb425afd950a9775 Mon Sep 17 00:00:00 2001 From: Maksym Yezhov Date: Thu, 12 Feb 2026 17:56:27 -0800 Subject: [PATCH 011/225] - task panel visual changes --- src/routes/EditorV2/EditorV2.tsx | 2 +- src/routes/EditorV2/windows/TaskPanel.tsx | 10 +++++++--- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/src/routes/EditorV2/EditorV2.tsx b/src/routes/EditorV2/EditorV2.tsx index 7c62b45f0..b60ea218d 100644 --- a/src/routes/EditorV2/EditorV2.tsx +++ b/src/routes/EditorV2/EditorV2.tsx @@ -209,7 +209,7 @@ const PipelineEditor = withSuspenseWrapper(() => { openWindow(, { id: COMPONENT_LIBRARY_WINDOW_ID, title: "Components", - position: { x: 0, y: 60 }, + position: { x: 0, y: 100 }, size: { width: 280, height: 500 }, disabledActions: ["close"], }); diff --git a/src/routes/EditorV2/windows/TaskPanel.tsx b/src/routes/EditorV2/windows/TaskPanel.tsx index 52e52d0f1..339a055e8 100644 --- a/src/routes/EditorV2/windows/TaskPanel.tsx +++ b/src/routes/EditorV2/windows/TaskPanel.tsx @@ -4,11 +4,12 @@ import { Button } from "@/components/ui/button"; import { Icon } from "@/components/ui/icon"; import { InlineStack } from "@/components/ui/layout"; import { Text } from "@/components/ui/typography"; +import { TOP_NAV_HEIGHT } from "@/utils/constants"; import { closeWindow, restoreWindow, windowStore } from "./windowStore"; /** - * Fixed bottom panel that displays hidden windows. + * Fixed top panel that displays hidden windows (below the AppMenu). * Only renders when there are hidden windows. * Click on a window title restores it, click X closes it. */ @@ -26,7 +27,10 @@ export function TaskPanel() { } return ( -
+
{hiddenWindows.map((window) => { const canClose = !window.disabledActions?.includes("close"); @@ -39,7 +43,7 @@ export function TaskPanel() { >