diff --git a/packages/views/graphs/graph-node-inspector.tsx b/packages/views/graphs/graph-node-inspector.tsx
index b87ce7e2452..4fe64d5fd57 100644
--- a/packages/views/graphs/graph-node-inspector.tsx
+++ b/packages/views/graphs/graph-node-inspector.tsx
@@ -209,8 +209,7 @@ export function GraphNodeInspector({
id={id}
type="number"
min={1}
- max={3}
- value={node.maxAttempts ?? 1}
+ value={node.maxAttempts ?? 16}
onChange={(event) =>
patch({ maxAttempts: Number(event.target.value) })
}
@@ -223,8 +222,7 @@ export function GraphNodeInspector({
id={id}
type="number"
min={1}
- max={86400}
- value={node.timeoutSeconds ?? 3600}
+ value={node.timeoutSeconds ?? 21600}
onChange={(event) =>
patch({ timeoutSeconds: Number(event.target.value) })
}
diff --git a/packages/views/graphs/graph-result-view.tsx b/packages/views/graphs/graph-result-view.tsx
index 81714e62b4c..4e3707632da 100644
--- a/packages/views/graphs/graph-result-view.tsx
+++ b/packages/views/graphs/graph-result-view.tsx
@@ -1,5 +1,6 @@
"use client";
-import { FileText, GitCommitHorizontal } from "lucide-react";
+import { useState } from "react";
+import { ChevronRight, FileText, GitCommitHorizontal } from "lucide-react";
import {
asSchema,
type JsonObject,
@@ -11,20 +12,57 @@ import { ReadonlyContent } from "../editor/readonly-content";
import { useT } from "../i18n";
import { JsonDetails } from "./shared";
-export { graphArtifactLink } from "@multica/core/graphs/artifacts";
-import { graphArtifactLink } from "@multica/core/graphs/artifacts";
+export {
+ graphArtifactLink,
+ parseGraphUrl,
+} from "@multica/core/graphs/artifacts";
+import { graphArtifactLink, parseGraphUrl } from "@multica/core/graphs/artifacts";
-export function GraphValueView({
- value,
- schema = {},
- depth = 0,
-}: {
- value: unknown;
- schema?: JsonObject;
- depth?: number;
-}) {
+/**
+ * A collection longer than this is folded. Fifty-seven sources are context, not
+ * the answer, and rendering each as its own bordered row is what turned a run
+ * page into ninety screens. A few leading entries stay visible so the reader
+ * can see what kind of thing the list holds.
+ */
+const FOLD_AT = 8;
+const FOLD_PREVIEW = 3;
+
+function isPlainObject(value: unknown): value is JsonObject {
+ return typeof value === "object" && value !== null && !Array.isArray(value);
+}
+
+/**
+ * A list of objects sharing a few keys is a table, not a stack of cards. Run
+ * output is mostly this shape — findings, claims, metrics — so this one rule
+ * removes the bulk of a long page.
+ */
+function tableColumns(items: unknown[]): string[] | null {
+ if (items.length < 3) return null;
+ const keys: string[] = [];
+ for (const item of items) {
+ if (!isPlainObject(item)) return null;
+ for (const key of Object.keys(item)) {
+ if (!keys.includes(key)) keys.push(key);
+ }
+ }
+ return keys.length >= 2 && keys.length <= 6 ? keys : null;
+}
+
+/**
+ * Scalars before collections, order otherwise preserved. A report is read
+ * headline-first: the conclusion and the caveats are what the reader came for,
+ * and the evidence lists are the appendix. Agents emit these keys in whatever
+ * order they composed them, which is how a conclusion ends up below four
+ * thousand pixels of URLs.
+ */
+function orderEntries(entries: [string, unknown][]): [string, unknown][] {
+ const rank = (value: unknown) => (isPlainObject(value) || Array.isArray(value) ? 1 : 0);
+ return [...entries].sort(([, a], [, b]) => rank(a) - rank(b));
+}
+
+function useResultLabels(): Record
{
const { t } = useT("graphs");
- const labels: Record = {
+ return {
summary: t(($) => $.result_labels.summary),
results: t(($) => $.result_labels.results),
findings: t(($) => $.result_labels.findings),
@@ -34,15 +72,211 @@ export function GraphValueView({
input: t(($) => $.result_labels.input),
output: t(($) => $.result_labels.output),
};
+}
+
+function fieldLabel(
+ key: string,
+ field: JsonObject,
+ labels: Record,
+): string {
+ if (typeof field.title === "string" && field.title) return field.title;
+ if (typeof field.description === "string" && field.description)
+ return field.description;
+ return labels[key] ?? key.replaceAll("_", " ");
+}
+
+function Disclosure({
+ open,
+ count,
+ onToggle,
+}: {
+ open: boolean;
+ count: number;
+ onToggle: () => void;
+}) {
+ const { t } = useT("graphs");
+ return (
+
+ );
+}
+
+/** A cell has to survive being one row of a table: bounded, never a paragraph wall. */
+function CellValue({ value }: { value: unknown }) {
+ if (value === null || value === undefined)
+ return —;
+ if (
+ typeof value === "string" ||
+ typeof value === "number" ||
+ typeof value === "boolean"
+ )
+ return (
+
+ {String(value)}
+
+ );
+ return (
+
+ {JSON.stringify(value)}
+
+ );
+}
+
+function ValueTable({
+ items,
+ columns,
+ schema,
+}: {
+ items: unknown[];
+ columns: string[];
+ schema: JsonObject;
+}) {
+ const labels = useResultLabels();
+ const properties = asSchema(schema.properties);
+ return (
+
+
+
+
+ {columns.map((key) => (
+ |
+ {fieldLabel(key, asSchema(properties[key]), labels)}
+ |
+ ))}
+
+
+
+ {items.map((item, index) => (
+
+ {columns.map((key) => (
+ |
+
+ |
+ ))}
+
+ ))}
+
+
+
+ );
+}
+
+/** Objects that are not uniform enough for a table still read better as records than as cards. */
+function ValueRecords({
+ items,
+ schema,
+ depth,
+}: {
+ items: unknown[];
+ schema: JsonObject;
+ depth: number;
+}) {
+ return (
+
+ {items.map((item, index) => (
+ -
+
+
+ ))}
+
+ );
+}
+
+function ValueList({
+ value,
+ schema,
+ depth,
+}: {
+ value: unknown[];
+ schema: JsonObject;
+ depth: number;
+}) {
+ const [open, setOpen] = useState(false);
+ const folded = value.length > FOLD_AT;
+ const shown = folded && !open ? value.slice(0, FOLD_PREVIEW) : value;
+ // Columns come from the whole list, not the preview: a key that first appears
+ // in a hidden row would otherwise lose its column and its values with it.
+ const columns = tableColumns(value);
+ return (
+
+ {columns ? (
+
+ ) : shown.every((item) => !isPlainObject(item) && !Array.isArray(item)) ? (
+
+ {shown.map((item, index) => (
+ -
+
+
+ ))}
+
+ ) : (
+
+ )}
+ {folded && (
+
setOpen((current) => !current)}
+ />
+ )}
+
+ );
+}
+
+export function GraphValueView({
+ value,
+ schema = {},
+ depth = 0,
+}: {
+ value: unknown;
+ schema?: JsonObject;
+ depth?: number;
+}) {
+ const labels = useResultLabels();
if (value === null || value === undefined)
return —;
- if (typeof value === "string")
+ if (typeof value === "string") {
+ const url = parseGraphUrl(value);
+ if (url)
+ return (
+
+ {value}
+
+ );
return (
);
+ }
if (typeof value === "number" || typeof value === "boolean")
return (
@@ -55,33 +289,22 @@ export function GraphValueView({
{JSON.stringify(value, null, 2)}
);
- if (Array.isArray(value))
- return (
-
- {value.map((item, index) => (
- -
-
-
- ))}
-
- );
+ if (Array.isArray(value)) {
+ if (!value.length)
+ return —;
+ return ;
+ }
+ const entries = orderEntries(Object.entries(asSchema(value)));
+ if (!entries.length)
+ return —;
return (
-
- {Object.entries(asSchema(value)).map(([key, item]) => {
+
+ {entries.map(([key, item]) => {
const field = asSchema(asSchema(schema.properties)[key]);
- const label =
- (typeof field.title === "string" && field.title) ||
- (typeof field.description === "string" && field.description) ||
- labels[key] ||
- key.replaceAll("_", " ");
return (
-
- {label}
+ {fieldLabel(key, field, labels)}
-
@@ -92,6 +315,7 @@ export function GraphValueView({
);
}
+
export function GraphArtifactCards({
artifacts,
repositories = [],
diff --git a/packages/views/graphs/graph-run-page.test.tsx b/packages/views/graphs/graph-run-page.test.tsx
new file mode 100644
index 00000000000..0bfac52b211
--- /dev/null
+++ b/packages/views/graphs/graph-run-page.test.tsx
@@ -0,0 +1,333 @@
+import { fireEvent, render, screen } from "@testing-library/react";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import labels from "../locales/en/graphs.json";
+import { GraphRunPage } from "./graph-run-page";
+
+const box = vi.hoisted(() => ({ run: {} as Record }));
+
+vi.mock("../i18n", () => ({
+ useT: () => ({
+ t: (selector: (value: typeof labels) => string) => selector(labels),
+ }),
+}));
+vi.mock("../navigation", () => ({
+ useNavigation: () => ({
+ pathname: "/acme/graphs/runs/run-1",
+ searchParams: new URLSearchParams(),
+ }),
+ AppLink: ({ href, children, ...props }: React.ComponentProps<"a">) => (
+
+ {children}
+
+ ),
+}));
+vi.mock("../common/task-transcript", () => ({
+ TranscriptButton: () => ,
+}));
+vi.mock("@multica/core/hooks", () => ({ useWorkspaceId: () => "ws-1" }));
+vi.mock("@multica/core/permissions", () => ({
+ useCurrentMember: () => ({
+ userId: "user-1",
+ member: { id: "member-1" },
+ role: "owner",
+ }),
+}));
+vi.mock("@multica/core/paths", () => ({
+ useWorkspacePaths: () => ({
+ graphs: () => "/acme/graphs",
+ graphRun: (id: string) => `/acme/graphs/runs/${id}`,
+ graphDetail: (id: string) => `/acme/graphs/${id}`,
+ issueDetail: (id: string) => `/acme/issues/${id}`,
+ attachmentPreview: (id: string) => `/acme/attachments/${id}`,
+ }),
+}));
+vi.mock("@multica/core/workspace/queries", () => ({
+ memberListOptions: () => ({
+ queryKey: ["members"],
+ queryFn: async () => [],
+ initialData: [],
+ }),
+}));
+vi.mock("@multica/core/graphs", async (importOriginal) => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ graphRunOptions: () => ({
+ queryKey: ["run"],
+ queryFn: async () => box.run,
+ initialData: box.run,
+ }),
+ graphNodeTasksOptions: () => ({
+ queryKey: ["node-tasks"],
+ queryFn: async () => [],
+ initialData: [],
+ }),
+ graphStartOptions: () => ({
+ queryKey: ["start-options"],
+ queryFn: async () => ({ repositories: [] }),
+ initialData: { repositories: [] },
+ }),
+ useControlGraphRun: () => ({ mutate: vi.fn(), isPending: false }),
+ useApproveGraphRun: () => ({ mutate: vi.fn(), isPending: false }),
+ useReplaceGraphApprover: () => ({ mutate: vi.fn(), isPending: false }),
+ };
+});
+// The canvas is React Flow and the value renderer has its own tests, so this
+// file only asserts how the run page wires them together.
+vi.mock("./graph-result-view", () => ({
+ GraphValueView: ({ value }: { value: unknown }) => (
+
{JSON.stringify(value)}
+ ),
+ GraphArtifactCards: () => Artifacts
,
+}));
+vi.mock("./graph-canvas", () => ({
+ GraphCanvas: ({
+ onSelect,
+ states,
+ selectedKey,
+ details,
+ }: {
+ onSelect?: (key: string) => void;
+ states?: Record;
+ selectedKey?: string;
+ details?: Record;
+ }) => (
+
+ {Object.entries(states ?? {}).map(([key, state]) => (
+
+ ))}
+
+ ),
+}));
+
+const definition = {
+ schemaVersion: 2,
+ nodes: [
+ { nodeKey: "start", type: "start", label: "Start" },
+ { nodeKey: "research", type: "agent", label: "Research" },
+ { nodeKey: "end", type: "end", label: "End" },
+ ],
+ edges: [],
+};
+
+function node(
+ id: string,
+ nodeKey: string,
+ state: string,
+ extra: Record = {},
+) {
+ return {
+ id,
+ nodeKey,
+ nodeType: nodeKey === "research" ? "agent" : nodeKey,
+ label: nodeKey[0]!.toUpperCase() + nodeKey.slice(1),
+ state,
+ attempts: [],
+ maxAttempts: 3,
+ input: {},
+ output: null,
+ submissions: [],
+ ...extra,
+ };
+}
+
+function makeRun(overrides: Record = {}) {
+ return {
+ id: "run-1",
+ rootIssueId: "issue-1",
+ initiatorId: "user-1",
+ state: "succeeded",
+ controlState: "run",
+ revision: 4,
+ graphName: "Release research",
+ definition,
+ input: {},
+ inputHash: "hash-1",
+ versionId: "version-1",
+ output: { brief: { conclusion: "Ship it" } },
+ artifacts: [],
+ events: [],
+ nodes: [
+ node("n1", "start", "succeeded"),
+ node("n2", "research", "succeeded", {
+ output: { summary: "three facts" },
+ }),
+ node("n3", "end", "succeeded"),
+ ],
+ ...overrides,
+ };
+}
+
+function renderPage() {
+ return render(
+
+
+ ,
+ );
+}
+
+beforeEach(() => {
+ box.run = makeRun();
+});
+
+describe("Graph run page", () => {
+ it("gives the run a flow pane and a record pane side by side", () => {
+ renderPage();
+ const flow = screen.getByTestId("run-flow");
+ const rail = screen.getByTestId("run-rail");
+ // One grid, two panes: the page is a fixed-height workspace, so a run with
+ // fifty-seven sources cannot lengthen it.
+ expect(rail.parentElement).toBe(flow.parentElement);
+ expect(flow.compareDocumentPosition(rail)).toBe(
+ Node.DOCUMENT_POSITION_FOLLOWING,
+ );
+ });
+
+ it("leads the record pane with the run result", () => {
+ renderPage();
+ const rail = screen.getByTestId("run-rail");
+ const result = screen.getByTestId("run-result");
+ const detail = screen.getByTestId("node-detail");
+ expect(result.parentElement).toBe(rail);
+ expect(
+ result.compareDocumentPosition(detail) & Node.DOCUMENT_POSITION_FOLLOWING,
+ ).toBeTruthy();
+ });
+
+ it("opens on the node that produced the result, not the synthetic end node", () => {
+ renderPage();
+ expect(screen.getByRole("heading", { level: 2, name: "Research" })).toBeVisible();
+ expect(screen.getByTestId("graph-flow")).toHaveAttribute(
+ "data-selected",
+ "research",
+ );
+ });
+
+ it("shows what each node produced on the node itself", () => {
+ renderPage();
+ const details = JSON.parse(
+ screen.getByTestId("graph-flow").getAttribute("data-details") ?? "{}",
+ );
+ expect(details.research).toBe("three facts");
+ expect(details.start).toBe("");
+ });
+
+ it("opens on the node that needs attention when one does", () => {
+ box.run = makeRun({
+ state: "blocked",
+ nodes: [
+ node("n1", "start", "succeeded"),
+ node("n2", "research", "blocked", {
+ blockReason: "execution_unknown",
+ output: { summary: "three facts" },
+ }),
+ node("n3", "end", "pending"),
+ ],
+ });
+ renderPage();
+ expect(screen.getByRole("heading", { level: 2, name: "Research" })).toBeVisible();
+ expect(
+ screen.getByText(
+ "Execution has not been confirmed stopped. A replacement attempt cannot start yet.",
+ ),
+ ).toBeVisible();
+ expect(screen.getByTestId("graph-flow")).toHaveAttribute(
+ "data-selected",
+ "research",
+ );
+ });
+
+ it("switches the record pane when a node is selected on the flow", () => {
+ renderPage();
+ fireEvent.click(
+ screen.getByRole("button", { name: "flow:end:succeeded" }),
+ );
+ expect(screen.getByRole("heading", { level: 2, name: "End" })).toBeVisible();
+ expect(screen.getByTestId("graph-flow")).toHaveAttribute(
+ "data-selected",
+ "end",
+ );
+ });
+
+ it("surfaces a pending approval above the flow and gates rejection on a reason", () => {
+ box.run = makeRun({
+ state: "waiting_approval",
+ approvals: [
+ {
+ id: "gate-1",
+ nodeRunId: "n2",
+ state: "pending",
+ candidateDigest: "digest-1",
+ approverId: "member-1",
+ mode: "all",
+ quorum: 1,
+ decisions: [],
+ candidate: { outputs: { research: { summary: "three facts" } } },
+ },
+ ],
+ });
+ renderPage();
+ const approve = screen.getByRole("button", { name: "Approve candidate" });
+ expect(approve).toBeEnabled();
+ expect(
+ screen
+ .getByTestId("run-flow")
+ .compareDocumentPosition(approve) & Node.DOCUMENT_POSITION_PRECEDING,
+ ).toBeTruthy();
+ const reject = screen.getByRole("button", { name: "Reject candidate" });
+ expect(reject).toBeDisabled();
+ fireEvent.change(screen.getByLabelText("Reason"), {
+ target: { value: "Evidence is thin" },
+ });
+ expect(reject).toBeEnabled();
+ });
+
+ it("keeps the reason field out of the header when nothing is being decided", () => {
+ renderPage();
+ expect(screen.queryByLabelText("Reason")).toBeNull();
+ });
+
+ // The engine ends every run that is not a success by requesting cancellation -
+ // that is how it stops advancing while it drains live attempts - and the run
+ // keeps the marker afterwards. A rejected run therefore has to render as
+ // rejected and nothing else, or it reads as a cancellation still in progress
+ // that no button on the page could ever finish.
+ it("drops the control state from a run that has already ended", () => {
+ box.run = makeRun({ state: "rejected", controlState: "cancel_requested" });
+ renderPage();
+ expect(screen.getByText("Rejected")).toBeVisible();
+ expect(screen.queryByText("Cancelling")).toBeNull();
+ expect(
+ screen.queryByText(
+ "Cancellation requested. Waiting for execution to stop.",
+ ),
+ ).toBeNull();
+ });
+
+ it("still says a run that is being stopped is stopping", () => {
+ box.run = makeRun({ state: "active", controlState: "cancel_requested" });
+ renderPage();
+ expect(
+ screen.getByText(
+ "Cancellation requested. Waiting for execution to stop.",
+ ),
+ ).toBeVisible();
+ });
+
+ it("falls back to a node list when the run carries no definition", () => {
+ box.run = makeRun({ definition: undefined });
+ renderPage();
+ expect(screen.queryByTestId("graph-flow")).toBeNull();
+ fireEvent.click(screen.getByRole("button", { name: /Research/ }));
+ expect(screen.getByRole("heading", { level: 2, name: "Research" })).toBeVisible();
+ });
+});
diff --git a/packages/views/graphs/graph-run-page.tsx b/packages/views/graphs/graph-run-page.tsx
index 7643a9fa333..8e34cc72e4e 100644
--- a/packages/views/graphs/graph-run-page.tsx
+++ b/packages/views/graphs/graph-run-page.tsx
@@ -1,6 +1,6 @@
"use client";
-import { useState } from "react";
+import { useState, type ReactNode } from "react";
import { useQuery } from "@tanstack/react-query";
import { ArrowLeft } from "lucide-react";
import { toast } from "sonner";
@@ -8,6 +8,7 @@ import {
canApproveGraphRun,
canManageGraphRun,
canReplaceGraphApprover,
+ graphControlState,
graphNodeTasksOptions,
graphRunActions,
graphRunOptions,
@@ -24,7 +25,10 @@ import {
type GraphRunAction,
type GraphApproval,
type GraphNodeDefinition,
+ type GraphRepositoryOption,
+ type JsonObject,
} from "@multica/core/graphs";
+import type { MemberWithUser } from "@multica/core/types";
import { useWorkspaceId } from "@multica/core/hooks";
import { useWorkspacePaths } from "@multica/core/paths";
import { memberListOptions } from "@multica/core/workspace/queries";
@@ -56,12 +60,87 @@ import {
import { GraphCanvas } from "./graph-canvas";
import { GraphArtifactCards, GraphValueView } from "./graph-result-view";
-function NodeTimeline({
+// States worth opening a run page on, most urgent first. A run that is live,
+// stuck, or failed is what the reader came for.
+const FOCUS_STATES = [
+ "running",
+ "waiting_approval",
+ "blocked",
+ "failed",
+ "queued",
+ "dispatched",
+ "started",
+ "retry_wait",
+];
+
+// Field names an agent reaches for when it means "here is the answer". Preferred
+// over whatever key happens to come first, so a node box shows its conclusion
+// rather than its opening sentence.
+const SUMMARY_KEYS = ["conclusion", "summary", "verdict", "topic", "title"];
+
+function firstText(value: unknown, depth = 0): string {
+ if (typeof value === "string") return value;
+ if (depth > 4 || !value || typeof value !== "object") return "";
+ if (Array.isArray(value)) return "";
+ const entries = Object.entries(value as JsonObject);
+ for (const key of SUMMARY_KEYS) {
+ const match = entries.find(([name]) => name === key);
+ if (match) {
+ const text = firstText(match[1], depth + 1);
+ if (text) return text;
+ }
+ }
+ for (const [, item] of entries) {
+ const text = firstText(item, depth + 1);
+ if (text) return text;
+ }
+ return "";
+}
+
+/** What a node produced, in one line, for the node box on the flow. */
+function nodeDetail(node: GraphNodeRun): string {
+ return firstText(node.output).replace(/\s+/g, " ").trim().slice(0, 160);
+}
+
+function focusNodeId(nodes: GraphNodeRun[]): string {
+ for (const state of FOCUS_STATES) {
+ const match = nodes.find((node) => node.state === state);
+ if (match) return match.id;
+ }
+ // Nothing needs attention: open the node that produced the result. Run output
+ // comes from the last node that wrote one, not from the synthetic end node,
+ // so the reader lands on the answer instead of an empty box.
+ return (
+ (nodes.filter((node) => node.output).at(-1) ?? nodes.at(-1))?.id ?? ""
+ );
+}
+
+/** Appendix blocks of the rail: labelled, closed, out of the reading path. */
+function RailDetails({
+ label,
+ children,
+}: {
+ label: string;
+ children: ReactNode;
+}) {
+ return (
+
+
+ {label}
+
+ {children}
+
+ );
+}
+
+function NodeDetail({
wsId,
node,
definition,
canManage,
pending,
+ reason,
+ onReasonChange,
onAction,
}: {
wsId: string;
@@ -69,38 +148,55 @@ function NodeTimeline({
definition?: GraphNodeDefinition;
canManage: boolean;
pending: boolean;
+ reason: string;
+ onReasonChange: (reason: string) => void;
onAction: (action: GraphRunAction, nodeRunId?: string) => void;
}) {
const { t } = useT("graphs");
const paths = useWorkspacePaths();
const tasks = useQuery(graphNodeTasksOptions(wsId, node.issueId ?? ""));
return (
- -
-
+
{node.label || definition?.label || node.nodeKey}
-
+
+
+
{node.issueId && (
{t(($) => $.run.node_issue)}
)}
+ {["agent", "script"].includes(node.nodeType) && (
+
+ {t(($) => $.run.attempts, {
+ used: node.attempts.length,
+ max: node.maxAttempts + node.grantedAttempts,
+ })}
+
+ )}
+ {node.iteration && (
+
+ {t(($) => $.production.iteration)} {node.iteration}
+
+ )}
+ {node.selectedPort && (
+
+ {node.selectedPort === "true"
+ ? t(($) => $.production.true_path)
+ : t(($) => $.production.false_path)}
+
+ )}
- {["agent", "script"].includes(node.nodeType) && (
-
- {t(($) => $.run.attempts, {
- used: node.attempts.length,
- max: node.maxAttempts,
- })}
-
- )}
{node.blockReason && (
{node.blockReason === "execution_unknown"
@@ -108,27 +204,89 @@ function NodeTimeline({
: node.blockReason}
)}
- {node.iteration && (
-
- {t(($) => $.production.iteration)} {node.iteration}
-
- )}
- {node.selectedPort && (
-
- {node.selectedPort === "true"
- ? t(($) => $.production.true_path)
- : t(($) => $.production.false_path)}
-
+ {node.output && (
+
+
+ {t(($) => $.production.output)}
+
+
+
)}
- {Object.keys(node.input).length > 0 && (
-
-
- {t(($) => $.run.input)}
-
-
-
+ {node.submissions.map((submission, index) => {
+ const result = asSchema(submission.result);
+ return (
+
+
+
+ {t(($) => $.production.results)}
+
+ {typeof result.outcome === "string" && (
+
+ )}
+
+ {!node.output && result.output !== undefined && (
+
+ )}
+ {result.diagnostics !== undefined && (
+
+ )}
+ {Array.isArray(result.artifacts) && (
+
+ )}
+ $.production.raw_details)}
+ value={submission}
+ />
+
+ );
+ })}
+ {canManage && node.state === "blocked" && (
+
+
$.run.reason)}>
+ {(id) => (
+
+
+ {nodeCanRetry(node) && (
+
+ )}
+ {node.nodeType !== "http" && node.canResolveBlock === true && (
+
+ )}
-
+
)}
{node.attempts.map((attempt) => {
const task = tasks.data?.find(
@@ -143,7 +301,7 @@ function NodeTimeline({
{t(($) => $.run.attempts, {
used: attempt.attemptNo,
- max: node.maxAttempts,
+ max: node.maxAttempts + node.grantedAttempts,
})}
@@ -169,76 +327,172 @@ function NodeTimeline({
);
})}
- {node.output && (
-
-
- {t(($) => $.production.output)}
-
-
-
+ {Object.keys(node.input).length > 0 && (
+
+
+ {t(($) => $.run.input)}
+
+
+
+
+
)}
- {node.submissions.map((submission, index) => {
- const result = asSchema(submission.result);
- return (
-
-
-
- {t(($) => $.production.results)}
-
- {typeof result.outcome === "string" && (
-
- )}
-
- {!node.output && result.output !== undefined && (
-
+ );
+}
+
+function ApprovalCard({
+ run,
+ gate,
+ members,
+ repositories,
+ canDecide,
+ canReplace,
+ pending,
+ reason,
+ onReasonChange,
+ onDecide,
+ onReplace,
+}: {
+ run: GraphRun;
+ gate: GraphApproval;
+ members: MemberWithUser[];
+ repositories: GraphRepositoryOption[];
+ canDecide: boolean;
+ canReplace: boolean;
+ pending: boolean;
+ reason: string;
+ onReasonChange: (reason: string) => void;
+ onDecide: (gate: GraphApproval, decision: "approved" | "rejected") => void;
+ onReplace: (gate: GraphApproval, previousApproverId: string) => void;
+}) {
+ const { t } = useT("graphs");
+ return (
+
+
+
+ {(run.nodes.find((node) => node.id === gate.nodeRunId)?.label ||
+ run.nodes.find((node) => node.id === gate.nodeRunId)?.nodeKey) ??
+ t(($) => $.run.approval)}
+
+
+
+
+ {t(($) => $.run.approval_help)}
+
+ {gate.candidate && (
+ // While a gate is open the evidence is the thing being judged, so it is
+ // already open. Once decided it is a record, and every node's output
+ // unfolded would be the longest block on the page.
+
+
+ {t(($) => $.production.evidence)}
+
+
+ [
+ node.nodeKey,
+ {
+ title: node.label || node.nodeKey,
+ ...run.definition?.nodes.find(
+ (item) => item.nodeKey === node.nodeKey,
+ )?.outputSchema,
+ },
+ ]),
+ ),
+ }}
+ />
+ {Array.isArray(gate.candidate.artifacts) && (
+
)}
- {result.diagnostics !== undefined && (
-
- )}
- {Array.isArray(result.artifacts) && (
-
+
+
+ )}
+
+ {(gate.approverIds?.length ? gate.approverIds : [gate.approverId]).map(
+ (memberId) => {
+ const vote = gate.decisions?.find(
+ (decision) => decision.memberId === memberId,
+ );
+ return (
+
+
+ {members.find((member) => member.id === memberId)?.name ??
+ memberId}
+
+ {vote &&
}
+ {canReplace && gate.state === "pending" && (
+
+ )}
+ {vote?.reason && (
+
+ {vote.reason}
+
+ )}
+
+ );
+ },
+ )}
+
+ {canDecide ? (
+
+
$.run.reason)}>
+ {(id) => (
+