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); +}