diff --git a/HANDOFF.md b/HANDOFF.md index ad93787..ef79101 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -2,39 +2,37 @@ ## Finished -- Added verified same-origin `/bootstrap.json` discovery with explicit loopback/offline fallback. -- Added static OIDC authorization-code + PKCE login with state, nonce, and the Dander API resource. -- Kept access tokens in memory and callback transactions in session storage; refresh and URL tokens - fail closed. -- Added fixed-origin Bearer requests, expiry/401 clearing, and token-free correlated logout. -- Exported `/auth/callback` and `/signed-out` without adding a Next server or remote graph UI. +- Added authenticated hosted project/graph list, create, open, conditional save, and confirmed delete. +- Preserved dirty/conflict/reload behavior, detached drafts, and local import/export. +- Kept opaque ETags separate from canonical-content SHA and normalized all DTOs through generated + RC19 validators. +- Retained create/delete idempotency keys across ambiguous retries and synthetic service restarts. +- Kept legacy localhost operations completely unavailable in hosted mode. ## Try It -Run `pnpm dev` with no bootstrap file to see labeled loopback/offline mode. A hosted deployment must -serve Dander's generated public descriptor at `/bootstrap.json`; do not hand-author it. +Run `pnpm test:e2e` for a fresh static export and browser acceptance. Loopback behavior remains +available with `pnpm dev`; hosted mode requires Dander's generated `/bootstrap.json` descriptor. ## Checks -- Contract drift, ESLint, strict TypeScript, and Prettier checks passed. -- Focused hosted-control and generated-contract suites passed 40 tests. -- Full Vitest passed 61 files and 627 tests; Playwright passed all 10 browser journeys. -- Static production build passed and exported `/`, `/auth/callback`, and `/signed-out`. +- Contract drift, ESLint, strict TypeScript, Prettier, and static production build passed. +- Full Vitest passed 63 files and 643 tests. +- All 11 Playwright journeys passed, including hosted conflict/retry/restart acceptance. ## Decisions -- Use `oidc-client-ts` 3.5.0 for discovery, code exchange, PKCE, and callback-state validation. -- Request `api_audience` as OAuth `resource`; never request `offline_access` or accept refresh tokens. -- Keep DRUFF-25 limited to identity/bootstrap; DRUFF-26 owns hosted project/graph behavior. +- Reuse one persistence controller; the hosted client adds collection operations without a second store. +- Retain mutation keys only for ambiguous retries; classify conflicts by operation and error code. +- Complete verified callbacks in place so generic static navigation cannot discard memory-only auth. ## Remaining -- Merge the focused protected PR and verify exact-main frontend, secret, and source-free-container CI. -- Implement DRUFF-26 remote project/graph management against this authenticated boundary. +- Merge the independently reviewed focused PR and verify exact-main CI. - Continue DRUFF-27 through DRUFF-29 in roadmap order. ## Review First -- `src/features/hosted-control/HostedControlProvider.tsx` -- `src/features/hosted-control/oidc-session.ts` -- `src/features/hosted-control/bootstrap.ts` +- `src/lib/persistence/graph-persistence.ts` +- `src/features/graph-io/useGraphPersistence.ts` +- `e2e/hosted-graph-management.spec.ts` diff --git a/e2e/hosted-graph-management.spec.ts b/e2e/hosted-graph-management.spec.ts new file mode 100644 index 0000000..16b58b5 --- /dev/null +++ b/e2e/hosted-graph-management.spec.ts @@ -0,0 +1,529 @@ +import { expect, test, type Page, type Route } from "@playwright/test"; +import { execFileSync } from "node:child_process"; +import { createReadStream, existsSync, mkdtempSync, readFileSync, rmSync, statSync } from "node:fs"; +import { createServer, type Server } from "node:https"; +import { tmpdir } from "node:os"; +import { extname, join, resolve } from "node:path"; +import { + DANDER_CONTRACT_BUNDLE_ID, + DANDER_CONTRACT_BUNDLE_SHA256, +} from "../src/generated/dander-contracts/metadata"; + +let druffOrigin = ""; +const ISSUER = "https://identity.example.test/tenant"; +const API_ORIGIN = "https://control.example.test"; +const PROJECT = "demo-project"; + +test.use({ ignoreHTTPSErrors: true }); + +let httpsProxy: Awaited>; +test.beforeEach(async () => { + httpsProxy = await startDruffHttpsProxy(); +}); +test.afterEach(async () => { + await httpsProxy.close(); +}); + +type StoredGraph = { + graph: string; + document: Record; + revision: string; + contentSha: string; + createdAt: string; + updatedAt: string; +}; + +type DurableControlState = { + records: Map; + creates: Map; + deletes: Set; + revision: number; + loseNextCreate: boolean; + loseNextDelete: boolean; +}; + +function graphDocument(name: string, nodeName: string) { + return { + name, + nodes: [ + { + id: "source", + type: "source", + name: nodeName, + visual: { position: { x: 20, y: 40 }, color: "green", icon: "database" }, + }, + ], + edges: [], + }; +} + +function nextRecord( + durable: DurableControlState, + graph: string, + document: Record, + createdAt = "2026-08-14T10:00:00Z", +): StoredGraph { + durable.revision += 1; + return { + graph, + document, + revision: `"revision-${durable.revision}"`, + contentSha: durable.revision.toString(16).padStart(64, "0"), + createdAt, + updatedAt: `2026-08-14T10:00:${String(durable.revision).padStart(2, "0")}Z`, + }; +} + +function resource(record: StoredGraph) { + return { + project: PROJECT, + graph: record.graph, + document: record.document, + content_sha256: record.contentSha, + created_at: record.createdAt, + updated_at: record.updatedAt, + }; +} + +function errorEnvelope(code: string, message: string) { + return { error: { code, message, correlation_id: "e2e-correlation", details: [] } }; +} + +function corsHeaders() { + return { + "Access-Control-Allow-Origin": druffOrigin, + "Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS", + "Access-Control-Allow-Headers": + "Authorization, Content-Type, If-Match, Idempotency-Key, X-Correlation-ID", + "Access-Control-Expose-Headers": "ETag, X-Correlation-ID", + "Content-Type": "application/json", + }; +} + +async function startDruffHttpsProxy(): Promise<{ close(): Promise }> { + const exportRoot = resolve(process.cwd(), "out"); + if (!existsSync(join(exportRoot, "index.html"))) { + throw new Error("Hosted Playwright acceptance requires a fresh `pnpm build` static export."); + } + const directory = mkdtempSync(join(tmpdir(), "druff-hosted-e2e-")); + const keyPath = join(directory, "localhost-key.pem"); + const certificatePath = join(directory, "localhost.pem"); + execFileSync( + "openssl", + [ + "req", + "-x509", + "-newkey", + "rsa:2048", + "-nodes", + "-keyout", + keyPath, + "-out", + certificatePath, + "-days", + "1", + "-subj", + "/CN=localhost", + "-addext", + "subjectAltName=DNS:localhost,IP:127.0.0.1", + ], + { stdio: "ignore" }, + ); + const server: Server = createServer( + { key: readFileSync(keyPath), cert: readFileSync(certificatePath) }, + (incoming, outgoing) => { + if (incoming.url === "/bootstrap.json") { + const body = JSON.stringify({ + schema_version: 1, + api_url: API_ORIGIN, + issuer: ISSUER, + public_client_id: "druff-public-client", + api_audience: `${API_ORIGIN}/api`, + redirect_uri: `${druffOrigin}/auth/callback`, + logout_uri: `${druffOrigin}/signed-out`, + contract: { id: DANDER_CONTRACT_BUNDLE_ID, sha256: DANDER_CONTRACT_BUNDLE_SHA256 }, + compatibility: { minimum_druff_contract: "1.0.0", maximum_druff_contract: "1.x" }, + }); + outgoing.writeHead(200, { + "Content-Type": "application/json", + "Content-Length": Buffer.byteLength(body), + "Cache-Control": "no-store", + }); + outgoing.end(body); + return; + } + const url = new URL(incoming.url ?? "/", "https://localhost"); + let relative = decodeURIComponent(url.pathname).replace(/^\/+/, ""); + const rscRequest = incoming.headers.rsc === "1" || url.searchParams.has("_rsc"); + if (relative === "") relative = rscRequest ? "index.txt" : "index.html"; + else if (rscRequest && !extname(relative)) relative = `${relative}.txt`; + let filePath = resolve(exportRoot, relative); + if (!extname(filePath) && existsSync(`${filePath}.html`)) filePath = `${filePath}.html`; + if ( + !filePath.startsWith(`${exportRoot}/`) || + !existsSync(filePath) || + !statSync(filePath).isFile() + ) { + outgoing.writeHead(404); + outgoing.end(); + return; + } + outgoing.writeHead(200, { + "Content-Type": contentType(filePath), + "Content-Length": statSync(filePath).size, + "Cache-Control": "no-store", + }); + createReadStream(filePath).pipe(outgoing); + }, + ); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", () => resolve()); + }); + const address = server.address(); + if (address === null || typeof address === "string") throw new Error("HTTPS proxy did not bind."); + druffOrigin = `https://localhost:${address.port}`; + return { + async close() { + await new Promise((resolve, reject) => + server.close((error) => (error ? reject(error) : resolve())), + ); + rmSync(directory, { recursive: true, force: true }); + }, + }; +} + +function contentType(path: string): string { + switch (extname(path)) { + case ".html": + return "text/html; charset=utf-8"; + case ".js": + return "text/javascript; charset=utf-8"; + case ".css": + return "text/css; charset=utf-8"; + case ".json": + return "application/json"; + case ".svg": + return "image/svg+xml"; + case ".png": + return "image/png"; + case ".woff2": + return "font/woff2"; + case ".ico": + return "image/x-icon"; + default: + return "application/octet-stream"; + } +} + +function json(route: Route, status: number, body: unknown, headers: Record = {}) { + return route.fulfill({ + status, + headers: { ...corsHeaders(), ...headers }, + body: JSON.stringify(body), + }); +} + +function createControlService(durable: DurableControlState) { + return async (route: Route): Promise => { + const request = route.request(); + const url = new URL(request.url()); + if (request.method() === "OPTIONS") { + await route.fulfill({ status: 204, headers: corsHeaders() }); + return; + } + if (request.headers().authorization !== "Bearer e2e-access-token") { + await json(route, 401, errorEnvelope("authentication_required", "Sign in again.")); + return; + } + if (url.pathname === "/v1/projects" && request.method() === "GET") { + await json(route, 200, { projects: [{ id: PROJECT }] }); + return; + } + if (url.pathname === `/v1/projects/${PROJECT}/graphs` && request.method() === "GET") { + const records = [...durable.records.values()].sort((left, right) => + left.graph.localeCompare(right.graph), + ); + const start = url.searchParams.get("cursor") === "page-2" ? 1 : 0; + const selected = records.slice(start, start + 1); + await json(route, 200, { + items: selected.map((record) => ({ + project: PROJECT, + graph: record.graph, + content_sha256: record.contentSha, + created_at: record.createdAt, + updated_at: record.updatedAt, + })), + next_cursor: start + 1 < records.length ? "page-2" : null, + }); + return; + } + if (url.pathname === `/v1/projects/${PROJECT}/graphs` && request.method() === "POST") { + const key = request.headers()["idempotency-key"]!; + const replay = durable.creates.get(key); + if (replay) { + await json(route, 201, resource(replay), { ETag: replay.revision }); + return; + } + const body = JSON.parse(request.postData() ?? "{}") as { + graph: string; + document: Record; + }; + if (durable.records.has(body.graph)) { + await json(route, 409, errorEnvelope("graph_exists", "That graph already exists.")); + return; + } + const created = nextRecord(durable, body.graph, body.document); + durable.records.set(body.graph, created); + durable.creates.set(key, created); + if (durable.loseNextCreate) { + durable.loseNextCreate = false; + await route.abort("connectionrefused"); + return; + } + await json(route, 201, resource(created), { ETag: created.revision }); + return; + } + + const match = /^\/v1\/projects\/demo-project\/graphs\/([^/]+)$/.exec(url.pathname); + if (match) { + const graph = decodeURIComponent(match[1]!); + const current = durable.records.get(graph); + if (request.method() === "GET") { + if (!current) { + await json(route, 404, errorEnvelope("graph_not_found", "The graph does not exist.")); + return; + } + await json(route, 200, resource(current), { ETag: current.revision }); + return; + } + if (request.method() === "PUT") { + if (!current || request.headers()["if-match"] !== current.revision) { + await json(route, 409, errorEnvelope("graph_conflict", "The graph revision changed.")); + return; + } + const updated = nextRecord( + durable, + graph, + JSON.parse(request.postData() ?? "{}") as Record, + current.createdAt, + ); + durable.records.set(graph, updated); + await json(route, 200, resource(updated), { ETag: updated.revision }); + return; + } + if (request.method() === "DELETE") { + const key = request.headers()["idempotency-key"]!; + if (durable.deletes.has(key)) { + await route.fulfill({ status: 204, headers: corsHeaders() }); + return; + } + if (!current || request.headers()["if-match"] !== current.revision) { + await json(route, 409, errorEnvelope("graph_conflict", "The graph revision changed.")); + return; + } + durable.records.delete(graph); + durable.deletes.add(key); + if (durable.loseNextDelete) { + durable.loseNextDelete = false; + await route.abort("connectionrefused"); + return; + } + await route.fulfill({ status: 204, headers: corsHeaders() }); + return; + } + } + await json(route, 404, errorEnvelope("not_found", "Synthetic route missing.")); + }; +} + +function unsignedIdToken(nonce: string): string { + const encoded = (value: object) => Buffer.from(JSON.stringify(value)).toString("base64url"); + const now = Math.floor(Date.now() / 1000); + return `${encoded({ alg: "none", typ: "JWT" })}.${encoded({ + iss: ISSUER, + aud: "druff-public-client", + sub: "e2e-person", + nonce, + iat: now, + exp: now + 3600, + })}.`; +} + +async function installHostedRoutes(page: Page, durable: DurableControlState) { + let nonce = ""; + let service = createControlService(durable); + const restart = () => { + service = createControlService(durable); + }; + + await page.route("**/*", async (route) => { + const url = new URL(route.request().url()); + if (url.href === `${ISSUER}/.well-known/openid-configuration`) { + await json(route, 200, { + issuer: ISSUER, + authorization_endpoint: `${ISSUER}/authorize`, + token_endpoint: `${ISSUER}/token`, + jwks_uri: `${ISSUER}/jwks`, + }); + return; + } + if (url.href.startsWith(`${ISSUER}/authorize?`)) { + nonce = url.searchParams.get("nonce") ?? ""; + const state = url.searchParams.get("state")!; + const redirect = url.searchParams.get("redirect_uri")!; + await route.fulfill({ + status: 200, + contentType: "text/html", + body: ``, + }); + return; + } + if (url.href === `${ISSUER}/token`) { + await json(route, 200, { + access_token: "e2e-access-token", + token_type: "Bearer", + expires_in: 3600, + id_token: unsignedIdToken(nonce), + }); + return; + } + if (url.origin === API_ORIGIN) { + await service(route); + return; + } + await route.continue(); + }); + return restart; +} + +async function signIn(page: Page, browserEvents: string[]) { + await page.getByRole("button", { name: "Sign in" }).first().click(); + await expect(page.getByText("Hosted session", { exact: true })) + .toBeVisible() + .catch(async () => { + throw new Error( + `Hosted sign-in failed at ${page.url()}:\n${browserEvents.join("\n")}\n${await page.locator("body").innerText()}`, + ); + }); +} + +test("manages hosted graphs safely across conflicts, ambiguous retries, and service restart", async ({ + page, +}) => { + test.setTimeout(60_000); + const browserEvents: string[] = []; + page.on("console", (message) => + browserEvents.push(`console ${message.type()}: ${message.text()}`), + ); + page.on("requestfailed", (request) => + browserEvents.push( + `failed ${request.method()} ${request.url()}: ${request.failure()?.errorText}`, + ), + ); + page.on("request", (request) => { + if ( + request.url().includes("bootstrap.json") || + request.url().startsWith(ISSUER) || + request.url().startsWith(API_ORIGIN) || + (request.url().startsWith(druffOrigin) && + (request.url().includes("_rsc") || request.url().endsWith(".txt"))) + ) { + browserEvents.push(`request ${request.method()} ${request.url()}`); + } + }); + page.on("response", (response) => { + if (response.url().includes("bootstrap.json")) { + browserEvents.push(`response ${response.status()} ${response.url()}`); + } + }); + const durable: DurableControlState = { + records: new Map(), + creates: new Map(), + deletes: new Set(), + revision: 0, + loseNextCreate: false, + loseNextDelete: false, + }; + const alpha = nextRecord(durable, "alpha-graph", graphDocument("alpha-pipeline", "Source")); + const beta = nextRecord(durable, "beta-graph", graphDocument("beta-pipeline", "Beta source")); + durable.records.set(alpha.graph, alpha); + durable.records.set(beta.graph, beta); + const restartService = await installHostedRoutes(page, durable); + + await page.goto(`${druffOrigin}/`); + await expect(page.getByRole("button", { name: "Sign in" }).first()) + .toBeVisible({ timeout: 8_000 }) + .catch(() => { + throw new Error(`Hosted bootstrap did not settle:\n${browserEvents.join("\n")}`); + }); + await signIn(page, browserEvents); + await page.getByRole("button", { name: "Browse hosted graphs" }).click(); + await expect(page.getByText("alpha-graph", { exact: true })).toBeVisible(); + await expect(page.getByText("beta-graph", { exact: true })).toHaveCount(0); + await page.getByRole("button", { name: "Load more" }).click(); + await expect(page.getByText("beta-graph", { exact: true })).toBeVisible(); + await page + .getByRole("listitem") + .filter({ hasText: "alpha-graph" }) + .getByRole("button", { name: "Open" }) + .click(); + + const source = page.locator(".react-flow__node", { hasText: "Source" }); + await source.click(); + await page.getByLabel("Name", { exact: true }).fill("Client edit"); + await page.getByRole("button", { name: "Save hosted graph" }).click(); + await expect(page.getByText("Saved", { exact: true })).toBeVisible(); + + const serverRecord = durable.records.get("alpha-graph")!; + durable.records.set( + "alpha-graph", + nextRecord( + durable, + "alpha-graph", + graphDocument("alpha-pipeline", "Server edit"), + serverRecord.createdAt, + ), + ); + await page.locator(".react-flow__node", { hasText: "Client edit" }).click(); + await page.getByLabel("Name", { exact: true }).fill("Conflicting client edit"); + await page.getByRole("button", { name: "Save hosted graph" }).click(); + await expect(page.getByText("File changed elsewhere — reopen", { exact: true })).toBeVisible(); + await page.getByRole("button", { name: "Reload hosted version" }).click(); + await expect(page.locator(".react-flow__node", { hasText: "Server edit" })).toBeVisible(); + + durable.loseNextDelete = true; + await page.getByRole("button", { name: "Delete" }).click(); + const confirmation = page.getByRole("dialog", { name: "Delete hosted graph?" }); + await confirmation.getByRole("button", { name: "Delete hosted graph" }).click(); + await expect(confirmation.getByRole("button", { name: "Delete hosted graph" })).toBeVisible(); + restartService(); + await confirmation.getByRole("button", { name: "Delete hosted graph" }).click(); + await expect(page.getByText("Local draft", { exact: true })).toBeVisible(); + await expect(page.locator(".react-flow__node", { hasText: "Server edit" })).toBeVisible(); + + durable.loseNextCreate = true; + await page.getByRole("button", { name: "Browse hosted graphs" }).click(); + await page.getByLabel("Create from current local draft").fill("restart-graph"); + await page.getByRole("button", { name: "Create" }).click(); + await expect(page.getByText(/could not reach/i)).toBeVisible(); + restartService(); + await page.getByRole("button", { name: "Browse hosted graphs" }).click(); + await page.getByRole("button", { name: "Create" }).click(); + await expect(page.getByText("Saved", { exact: true })).toBeVisible(); + + restartService(); + await page.reload(); + await signIn(page, browserEvents); + await page.getByRole("button", { name: "Browse hosted graphs" }).click(); + await page.getByRole("button", { name: "Load more" }).click(); + await page + .getByRole("listitem") + .filter({ hasText: "restart-graph" }) + .getByRole("button", { name: "Open" }) + .click(); + await expect(page.locator(".react-flow__node", { hasText: "Server edit" })).toBeVisible(); + await expect(page.getByLabel("Hosted graph identity")).toContainText( + "demo-project/restart-graph", + ); +}); diff --git a/package.json b/package.json index c9c43ea..96bc759 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ "contracts:check": "node scripts/generate-dander-contracts.mjs --check", "test": "vitest run", "test:watch": "vitest", - "test:e2e": "playwright test" + "test:e2e": "pnpm build && playwright test" }, "dependencies": { "@monaco-editor/react": "^4.7.0", diff --git a/src/app/page.tsx b/src/app/page.tsx index 687b258..c561247 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,28 +1,5 @@ -import { GraphEditor } from "@/features/graph-io/GraphEditor"; -import { Inspector } from "@/features/pipeline-canvas/inspector/Inspector"; -import { - HostedControlGate, - HostedControlStatus, -} from "@/features/hosted-control/HostedControlProvider"; +import { HostedWorkspace } from "@/features/hosted-control/HostedWorkspace"; export default function Home() { - return ( -
-
-
-

