From 707cd98181024dadc189e4923d8fdd3db62f5385 Mon Sep 17 00:00:00 2001 From: Morgan Wowk Date: Fri, 5 Jun 2026 12:18:29 -0700 Subject: [PATCH] Capture component lineage on task creation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stamp a stable origin link (the source url, else the original digest) on each task as a task-level annotation when it first enters a pipeline. Lineage lives on the task — not in the component spec text — so it never perturbs the content-addressed digest and survives edits for free (a componentRef swap leaves task annotations intact). This is the foundation for tracing and reconciling every instance descended from one origin, even after edits change their digests. - src/utils/lineage.ts: ComponentLineage type/schema + originIdOf, makeLineage, embeddedLineageOf, resolveLineageForRef helpers - register the lineage annotation key + JSON codec so it round-trips through YAML - stamp at createTaskFromComponentRef (the v2 creation choke point); seed from an embedded spec lineage when present (published origin) - edit -> 'Place as a new task' inherits the edited task's lineage via a new optional addTask lineage override - duplicate/paste already preserve it (clone copies task annotations) --- src/models/componentSpec/annotations.ts | 12 ++ .../factories/taskFactory.test.ts | 54 ++++++++ .../componentSpec/factories/taskFactory.ts | 17 ++- .../context/TaskDetails/TaskDetails.tsx | 12 +- .../Editor/store/actions/task.actions.ts | 13 ++ src/utils/annotations.ts | 6 + src/utils/lineage.test.ts | 120 ++++++++++++++++++ src/utils/lineage.ts | 108 ++++++++++++++++ 8 files changed, 340 insertions(+), 2 deletions(-) create mode 100644 src/models/componentSpec/factories/taskFactory.test.ts create mode 100644 src/utils/lineage.test.ts create mode 100644 src/utils/lineage.ts diff --git a/src/models/componentSpec/annotations.ts b/src/models/componentSpec/annotations.ts index b57929af1..b1ac650bc 100644 --- a/src/models/componentSpec/annotations.ts +++ b/src/models/componentSpec/annotations.ts @@ -5,6 +5,7 @@ import { z } from "zod"; import type { FlexNodeData } from "@/components/shared/ReactFlow/FlowCanvas/FlexNode/types"; import { isFlexNodeData } from "@/components/shared/ReactFlow/FlowCanvas/FlexNode/types"; +import { type ComponentLineage, componentLineageSchema } from "@/utils/lineage"; import type { Annotation } from "./entities/types"; @@ -35,6 +36,7 @@ interface AnnotationTypeMap { "editor.position": XYPosition; "tangleml.com/editor/task-color": string; "tangleml.com/editor/edge-conduits": EdgeConduit[]; + "tangleml.com/lineage/origin": ComponentLineage | undefined; "flex-nodes": FlexNodeData[]; notes: string; tags: string[]; @@ -104,6 +106,16 @@ const codecs = { defaultValue: "transparent", }, "tangleml.com/editor/edge-conduits": jsonArrayCodec(edgeConduitSchema), + "tangleml.com/lineage/origin": { + serialize: (value: ComponentLineage | undefined) => + value ? JSON.stringify(value) : undefined, + deserialize: (raw: unknown): ComponentLineage | undefined => { + const obj = typeof raw === "string" ? safeJsonParse(raw) : raw; + const result = componentLineageSchema.safeParse(obj); + return result.success ? result.data : undefined; + }, + defaultValue: undefined, + }, "flex-nodes": jsonArrayCodec(flexNodeDataSchema), notes: { serialize: (value: string) => value, diff --git a/src/models/componentSpec/factories/taskFactory.test.ts b/src/models/componentSpec/factories/taskFactory.test.ts new file mode 100644 index 000000000..186d5642e --- /dev/null +++ b/src/models/componentSpec/factories/taskFactory.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from "vitest"; + +import { LINEAGE_ORIGIN_ANNOTATION } from "@/utils/annotations"; +import type { ComponentLineage } from "@/utils/lineage"; +import { EMBEDDED_LINEAGE_KEY } from "@/utils/lineage"; + +import type { ComponentReference } from "../entities/types"; +import type { IdGenerator } from "./idGenerator"; +import { createTaskFromComponentRef } from "./taskFactory"; + +const stubIdGen: IdGenerator = { next: () => "task_test_1" }; + +describe("createTaskFromComponentRef lineage capture", () => { + it("stamps lineage from the component's own identity", () => { + const ref: ComponentReference = { + name: "Train", + digest: "origin-digest", + url: "https://x/train.yaml", + }; + + const task = createTaskFromComponentRef(stubIdGen, ref, "Train"); + const lineage = task.annotations.get(LINEAGE_ORIGIN_ANNOTATION); + + expect(lineage).toEqual({ + originId: "https://x/train.yaml", + originDigest: "origin-digest", + originName: "Train", + }); + }); + + it("seeds lineage from an embedded spec lineage when present", () => { + const embedded: ComponentLineage = { originId: "origin-published" }; + const ref: ComponentReference = { + name: "Train", + digest: "edited-digest", + spec: { + implementation: { container: { image: "x" } }, + metadata: { annotations: { [EMBEDDED_LINEAGE_KEY]: embedded } }, + }, + }; + + const task = createTaskFromComponentRef(stubIdGen, ref, "Train"); + + expect(task.annotations.get(LINEAGE_ORIGIN_ANNOTATION)).toEqual(embedded); + }); + + it("leaves lineage unset when the ref has no stable identity", () => { + const ref: ComponentReference = { name: "Nameless" }; + + const task = createTaskFromComponentRef(stubIdGen, ref, "Nameless"); + + expect(task.annotations.has(LINEAGE_ORIGIN_ANNOTATION)).toBe(false); + }); +}); diff --git a/src/models/componentSpec/factories/taskFactory.ts b/src/models/componentSpec/factories/taskFactory.ts index 268d415c7..61084bf6a 100644 --- a/src/models/componentSpec/factories/taskFactory.ts +++ b/src/models/componentSpec/factories/taskFactory.ts @@ -1,3 +1,8 @@ +import { + LINEAGE_ORIGIN_ANNOTATION, + resolveLineageForRef, +} from "@/utils/lineage"; + import { Task } from "../entities/task"; import type { Argument, ComponentReference } from "../entities/types"; import type { IdGenerator } from "./idGenerator"; @@ -15,10 +20,20 @@ export function createTaskFromComponentRef( } } - return new Task({ + const task = new Task({ $id: idGen.next("task"), name: taskName, componentRef, arguments: args, }); + + // Stamp the component's lineage so this instance can later be traced back to + // its origin and reconciled, even after edits change its digest. Preserved + // for free across edits (a componentRef swap leaves task annotations intact). + const lineage = resolveLineageForRef(componentRef); + if (lineage) { + task.annotations.set(LINEAGE_ORIGIN_ANNOTATION, lineage); + } + + return task; } diff --git a/src/routes/v2/pages/Editor/nodes/TaskNode/context/TaskDetails/TaskDetails.tsx b/src/routes/v2/pages/Editor/nodes/TaskNode/context/TaskDetails/TaskDetails.tsx index fc3214cb9..8d57bd02d 100644 --- a/src/routes/v2/pages/Editor/nodes/TaskNode/context/TaskDetails/TaskDetails.tsx +++ b/src/routes/v2/pages/Editor/nodes/TaskNode/context/TaskDetails/TaskDetails.tsx @@ -24,6 +24,7 @@ import { useSpec } from "@/routes/v2/shared/providers/SpecContext"; import { useSharedStores } from "@/routes/v2/shared/store/SharedStoreContext"; import { EDITOR_POSITION_ANNOTATION, + LINEAGE_ORIGIN_ANNOTATION, SYSTEM_ANNOTATIONS, ZINDEX_ANNOTATION, } from "@/utils/annotations"; @@ -170,7 +171,16 @@ export const TaskDetails = observer(function TaskDetails({ prefer: "below", }); - const newTask = addTask(spec, hydratedComponent, position); + // The placed task descends from the same origin as the edited task, so it + // inherits that task's lineage rather than deriving a fresh one from the + // edited component's (now-changed) digest. + const inheritedLineage = task.annotations.get(LINEAGE_ORIGIN_ANNOTATION); + const newTask = addTask( + spec, + hydratedComponent, + position, + inheritedLineage, + ); track("pipeline_editor.component.edited", { ...componentMetadata(hydratedComponent, "user"), diff --git a/src/routes/v2/pages/Editor/store/actions/task.actions.ts b/src/routes/v2/pages/Editor/store/actions/task.actions.ts index a8864509b..aef2fc3be 100644 --- a/src/routes/v2/pages/Editor/store/actions/task.actions.ts +++ b/src/routes/v2/pages/Editor/store/actions/task.actions.ts @@ -16,8 +16,10 @@ import type { SelectedNode } from "@/routes/v2/shared/store/editorStore"; import type { ParentContext } from "@/routes/v2/shared/store/navigationStore"; import { EDITOR_POSITION_ANNOTATION, + LINEAGE_ORIGIN_ANNOTATION, TASK_COLOR_ANNOTATION, } from "@/utils/annotations"; +import type { ComponentLineage } from "@/utils/lineage"; import { computeDiffComponentSpecs } from "./task.utils"; import { idGen } from "./utils"; @@ -27,6 +29,13 @@ export function addTask( spec: ComponentSpec, componentRef: ComponentReference, position: XYPosition, + /** + * Override the lineage stamped on the new task. Used when placing a task that + * descends from an existing instance (e.g. edit → "Place as a new task"): the + * placed task should inherit the edited task's origin, not derive a fresh one + * from the edited component's (now-changed) digest. + */ + lineageOverride?: ComponentLineage, ): Task { return undo.withGroup("Add task", () => { const componentName = @@ -39,6 +48,10 @@ export function addTask( y: position.y, }); + if (lineageOverride) { + task.annotations.set(LINEAGE_ORIGIN_ANNOTATION, lineageOverride); + } + spec.addTask(task); return task; }); diff --git a/src/utils/annotations.ts b/src/utils/annotations.ts index 8ee238d2f..b3b1d0820 100644 --- a/src/utils/annotations.ts +++ b/src/utils/annotations.ts @@ -4,6 +4,11 @@ import { getNodeTypeZIndexDefault } from "@/components/shared/ReactFlow/FlowCanv import type { AnnotationConfig, Annotations } from "@/types/annotations"; import type { ComponentSpec } from "./componentSpec"; +import { LINEAGE_ORIGIN_ANNOTATION } from "./lineage"; + +// Re-export so existing consumers can keep importing it from here; the source of +// truth lives in the lighter `./lineage` module (see its definition for why). +export { LINEAGE_ORIGIN_ANNOTATION }; export const DISPLAY_NAME_MAX_LENGTH = 100; export const TASK_DISPLAY_NAME_ANNOTATION = "display_name"; @@ -33,6 +38,7 @@ export const SYSTEM_ANNOTATIONS = [ EDITOR_FLOW_DIRECTION_ANNOTATION, TASK_COLOR_ANNOTATION, EDGE_CONDUITS_ANNOTATION, + LINEAGE_ORIGIN_ANNOTATION, ]; export const DEFAULT_COMMON_ANNOTATIONS: AnnotationConfig[] = [ diff --git a/src/utils/lineage.test.ts b/src/utils/lineage.test.ts new file mode 100644 index 000000000..8b3c0b0cd --- /dev/null +++ b/src/utils/lineage.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, it } from "vitest"; + +import type { ComponentSpec } from "./componentSpec"; +import { + type ComponentLineage, + componentLineageSchema, + EMBEDDED_LINEAGE_KEY, + embeddedLineageOf, + makeLineage, + originIdOf, + resolveLineageForRef, +} from "./lineage"; + +describe("originIdOf", () => { + it("prefers url over digest", () => { + expect(originIdOf({ url: "https://x/c.yaml", digest: "abc" })).toBe( + "https://x/c.yaml", + ); + }); + + it("falls back to digest when there is no url", () => { + expect(originIdOf({ digest: "abc" })).toBe("abc"); + }); + + it("returns undefined when neither is present", () => { + expect(originIdOf({ name: "Nameless" })).toBeUndefined(); + }); +}); + +describe("makeLineage", () => { + it("captures origin id, digest, and name", () => { + expect(makeLineage({ digest: "abc", name: "Train" })).toEqual({ + originId: "abc", + originDigest: "abc", + originName: "Train", + }); + }); + + it("uses url as the origin id but keeps the digest separately", () => { + expect( + makeLineage({ url: "https://x/c.yaml", digest: "abc", name: "Train" }), + ).toEqual({ + originId: "https://x/c.yaml", + originDigest: "abc", + originName: "Train", + }); + }); + + it("returns undefined when there is no stable identity", () => { + expect(makeLineage({ name: "Nameless" })).toBeUndefined(); + }); +}); + +describe("embeddedLineageOf", () => { + const lineage: ComponentLineage = { + originId: "https://x/c.yaml", + originDigest: "abc", + originName: "Train", + }; + + const specWith = (value: unknown): ComponentSpec => ({ + implementation: { container: { image: "x" } }, + metadata: { annotations: { [EMBEDDED_LINEAGE_KEY]: value } }, + }); + + it("reads an embedded lineage object", () => { + expect(embeddedLineageOf(specWith(lineage))).toEqual(lineage); + }); + + it("reads an embedded lineage stored as a JSON string", () => { + expect(embeddedLineageOf(specWith(JSON.stringify(lineage)))).toEqual( + lineage, + ); + }); + + it("returns undefined when absent or invalid", () => { + expect( + embeddedLineageOf({ implementation: { container: { image: "x" } } }), + ).toBeUndefined(); + expect(embeddedLineageOf(specWith({ nope: true }))).toBeUndefined(); + expect(embeddedLineageOf(undefined)).toBeUndefined(); + }); +}); + +describe("resolveLineageForRef", () => { + it("prefers an embedded spec lineage over the ref's own identity", () => { + const embedded: ComponentLineage = { originId: "origin-published" }; + const ref = { + digest: "edited-digest", + name: "Train", + spec: { + implementation: { container: { image: "x" } }, + metadata: { annotations: { [EMBEDDED_LINEAGE_KEY]: embedded } }, + } satisfies ComponentSpec, + }; + expect(resolveLineageForRef(ref)).toEqual(embedded); + }); + + it("derives lineage from the ref when no embedded lineage exists", () => { + expect(resolveLineageForRef({ digest: "abc", name: "Train" })).toEqual({ + originId: "abc", + originDigest: "abc", + originName: "Train", + }); + }); +}); + +describe("componentLineageSchema", () => { + it("rejects an empty origin id", () => { + expect(componentLineageSchema.safeParse({ originId: "" }).success).toBe( + false, + ); + }); + + it("accepts a minimal lineage", () => { + expect(componentLineageSchema.safeParse({ originId: "abc" }).success).toBe( + true, + ); + }); +}); diff --git a/src/utils/lineage.ts b/src/utils/lineage.ts new file mode 100644 index 000000000..b9ef01f93 --- /dev/null +++ b/src/utils/lineage.ts @@ -0,0 +1,108 @@ +import { z } from "zod"; + +import type { ComponentSpec } from "./componentSpec"; + +/** + * Stable lineage of a component instance: where it originally came from, + * captured the moment it first enters a pipeline (before any local edit). + * + * Tangle components are content-addressed, so a component's `digest` changes + * on every edit and cannot identify "the same component across versions". The + * lineage `originId` is a stable identifier — the source `url` for + * published/library components, otherwise the original `digest` — that + * survives edits, letting us trace and reconcile every instance descended from + * a single origin. + * + * Lineage is stored as a task-level annotation (`LINEAGE_ORIGIN_ANNOTATION`), + * NOT inside the component spec text, so it never perturbs the component digest + * and is preserved automatically when a task's `componentRef` is swapped. + */ +export interface ComponentLineage { + /** Stable origin identity: source `url` if available, else the original digest. */ + originId: string; + /** The origin component's digest at first entry (pre-edit), for display/diff. */ + originDigest?: string; + /** Human-readable origin name, for display. */ + originName?: string; +} + +export const componentLineageSchema = z.object({ + originId: z.string().min(1), + originDigest: z.string().optional(), + originName: z.string().optional(), +}); + +/** + * Task-level annotation key holding a task's {@link ComponentLineage}. Defined + * here (rather than in the heavier `@/utils/annotations`) so the model layer can + * read it without pulling app/component modules into its import graph. + */ +export const LINEAGE_ORIGIN_ANNOTATION = "tangleml.com/lineage/origin"; + +/** + * Key used to embed lineage inside a *published* component's spec metadata + * (the cross-pipeline discovery extension). Dot-free on purpose: Elasticsearch + * splits dotted field keys into nested objects, which would break filtering. + */ +export const EMBEDDED_LINEAGE_KEY = "lineage_origin"; + +type ReferenceLike = { + url?: string; + digest?: string; + name?: string; +}; + +function safeJsonParse(str: string): unknown { + try { + return JSON.parse(str); + } catch { + return undefined; + } +} + +/** + * The stable origin identifier for a component reference: its source `url` + * (published/library) if present, otherwise its content `digest`. + */ +export function originIdOf(ref: ReferenceLike): string | undefined { + return ref.url ?? ref.digest ?? undefined; +} + +/** + * Build a lineage record from a component reference at the moment it enters a + * pipeline. Returns `undefined` when the reference has no stable identity yet. + */ +export function makeLineage(ref: ReferenceLike): ComponentLineage | undefined { + const originId = originIdOf(ref); + if (!originId) return undefined; + return { + originId, + originDigest: ref.digest, + originName: ref.name, + }; +} + +/** + * Read a lineage previously embedded in a (published) component spec's metadata + * annotations, if present and valid. + */ +export function embeddedLineageOf( + spec: ComponentSpec | undefined, +): ComponentLineage | undefined { + const raw = spec?.metadata?.annotations?.[EMBEDDED_LINEAGE_KEY]; + if (raw == null) return undefined; + const value = typeof raw === "string" ? safeJsonParse(raw) : raw; + const result = componentLineageSchema.safeParse(value); + return result.success ? result.data : undefined; +} + +/** + * Resolve the lineage to stamp on a task created from `ref`: prefer a lineage + * already embedded in the component spec (a published origin), otherwise derive + * one from the reference's own identity. + */ +export function resolveLineageForRef( + ref: ReferenceLike & { spec?: ComponentSpec }, +): ComponentLineage | undefined { + return embeddedLineageOf(ref.spec) ?? makeLineage(ref); +}