diff --git a/src/routes/v2/pages/Editor/EditorV2.tsx b/src/routes/v2/pages/Editor/EditorV2.tsx index afdfbf9a3..3ac41b731 100644 --- a/src/routes/v2/pages/Editor/EditorV2.tsx +++ b/src/routes/v2/pages/Editor/EditorV2.tsx @@ -50,6 +50,7 @@ import { useSelectionWindowSync } from "./hooks/useSelectionWindowSync"; import { useSpecLifecycle } from "./hooks/useSpecLifecycle"; import { useTipOfTheDayWindow } from "./hooks/useTipOfTheDayWindow"; import { useUndoRedoKeyboard } from "./hooks/useUndoRedoKeyboard"; +import { useReconcileFromUrl } from "./lineage/useReconcileFromUrl"; import { editorRegistry } from "./nodes"; import { EditorSessionProvider } from "./store/EditorSessionContext"; @@ -132,6 +133,8 @@ const PipelineEditor = withSuspenseWrapper( function EditorV2Content({ pipelineRef }: { pipelineRef: PipelineRef | null }) { const { navigation } = useSharedStores(); + useReconcileFromUrl(); + useEffect(() => { navigation.setRequestedPipelineName(pipelineRef?.name ?? null); }, [navigation, pipelineRef?.name]); diff --git a/src/routes/v2/pages/Editor/lineage/ReconcileOverview.tsx b/src/routes/v2/pages/Editor/lineage/ReconcileOverview.tsx new file mode 100644 index 000000000..fc9732edc --- /dev/null +++ b/src/routes/v2/pages/Editor/lineage/ReconcileOverview.tsx @@ -0,0 +1,183 @@ +import { useLocation, useNavigate } from "@tanstack/react-router"; +import { useEffect, useRef, useState } from "react"; + +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Icon } from "@/components/ui/icon"; +import { BlockStack, InlineStack } from "@/components/ui/layout"; +import { Text } from "@/components/ui/typography"; +import { APP_ROUTES } from "@/routes/router"; +import { useEditorSession } from "@/routes/v2/pages/Editor/store/EditorSessionContext"; +import type { HydratedComponentReference } from "@/utils/componentSpec"; + +import { + createReconcileSession, + type ReconcileSession, + updateReconcileSession, +} from "./reconcileSession"; +import { + type PipelineLineageMatch, + scanPipelinesForLineage, +} from "./scanPipelinesForLineage"; + +interface ReconcileOverviewProps { + /** The edited (target) component every instance is reconciled to. */ + component: HydratedComponentReference; + originId: string; + onClose: () => void; +} + +/** + * Cross-pipeline reconcile overview: lists every locally-stored pipeline that + * uses the edited component's origin and lets the user reconcile each in turn. + * Clicking "Reconcile" flushes the current pipeline, then routes to the target + * pipeline in reconcile mode (`?reconcile=`), where the change is + * staged and committed via the node-anchored "Finish Reconciling" button. + */ +export function ReconcileOverview({ + component, + originId, + onClose, +}: ReconcileOverviewProps) { + const navigate = useNavigate(); + const location = useLocation(); + const { autoSave } = useEditorSession(); + + const sessionRef = useRef(null); + const [pipelines, setPipelines] = useState( + null, + ); + + useEffect(() => { + let cancelled = false; + void (async () => { + const results = await scanPipelinesForLineage(originId, component.digest); + if (cancelled) return; + + const session = createReconcileSession({ + originId, + targetDigest: component.digest, + targetComponentText: component.text, + targetName: component.name, + returnTo: "", + worklist: results.map((r) => ({ + storageKey: r.storageKey, + status: "pending" as const, + })), + }); + const returnTo = `${location.pathname}?reconcileOverview=${session.sessionId}`; + updateReconcileSession(session.sessionId, { returnTo }); + sessionRef.current = { ...session, returnTo }; + + setPipelines(results); + })(); + return () => { + cancelled = true; + }; + // Intentionally runs once when the overview opens; props are fixed for its + // lifetime (it is mounted only while active). + }, []); + + const handleReconcile = async (storageKey: string) => { + const session = sessionRef.current; + if (!session) return; + // Persist the current pipeline before navigating away. + await autoSave.save(); + await navigate({ + to: APP_ROUTES.EDITOR_V2_PIPELINE, + params: { pipelineName: storageKey }, + search: { reconcile: session.sessionId }, + }); + }; + + const totalPending = pipelines?.reduce((n, p) => n + p.pendingCount, 0) ?? 0; + + return ( + { + if (!isOpen) onClose(); + }} + > + + + + Reconcile “{component.name}” across pipelines + + + Update other pipelines that use this component’s origin to your + edited version. Each opens in the editor so you can review and apply + the change in context. + + + + + {pipelines === null && ( + + Scanning pipelines… + + )} + + {pipelines !== null && pipelines.length === 0 && ( + + No other pipelines use this component. + + )} + + {pipelines?.map((pipeline) => { + const done = pipeline.pendingCount === 0; + return ( + + + + {pipeline.pipelineName} + + + {done + ? `${pipeline.reconciledCount} reconciled` + : `${pipeline.pendingCount} of ${pipeline.tasks.length} to update`} + + + + {done ? ( + + + + Reconciled + + + ) : ( + + )} + + ); + })} + + + + + + + + ); +} diff --git a/src/routes/v2/pages/Editor/lineage/ReconcileSiblingsDialog.tsx b/src/routes/v2/pages/Editor/lineage/ReconcileSiblingsDialog.tsx index 96b171e7b..f05f4e5ea 100644 --- a/src/routes/v2/pages/Editor/lineage/ReconcileSiblingsDialog.tsx +++ b/src/routes/v2/pages/Editor/lineage/ReconcileSiblingsDialog.tsx @@ -22,6 +22,10 @@ interface ReconcileSiblingsDialogProps { matches: LineageUsage[]; onConfirm: (taskIds: string[]) => void; onCancel: () => void; + /** Number of OTHER pipelines that also use this origin (cross-pipeline). */ + crossPipelineCount?: number; + /** Open the cross-pipeline reconcile overview. */ + onReconcileAcrossPipelines?: () => void; } /** @@ -35,6 +39,8 @@ export function ReconcileSiblingsDialog({ matches, onConfirm, onCancel, + crossPipelineCount = 0, + onReconcileAcrossPipelines, }: ReconcileSiblingsDialogProps) { const [selected, setSelected] = useState>( () => new Set(matches.map((m) => m.taskId)), @@ -106,6 +112,31 @@ export function ReconcileSiblingsDialog({ ))} + {crossPipelineCount > 0 && onReconcileAcrossPipelines && ( + + )} + Not now ` URL param via {@link useReconcileFromUrl}. When active, + * the canvas stages the edited component on matching tasks (autosave held), the + * UI focuses on reconciling (chrome hidden), and navigation is guarded. + */ +class ReconcileModeStore { + @observable accessor session: ReconcileSession | null = null; + + constructor() { + makeObservable(this); + } + + @computed + get active(): boolean { + return this.session !== null; + } + + @action + enter(session: ReconcileSession): void { + this.session = session; + } + + @action + exit(): void { + this.session = null; + } +} + +export const reconcileModeStore = new ReconcileModeStore(); diff --git a/src/routes/v2/pages/Editor/lineage/reconcileSession.test.ts b/src/routes/v2/pages/Editor/lineage/reconcileSession.test.ts new file mode 100644 index 000000000..8b9dc322d --- /dev/null +++ b/src/routes/v2/pages/Editor/lineage/reconcileSession.test.ts @@ -0,0 +1,80 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { + createReconcileSession, + deleteReconcileSession, + getReconcileSession, + type ReconcileSession, + sweepExpiredReconcileSessions, + updateReconcileSession, +} from "./reconcileSession"; + +function fakeLocalStorage(): Storage { + const map = new Map(); + return { + get length() { + return map.size; + }, + key: (i: number) => [...map.keys()][i] ?? null, + getItem: (k: string) => map.get(k) ?? null, + setItem: (k: string, v: string) => void map.set(k, v), + removeItem: (k: string) => void map.delete(k), + clear: () => map.clear(), + }; +} + +const baseInput: Omit = { + originId: "https://x/train.yaml", + targetDigest: "edited-digest", + targetComponentText: "name: Train\n", + targetName: "Train", + returnTo: "/editor-v2/Origin?reconcileOverview=X", + worklist: [{ storageKey: "Pipeline A", status: "pending" }], +}; + +describe("reconcileSession", () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.stubGlobal("localStorage", fakeLocalStorage()); + vi.stubGlobal("crypto", { randomUUID: () => "uuid-1" }); + }); + afterEach(() => { + vi.unstubAllGlobals(); + vi.useRealTimers(); + }); + + it("creates, reads, updates, and deletes a session", () => { + const created = createReconcileSession(baseInput); + expect(created.sessionId).toBe("uuid-1"); + expect(getReconcileSession("uuid-1")).toMatchObject(baseInput); + + updateReconcileSession("uuid-1", { + worklist: [{ storageKey: "Pipeline A", status: "skipped" }], + }); + expect(getReconcileSession("uuid-1")?.worklist[0].status).toBe("skipped"); + + deleteReconcileSession("uuid-1"); + expect(getReconcileSession("uuid-1")).toBeUndefined(); + }); + + it("returns undefined for missing or malformed sessions", () => { + expect(getReconcileSession("nope")).toBeUndefined(); + localStorage.setItem("reconcile:bad", "{not json"); + expect(getReconcileSession("bad")).toBeUndefined(); + }); + + it("sweeps sessions older than the max age, keeping fresh ones", () => { + const now = Date.now(); + vi.setSystemTime(now); + createReconcileSession(baseInput); + + // Not expired yet. + sweepExpiredReconcileSessions(1000); + expect(getReconcileSession("uuid-1")).toBeDefined(); + + // Advance past the TTL. + vi.setSystemTime(now + 5000); + sweepExpiredReconcileSessions(1000); + expect(getReconcileSession("uuid-1")).toBeUndefined(); + }); +}); diff --git a/src/routes/v2/pages/Editor/lineage/reconcileSession.ts b/src/routes/v2/pages/Editor/lineage/reconcileSession.ts new file mode 100644 index 000000000..6595bc05b --- /dev/null +++ b/src/routes/v2/pages/Editor/lineage/reconcileSession.ts @@ -0,0 +1,105 @@ +import { z } from "zod"; + +/** + * A cross-pipeline reconcile session. Created when the user starts reconciling a + * component across pipelines; referenced by id from the `?reconcile=` / + * `?reconcileOverview=` URL params as they navigate between pipelines. + * + * Persisted in localStorage so it survives the navigation between pipelines (and + * a refresh). It is self-contained — it carries the edited component text — so a + * reconcile can proceed even if the content-addressed store has evicted it. + * Sessions are swept on editor mount once older than the TTL. + */ +const STORAGE_PREFIX = "reconcile:"; +const DEFAULT_TTL_MS = 24 * 60 * 60 * 1000; + +const worklistItemSchema = z.object({ + storageKey: z.string(), + status: z.enum(["pending", "skipped"]), +}); + +const reconcileSessionSchema = z.object({ + sessionId: z.string(), + /** Lineage origin being reconciled (the locator). */ + originId: z.string(), + /** Digest of the edited (target) version every instance is reconciled to. */ + targetDigest: z.string(), + /** Self-contained edited component YAML, so the session needs no store lookup. */ + targetComponentText: z.string(), + targetName: z.string(), + /** Where to return when reconciling ends (origin editor + reconcile overview). */ + returnTo: z.string(), + worklist: z.array(worklistItemSchema), + createdAt: z.number(), +}); + +export type ReconcileSession = z.infer; + +function storage(): Storage | undefined { + try { + return globalThis.localStorage; + } catch { + return undefined; + } +} + +const keyFor = (sessionId: string) => STORAGE_PREFIX + sessionId; + +export function createReconcileSession( + input: Omit, +): ReconcileSession { + const session: ReconcileSession = { + ...input, + sessionId: crypto.randomUUID(), + createdAt: Date.now(), + }; + storage()?.setItem(keyFor(session.sessionId), JSON.stringify(session)); + return session; +} + +export function getReconcileSession( + sessionId: string, +): ReconcileSession | undefined { + const raw = storage()?.getItem(keyFor(sessionId)); + if (!raw) return undefined; + try { + const result = reconcileSessionSchema.safeParse(JSON.parse(raw)); + return result.success ? result.data : undefined; + } catch { + return undefined; + } +} + +export function updateReconcileSession( + sessionId: string, + patch: Partial>, +): ReconcileSession | undefined { + const existing = getReconcileSession(sessionId); + if (!existing) return undefined; + const updated = { ...existing, ...patch }; + storage()?.setItem(keyFor(sessionId), JSON.stringify(updated)); + return updated; +} + +export function deleteReconcileSession(sessionId: string): void { + storage()?.removeItem(keyFor(sessionId)); +} + +/** Remove orphaned sessions older than `maxAgeMs`. Called on editor mount. */ +export function sweepExpiredReconcileSessions( + maxAgeMs: number = DEFAULT_TTL_MS, +): void { + const s = storage(); + if (!s) return; + const now = Date.now(); + const toRemove: string[] = []; + for (let i = 0; i < s.length; i++) { + const key = s.key(i); + if (!key?.startsWith(STORAGE_PREFIX)) continue; + const session = getReconcileSession(key.slice(STORAGE_PREFIX.length)); + if (!session || now - session.createdAt > maxAgeMs) { + toRemove.push(key); + } + } + for (const key of toRemove) s.removeItem(key); +} diff --git a/src/routes/v2/pages/Editor/lineage/useReconcileFromUrl.ts b/src/routes/v2/pages/Editor/lineage/useReconcileFromUrl.ts new file mode 100644 index 000000000..ce91fb834 --- /dev/null +++ b/src/routes/v2/pages/Editor/lineage/useReconcileFromUrl.ts @@ -0,0 +1,40 @@ +import { useSearch } from "@tanstack/react-router"; +import { useEffect } from "react"; + +import { reconcileModeStore } from "./reconcileModeStore"; +import { + getReconcileSession, + sweepExpiredReconcileSessions, +} from "./reconcileSession"; + +/** + * Drives reconcile mode from the URL. When the editor loads a pipeline with + * `?reconcile=`, the matching session is loaded and reconcile mode is + * entered; clearing the param (or an unknown/expired session) exits it. Orphaned + * sessions are swept once on mount. + */ +export function useReconcileFromUrl(): void { + const search = useSearch({ strict: false }); + const sessionId = + "reconcile" in search && typeof search.reconcile === "string" + ? search.reconcile + : undefined; + + useEffect(() => { + sweepExpiredReconcileSessions(); + }, []); + + useEffect(() => { + if (!sessionId) { + reconcileModeStore.exit(); + return; + } + const session = getReconcileSession(sessionId); + if (session) { + reconcileModeStore.enter(session); + } else { + reconcileModeStore.exit(); + } + return () => reconcileModeStore.exit(); + }, [sessionId]); +} 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 2203c26c0..62d8c991b 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 @@ -1,3 +1,4 @@ +import { useParams } from "@tanstack/react-router"; import { observer } from "mobx-react-lite"; import { useEffect, useRef, useState } from "react"; @@ -23,7 +24,9 @@ import { type LineageUsage, } from "@/routes/v2/pages/Editor/lineage/collectLineageUsages"; import { findTaskContext } from "@/routes/v2/pages/Editor/lineage/findTaskContext"; +import { ReconcileOverview } from "@/routes/v2/pages/Editor/lineage/ReconcileOverview"; import { ReconcileSiblingsDialog } from "@/routes/v2/pages/Editor/lineage/ReconcileSiblingsDialog"; +import { scanPipelinesForLineage } from "@/routes/v2/pages/Editor/lineage/scanPipelinesForLineage"; 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"; @@ -64,11 +67,22 @@ export const TaskDetails = observer(function TaskDetails({ const task = useTask(entityId); const { focusedArgumentName } = editor; + const params = useParams({ strict: false }); + const currentPipelineKey = + "pipelineName" in params && typeof params.pipelineName === "string" + ? params.pipelineName + : undefined; + const [detailsTab, setDetailsTab] = useState("arguments"); const [isRenaming, setIsRenaming] = useState(false); const [reconcile, setReconcile] = useState<{ component: HydratedComponentReference; matches: LineageUsage[]; + crossPipelineCount: number; + } | null>(null); + const [overview, setOverview] = useState<{ + component: HydratedComponentReference; + originId: string; } | null>(null); const renameInputRef = useRef(null); @@ -151,19 +165,34 @@ export const TaskDetails = observer(function TaskDetails({ 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. + // Offer to reconcile other tasks that share this component's origin — both + // in this pipeline (incl. nested subgraphs) and across other saved + // pipelines (client-side scan). 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 }); - } - } + if (!originId) return; + + const sameMatches = collectLineageUsages(spec, originId).filter( + (usage) => + usage.taskId !== task.$id && usage.digest !== hydratedComponent.digest, + ); + + void scanPipelinesForLineage(originId, hydratedComponent.digest).then( + (results) => { + const otherPipelinesPending = results.filter( + (r) => r.storageKey !== currentPipelineKey && r.pendingCount > 0, + ).length; + + if (sameMatches.length > 0) { + setReconcile({ + component: hydratedComponent, + matches: sameMatches, + crossPipelineCount: otherPipelinesPending, + }); + } else if (otherPipelinesPending > 0) { + setOverview({ component: hydratedComponent, originId }); + } + }, + ); }; const handleReconcileConfirm = (taskIds: string[]) => { @@ -419,10 +448,27 @@ export const TaskDetails = observer(function TaskDetails({ { + const { component } = reconcile; + const originId = task.annotations.get( + LINEAGE_ORIGIN_ANNOTATION, + )?.originId; + setReconcile(null); + if (originId) setOverview({ component, originId }); + }} onConfirm={handleReconcileConfirm} onCancel={() => setReconcile(null)} /> )} + + {overview && ( + setOverview(null)} + /> + )} ); });