diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4d4a822f..18f648c8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -102,6 +102,7 @@ jobs: git config --global user.email "ci@weft.invalid" git config --global init.defaultBranch main - run: pnpm install --frozen-lockfile + - run: pnpm verify:examples # Every example is deterministic and offline (03 builds a throwaway git repo). - name: Run all examples run: | diff --git a/apps/ui/package.json b/apps/ui/package.json index d036aa51..5376b0ff 100644 --- a/apps/ui/package.json +++ b/apps/ui/package.json @@ -15,6 +15,7 @@ "test:watch": "vitest" }, "dependencies": { + "@techery/weft-sdk": "workspace:*", "@tanstack/react-query": "^5.102.3", "@tanstack/react-router": "^1.170.32", "jotai": "^2.20.3", diff --git a/apps/ui/src/api/client.ts b/apps/ui/src/api/client.ts index 2d1d834d..e63226db 100644 --- a/apps/ui/src/api/client.ts +++ b/apps/ui/src/api/client.ts @@ -147,4 +147,14 @@ export const api = { if (!res.ok) throw new ApiError(res.status, `blob ${ref} is not readable`); return res.text(); }), + + blobJson: (ref: string) => + fetch(`/api/blobs/${encodeURIComponent(ref)}?as=json`).then(async (res) => { + if (GATEWAY.has(res.status)) throw new ApiError(res.status, UNREACHABLE); + if (!res.ok) throw new ApiError(res.status, `blob ${ref} is not readable`); + return (await res.json()) as unknown; + }), + + presentationFrameUrl: (runId: string, presentationId: string) => + `/api/runs/${encodeURIComponent(runId)}/presentations/${encodeURIComponent(presentationId)}/frame`, }; diff --git a/apps/ui/src/api/types.ts b/apps/ui/src/api/types.ts index a98c101c..0089887d 100644 --- a/apps/ui/src/api/types.ts +++ b/apps/ui/src/api/types.ts @@ -30,7 +30,25 @@ export type StepKind = | "fs" | "env" | "check" - | "sleep"; + | "sleep" + | "ui" + | "signal" + | "sideeffect"; + +export interface UiPresentation { + id: string; + asset: { + id: string; + revision: string; + bundleRef: { $blob: string; size: number; preview?: string }; + protocol: 1; + }; + props: + | { inline: unknown; hash: string } + | { ref: { $blob: string; size: number; preview?: string }; hash: string }; + mode: "display" | "input"; + slot?: string; +} export type Risk = "low" | "medium" | "high" | "irreversible"; export type ApprovalMode = "auto" | "ask"; @@ -80,6 +98,7 @@ export interface StepState { transcriptRef?: { $blob: string; size: number; preview?: string }; patchRef?: string; childRunId?: string; + presentation?: UiPresentation; } export interface HumanState { @@ -90,11 +109,12 @@ export interface HumanState { detail?: string; risk?: Risk; schema: unknown; - status: "pending" | "answered"; + status: "pending" | "answered" | "superseded"; answer?: unknown; answeredBy?: string; requestedAt: number; artifactRef?: { $blob: string; size: number; preview?: string }; + ui?: UiPresentation; } /** `GET /api/runs/:id`, with `?detail=1` adding `limits` and `inputs`. */ @@ -142,6 +162,7 @@ export interface PendingRequest { rootRunId: string; rootWorkflow: string; artifactRef?: { $blob: string; size: number; preview?: string }; + ui?: UiPresentation; } export interface PendingResponse { diff --git a/apps/ui/src/app/app.test.tsx b/apps/ui/src/app/app.test.tsx index f23246c9..de02a228 100644 --- a/apps/ui/src/app/app.test.tsx +++ b/apps/ui/src/app/app.test.tsx @@ -1,4 +1,4 @@ -import { screen, waitFor, within } from "@testing-library/react"; +import { fireEvent, screen, waitFor, within } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { type FakeDaemon, fakeDaemon } from "~/test/daemon"; import { renderApp } from "~/test/renderApp"; @@ -113,6 +113,64 @@ describe("a run", () => { ); }); + it("keeps host controls and the standard form around a workflow-provided input view", async () => { + const presentation = { + id: "h1", + asset: { + id: "release-review", + revision: "2", + bundleRef: { $blob: "e".repeat(64), size: 128 }, + protocol: 1 as const, + }, + props: { inline: { tag: "v0.9.0" }, hash: "f".repeat(64) }, + mode: "input" as const, + }; + daemon.state.detail["r-waiting"]!.humans[0]!.ui = presentation; + daemon.state.pending.pending[0]!.ui = presentation; + + const { user } = renderApp("/runs/r-waiting?from=queue&tab=steps&step=gate:h1"); + const view = await screen.findByRole("region", { name: "Workflow-provided view: release-review" }); + expect(within(view).getByText(/revision 2/)).toBeInTheDocument(); + expect(within(view).getByTitle("Workflow view release-review")).toHaveAttribute( + "src", + "/api/runs/r-waiting/presentations/h1/frame", + ); + expect(screen.getByLabelText("note")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /Approve/ })).toBeInTheDocument(); + + await user.click(within(view).getByRole("button", { name: "Disable" })); + expect(screen.getByText(/Custom view disabled/)).toBeInTheDocument(); + await user.click(screen.getByRole("button", { name: "Enable custom view" })); + const frame = await screen.findByTitle("Workflow view release-review"); + const contentWindow = frame.contentWindow; + if (!contentWindow) throw new Error("test iframe has no contentWindow"); + + let componentPort: MessagePort | undefined; + let init: Record | undefined; + contentWindow.postMessage = ((message: unknown, _origin: string, transfer?: Transferable[]) => { + init = message as Record; + componentPort = transfer?.[0] as MessagePort | undefined; + }) as typeof contentWindow.postMessage; + fireEvent.load(frame); + await waitFor(() => expect(componentPort).toBeDefined()); + componentPort!.postMessage({ + type: "candidate", + presentationId: init!.presentationId, + generation: init!.generation, + answer: { approved: false, note: "not yet" }, + }); + expect(await screen.findByRole("button", { name: "Submit and resume" })).toBeInTheDocument(); + await user.click(screen.getByRole("button", { name: "Submit and resume" })); + const answered = await waitFor(() => { + const call = daemon.calls.find( + (candidate) => candidate.method === "POST" && candidate.path.endsWith("/answer"), + ); + expect(call).toBeDefined(); + return call!; + }); + expect(answered.body).toMatchObject({ answer: { approved: false, note: "not yet" } }); + }); + it("keeps the attached report when the gate falls back to run detail", async () => { daemon.state.pending.pending = []; renderApp("/runs/r-waiting?from=runs&tab=steps&step=gate:h1"); diff --git a/apps/ui/src/components/molecules/WorkflowViewFrame.module.css b/apps/ui/src/components/molecules/WorkflowViewFrame.module.css new file mode 100644 index 00000000..1053f855 --- /dev/null +++ b/apps/ui/src/components/molecules/WorkflowViewFrame.module.css @@ -0,0 +1,65 @@ +.shell { + position: relative; + overflow: hidden; + border: 1px solid var(--color-line, #d8d6d0); + border-radius: 8px; + background: var(--color-surface, #fff); +} + +.header { + display: flex; + align-items: center; + gap: 12px; + min-height: 34px; + padding: 0 10px; + border-bottom: 1px solid var(--color-line, #d8d6d0); + color: var(--color-text-muted, #68645c); + font: + 11px / 1.2 ui-monospace, + SFMono-Regular, + Menlo, + monospace; +} + +.header span:nth-child(2) { + margin-left: auto; +} + +.header button, +.fallback button { + border: 0; + color: inherit; + background: transparent; + cursor: pointer; + text-decoration: underline; +} + +.frame { + display: block; + width: 100%; + min-height: 80px; + border: 0; + background: transparent; +} + +.loading, +.error, +.fallback { + display: block; + padding: 12px; + color: var(--color-text-muted, #68645c); + font-size: 12px; +} + +.error { + color: #9b2f24; + background: #fff2ef; +} + +.fallback { + display: flex; + justify-content: space-between; + gap: 16px; + border: 1px dashed var(--color-line, #d8d6d0); + border-radius: 8px; +} diff --git a/apps/ui/src/components/molecules/WorkflowViewFrame.test.tsx b/apps/ui/src/components/molecules/WorkflowViewFrame.test.tsx new file mode 100644 index 00000000..f4510c4f --- /dev/null +++ b/apps/ui/src/components/molecules/WorkflowViewFrame.test.tsx @@ -0,0 +1,58 @@ +import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { api } from "~/api/client"; +import type { UiPresentation } from "~/api/types"; +import { WorkflowViewFrame } from "./WorkflowViewFrame"; + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} + +function presentation(blob: string, hash: string): UiPresentation { + return { + id: "u1", + asset: { + id: "summary", + revision: "1", + bundleRef: { $blob: "a".repeat(64), size: 128 }, + protocol: 1, + }, + props: { ref: { $blob: blob, size: 32 }, hash }, + mode: "display", + }; +} + +describe("WorkflowViewFrame", () => { + it("does not post stale blob props after the run identity changes", async () => { + const oldProps = deferred(); + const nextProps = deferred(); + vi.spyOn(api, "blobJson").mockImplementation((ref) => + ref === "old" ? oldProps.promise : nextProps.promise, + ); + const posted: unknown[] = []; + const { rerender } = render( + , + ); + const oldFrame = screen.getByTitle("Workflow view summary"); + if (!oldFrame.contentWindow) throw new Error("test iframe has no contentWindow"); + oldFrame.contentWindow.postMessage = ((message: unknown) => posted.push(message)) as typeof postMessage; + fireEvent.load(oldFrame); + + rerender(); + const nextFrame = screen.getByTitle("Workflow view summary"); + if (!nextFrame.contentWindow) throw new Error("test iframe has no contentWindow"); + nextFrame.contentWindow.postMessage = ((message: unknown) => posted.push(message)) as typeof postMessage; + fireEvent.load(nextFrame); + + await act(async () => nextProps.resolve({ run: "next" })); + await waitFor(() => expect(posted).toHaveLength(1)); + expect(posted[0]).toMatchObject({ props: { run: "next" } }); + + await act(async () => oldProps.resolve({ run: "old" })); + await waitFor(() => expect(posted).toHaveLength(1)); + }); +}); diff --git a/apps/ui/src/components/molecules/WorkflowViewFrame.tsx b/apps/ui/src/components/molecules/WorkflowViewFrame.tsx new file mode 100644 index 00000000..e9581fc4 --- /dev/null +++ b/apps/ui/src/components/molecules/WorkflowViewFrame.tsx @@ -0,0 +1,184 @@ +import { UI_PROTOCOL_MAX_PROPS_BYTES } from "@techery/weft-sdk/ui"; +import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react"; +import { api } from "~/api/client"; +import type { UiPresentation } from "~/api/types"; +import styles from "./WorkflowViewFrame.module.css"; + +type Props = { + runId: string; + presentation: UiPresentation; + onCandidate?: (answer: unknown) => void; +}; + +type FrameStatus = "loading" | "ready" | "error" | "disabled"; + +const MAX_FRAME_MESSAGE_BYTES = 64 * 1024; +const MIN_HEIGHT = 80; +const MAX_HEIGHT = 720; +const READY_TIMEOUT_MS = 5_000; + +function jsonByteSize(value: unknown): number | undefined { + try { + const encoded = JSON.stringify(value); + return encoded === undefined ? undefined : new TextEncoder().encode(encoded).byteLength; + } catch { + return undefined; + } +} + +/** Capability-minimal host for one journaled workflow presentation. */ +export function WorkflowViewFrame({ runId, presentation, onCandidate }: Props) { + const frame = useRef(null); + const channel = useRef(null); + const initialization = useRef(0); + const generation = useRef(0); + const readyTimer = useRef | null>(null); + const lastResize = useRef(0); + const [status, setStatus] = useState("loading"); + const [height, setHeight] = useState(180); + const [message, setMessage] = useState(""); + const identity = JSON.stringify([ + runId, + presentation.id, + presentation.asset.bundleRef.$blob, + presentation.props.hash, + ]); + const activeIdentity = useRef(identity); + + useLayoutEffect(() => { + if (activeIdentity.current === identity) return; + activeIdentity.current = identity; + initialization.current += 1; + if (readyTimer.current) clearTimeout(readyTimer.current); + readyTimer.current = null; + channel.current?.port1.close(); + channel.current?.port2.close(); + channel.current = null; + setStatus("loading"); + setMessage(""); + setHeight(180); + }, [identity]); + + useEffect(() => { + return () => { + initialization.current += 1; + if (readyTimer.current) clearTimeout(readyTimer.current); + channel.current?.port1.close(); + channel.current?.port2.close(); + }; + }, []); + + const initialize = useCallback(async () => { + const target = frame.current?.contentWindow; + if (!target || status === "disabled") return; + const attempt = ++initialization.current; + const currentIdentity = identity; + const isCurrent = () => + initialization.current === attempt && + activeIdentity.current === currentIdentity && + frame.current?.contentWindow === target; + try { + const props = + "inline" in presentation.props + ? presentation.props.inline + : await api.blobJson(presentation.props.ref.$blob); + if (!isCurrent()) return; + const bytes = jsonByteSize(props); + if (bytes === undefined) throw new Error("presentation props must be JSON serializable"); + if (bytes > UI_PROTOCOL_MAX_PROPS_BYTES) throw new Error("presentation props are too large to render"); + const next = new MessageChannel(); + channel.current?.port1.close(); + channel.current?.port2.close(); + channel.current = next; + const mounted = String(++generation.current); + if (readyTimer.current) clearTimeout(readyTimer.current); + readyTimer.current = setTimeout(() => { + if (!isCurrent()) return; + setStatus("error"); + setMessage("custom view did not become ready in time"); + next.port1.close(); + }, READY_TIMEOUT_MS); + next.port1.onmessage = (event: MessageEvent) => { + const value = event.data; + if (typeof value !== "object" || value === null) return; + const data = value as Record; + if (data.presentationId !== presentation.id || data.generation !== mounted) return; + const size = jsonByteSize(data); + if (size === undefined || size > MAX_FRAME_MESSAGE_BYTES) return; + if (data.type === "ready") { + if (readyTimer.current) clearTimeout(readyTimer.current); + readyTimer.current = null; + setStatus("ready"); + } else if (data.type === "resize" && typeof data.height === "number") { + const now = performance.now(); + if (now - lastResize.current < 50) return; + lastResize.current = now; + setHeight(Math.max(MIN_HEIGHT, Math.min(MAX_HEIGHT, Math.ceil(data.height)))); + } else if (data.type === "candidate" && presentation.mode === "input") { + if (jsonByteSize(data.answer) === undefined) return; + onCandidate?.(data.answer); + } else if (data.type === "error" && typeof data.message === "string") { + if (readyTimer.current) clearTimeout(readyTimer.current); + readyTimer.current = null; + setStatus("error"); + setMessage(data.message.slice(0, 300)); + } + }; + next.port1.start(); + target.postMessage( + { + type: "weft.ui.init", + protocol: 1, + presentationId: presentation.id, + generation: mounted, + props, + }, + "*", + [next.port2], + ); + } catch (error) { + if (!isCurrent()) return; + if (readyTimer.current) clearTimeout(readyTimer.current); + readyTimer.current = null; + setStatus("error"); + setMessage(error instanceof Error ? error.message : String(error)); + } + }, [identity, onCandidate, presentation, status]); + + if (status === "disabled") { + return ( +
+ Custom view disabled. The standard data view remains available. + +
+ ); + } + + return ( +
+
+ Workflow-provided view + + {presentation.asset.id} · revision {presentation.asset.revision} + + +
+ {status === "error" ?
Custom view unavailable: {message}
: null} +