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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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: |
Expand Down
1 change: 1 addition & 0 deletions apps/ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
10 changes: 10 additions & 0 deletions apps/ui/src/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
};
25 changes: 23 additions & 2 deletions apps/ui/src/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -80,6 +98,7 @@ export interface StepState {
transcriptRef?: { $blob: string; size: number; preview?: string };
patchRef?: string;
childRunId?: string;
presentation?: UiPresentation;
}

export interface HumanState {
Expand All @@ -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`. */
Expand Down Expand Up @@ -142,6 +162,7 @@ export interface PendingRequest {
rootRunId: string;
rootWorkflow: string;
artifactRef?: { $blob: string; size: number; preview?: string };
ui?: UiPresentation;
}

export interface PendingResponse {
Expand Down
60 changes: 59 additions & 1 deletion apps/ui/src/app/app.test.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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<HTMLIFrameElement>("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<string, unknown> | undefined;
contentWindow.postMessage = ((message: unknown, _origin: string, transfer?: Transferable[]) => {
init = message as Record<string, unknown>;
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");
Expand Down
65 changes: 65 additions & 0 deletions apps/ui/src/components/molecules/WorkflowViewFrame.module.css
Original file line number Diff line number Diff line change
@@ -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;
}
58 changes: 58 additions & 0 deletions apps/ui/src/components/molecules/WorkflowViewFrame.test.tsx
Original file line number Diff line number Diff line change
@@ -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<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((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<unknown>();
const nextProps = deferred<unknown>();
vi.spyOn(api, "blobJson").mockImplementation((ref) =>
ref === "old" ? oldProps.promise : nextProps.promise,
);
const posted: unknown[] = [];
const { rerender } = render(
<WorkflowViewFrame runId="old-run" presentation={presentation("old", "old-hash")} />,
);
const oldFrame = screen.getByTitle<HTMLIFrameElement>("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(<WorkflowViewFrame runId="next-run" presentation={presentation("next", "next-hash")} />);
const nextFrame = screen.getByTitle<HTMLIFrameElement>("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));
});
});
Loading