From 631acc37d99aa615c524931c3e5c484fcc2310d6 Mon Sep 17 00:00:00 2001 From: Camiel van Schoonhoven Date: Thu, 18 Jun 2026 17:58:56 -0700 Subject: [PATCH] fix: Example Pipeline Serialization in V2 Editor --- .../serialization/jsonSerializer.test.ts | 31 +++++ .../serialization/yamlDeserializer.test.ts | 114 ++++++++++++++++++ .../serialization/jsonSerializer.ts | 5 +- .../serialization/yamlDeserializer.ts | 22 +++- 4 files changed, 168 insertions(+), 4 deletions(-) diff --git a/src/models/componentSpec/__tests__/serialization/jsonSerializer.test.ts b/src/models/componentSpec/__tests__/serialization/jsonSerializer.test.ts index 5c00a3a2b..871054ccb 100644 --- a/src/models/componentSpec/__tests__/serialization/jsonSerializer.test.ts +++ b/src/models/componentSpec/__tests__/serialization/jsonSerializer.test.ts @@ -73,6 +73,37 @@ describe("JsonSerializer", () => { }); }); + it("drops the derived spec from a text-backed componentRef on write", () => { + const spec = new ComponentSpec({ + $id: idGen.next("spec"), + name: "Pipeline", + }); + const task = new Task({ + $id: idGen.next("task"), + name: "Add", + componentRef: { + url: "http://x/c.yaml", + digest: "abc", + text: "name: Adder\nimplementation:\n container:\n image: python:3.9", + spec: { + name: "Adder", + implementation: { container: { image: "python:3.9" } }, + }, + }, + }); + spec.addTask(task); + + const componentRef = getGraph(serializer.serialize(spec)).tasks["Add"] + .componentRef; + + expect(componentRef.spec).toBeUndefined(); + expect(componentRef).toMatchObject({ + url: "http://x/c.yaml", + digest: "abc", + }); + expect(componentRef.text).toContain("Adder"); + }); + it("serializes task with isEnabled", () => { const spec = new ComponentSpec({ $id: idGen.next("spec"), diff --git a/src/models/componentSpec/__tests__/serialization/yamlDeserializer.test.ts b/src/models/componentSpec/__tests__/serialization/yamlDeserializer.test.ts index 99d95d783..ee90da31a 100644 --- a/src/models/componentSpec/__tests__/serialization/yamlDeserializer.test.ts +++ b/src/models/componentSpec/__tests__/serialization/yamlDeserializer.test.ts @@ -438,4 +438,118 @@ describe("YamlDeserializer", () => { expect(spec.bindings.length).toBe(0); expect(spec.tasks.at(0)?.arguments.length).toBe(1); }); + + describe("componentRef spec hydration", () => { + const containerText = [ + "name: Adder", + "inputs:", + "- {name: a, type: Integer}", + "- {name: b, type: Integer}", + "outputs:", + "- {name: sum, type: Integer}", + "implementation:", + " container:", + " image: python:3.9", + ].join("\n"); + + it("hydrates componentRef.spec from text when spec is absent", () => { + const yaml = { + name: "Pipeline", + implementation: { + graph: { + tasks: { + Add: { + componentRef: { url: "http://x/c.yaml", digest: "abc", text: containerText }, + }, + }, + }, + }, + }; + + const spec = deserializer.deserialize(yaml); + const componentRef = spec.tasks.at(0)?.componentRef; + + expect(componentRef?.spec).toBeDefined(); + expect(componentRef?.spec?.inputs?.map((i) => i.name)).toEqual(["a", "b"]); + expect(componentRef?.spec?.outputs?.map((o) => o.name)).toEqual(["sum"]); + // url/digest/text are preserved alongside the derived spec. + expect(componentRef?.url).toBe("http://x/c.yaml"); + expect(componentRef?.digest).toBe("abc"); + expect(componentRef?.text).toBe(containerText); + }); + + it("keeps an existing parsed spec and does not re-derive from text", () => { + const inlineSpec = { + name: "Inline", + implementation: { container: { image: "node:20" } }, + }; + const yaml = { + name: "Pipeline", + implementation: { + graph: { + tasks: { + Task1: { + componentRef: { spec: inlineSpec, text: containerText }, + }, + }, + }, + }, + }; + + const spec = deserializer.deserialize(yaml); + + // The pre-parsed spec wins over the (different) text. + expect(spec.tasks.at(0)?.componentRef.spec?.name).toBe("Inline"); + }); + + it("promotes a text-backed graph implementation to subgraphSpec", () => { + const graphText = [ + "name: InnerPipeline", + "implementation:", + " graph:", + " tasks: {}", + ].join("\n"); + const yaml = { + name: "Pipeline", + implementation: { + graph: { + tasks: { + Sub: { componentRef: { text: graphText } }, + }, + }, + }, + }; + + const spec = deserializer.deserialize(yaml); + const task = spec.tasks.at(0); + + expect(task?.subgraphSpec).toBeDefined(); + expect(task?.subgraphSpec?.name).toBe("InnerPipeline"); + // Graph spec is promoted, not left inline on the ref. + expect(task?.componentRef.spec).toBeUndefined(); + }); + + it("falls back gracefully when text is not a valid component spec", () => { + const yaml = { + name: "Pipeline", + implementation: { + graph: { + tasks: { + Broken: { + componentRef: { text: "this is not a component spec" }, + }, + }, + }, + }, + }; + + expect(() => deserializer.deserialize(yaml)).not.toThrow(); + + const componentRef = deserializer + .deserialize(yaml) + .tasks.at(0)?.componentRef; + expect(componentRef?.spec).toBeUndefined(); + expect(componentRef?.text).toBe("this is not a component spec"); + }); + }); }); diff --git a/src/models/componentSpec/serialization/jsonSerializer.ts b/src/models/componentSpec/serialization/jsonSerializer.ts index 1ed909e0a..1d2c061c0 100644 --- a/src/models/componentSpec/serialization/jsonSerializer.ts +++ b/src/models/componentSpec/serialization/jsonSerializer.ts @@ -85,9 +85,12 @@ export class JsonSerializer { ); const args = this.serializeArguments(task.arguments, taskBindings, spec); + // For text-backed refs, `text` is authoritative; drop the load-time derived spec. const componentRef = task.subgraphSpec ? { ...task.componentRef, spec: this.serialize(task.subgraphSpec) } - : task.componentRef; + : task.componentRef.text + ? { ...task.componentRef, spec: undefined } + : task.componentRef; const result: TaskSpec = { componentRef, diff --git a/src/models/componentSpec/serialization/yamlDeserializer.ts b/src/models/componentSpec/serialization/yamlDeserializer.ts index 3c4a4a1c7..85f18ac0b 100644 --- a/src/models/componentSpec/serialization/yamlDeserializer.ts +++ b/src/models/componentSpec/serialization/yamlDeserializer.ts @@ -1,3 +1,5 @@ +import { componentSpecFromYaml } from "@/utils/yaml"; + import { Annotations, deserializeAnnotationValue } from "../annotations"; import { Binding } from "../entities/binding"; import { ComponentSpec } from "../entities/componentSpec"; @@ -139,10 +141,11 @@ export class YamlDeserializer { } } - const subgraphSpec = this.maybeDeserializeSubgraph(taskJson.componentRef); + const resolvedRef = this.resolveComponentRefSpec(taskJson.componentRef); + const subgraphSpec = this.maybeDeserializeSubgraph(resolvedRef); const componentRef = subgraphSpec - ? { ...taskJson.componentRef, spec: undefined } - : taskJson.componentRef; + ? { ...resolvedRef, spec: undefined } + : resolvedRef; return new Task({ $id: this.idGen.next("task"), @@ -157,6 +160,19 @@ export class YamlDeserializer { }); } + // Derive `spec` from inlined `text` as a fallback, only when no spec exists. + private resolveComponentRefSpec(ref: ComponentReference): ComponentReference { + if (ref.spec) return ref; + if (!ref.text) return ref; + + try { + return { ...ref, spec: componentSpecFromYaml(ref.text) }; + } catch (error) { + console.warn("Failed to parse componentRef.text into spec:", error); + return ref; + } + } + private maybeDeserializeSubgraph( ref: ComponentReference, ): ComponentSpec | undefined {