Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions src/models/componentSpec/annotations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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[];
Expand Down Expand Up @@ -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,
Expand Down
54 changes: 54 additions & 0 deletions src/models/componentSpec/factories/taskFactory.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
17 changes: 16 additions & 1 deletion src/models/componentSpec/factories/taskFactory.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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"),
Expand Down
13 changes: 13 additions & 0 deletions src/routes/v2/pages/Editor/store/actions/task.actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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 =
Expand All @@ -39,6 +48,10 @@ export function addTask(
y: position.y,
});

if (lineageOverride) {
task.annotations.set(LINEAGE_ORIGIN_ANNOTATION, lineageOverride);
}

spec.addTask(task);
return task;
});
Expand Down
6 changes: 6 additions & 0 deletions src/utils/annotations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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[] = [
Expand Down
120 changes: 120 additions & 0 deletions src/utils/lineage.test.ts
Original file line number Diff line number Diff line change
@@ -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,
);
});
});
Loading
Loading