From 21e28f7d1dc6d166af533184196bdc5dc09b1b2f Mon Sep 17 00:00:00 2001 From: Morgan Wowk Date: Fri, 5 Jun 2026 12:31:33 -0700 Subject: [PATCH] Offer to reconcile same-origin tasks after an edit After 'Update this task', detect other tasks in the pipeline that share the edited component's lineage origin (including ones nested in subgraphs) but are still on the old version, and offer to update them too. - findTaskContext: locate a task by id anywhere in the spec tree so reconcile targets the correct (sub)spec for binding cleanup - ReconcileSiblingsDialog: Tangle alert-dialog listing matching tasks (with subgraph path) and per-task checkboxes; default all selected - TaskDetails wires detection into updateInPlace and applies the selected replacements in a single 'Reconcile component' undo group Reconcile is opt-in and non-coercive: the default single-task update is unchanged; the prompt only appears when same-origin siblings exist. --- .../lineage/ReconcileSiblingsDialog.tsx | 121 ++++++++++++++++++ .../Editor/lineage/findTaskContext.test.ts | 45 +++++++ .../pages/Editor/lineage/findTaskContext.ts | 27 ++++ .../context/TaskDetails/TaskDetails.tsx | 57 +++++++++ 4 files changed, 250 insertions(+) create mode 100644 src/routes/v2/pages/Editor/lineage/ReconcileSiblingsDialog.tsx create mode 100644 src/routes/v2/pages/Editor/lineage/findTaskContext.test.ts create mode 100644 src/routes/v2/pages/Editor/lineage/findTaskContext.ts diff --git a/src/routes/v2/pages/Editor/lineage/ReconcileSiblingsDialog.tsx b/src/routes/v2/pages/Editor/lineage/ReconcileSiblingsDialog.tsx new file mode 100644 index 000000000..96b171e7b --- /dev/null +++ b/src/routes/v2/pages/Editor/lineage/ReconcileSiblingsDialog.tsx @@ -0,0 +1,121 @@ +import { useState } from "react"; + +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Icon } from "@/components/ui/icon"; +import { BlockStack, InlineStack } from "@/components/ui/layout"; +import { Text } from "@/components/ui/typography"; + +import type { LineageUsage } from "./collectLineageUsages"; + +interface ReconcileSiblingsDialogProps { + componentName: string; + matches: LineageUsage[]; + onConfirm: (taskIds: string[]) => void; + onCancel: () => void; +} + +/** + * Shown after an in-place component edit when other tasks in the pipeline share + * the same component origin. Offers to update the matching tasks to the edited + * version too, with per-task selection. Mounted only while active, so selection + * state resets for each prompt. + */ +export function ReconcileSiblingsDialog({ + componentName, + matches, + onConfirm, + onCancel, +}: ReconcileSiblingsDialogProps) { + const [selected, setSelected] = useState>( + () => new Set(matches.map((m) => m.taskId)), + ); + + const toggle = (taskId: string, checked: boolean) => { + setSelected((prev) => { + const next = new Set(prev); + if (checked) { + next.add(taskId); + } else { + next.delete(taskId); + } + return next; + }); + }; + + const count = selected.size; + + return ( + { + if (!isOpen) onCancel(); + }} + > + + + Update matching tasks? + + {matches.length} other{" "} + {matches.length === 1 ? "task uses" : "tasks use"} the same origin + as “{componentName}”. Update {matches.length === 1 ? "it" : "them"}{" "} + to your edited version too? Inputs or outputs that no longer exist + will be removed, along with their connections. + + + + + {matches.map((match) => ( + + ))} + + + + Not now + onConfirm([...selected])} + > + Update {count} {count === 1 ? "task" : "tasks"} + + + + + ); +} diff --git a/src/routes/v2/pages/Editor/lineage/findTaskContext.test.ts b/src/routes/v2/pages/Editor/lineage/findTaskContext.test.ts new file mode 100644 index 000000000..4b57eabee --- /dev/null +++ b/src/routes/v2/pages/Editor/lineage/findTaskContext.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from "vitest"; + +import { ComponentSpec } from "@/models/componentSpec/entities/componentSpec"; +import { Task } from "@/models/componentSpec/entities/task"; + +import { findTaskContext } from "./findTaskContext"; + +const task = ($id: string, name: string, subgraphSpec?: ComponentSpec) => + new Task({ $id, name, componentRef: {}, subgraphSpec }); + +describe("findTaskContext", () => { + it("finds a root-level task in the root spec", () => { + const root = new ComponentSpec({ + name: "Root", + tasks: [task("a", "A"), task("b", "B")], + }); + + const ctx = findTaskContext(root, "b"); + + expect(ctx?.spec).toBe(root); + expect(ctx?.task.name).toBe("B"); + }); + + it("finds a nested task and returns its containing subgraph spec", () => { + const sub = new ComponentSpec({ + name: "Sub", + tasks: [task("nested", "Nested")], + }); + const root = new ComponentSpec({ + name: "Root", + tasks: [task("group", "Group", sub)], + }); + + const ctx = findTaskContext(root, "nested"); + + expect(ctx?.spec).toBe(sub); + expect(ctx?.task.name).toBe("Nested"); + }); + + it("returns undefined when the task is absent", () => { + const root = new ComponentSpec({ name: "Root", tasks: [task("a", "A")] }); + + expect(findTaskContext(root, "missing")).toBeUndefined(); + }); +}); diff --git a/src/routes/v2/pages/Editor/lineage/findTaskContext.ts b/src/routes/v2/pages/Editor/lineage/findTaskContext.ts new file mode 100644 index 000000000..031c116f9 --- /dev/null +++ b/src/routes/v2/pages/Editor/lineage/findTaskContext.ts @@ -0,0 +1,27 @@ +import type { ComponentSpec, Task } from "@/models/componentSpec"; + +export interface TaskContext { + /** The spec whose direct `tasks` array contains the task. */ + spec: ComponentSpec; + task: Task; +} + +/** + * Locate a task by id anywhere in a spec tree, recursing through subgraphs. + * Returns the task together with the (sub)spec that directly contains it, so + * callers can mutate it in the right scope (e.g. `replaceTask` cleans up the + * containing spec's bindings). + */ +export function findTaskContext( + rootSpec: ComponentSpec, + taskId: string, +): TaskContext | undefined { + for (const task of rootSpec.tasks) { + if (task.$id === taskId) return { spec: rootSpec, task }; + if (task.subgraphSpec) { + const found = findTaskContext(task.subgraphSpec, taskId); + if (found) return found; + } + } + return undefined; +} 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 8d57bd02d..2203c26c0 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 @@ -18,6 +18,12 @@ import { useAnalytics } from "@/providers/AnalyticsProvider"; import { AnnotationsBlock } from "@/routes/v2/pages/Editor/components/AnnotationsBlock/AnnotationsBlock"; import { PredictedIssuesSection } from "@/routes/v2/pages/Editor/components/UpgradeComponents/components/UpgradeCandidateDetail"; import { buildUpgradeCandidateFromResolved } from "@/routes/v2/pages/Editor/components/UpgradeComponents/utils/buildUpgradeCandidateFromResolved"; +import { + collectLineageUsages, + type LineageUsage, +} from "@/routes/v2/pages/Editor/lineage/collectLineageUsages"; +import { findTaskContext } from "@/routes/v2/pages/Editor/lineage/findTaskContext"; +import { ReconcileSiblingsDialog } from "@/routes/v2/pages/Editor/lineage/ReconcileSiblingsDialog"; import { useTaskActions } from "@/routes/v2/pages/Editor/store/actions/useTaskActions"; import { useEditorSession } from "@/routes/v2/pages/Editor/store/EditorSessionContext"; import { useSpec } from "@/routes/v2/shared/providers/SpecContext"; @@ -60,6 +66,10 @@ export const TaskDetails = observer(function TaskDetails({ const [detailsTab, setDetailsTab] = useState("arguments"); const [isRenaming, setIsRenaming] = useState(false); + const [reconcile, setReconcile] = useState<{ + component: HydratedComponentReference; + matches: LineageUsage[]; + } | null>(null); const renameInputRef = useRef(null); useEffect(() => { @@ -140,6 +150,44 @@ export const TaskDetails = observer(function TaskDetails({ } else { notify("Component updated", "success"); } + + // Offer to reconcile other tasks that share this component's origin (incl. + // nested in subgraphs) but haven't been updated to the edited version yet. + const originId = task.annotations.get(LINEAGE_ORIGIN_ANNOTATION)?.originId; + if (originId) { + const matches = collectLineageUsages(spec, originId).filter( + (usage) => + usage.taskId !== task.$id && + usage.digest !== hydratedComponent.digest, + ); + if (matches.length > 0) { + setReconcile({ component: hydratedComponent, matches }); + } + } + }; + + const handleReconcileConfirm = (taskIds: string[]) => { + if (!reconcile) return; + const { component } = reconcile; + + undo.withGroup("Reconcile component", () => { + for (const taskId of taskIds) { + const ctx = findTaskContext(spec, taskId); + if (ctx) replaceTask(ctx.spec, taskId, component); + } + }); + + track("pipeline_editor.component.lineage_reconcile", { + ...componentMetadata(component, "user"), + applied_count: taskIds.length, + matched_count: reconcile.matches.length, + }); + + notify( + `Updated ${taskIds.length} matching ${taskIds.length === 1 ? "task" : "tasks"}.`, + "success", + ); + setReconcile(null); }; const placeAsNewTask = (hydratedComponent: HydratedComponentReference) => { @@ -366,6 +414,15 @@ export const TaskDetails = observer(function TaskDetails({ + + {reconcile && ( + setReconcile(null)} + /> + )} ); });