Druff

-

Dander pipeline-graph editor

-
- -
- -
-
- -
- -
-
-
- ); + return ; } diff --git a/src/features/graph-io/GraphEditor.test.tsx b/src/features/graph-io/GraphEditor.test.tsx new file mode 100644 index 0000000..c0a04bd --- /dev/null +++ b/src/features/graph-io/GraphEditor.test.tsx @@ -0,0 +1,80 @@ +import { render, screen } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { GraphEditor } from "@/features/graph-io/GraphEditor"; +import { useHostedControl } from "@/features/hosted-control/HostedControlProvider"; +import { useGraphPersistence } from "@/features/graph-io/useGraphPersistence"; +import { useGraphOperations } from "@/features/graph-operations/useGraphOperations"; + +vi.mock("@/features/hosted-control/HostedControlProvider", () => ({ + useHostedControl: vi.fn(), +})); +vi.mock("@/features/graph-io/useGraphPersistence", () => ({ + useGraphPersistence: vi.fn(), +})); +vi.mock("@/features/graph-operations/useGraphOperations", () => ({ + useGraphOperations: vi.fn(() => ({ status: null })), +})); +vi.mock("@/features/graph-io/GraphToolbar", () => ({ + GraphToolbar: () =>
graph toolbar
, +})); +vi.mock("@/features/graph-operations/GraphOperationsBar", () => ({ + GraphOperationsBar: () =>
legacy loopback operations
, +})); +vi.mock("@/features/pipeline-canvas/PipelineCanvas", () => ({ + PipelineCanvas: () =>
pipeline canvas
, +})); +vi.mock("@/features/graph-io/SourceView", () => ({ SourceView: () =>
source
})); + +const request = vi.fn(); +const controls = { + status: "disconnected", + error: null, + revision: null, + address: null, + contentSha256: null, + attached: false, + managed: true, + projects: [], + graphs: [], + nextCursor: null, + browsing: false, + browseError: null, + loadProjects: vi.fn(async () => []), + loadGraphs: vi.fn(), + create: vi.fn(), + open: vi.fn(), + reload: vi.fn(), + save: vi.fn(), + delete: vi.fn(), + detach: vi.fn(), +}; + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(useGraphPersistence).mockReturnValue(controls as never); +}); + +describe("GraphEditor control mode", () => { + it("never constructs or exposes the legacy loopback operations path in hosted mode", () => { + const browserFetch = vi.spyOn(globalThis, "fetch"); + vi.mocked(useHostedControl).mockReturnValue({ mode: "hosted", request } as never); + + render(); + + expect(screen.getByText(/hosted canonical graph editing/i)).toBeInTheDocument(); + expect(screen.queryByText("legacy loopback operations")).not.toBeInTheDocument(); + expect(useGraphOperations).not.toHaveBeenCalled(); + expect(browserFetch).not.toHaveBeenCalled(); + browserFetch.mockRestore(); + }); + + it("keeps the existing served-file operations path in loopback mode", () => { + vi.mocked(useHostedControl).mockReturnValue({ mode: "loopback", request } as never); + + render(); + + expect(screen.getByText("legacy loopback operations")).toBeInTheDocument(); + expect(screen.getByText(/explicitly served graph file/i)).toBeInTheDocument(); + expect(useGraphOperations).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/features/graph-io/GraphEditor.tsx b/src/features/graph-io/GraphEditor.tsx index 1d53c45..2be59b6 100644 --- a/src/features/graph-io/GraphEditor.tsx +++ b/src/features/graph-io/GraphEditor.tsx @@ -1,12 +1,14 @@ "use client"; -import { useState } from "react"; +import { useMemo, useState } from "react"; import { GraphToolbar, type ViewMode } from "@/features/graph-io/GraphToolbar"; import { SourceView } from "@/features/graph-io/SourceView"; import { useGraphPersistence } from "@/features/graph-io/useGraphPersistence"; import { GraphOperationsBar } from "@/features/graph-operations/GraphOperationsBar"; import { useGraphOperations } from "@/features/graph-operations/useGraphOperations"; import { PipelineCanvas } from "@/features/pipeline-canvas/PipelineCanvas"; +import { useHostedControl } from "@/features/hosted-control/HostedControlProvider"; +import { HostedGraphPersistence } from "@/lib/persistence/graph-persistence"; /** * Top-level container for DRUFF-5: owns which half (`canvas` | `source`) is showing, wires up @@ -16,15 +18,46 @@ import { PipelineCanvas } from "@/features/pipeline-canvas/PipelineCanvas"; */ export function GraphEditor() { const [viewMode, setViewMode] = useState("canvas"); - const persistence = useGraphPersistence(); + const control = useHostedControl(); + const hostedPersistence = useMemo( + () => (control.mode === "hosted" ? new HostedGraphPersistence(control.request) : null), + [control.mode, control.request], + ); + const persistence = useGraphPersistence( + hostedPersistence === null ? {} : { persistence: hostedPersistence }, + ); + + return ( +
+ + {control.mode === "hosted" ? ( +
+ Hosted canonical graph editing. Validation, catalogs, deployment previews, and run + controls arrive in the next focused steps; this view never contacts a loopback Dander + service. +
+ ) : ( + + )} +
+ {viewMode === "canvas" ? : } +
+
+ ); +} + +function LoopbackOperations({ + persistence, +}: { + persistence: ReturnType; +}) { const operations = useGraphOperations({ revision: persistence.revision, graphIsClean: persistence.status === "clean", }); return ( -
- + <>
-
- {viewMode === "canvas" ? : } -
-
+ ); } diff --git a/src/features/graph-io/GraphToolbar.tsx b/src/features/graph-io/GraphToolbar.tsx index feaf46f..088900e 100644 --- a/src/features/graph-io/GraphToolbar.tsx +++ b/src/features/graph-io/GraphToolbar.tsx @@ -1,13 +1,32 @@ "use client"; import { useRef, type ChangeEvent } from "react"; -import { Code2, Download, FolderOpen, LayoutGrid, Save, Upload } from "lucide-react"; +import { + Code2, + Download, + FolderOpen, + LayoutGrid, + RotateCcw, + Save, + Trash2, + Upload, +} from "lucide-react"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; import { useGraphStore } from "@/lib/graph-store"; import { canvasToGraph, graphToCanvas, type GraphFormat } from "@/lib/pipeline-graph"; import { exportGraphToFile, parseImportedFile } from "@/lib/graph-io/graph-file"; import type { GraphPersistenceControls } from "@/features/graph-io/useGraphPersistence"; +import { RemoteGraphDialog } from "@/features/graph-io/RemoteGraphDialog"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; /** Which half of `GraphEditor` is currently showing. Ephemeral UI state — deliberately not part of * the graph store (`steering/02-engineering.md`: this is view state, not graph data). */ @@ -75,22 +94,54 @@ export function GraphToolbar({ viewMode, onViewModeChange, persistence }: GraphT return (
- + {persistence.managed ? ( + + ) : ( + + )} + {persistence.managed && persistence.status === "conflict" ? ( + + ) : null} + {persistence.managed && persistence.attached ? ( + + + + + + + Delete hosted graph? + + Delete {persistence.address?.project}/{persistence.address?.graph} at its current + opaque revision. The canvas stays open as a detached local draft. + + + + + + + + ) : null} {statusLabel(persistence)} + {persistence.managed && persistence.address ? ( + + + {persistence.address.project}/{persistence.address.graph} + + {persistence.revision ? ( + + Opaque revision: {compact(persistence.revision)} + + ) : null} + {persistence.contentSha256 ? ( + + Content SHA-256: {compact(persistence.contentSha256)} + + ) : null} + + ) : null} @@ -151,9 +222,15 @@ function statusLabel(persistence: GraphPersistenceControls): string { return "Unsaved changes"; case "saving": return "Saving…"; + case "deleting": + return "Deleting…"; case "conflict": return "File changed elsewhere — reopen"; case "error": return persistence.error ?? "Dander unavailable"; } } + +function compact(value: string): string { + return value.length > 18 ? `${value.slice(0, 18)}…` : value; +} diff --git a/src/features/graph-io/README.md b/src/features/graph-io/README.md index c94164c..1008e63 100644 --- a/src/features/graph-io/README.md +++ b/src/features/graph-io/README.md @@ -1,10 +1,12 @@ # `graph-io` -Canonical graph Open/Save and the canvas ⇄ source view toggle. This is glue between the canvas -store, the pure pipeline-graph model/converters, and three browser affordances: +Canonical graph collection/open/save and the canvas ⇄ source view toggle. This is glue between the +canvas store, the pure pipeline-graph model/converters, and three browser affordances: -- **Dander persistence** — explicit Open/Save against the graph selected by `dander graph serve`. - Every save carries the last ETag revision; conflicts are visible and never overwrite the file. +- **Dander persistence** — loopback mode retains explicit Open/Save against `dander graph serve`. + Hosted mode lists logical projects and paginated graph summaries, then creates, opens, saves, or + deletes through Dander's generated Control API contract. Every mutation carries the exact opaque + ETag; conflicts are visible and never overwrite a graph. - **File export/import** — `GraphToolbar` exports explicitly named Druff graph drafts and imports either those drafts or a version-1 `dander.yaml`, via `src/lib/graph-io/graph-file.ts`. - **Canvas ⇄ source toggle** — `GraphEditor` owns `viewMode` and renders either `PipelineCanvas` or @@ -12,11 +14,13 @@ store, the pure pipeline-graph model/converters, and three browser affordances: ## Files -- `GraphEditor.tsx` — container: owns `viewMode`, mounts persistence, renders the toolbar above the - active view, and passes the opened revision to the separate graph-operations feature. -- `GraphToolbar.tsx` — Dander Open/Save, draft export, graph/manifest import, status, and view toggle. +- `GraphEditor.tsx` — chooses the existing loopback seam or authenticated hosted collection client. + Legacy deployed-job operations remain loopback-only until their dedicated hosted tickets. +- `GraphToolbar.tsx` / `RemoteGraphDialog.tsx` — hosted browse/create/open/save/reload/confirmed + delete, local Open/Save, draft import/export, status, identities, and the view toggle. - `SourceView.tsx` — read-only Monaco view of the live canvas encoded as YAML/JSON. -- `useGraphPersistence.ts` — explicit async Open/Save controller with dirty/conflict state. +- `useGraphPersistence.ts` — one async controller for local and hosted persistence, retaining + dirty/saving/conflict/reload state and detaching imports or deleted graphs safely. On Open, the controller also discovers the connected runtime's presentation-only connector, package, and transform-operation catalogs. Catalog failure never blocks graph access. The operation @@ -36,10 +40,10 @@ never re-encoded as or written back to `dander.yaml`. - `SourceView` is **read-only** — editing YAML/JSON back into the canvas is a future ticket. - The Dander manifest projection does not read connector/model file contents and cannot write back or deploy changes. -- Dander exposes exactly one operator-selected graph file. Multi-file browsing is intentionally - deferred until the product has a real graph registry convention. +- Hosted list responses remain document-free and bounded. Only an explicit Open retrieves a graph; + opaque pagination cursors are sent back unchanged. - Saving normalizes YAML formatting/comments. Model fields are preserved; byte formatting is not. - Execution controls live in `src/features/graph-operations` and can only start an already-deployed, operator-bound job. Terraform deployment remains out of scope. -- Provider create/update/delete, deleted-record feeds, arbitrary SQL hooks, and browser-side - operation execution are out of scope. +- Provider branches, deleted-record feeds, arbitrary SQL hooks, and browser-side operation + execution are out of scope. The hosted browser sees only provider-neutral Control API DTOs. diff --git a/src/features/graph-io/RemoteGraphDialog.tsx b/src/features/graph-io/RemoteGraphDialog.tsx new file mode 100644 index 0000000..caabab8 --- /dev/null +++ b/src/features/graph-io/RemoteGraphDialog.tsx @@ -0,0 +1,167 @@ +"use client"; + +import { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import type { GraphPersistenceControls } from "@/features/graph-io/useGraphPersistence"; + +const PORTABLE_GRAPH_ID = /^[a-z0-9][a-z0-9_-]{0,62}$/; + +export function RemoteGraphDialog({ persistence }: { persistence: GraphPersistenceControls }) { + const [open, setOpen] = useState(false); + const [project, setProject] = useState(""); + const [graphId, setGraphId] = useState(""); + + function handleOpenChange(next: boolean): void { + setOpen(next); + if (next) { + setProject(""); + void persistence.loadProjects().then((projects) => { + const first = projects[0]; + if (!first) return; + setProject(first); + return persistence.loadGraphs(first); + }); + } + } + + return ( + + + + + + + Hosted projects and graphs + + Open a canonical graph, or attach the current local draft by creating a new graph. + + + +
+ + +
+ +
+ {persistence.graphs.length === 0 ? ( +

+ {persistence.browsing ? "Loading graphs…" : "No graphs in this project."} +

+ ) : ( +
    + {persistence.graphs.map((graph) => ( +
  • +
    +
    {graph.graph}
    +
    + Content SHA-256: {graph.content_sha256} +
    +
    + +
  • + ))} +
+ )} +
+ + {persistence.nextCursor !== null ? ( + + ) : null} + + {!persistence.attached ? ( +
+ +
+ setGraphId(event.target.value)} + /> + +
+

+ Use lowercase letters, numbers, hyphens, or underscores; begin with a letter or + number. +

+
+ ) : null} + + {persistence.browseError ? ( +

+ {persistence.browseError} +

+ ) : null} + +
+
+ ); +} diff --git a/src/features/graph-io/RemoteGraphManagement.test.tsx b/src/features/graph-io/RemoteGraphManagement.test.tsx new file mode 100644 index 0000000..d70ab2a --- /dev/null +++ b/src/features/graph-io/RemoteGraphManagement.test.tsx @@ -0,0 +1,88 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import { RemoteGraphDialog } from "@/features/graph-io/RemoteGraphDialog"; +import { GraphToolbar } from "@/features/graph-io/GraphToolbar"; +import type { GraphPersistenceControls } from "@/features/graph-io/useGraphPersistence"; + +function controls(overrides: Partial = {}): GraphPersistenceControls { + return { + status: "disconnected", + error: null, + revision: null, + address: null, + contentSha256: null, + attached: false, + managed: true, + projects: ["demo-project"], + graphs: [ + { + project: "demo-project", + graph: "alpha-graph", + content_sha256: "a".repeat(64), + created_at: "2026-08-14T00:00:00Z", + updated_at: "2026-08-14T00:00:00Z", + }, + ], + nextCursor: "cursor-2", + browsing: false, + browseError: null, + loadProjects: vi.fn(async () => ["demo-project"]), + loadGraphs: vi.fn(async () => undefined), + create: vi.fn(async () => undefined), + open: vi.fn(async () => undefined), + reload: vi.fn(async () => undefined), + save: vi.fn(async () => undefined), + delete: vi.fn(async () => undefined), + detach: vi.fn(), + ...overrides, + }; +} + +describe("remote graph management UI", () => { + it("browses pages, opens a graph, and creates the detached draft under a portable id", async () => { + const user = userEvent.setup(); + const persistence = controls(); + render(); + + await user.click(screen.getByRole("button", { name: "Browse hosted graphs" })); + expect(persistence.loadProjects).toHaveBeenCalledOnce(); + expect(await screen.findByText("alpha-graph")).toBeInTheDocument(); + expect(persistence.loadGraphs).toHaveBeenCalledWith("demo-project"); + + await user.click(screen.getByRole("button", { name: "Load more" })); + expect(persistence.loadGraphs).toHaveBeenCalledWith("demo-project", true); + await user.click(screen.getByRole("button", { name: "Open" })); + expect(persistence.open).toHaveBeenCalledWith({ + project: "demo-project", + graph: "alpha-graph", + }); + + await user.click(screen.getByRole("button", { name: "Browse hosted graphs" })); + await user.type(screen.getByLabelText("Create from current local draft"), "new-graph"); + await user.click(screen.getByRole("button", { name: "Create" })); + expect(persistence.create).toHaveBeenCalledWith("demo-project", "new-graph"); + }); + + it("requires delete confirmation and exposes a separate reload path for conflicts", async () => { + const user = userEvent.setup(); + const persistence = controls({ + status: "conflict", + attached: true, + revision: '"opaque-revision"', + address: { project: "demo-project", graph: "alpha-graph" }, + contentSha256: "b".repeat(64), + }); + render(); + + expect(screen.getByText(/opaque revision:/i)).toBeInTheDocument(); + expect(screen.getByText(/content sha-256:/i)).toBeInTheDocument(); + await user.click(screen.getByRole("button", { name: /reload hosted version/i })); + expect(persistence.reload).toHaveBeenCalledOnce(); + + await user.click(screen.getByRole("button", { name: "Delete" })); + expect(screen.getByText("Delete hosted graph?")).toBeInTheDocument(); + await user.click(screen.getByRole("button", { name: "Delete hosted graph" })); + expect(persistence.delete).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/features/graph-io/useGraphPersistence.test.tsx b/src/features/graph-io/useGraphPersistence.test.tsx index 0e2f12d..f452cf1 100644 --- a/src/features/graph-io/useGraphPersistence.test.tsx +++ b/src/features/graph-io/useGraphPersistence.test.tsx @@ -21,7 +21,11 @@ import { } from "@/features/pipeline-operations/catalog-store"; import { SEED_GRAPH, useGraphStore } from "@/lib/graph-store"; import { PipelineGraphSchema, graphToCanvas } from "@/lib/pipeline-graph"; -import { GraphPersistenceError, type GraphPersistence } from "@/lib/persistence/graph-persistence"; +import { + GraphPersistenceError, + type GraphPersistence, + type ManagedGraphPersistence, +} from "@/lib/persistence/graph-persistence"; const GRAPH = PipelineGraphSchema.parse({ name: "served-graph", @@ -252,4 +256,220 @@ describe("useGraphPersistence", () => { expect(result.current.revision).toBeNull(); expect(result.current.error).toMatch(/open a graph/i); }); + + it("loads hosted projects and appends graph pages through the collection persistence seam", async () => { + const managed: ManagedGraphPersistence = { + mode: "collection", + listProjects: vi.fn(async () => ["demo-project"]), + listGraphs: vi + .fn() + .mockResolvedValueOnce({ + items: [ + { + project: "demo-project", + graph: "alpha", + content_sha256: "a".repeat(64), + created_at: "now", + updated_at: "now", + }, + ], + nextCursor: "cursor-2", + }) + .mockResolvedValueOnce({ + items: [ + { + project: "demo-project", + graph: "beta", + content_sha256: "b".repeat(64), + created_at: "now", + updated_at: "now", + }, + ], + nextCursor: null, + }), + load: vi.fn(async () => ({ graph: GRAPH, revision: '"revision-1"' })), + save: vi.fn(async (graph) => ({ graph, revision: '"revision-2"' })), + create: vi.fn(async () => ({ graph: GRAPH, revision: '"revision-1"' })), + delete: vi.fn(async () => undefined), + }; + const { result } = renderHook(() => useGraphPersistence({ persistence: managed })); + + await act(async () => result.current.loadProjects()); + await act(async () => result.current.loadGraphs("demo-project")); + await act(async () => result.current.loadGraphs("demo-project", true)); + + expect(result.current.projects).toEqual(["demo-project"]); + expect(result.current.graphs.map((graph) => graph.graph)).toEqual(["alpha", "beta"]); + expect(managed.listGraphs).toHaveBeenNthCalledWith(2, "demo-project", "cursor-2"); + }); + + it("creates a hosted graph from a detached draft and deletes it without clearing the canvas", async () => { + const address = { project: "demo-project", graph: "created-graph" }; + const managed: ManagedGraphPersistence = { + mode: "collection", + listProjects: vi.fn(async () => [address.project]), + listGraphs: vi.fn(async () => ({ items: [], nextCursor: null })), + load: vi.fn(async () => ({ graph: GRAPH, revision: '"revision-1"', address })), + save: vi.fn(async (graph) => ({ graph, revision: '"revision-2"', address })), + create: vi.fn(async (_address, graph) => ({ + graph, + revision: '"revision-1"', + address, + contentSha256: "c".repeat(64), + })), + delete: vi.fn(async () => undefined), + }; + const { result } = renderHook(() => useGraphPersistence({ persistence: managed })); + + await act(async () => result.current.create(address.project, address.graph)); + expect(result.current).toMatchObject({ + status: "clean", + attached: true, + address, + contentSha256: "c".repeat(64), + }); + act(() => useGraphStore.getState().updateNodeData("1", { name: "Detached after delete" })); + await act(async () => result.current.delete()); + + expect(managed.delete).toHaveBeenCalledWith(address, '"revision-1"'); + expect(result.current.status).toBe("disconnected"); + expect(result.current.attached).toBe(false); + expect(useGraphStore.getState().nodes[0]?.data.name).toBe("Detached after delete"); + }); + + it("shows a hosted save conflict and reloads the canonical graph explicitly", async () => { + const address = { project: "demo-project", graph: "alpha" }; + const remote = PipelineGraphSchema.parse({ ...GRAPH, name: "reloaded-graph" }); + const managed: ManagedGraphPersistence = { + mode: "collection", + listProjects: vi.fn(async () => [address.project]), + listGraphs: vi.fn(async () => ({ items: [], nextCursor: null })), + load: vi + .fn() + .mockResolvedValueOnce({ graph: GRAPH, revision: '"revision-1"', address }) + .mockResolvedValueOnce({ + graph: remote, + revision: '"revision-2"', + address, + contentSha256: "d".repeat(64), + }), + save: vi.fn(async () => { + throw new GraphPersistenceError("The revision changed.", { conflict: true }); + }), + create: vi.fn(async () => ({ graph: GRAPH, revision: '"revision-1"', address })), + delete: vi.fn(async () => undefined), + }; + const { result } = renderHook(() => useGraphPersistence({ persistence: managed })); + await act(async () => result.current.open(address)); + act(() => useGraphStore.getState().updateNodeData("source", { name: "Dirty local edit" })); + + await act(async () => result.current.save()); + expect(result.current.status).toBe("conflict"); + await act(async () => result.current.reload()); + + expect(result.current.status).toBe("clean"); + expect(result.current.revision).toBe('"revision-2"'); + expect(useGraphStore.getState().graphName).toBe("reloaded-graph"); + }); + + it("keeps an edit made during a hosted save dirty against the returned revision", async () => { + const address = { project: "demo-project", graph: "alpha" }; + let resolveSave!: (document: { + graph: typeof GRAPH; + revision: string; + address: typeof address; + }) => void; + const saveResult = new Promise<{ + graph: typeof GRAPH; + revision: string; + address: typeof address; + }>((resolve) => { + resolveSave = resolve; + }); + const managed: ManagedGraphPersistence = { + mode: "collection", + listProjects: vi.fn(async () => [address.project]), + listGraphs: vi.fn(async () => ({ items: [], nextCursor: null })), + load: vi.fn(async () => ({ graph: GRAPH, revision: '"revision-1"', address })), + save: vi.fn(async () => saveResult), + create: vi.fn(async () => ({ graph: GRAPH, revision: '"revision-1"', address })), + delete: vi.fn(async () => undefined), + }; + const { result } = renderHook(() => useGraphPersistence({ persistence: managed })); + await act(async () => result.current.open(address)); + act(() => useGraphStore.getState().updateNodeData("source", { name: "Saved snapshot" })); + + let saving!: Promise; + act(() => { + saving = result.current.save(); + }); + act(() => useGraphStore.getState().updateNodeData("source", { name: "Newer local edit" })); + resolveSave({ + graph: PipelineGraphSchema.parse({ + ...GRAPH, + nodes: [{ ...GRAPH.nodes[0], name: "Saved snapshot" }], + }), + revision: '"revision-2"', + address, + }); + await act(async () => saving); + + expect(result.current.status).toBe("dirty"); + expect(result.current.revision).toBe('"revision-2"'); + expect(useGraphStore.getState().nodes[0]?.data.name).toBe("Newer local edit"); + }); + + it("keeps an edit made during hosted create dirty against the returned revision", async () => { + const address = { project: "demo-project", graph: "created-graph" }; + let resolveCreate!: (document: { + graph: typeof GRAPH; + revision: string; + address: typeof address; + contentSha256: string; + }) => void; + const createResult = new Promise<{ + graph: typeof GRAPH; + revision: string; + address: typeof address; + contentSha256: string; + }>((resolve) => { + resolveCreate = resolve; + }); + let submittedGraph!: typeof GRAPH; + const managed: ManagedGraphPersistence = { + mode: "collection", + listProjects: vi.fn(async () => [address.project]), + listGraphs: vi.fn(async () => ({ items: [], nextCursor: null })), + load: vi.fn(async () => ({ graph: GRAPH, revision: '"revision-1"', address })), + save: vi.fn(async (graph) => ({ graph, revision: '"revision-2"', address })), + create: vi.fn(async (_address, graph) => { + submittedGraph = graph; + return createResult; + }), + delete: vi.fn(async () => undefined), + }; + const { result } = renderHook(() => useGraphPersistence({ persistence: managed })); + + let creating!: Promise; + act(() => { + creating = result.current.create(address.project, address.graph); + }); + act(() => useGraphStore.getState().updateNodeData("1", { name: "Newer local edit" })); + resolveCreate({ + graph: submittedGraph, + revision: '"revision-1"', + address, + contentSha256: "e".repeat(64), + }); + await act(async () => creating); + + expect(result.current).toMatchObject({ + status: "dirty", + attached: true, + revision: '"revision-1"', + address, + contentSha256: "e".repeat(64), + }); + expect(useGraphStore.getState().nodes[0]?.data.name).toBe("Newer local edit"); + }); }); diff --git a/src/features/graph-io/useGraphPersistence.ts b/src/features/graph-io/useGraphPersistence.ts index 3e56f93..cc77962 100644 --- a/src/features/graph-io/useGraphPersistence.ts +++ b/src/features/graph-io/useGraphPersistence.ts @@ -27,19 +27,35 @@ import { canvasToGraph, graphToCanvas } from "@/lib/pipeline-graph"; import { DanderApiGraphPersistence, GraphPersistenceError, + isManagedGraphPersistence, + type GraphAddress, + type GraphSummary, type GraphPersistence, } from "@/lib/persistence/graph-persistence"; export type GraphPersistenceStatus = - "disconnected" | "loading" | "clean" | "dirty" | "saving" | "conflict" | "error"; + "disconnected" | "loading" | "clean" | "dirty" | "saving" | "deleting" | "conflict" | "error"; export type GraphPersistenceControls = { status: GraphPersistenceStatus; error: string | null; revision: string | null; + address: GraphAddress | null; + contentSha256: string | null; attached: boolean; - open: () => Promise; + managed: boolean; + projects: string[]; + graphs: GraphSummary[]; + nextCursor: string | null; + browsing: boolean; + browseError: string | null; + loadProjects: () => Promise; + loadGraphs: (project: string, append?: boolean) => Promise; + create: (project: string, graph: string) => Promise; + open: (address?: GraphAddress) => Promise; + reload: () => Promise; save: () => Promise; + delete: () => Promise; detach: () => void; }; @@ -88,6 +104,9 @@ export function useGraphPersistence( } const revisionRef = useRef(null); + const addressRef = useRef(null); + const contentSha256Ref = useRef(null); + const nextCursorRef = useRef(null); const baselineRef = useRef(null); const lastSemanticRef = useRef(null); const attachedRef = useRef(false); @@ -96,6 +115,16 @@ export function useGraphPersistence( const [status, setStatus] = useState("disconnected"); const [error, setError] = useState(null); const [revision, setRevision] = useState(null); + const [address, setAddress] = useState(null); + const [contentSha256, setContentSha256] = useState(null); + const [projects, setProjects] = useState([]); + const [graphs, setGraphs] = useState([]); + const [nextCursor, setNextCursor] = useState(null); + const [browsing, setBrowsing] = useState(false); + const [browseError, setBrowseError] = useState(null); + const [managed] = useState(() => + options.persistence === undefined ? false : isManagedGraphPersistence(options.persistence), + ); useEffect( () => @@ -111,7 +140,59 @@ export function useGraphPersistence( [], ); - const open = useCallback(async () => { + const loadProjects = useCallback(async () => { + const persistence = persistenceRef.current!; + if (!isManagedGraphPersistence(persistence)) return []; + setBrowsing(true); + setBrowseError(null); + try { + const loaded = await persistence.listProjects(); + setProjects(loaded); + return loaded; + } catch (cause) { + setBrowseError(describeError(cause, true)); + return []; + } finally { + setBrowsing(false); + } + }, []); + + const loadGraphs = useCallback(async (project: string, append = false) => { + const persistence = persistenceRef.current!; + if (!isManagedGraphPersistence(persistence)) return; + const cursor = append ? nextCursorRef.current : null; + if (append && cursor === null) return; + setBrowsing(true); + setBrowseError(null); + try { + const page = await persistence.listGraphs(project, cursor); + setGraphs((current) => (append ? [...current, ...page.items] : page.items)); + nextCursorRef.current = page.nextCursor; + setNextCursor(page.nextCursor); + } catch (cause) { + setBrowseError(describeError(cause, true)); + } finally { + setBrowsing(false); + } + }, []); + + const openDocument = useCallback(async (target?: GraphAddress, discardLocal = false) => { + const persistence = persistenceRef.current!; + if (isManagedGraphPersistence(persistence) && !target) { + setStatus("error"); + setError("Choose a hosted graph to open."); + return; + } + if ( + !discardLocal && + attachedRef.current && + baselineRef.current !== null && + serializeGraphState(useGraphStore.getState()) !== baselineRef.current + ) { + setStatus("dirty"); + setError("Save or export this draft before opening another hosted graph."); + return; + } setStatus("loading"); setError(null); try { @@ -149,7 +230,7 @@ export function useGraphPersistence( } })(), ]); - const document = await persistenceRef.current!.load(); + const document = await persistence.load(target); const restored = graphToCanvas(document.graph); applyingRemoteRef.current = true; useGraphStore @@ -158,6 +239,10 @@ export function useGraphPersistence( applyingRemoteRef.current = false; revisionRef.current = document.revision; setRevision(document.revision); + addressRef.current = document.address ?? null; + setAddress(document.address ?? null); + contentSha256Ref.current = document.contentSha256 ?? null; + setContentSha256(document.contentSha256 ?? null); attachedRef.current = true; changeVersionRef.current = 0; baselineRef.current = serializeGraphState(useGraphStore.getState()); @@ -165,13 +250,79 @@ export function useGraphPersistence( setStatus("clean"); } catch (cause) { applyingRemoteRef.current = false; - attachedRef.current = false; - revisionRef.current = null; - setRevision(null); - baselineRef.current = null; - lastSemanticRef.current = null; + if (!isManagedGraphPersistence(persistence) || !attachedRef.current) { + attachedRef.current = false; + revisionRef.current = null; + setRevision(null); + addressRef.current = null; + setAddress(null); + contentSha256Ref.current = null; + setContentSha256(null); + baselineRef.current = null; + lastSemanticRef.current = null; + } setStatus("error"); - setError(describeError(cause)); + setError(describeError(cause, isManagedGraphPersistence(persistence))); + } + }, []); + + const open = useCallback( + async (target?: GraphAddress) => openDocument(target, false), + [openDocument], + ); + + const reload = useCallback(async () => { + const target = addressRef.current ?? undefined; + await openDocument(target, true); + }, [openDocument]); + + const create = useCallback(async (project: string, graph: string) => { + const persistence = persistenceRef.current!; + if (!isManagedGraphPersistence(persistence)) return; + if (attachedRef.current) { + setStatus("error"); + setError("Detach this graph before creating a separate hosted graph."); + return; + } + const address = { project, graph }; + const state = useGraphStore.getState(); + const document = canvasToGraph(state.nodes, state.edges, state.graphName, state.graphTrigger); + const submittedSemantic = JSON.stringify(document); + setStatus("saving"); + setError(null); + try { + const created = await persistence.create(address, document); + revisionRef.current = created.revision; + setRevision(created.revision); + addressRef.current = address; + setAddress(address); + contentSha256Ref.current = created.contentSha256 ?? null; + setContentSha256(created.contentSha256 ?? null); + attachedRef.current = true; + changeVersionRef.current = 0; + const currentSemantic = serializeGraphState(useGraphStore.getState()); + if (currentSemantic === submittedSemantic) { + const restored = graphToCanvas(created.graph); + applyingRemoteRef.current = true; + useGraphStore + .getState() + .setGraph(restored.nodes, restored.edges, created.graph.name, created.graph.trigger); + applyingRemoteRef.current = false; + baselineRef.current = serializeGraphState(useGraphStore.getState()); + lastSemanticRef.current = baselineRef.current; + setStatus("clean"); + } else { + // The detached draft remained editable while Create was in flight. Attach the accepted + // remote identity and baseline, but keep the newer local canvas dirty instead of erasing it. + baselineRef.current = JSON.stringify(created.graph); + lastSemanticRef.current = currentSemantic; + changeVersionRef.current = 1; + setStatus("dirty"); + } + } catch (cause) { + applyingRemoteRef.current = false; + setStatus("error"); + setError(describeError(cause, true)); } }, []); @@ -190,9 +341,15 @@ export function useGraphPersistence( setError(null); try { - const document = await persistenceRef.current!.save(graph, revision); + const target = addressRef.current; + const document = + target === null + ? await persistenceRef.current!.save(graph, revision) + : await persistenceRef.current!.save(graph, revision, target); revisionRef.current = document.revision; setRevision(document.revision); + setContentSha256(document.contentSha256 ?? contentSha256Ref.current); + contentSha256Ref.current = document.contentSha256 ?? contentSha256Ref.current; baselineRef.current = JSON.stringify(document.graph); if (changeVersionRef.current === savedChangeVersion) { const restored = graphToCanvas(document.graph); @@ -214,7 +371,40 @@ export function useGraphPersistence( } catch (cause) { applyingRemoteRef.current = false; setStatus(cause instanceof GraphPersistenceError && cause.conflict ? "conflict" : "error"); - setError(describeError(cause)); + setError(describeError(cause, isManagedGraphPersistence(persistenceRef.current!))); + } + }, []); + + const deleteGraph = useCallback(async () => { + const persistence = persistenceRef.current!; + const target = addressRef.current; + const revision = revisionRef.current; + if (!isManagedGraphPersistence(persistence) || target === null || revision === null) { + setStatus("error"); + setError("Open a hosted graph before deleting it."); + return; + } + setStatus("deleting"); + setError(null); + try { + await persistence.delete(target, revision); + attachedRef.current = false; + revisionRef.current = null; + setRevision(null); + addressRef.current = null; + setAddress(null); + contentSha256Ref.current = null; + setContentSha256(null); + baselineRef.current = null; + lastSemanticRef.current = null; + changeVersionRef.current = 0; + setGraphs((current) => + current.filter((item) => item.project !== target.project || item.graph !== target.graph), + ); + setStatus("disconnected"); + } catch (cause) { + setStatus(cause instanceof GraphPersistenceError && cause.conflict ? "conflict" : "error"); + setError(describeError(cause, true)); } }, []); @@ -222,6 +412,10 @@ export function useGraphPersistence( attachedRef.current = false; revisionRef.current = null; setRevision(null); + addressRef.current = null; + setAddress(null); + contentSha256Ref.current = null; + setContentSha256(null); baselineRef.current = null; lastSemanticRef.current = null; changeVersionRef.current = 0; @@ -229,11 +423,35 @@ export function useGraphPersistence( setError(null); }, []); - return { status, error, revision, attached: revision !== null, open, save, detach }; + return { + status, + error, + revision, + address, + contentSha256, + attached: revision !== null, + managed, + projects, + graphs, + nextCursor, + browsing, + browseError, + loadProjects, + loadGraphs, + create, + open, + reload, + save, + delete: deleteGraph, + detach, + }; } -function describeError(error: unknown): string { +function describeError(error: unknown, hosted = false): string { if (error instanceof TypeError) { + if (hosted) { + return "Could not reach hosted Dander. Check the connection and retry the same operation safely."; + } return "Could not reach Dander. Start `dander graph serve --file ` and try again."; } return error instanceof Error ? error.message : "Dander graph request failed."; diff --git a/src/features/hosted-control/HostedControlProvider.test.tsx b/src/features/hosted-control/HostedControlProvider.test.tsx index 0ab89b4..9ac4569 100644 --- a/src/features/hosted-control/HostedControlProvider.test.tsx +++ b/src/features/hosted-control/HostedControlProvider.test.tsx @@ -12,10 +12,11 @@ import { hostedControlDescriptor } from "@/features/hosted-control/test-fixtures const mocks = vi.hoisted(() => ({ createSession: vi.fn(), discover: vi.fn(), - routerReplace: vi.fn(), })); -vi.mock("next/navigation", () => ({ useRouter: () => ({ replace: mocks.routerReplace }) })); +vi.mock("@/features/hosted-control/HostedWorkspace", () => ({ + HostedWorkspace: () =>
Verified hosted workspace
, +})); vi.mock("@/features/hosted-control/bootstrap", async (importOriginal) => ({ ...(await importOriginal()), discoverControlBootstrap: mocks.discover, @@ -79,7 +80,7 @@ describe("hosted-control presentation boundary", () => { "/auth/callback?code=authorization-code&state=stored-state", ); - render( + const callback = render( @@ -90,6 +91,21 @@ describe("hosted-control presentation boundary", () => { await waitFor(() => expect(window.location.href).toBe("http://localhost:3000/auth/callback")); await waitFor(() => expect(session.completeSignIn).toHaveBeenCalledWith(original)); expect(session.completeSignIn).toHaveBeenCalledOnce(); - expect(mocks.routerReplace).toHaveBeenCalledWith("/"); + await waitFor(() => expect(window.location.href).toBe("http://localhost:3000/")); + expect(screen.getByText("Verified hosted workspace")).toBeVisible(); + + callback.unmount(); + window.history.replaceState(null, "", "/"); + render( + + + +
Hosted workspace after callback navigation
+
+
, + ); + + expect(await screen.findByText("Hosted session")).toBeVisible(); + expect(screen.getByText("Hosted workspace after callback navigation")).toBeVisible(); }); }); diff --git a/src/features/hosted-control/HostedControlProvider.tsx b/src/features/hosted-control/HostedControlProvider.tsx index 7a0b406..16356a7 100644 --- a/src/features/hosted-control/HostedControlProvider.tsx +++ b/src/features/hosted-control/HostedControlProvider.tsx @@ -36,6 +36,34 @@ type ControlState = access: BrowserAccessToken | null; }; +type RetainedAccess = { + binding: string; + access: BrowserAccessToken; +}; + +// Static callback navigation may remount this provider even though the JavaScript realm remains +// alive. Retain the token only in module memory, bound to the exact reviewed identity/API tuple. +// A full reload, new tab, configuration change, expiry, 401, or sign-out clears it. +let retainedAccess: RetainedAccess | null = null; + +function accessBinding(descriptor: ControlBootstrapDescriptor): string { + return JSON.stringify([ + descriptor.issuer, + descriptor.public_client_id, + descriptor.api_audience, + descriptor.api_url, + ]); +} + +function activeRetainedAccess(descriptor: ControlBootstrapDescriptor): BrowserAccessToken | null { + const binding = accessBinding(descriptor); + if (retainedAccess?.binding === binding && retainedAccess.access.expiresAt > Date.now() / 1000) { + return retainedAccess.access; + } + retainedAccess = null; + return null; +} + interface HostedControlContextValue { mode: ControlState["mode"]; authenticated: boolean; @@ -65,11 +93,16 @@ export function HostedControlProvider({ children }: { children: ReactNode }) { .then((discovery: BootstrapDiscovery) => { if (!active) return; if (discovery.mode === "loopback") { + retainedAccess = null; setState({ mode: "loopback" }); return; } sessionRef.current = createHostedOidcSession(discovery.descriptor, window.sessionStorage); - setState({ ...discovery, mode: "hosted", access: null }); + setState({ + ...discovery, + mode: "hosted", + access: activeRetainedAccess(discovery.descriptor), + }); }) .catch((error: unknown) => { if (active) setState({ mode: "error", message: messageFrom(error) }); @@ -81,6 +114,7 @@ export function HostedControlProvider({ children }: { children: ReactNode }) { }, []); const clearAuthentication = useCallback(async () => { + retainedAccess = null; await sessionRef.current?.clear(); setState((current) => (current.mode === "hosted" ? { ...current, access: null } : current)); }, []); @@ -109,12 +143,17 @@ export function HostedControlProvider({ children }: { children: ReactNode }) { const completeSignIn = useCallback(async (callbackUrl: string) => { if (!sessionRef.current) throw new Error("Hosted sign-in is not configured."); const access = await sessionRef.current.completeSignIn(callbackUrl); + setState((current) => { + if (current.mode !== "hosted") return current; + retainedAccess = { binding: accessBinding(current.descriptor), access }; + return { ...current, access }; + }); setActionError(null); - setState((current) => (current.mode === "hosted" ? { ...current, access } : current)); }, []); const signOut = useCallback(async () => { if (!sessionRef.current) throw new Error("Hosted sign-out is not configured."); + retainedAccess = null; setState((current) => (current.mode === "hosted" ? { ...current, access: null } : current)); setActionError(null); try { @@ -128,6 +167,7 @@ export function HostedControlProvider({ children }: { children: ReactNode }) { const completeSignOut = useCallback(async (callbackUrl: string) => { if (!sessionRef.current) throw new Error("Hosted sign-out is not configured."); await sessionRef.current.completeSignOut(callbackUrl); + retainedAccess = null; setActionError(null); setState((current) => (current.mode === "hosted" ? { ...current, access: null } : current)); }, []); diff --git a/src/features/hosted-control/HostedWorkspace.tsx b/src/features/hosted-control/HostedWorkspace.tsx new file mode 100644 index 0000000..e3cd307 --- /dev/null +++ b/src/features/hosted-control/HostedWorkspace.tsx @@ -0,0 +1,31 @@ +"use client"; + +import { GraphEditor } from "@/features/graph-io/GraphEditor"; +import { Inspector } from "@/features/pipeline-canvas/inspector/Inspector"; +import { + HostedControlGate, + HostedControlStatus, +} from "@/features/hosted-control/HostedControlProvider"; + +/** The shared static workspace rendered both at `/` and after an in-memory OIDC callback. */ +export function HostedWorkspace() { + return ( +
+
+
+

Druff

+

Dander pipeline-graph editor

+
+ +
+ +
+
+ +
+ +
+
+
+ ); +} diff --git a/src/features/hosted-control/OidcCallbackPage.tsx b/src/features/hosted-control/OidcCallbackPage.tsx index 77ad003..5df95cc 100644 --- a/src/features/hosted-control/OidcCallbackPage.tsx +++ b/src/features/hosted-control/OidcCallbackPage.tsx @@ -2,16 +2,16 @@ import { useEffect, useRef, useState } from "react"; import Link from "next/link"; -import { useRouter } from "next/navigation"; import { SIGNIN_CALLBACK_PATH, SIGNOUT_CALLBACK_PATH } from "@/features/hosted-control/bootstrap"; import { useHostedControl } from "@/features/hosted-control/HostedControlProvider"; +import { HostedWorkspace } from "@/features/hosted-control/HostedWorkspace"; export function OidcCallbackPage({ kind }: { kind: "signin" | "signout" }) { const control = useHostedControl(); - const router = useRouter(); const started = useRef(false); const callbackUrl = useRef(null); const [failed, setFailed] = useState(false); + const [completed, setCompleted] = useState(false); useEffect(() => { if (callbackUrl.current !== null) return; @@ -28,11 +28,16 @@ export function OidcCallbackPage({ kind }: { kind: "signin" | "signout" }) { ? control.completeSignIn(callbackUrl.current) : control.completeSignOut(callbackUrl.current); void complete - .then(() => router.replace("/")) + .then(() => { + window.history.replaceState(null, "", "/"); + setCompleted(true); + }) .catch(() => { setFailed(true); }); - }, [control, kind, router]); + }, [control, kind]); + + if (completed) return ; if (control.mode === "loading") { return

Verifying the identity response…

; diff --git a/src/lib/persistence/graph-persistence.test.ts b/src/lib/persistence/graph-persistence.test.ts index 6669ce0..eb5b4fb 100644 --- a/src/lib/persistence/graph-persistence.test.ts +++ b/src/lib/persistence/graph-persistence.test.ts @@ -3,9 +3,11 @@ import { EXAMPLE_GRAPH } from "@/lib/pipeline-graph/__fixtures__/example-graph"; import { DanderApiGraphPersistence, GraphPersistenceError, + HostedGraphPersistence, LocalStorageGraphPersistence, type GraphSnapshot, } from "@/lib/persistence/graph-persistence"; +import type { HostedControlFetch } from "@/features/hosted-control/authorized-fetch"; const STORAGE_KEY = "druff.graph.v1"; @@ -210,3 +212,181 @@ describe("DanderApiGraphPersistence", () => { await expect(persistence.load()).rejects.toThrow(/cannot preserve/i); }); }); + +const ADDRESS = { project: "demo-project", graph: "alpha-graph" }; +const CONTENT_SHA = "a".repeat(64); + +function hostedResource(graph = EXAMPLE_GRAPH) { + return { + project: ADDRESS.project, + graph: ADDRESS.graph, + document: graph, + content_sha256: CONTENT_SHA, + created_at: "2026-08-14T00:00:00Z", + updated_at: "2026-08-14T00:00:00Z", + }; +} + +function apiError(code: string, message: string) { + return { + error: { code, message, correlation_id: "corr-123", details: [] }, + }; +} + +describe("HostedGraphPersistence", () => { + it("lists generated-contract projects and paginated document-free graph summaries", async () => { + const request = vi + .fn() + .mockResolvedValueOnce( + new Response(JSON.stringify({ projects: [{ id: "demo-project" }] }), { status: 200 }), + ) + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + items: [ + { + project: "demo-project", + graph: "alpha-graph", + content_sha256: CONTENT_SHA, + created_at: "2026-08-14T00:00:00Z", + updated_at: "2026-08-14T00:00:00Z", + }, + ], + next_cursor: "opaque cursor/+", + }), + { status: 200 }, + ), + ) + .mockResolvedValueOnce( + new Response(JSON.stringify({ items: [], next_cursor: null }), { status: 200 }), + ); + const persistence = new HostedGraphPersistence(request); + + await expect(persistence.listProjects()).resolves.toEqual(["demo-project"]); + await expect(persistence.listGraphs("demo-project")).resolves.toMatchObject({ + items: [expect.objectContaining({ graph: "alpha-graph" })], + nextCursor: "opaque cursor/+", + }); + await persistence.listGraphs("demo-project", "opaque cursor/+"); + + expect(request.mock.calls[2]?.[0]).toBe( + "/v1/projects/demo-project/graphs?limit=50&cursor=opaque+cursor%2F%2B", + ); + }); + + it("opens and conditionally saves one generated graph while keeping ETag and content identity separate", async () => { + const request = vi + .fn() + .mockResolvedValueOnce( + new Response(JSON.stringify(hostedResource()), { + status: 200, + headers: { ETag: '"revision-1"' }, + }), + ) + .mockResolvedValueOnce( + new Response(JSON.stringify({ ...hostedResource(), content_sha256: "b".repeat(64) }), { + status: 200, + headers: { ETag: '"revision-2"' }, + }), + ); + const persistence = new HostedGraphPersistence(request); + + await expect(persistence.load(ADDRESS)).resolves.toMatchObject({ + address: ADDRESS, + revision: '"revision-1"', + contentSha256: CONTENT_SHA, + graph: EXAMPLE_GRAPH, + }); + await persistence.save(EXAMPLE_GRAPH, '"revision-1"', ADDRESS); + + expect(request.mock.calls[1]?.[1]).toEqual( + expect.objectContaining({ + method: "PUT", + headers: expect.objectContaining({ "If-Match": '"revision-1"' }), + body: JSON.stringify(EXAMPLE_GRAPH), + }), + ); + }); + + it("reuses a create idempotency key after an ambiguous lost response", async () => { + let serviceAttempt = 0; + const request = vi.fn(async () => { + serviceAttempt += 1; + if (serviceAttempt === 1) throw new TypeError("response lost after service restart"); + return new Response(JSON.stringify(hostedResource()), { + status: 201, + headers: { ETag: '"revision-1"' }, + }); + }); + const persistence = new HostedGraphPersistence(request); + + await expect(persistence.create(ADDRESS, EXAMPLE_GRAPH)).rejects.toThrow(/response lost/); + await expect(persistence.create(ADDRESS, EXAMPLE_GRAPH)).resolves.toMatchObject({ + revision: '"revision-1"', + }); + + const firstKey = new Headers(request.mock.calls[0]?.[1]?.headers).get("Idempotency-Key"); + const retryKey = new Headers(request.mock.calls[1]?.[1]?.headers).get("Idempotency-Key"); + expect(firstKey).toMatch(/^druff-/); + expect(retryKey).toBe(firstKey); + }); + + it("does not misclassify a create-name collision as a reloadable revision conflict", async () => { + const request = vi.fn().mockResolvedValue( + new Response(JSON.stringify(apiError("graph_exists", "That graph already exists.")), { + status: 409, + }), + ); + const persistence = new HostedGraphPersistence(request); + + const failure = await persistence.create(ADDRESS, EXAMPLE_GRAPH).catch((error) => error); + + expect(failure).toBeInstanceOf(GraphPersistenceError); + expect(failure).toMatchObject({ conflict: false }); + expect(failure.message).toBe("That graph already exists. Correlation ID: corr-123."); + }); + + it("maps only a conditional update graph_conflict to the reload path", async () => { + const request = vi.fn().mockResolvedValue( + new Response(JSON.stringify(apiError("graph_conflict", "The revision changed.")), { + status: 409, + }), + ); + const persistence = new HostedGraphPersistence(request); + + const failure = await persistence + .save(EXAMPLE_GRAPH, '"stale"', ADDRESS) + .catch((error) => error); + + expect(failure).toMatchObject({ conflict: true }); + }); + + it("reuses a delete key after an ambiguous failure and accepts only the bodyless 204", async () => { + const request = vi + .fn() + .mockRejectedValueOnce(new TypeError("response lost")) + .mockResolvedValueOnce(new Response(null, { status: 204 })); + const persistence = new HostedGraphPersistence(request); + + await expect(persistence.delete(ADDRESS, '"revision-2"')).rejects.toThrow(/response lost/); + await expect(persistence.delete(ADDRESS, '"revision-2"')).resolves.toBeUndefined(); + + const firstKey = new Headers(request.mock.calls[0]?.[1]?.headers).get("Idempotency-Key"); + const retryKey = new Headers(request.mock.calls[1]?.[1]?.headers).get("Idempotency-Key"); + expect(retryKey).toBe(firstKey); + }); + + it("never exposes an unstructured provider response body", async () => { + const request = vi + .fn() + .mockResolvedValue( + new Response("provider-secret-looking-payload", { status: 502, statusText: "Bad Gateway" }), + ); + const persistence = new HostedGraphPersistence(request); + + await expect(persistence.listProjects()).rejects.toThrow( + "Dander could not list hosted projects. (502 Bad Gateway).", + ); + await expect(persistence.listProjects()).rejects.not.toThrow(/provider-secret/); + }); +}); diff --git a/src/lib/persistence/graph-persistence.ts b/src/lib/persistence/graph-persistence.ts index 2953739..fb07609 100644 --- a/src/lib/persistence/graph-persistence.ts +++ b/src/lib/persistence/graph-persistence.ts @@ -1,5 +1,14 @@ import { PipelineGraphSchema, type GraphLayout, type PipelineGraph } from "@/lib/pipeline-graph"; import { localNetworkRequest } from "@/lib/local-network-request"; +import type { HostedControlFetch } from "@/features/hosted-control/authorized-fetch"; +import { + ApiErrorEnvelopeSchema, + GraphCreateRequestSchema, + GraphPageResponseSchema, + GraphResourceResponseSchema, + ProjectListResponseSchema, + type GraphPageResponse, +} from "@/lib/dander-contracts"; /** On-disk envelope version. Bump when the stored shape changes incompatibly; `load()` treats any * other version as absent rather than attempting a migration (none is needed yet). */ @@ -24,6 +33,20 @@ export type GraphSnapshot = { export type GraphDocument = { graph: PipelineGraph; revision: string; + address?: GraphAddress; + contentSha256?: string; +}; + +export type GraphAddress = { + project: string; + graph: string; +}; + +export type GraphSummary = GraphPageResponse["items"][number]; + +export type GraphPage = { + items: GraphSummary[]; + nextCursor: string | null; }; /** @@ -32,8 +55,17 @@ export type GraphDocument = { * that changed elsewhere. */ export interface GraphPersistence { - load(): Promise; - save(graph: PipelineGraph, revision: string): Promise; + load(address?: GraphAddress): Promise; + save(graph: PipelineGraph, revision: string, address?: GraphAddress): Promise; +} + +/** The collection operations available only from Dander's hosted project/graph API. */ +export interface ManagedGraphPersistence extends GraphPersistence { + readonly mode: "collection"; + listProjects(): Promise; + listGraphs(project: string, cursor?: string | null): Promise; + create(address: GraphAddress, graph: PipelineGraph): Promise; + delete(address: GraphAddress, revision: string): Promise; } export class GraphPersistenceError extends Error { @@ -48,6 +80,160 @@ export class GraphPersistenceError extends Error { type FetchGraph = typeof fetch; +const PAGE_SIZE = 50; + +type PendingMutation = { + fingerprint: string; + idempotencyKey: string; +}; + +/** Generated-contract client for Dander's provider-neutral hosted graph collection. */ +export class HostedGraphPersistence implements ManagedGraphPersistence { + readonly mode = "collection" as const; + private readonly request: HostedControlFetch; + private readonly pendingCreates = new Map(); + private readonly pendingDeletes = new Map(); + + constructor(request: HostedControlFetch) { + this.request = request; + } + + async listProjects(): Promise { + const response = await this.request("/v1/projects", { + method: "GET", + headers: { Accept: "application/json" }, + }); + await requireStatus(response, 200, "list hosted projects"); + const result = ProjectListResponseSchema.safeParse(await hostedJson(response, "project list")); + if (!result.success) throw incompatibleHostedResponse("project list"); + return result.data.projects.map((project) => project.id); + } + + async listGraphs(project: string, cursor: string | null = null): Promise { + const query = new URLSearchParams({ limit: String(PAGE_SIZE) }); + if (cursor !== null) query.set("cursor", cursor); + const response = await this.request( + `/v1/projects/${encodeURIComponent(project)}/graphs?${query.toString()}`, + { method: "GET", headers: { Accept: "application/json" } }, + ); + await requireStatus(response, 200, "list hosted graphs"); + const result = GraphPageResponseSchema.safeParse(await hostedJson(response, "graph page")); + if (!result.success) throw incompatibleHostedResponse("graph page"); + return { items: [...result.data.items], nextCursor: result.data.next_cursor ?? null }; + } + + async load(address?: GraphAddress): Promise { + const target = requireAddress(address); + const response = await this.request(graphPath(target), { + method: "GET", + headers: { Accept: "application/json" }, + }); + await requireStatus(response, 200, "open the hosted graph"); + return readHostedDocument(response, target); + } + + async create(address: GraphAddress, graph: PipelineGraph): Promise { + const parsedBody = GraphCreateRequestSchema.safeParse({ + graph: address.graph, + document: graph, + }); + if (!parsedBody.success) throw incompatibleHostedResponse("graph create request"); + const body = parsedBody.data; + const serialized = JSON.stringify(body); + const mutation = this.mutationFor(this.pendingCreates, address, serialized); + let response: Response; + try { + response = await this.request(collectionPath(address.project), { + method: "POST", + headers: { + Accept: "application/json", + "Content-Type": "application/json", + "Idempotency-Key": mutation.idempotencyKey, + }, + body: serialized, + }); + } catch (error) { + // The request may have reached Dander. Retain the key for an identical explicit retry. + throw error; + } + if (response.status !== 201) { + if (response.status >= 400 && response.status < 500) { + this.pendingCreates.delete(addressKey(address)); + } + throw await hostedError(response, "Dander could not create the hosted graph."); + } + const document = await readHostedDocument(response, address); + this.pendingCreates.delete(addressKey(address)); + return document; + } + + async save( + graph: PipelineGraph, + revision: string, + address?: GraphAddress, + ): Promise { + const target = requireAddress(address); + const response = await this.request(graphPath(target), { + method: "PUT", + headers: { + Accept: "application/json", + "Content-Type": "application/json", + "If-Match": revision, + }, + body: JSON.stringify(graph), + }); + if (response.status !== 200) { + throw await hostedMutationError(response, "Dander could not save the hosted graph."); + } + return readHostedDocument(response, target); + } + + async delete(address: GraphAddress, revision: string): Promise { + const fingerprint = revision; + const mutation = this.mutationFor(this.pendingDeletes, address, fingerprint); + let response: Response; + try { + response = await this.request(graphPath(address), { + method: "DELETE", + headers: { + "If-Match": revision, + "Idempotency-Key": mutation.idempotencyKey, + }, + }); + } catch (error) { + // A lost response is ambiguous. Reuse this key if the operator retries the same deletion. + throw error; + } + if (response.status !== 204) { + if (response.status >= 400 && response.status < 500) { + this.pendingDeletes.delete(addressKey(address)); + } + throw await hostedMutationError(response, "Dander could not delete the hosted graph."); + } + // A successful delete is bodyless by contract; never attempt to parse it. + this.pendingDeletes.delete(addressKey(address)); + } + + private mutationFor( + pending: Map, + address: GraphAddress, + fingerprint: string, + ): PendingMutation { + const key = addressKey(address); + const existing = pending.get(key); + if (existing?.fingerprint === fingerprint) return existing; + const next = { fingerprint, idempotencyKey: `druff-${crypto.randomUUID()}` }; + pending.set(key, next); + return next; + } +} + +export function isManagedGraphPersistence( + persistence: GraphPersistence, +): persistence is ManagedGraphPersistence { + return "mode" in persistence && persistence.mode === "collection"; +} + /** HTTP persistence backed by `dander graph serve --file ...`. */ export class DanderApiGraphPersistence implements GraphPersistence { private readonly endpoint: string; @@ -227,3 +413,96 @@ async function responseError(response: Response): Promise { } return `Dander graph request failed (${response.status} ${response.statusText}).`; } + +function requireAddress(address: GraphAddress | undefined): GraphAddress { + if (address) return address; + throw new GraphPersistenceError("Choose a hosted graph before opening or saving."); +} + +function collectionPath(project: string): string { + return `/v1/projects/${encodeURIComponent(project)}/graphs`; +} + +function graphPath(address: GraphAddress): string { + return `${collectionPath(address.project)}/${encodeURIComponent(address.graph)}`; +} + +function addressKey(address: GraphAddress): string { + return `${address.project}\u0000${address.graph}`; +} + +async function requireStatus(response: Response, status: number, operation: string): Promise { + if (response.status === status) return; + throw await hostedError(response, `Dander could not ${operation}.`); +} + +async function readHostedDocument( + response: Response, + address: GraphAddress, +): Promise { + const revision = response.headers.get("ETag"); + if (!revision) { + throw new GraphPersistenceError("Dander did not return an opaque graph revision (ETag)."); + } + const resource = GraphResourceResponseSchema.safeParse( + await hostedJson(response, "graph resource"), + ); + if (!resource.success) throw incompatibleHostedResponse("graph resource"); + if (resource.data.project !== address.project || resource.data.graph !== address.graph) { + throw new GraphPersistenceError( + "Dander returned a different hosted graph than Druff requested.", + ); + } + const graph = PipelineGraphSchema.safeParse(resource.data.document); + if (!graph.success) throw incompatibleHostedResponse("canonical graph document"); + return { + graph: graph.data, + revision, + address, + contentSha256: resource.data.content_sha256, + }; +} + +async function hostedMutationError(response: Response, fallback: string): Promise { + const parsed = await parsedHostedError(response, fallback); + const conflict = + (response.status === 409 || response.status === 412) && parsed.code === "graph_conflict"; + return new GraphPersistenceError(parsed.message, { conflict }); +} + +async function hostedError(response: Response, fallback: string): Promise { + const parsed = await parsedHostedError(response, fallback); + return new GraphPersistenceError(parsed.message); +} + +async function parsedHostedError( + response: Response, + fallback: string, +): Promise<{ code: string | null; message: string }> { + try { + const parsed = ApiErrorEnvelopeSchema.safeParse(await response.json()); + if (parsed.success) { + return { + code: parsed.data.error.code, + message: `${parsed.data.error.message} Correlation ID: ${parsed.data.error.correlation_id}.`, + }; + } + } catch { + // Never expose a raw response body or provider payload. + } + return { code: null, message: `${fallback} (${response.status} ${response.statusText}).` }; +} + +function incompatibleHostedResponse(label: string): GraphPersistenceError { + return new GraphPersistenceError( + `Dander returned a ${label} this Druff version cannot preserve. Upgrade the incompatible application.`, + ); +} + +async function hostedJson(response: Response, label: string): Promise { + try { + return await response.json(); + } catch { + throw incompatibleHostedResponse(label); + } +} diff --git a/tickets/DRUFF-26-remote-graph-management.md b/tickets/DRUFF-26-remote-graph-management.md index dbfb449..81475c7 100644 --- a/tickets/DRUFF-26-remote-graph-management.md +++ b/tickets/DRUFF-26-remote-graph-management.md @@ -1,7 +1,7 @@ --- id: DRUFF-26 title: Add remote project and graph management -status: open +status: done component: frontend epic: self-hosted-control-plane depends_on: [DRUFF-24, DRUFF-25] @@ -15,12 +15,12 @@ local drafts or conflict safety. ## Acceptance Criteria -- [ ] List/create/open/save/delete graphs through the generated Control API client. -- [ ] Preserve dirty/saving/conflict/reload behavior with opaque ETags and display portable content +- [x] List/create/open/save/delete graphs through the generated Control API client. +- [x] Preserve dirty/saving/conflict/reload behavior with opaque ETags and display portable content identity separately. -- [ ] Require confirmation for delete and preserve import/export plus detached offline drafts. -- [ ] Handle pagination, compatibility, auth, and safe errors without provider branches. -- [ ] Unit/component and Playwright tests cover create, save, stale conflict, reload, delete, and +- [x] Require confirmation for delete and preserve import/export plus detached offline drafts. +- [x] Handle pagination, compatibility, auth, and safe errors without provider branches. +- [x] Unit/component and Playwright tests cover create, save, stale conflict, reload, delete, and persistence after a synthetic service restart. ## Design @@ -30,8 +30,34 @@ or provider-specific graph format. ## Implementation Notes -_Pending._ +- `HostedGraphPersistence` extends the existing persistence seam with generated-contract project, + page, create, resource, and structured-error boundaries. It never exposes provider payloads. +- Create and delete retain an idempotency key across ambiguous retries; update/delete map only a + structured `graph_conflict` to the explicit reload path. Delete accepts the bodyless 204 only. +- Hosted UI shows logical address, opaque ETag, and canonical-content SHA separately. Import and + successful delete retain the canvas as a detached local draft. +- The HTTPS static-export Playwright journey uses synthetic OIDC and a provider-neutral in-memory + Control API. A durable fake store survives service-instance swaps without committing row data or + credentials. ## Review Log -_Pending._ +### 2026-08-14 — pre-implementation adversarial review + +The review caught two narrow boundary errors in the plan. Hosted mode now hides the legacy +localhost operations client/banner until DRUFF-27/28, with a no-loopback-fetch regression. CRUD +status/conflict handling is operation-specific, and create/delete reuse their idempotency keys +after a lost response and synthetic service restart. + +### 2026-08-14 — implementation correction + +Static callback acceptance showed that generic static hosting can reload the root provider during +callback-to-home navigation, destroying a memory-only token. The verified callback now cleans the +URL and renders the shared workspace in place. A narrowly bound module-memory handoff still clears +on full reload, expiry, 401, logout, loopback, or configuration change. + +### 2026-08-14 — completion-review correction + +The final review caught that a slow hosted Create could replace edits made while its request was in +flight. Create now attaches the accepted address, ETag, content SHA, and remote baseline while +retaining any newer canvas state as dirty; a deferred-request regression covers the boundary.