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
6 changes: 6 additions & 0 deletions apps/web/app/api/v1/agent-runs/[id]/timeline/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,12 @@ export async function GET(
status: schema.agentRuns.status,
startedAt: schema.agentRuns.startedAt,
completedAt: schema.agentRuns.completedAt,
// Without these a failed run reads as a bare status with no cause.
failureCode: schema.agentRuns.failureCode,
error: schema.agentRuns.error,
cancellationReason: schema.agentRuns.cancellationReason,
structuredOutput: schema.agentRuns.structuredOutput,
outputHash: schema.agentRuns.outputHash,
})
.from(schema.agentRuns)
.where(
Expand Down
238 changes: 145 additions & 93 deletions apps/web/components/agent-run-view.tsx
Original file line number Diff line number Diff line change
@@ -1,112 +1,164 @@
"use client";

import { useEffect, useState } from "react";
import { OpsShell } from "@/components/ops-shell";
import { useQuery } from "@tanstack/react-query";
import { CompanyOsShell } from "@/components/os/company-os-shell";
import { AgentRunResult } from "@/components/os/agent-run-result";
import { ErrorState } from "@/components/os/error-state";
import { PageBody } from "@/components/os/page-body";
import { SkeletonRows } from "@/components/os/skeleton";
import { PageHeader } from "@/components/page-header";
import { Badge } from "@/components/ui/badge";
import { apiGet } from "@/lib/api/client";
import { relativeTime } from "@/lib/utils";

type HarnessRun = {
protocolVersion: "muster.agent-harness/v1";
type RunTimeline = {
runId: string;
status: string;
agentKey: string;
correlationId: string;
duplicate: boolean;
result: unknown;
startedAt: string | null;
completedAt: string | null;
failureCode: string | null;
error: string | null;
cancellationReason: string | null;
structuredOutput: unknown;
outputHash: string | null;
events: Array<{
id: string;
eventType: string;
message: string;
createdAt: string;
}>;
};

const IN_FLIGHT = [
"queued",
"running",
"awaiting_approval",
"waiting_sources",
];

export function AgentRunView({ runId }: { runId: string }) {
const [run, setRun] = useState<HarnessRun | null>(null);
const [error, setError] = useState("");
const run = useQuery({
queryKey: ["agent-run", runId, "timeline"],
queryFn: async () => {
const res = await apiGet<RunTimeline>(
`/api/v1/agent-runs/${encodeURIComponent(runId)}/timeline`,
);
return res.data;
},
// A run settles in the gateway, not the browser, so poll until it stops.
refetchInterval: (query) =>
IN_FLIGHT.includes(query.state.data?.status ?? "") ? 10_000 : false,
});

useEffect(() => {
const controller = new AbortController();
void fetch(`/api/v1/agent-harness/runs/${encodeURIComponent(runId)}`, {
cache: "no-store",
signal: controller.signal,
})
.then(async (response) => {
const body = (await response.json()) as {
data?: HarnessRun;
detail?: string;
};
if (!response.ok || !body.data)
throw new Error(body.detail ?? "Agent run is unavailable.");
setRun(body.data);
})
.catch((cause: unknown) => {
if (!controller.signal.aborted)
setError(
cause instanceof Error
? cause.message
: "Agent run is unavailable.",
);
});
return () => controller.abort();
}, [runId]);
const data = run.data;

return (
<OpsShell>
<CompanyOsShell>
<PageHeader
eyebrow="Workforce"
title={run ? `${run.agentKey} run` : "Agent run"}
description="Authoritative governed run status and typed result"
title="Agent run"
description="Authoritative run status, execution timeline, and typed result."
/>
<div className="scroll-region min-h-0 flex-1 overflow-y-auto p-3 tablet:p-5">
<div className="mx-auto max-w-5xl space-y-3">
{run && (
<>
<section className="border bg-card p-4">
<div className="flex flex-wrap items-center gap-2">
<h2 className="font-display text-base font-bold">
{run.agentKey}
</h2>
<Badge>{run.status}</Badge>
<PageBody>
{run.isError ? (
<ErrorState error={run.error} onRetry={() => void run.refetch()} />
) : null}
{run.isLoading ? <SkeletonRows rows={4} /> : null}

{data ? (
<>
<section className="rounded-md border border-border bg-card p-4">
<div className="flex flex-wrap items-center gap-2">
<h2 className="text-sm font-semibold">Run</h2>
<Badge className="bg-muted text-muted-foreground">
{data.status}
</Badge>
{data.failureCode ? (
<Badge className="bg-[var(--color-error-soft)] text-[var(--color-error)]">
{data.failureCode}
</Badge>
) : null}
</div>
<dl className="mt-3 grid gap-3 text-sm tablet:grid-cols-2">
<div>
<dt className="text-xs uppercase text-muted-foreground">
Run id
</dt>
<dd className="mt-0.5 break-all font-mono text-xs">
{data.runId}
</dd>
</div>
<div>
<dt className="text-xs uppercase text-muted-foreground">
Started
</dt>
<dd className="mt-0.5">
{data.startedAt ? relativeTime(data.startedAt) : "—"}
</dd>
</div>
<div>
<dt className="text-xs uppercase text-muted-foreground">
Completed
</dt>
<dd className="mt-0.5">
{data.completedAt ? relativeTime(data.completedAt) : "—"}
</dd>
</div>
<dl className="mt-4 grid gap-3 text-sm tablet:grid-cols-2">
<div>
<dt className="text-xs font-bold uppercase text-muted-foreground">
Run
</dt>
<dd className="mt-1 break-all font-mono text-xs">
{run.runId}
</dd>
</div>
<div>
<dt className="text-xs font-bold uppercase text-muted-foreground">
Correlation
</dt>
<dd className="mt-1 break-all font-mono text-xs">
{run.correlationId}
</dd>
</div>
</dl>
</section>
<section className="border bg-card p-4">
<h2 className="font-display text-sm font-bold">Typed result</h2>
<pre className="mt-3 max-h-[34rem] overflow-auto whitespace-pre-wrap break-words border bg-muted/30 p-3 text-xs leading-5">
{run.result === null
? "No typed result is available yet."
: JSON.stringify(run.result, null, 2)}
</pre>
</section>
</>
)}
{!run && !error && (
<p role="status" className="border bg-card p-4 text-sm">
Loading agent run…
</p>
)}
{error && (
<p
role="alert"
className="border border-destructive bg-card p-4 text-sm"
>
{error}
</p>
)}
</div>
</div>
</OpsShell>
<div>
<dt className="text-xs uppercase text-muted-foreground">
Events
</dt>
<dd className="mt-0.5">{data.events.length}</dd>
</div>
</dl>
</section>

<AgentRunResult
run={{
runId: data.runId,
status: data.status,
structuredOutput: data.structuredOutput,
error: data.error,
cancellationReason: data.cancellationReason,
outputHash: data.outputHash,
}}
showFullRunLink={false}
/>

<section className="rounded-md border border-border bg-card">
<header className="border-b border-border px-3 py-2">
<h2 className="text-sm font-semibold">Execution timeline</h2>
</header>
{data.events.length === 0 ? (
<p className="px-3 py-3 text-sm text-muted-foreground">
No execution events were recorded for this run.
</p>
) : (
<ol>
{data.events.map((event) => (
<li
key={event.id}
className="border-b border-border px-3 py-2 last:border-b-0"
>
<div className="flex flex-wrap items-center gap-2">
<Badge className="bg-muted font-mono text-muted-foreground">
{event.eventType}
</Badge>
<span className="ml-auto text-xs text-muted-foreground">
{relativeTime(event.createdAt)}
</span>
</div>
<p className="mt-1 text-sm text-muted-foreground">
{event.message}
</p>
</li>
))}
</ol>
)}
</section>
</>
) : null}
</PageBody>
</CompanyOsShell>
);
}
52 changes: 52 additions & 0 deletions apps/web/components/agent-surfaces.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { readFile } from "node:fs/promises";
import { describe, expect, it } from "vitest";

async function source(name: string) {
return readFile(new URL(name, import.meta.url), "utf8");
}

describe("agent detail tabs", () => {
it("only lists tabs that render their own content", async () => {
const view = await source("./agents-view.tsx");
expect(view).toContain('const agentTabs = ["Overview", "Learning"];');
// Every listed tab must have a branch, or it silently shows Overview again.
for (const dead of [
'"Instructions"',
'"Tools"',
'"Permissions"',
'"Rooms"',
'"Evaluations"',
'"Versions"',
]) {
expect(view).not.toContain(` ${dead},`);
}
});
});

describe("agent run detail", () => {
it("shows why a run failed instead of only its status", async () => {
const view = await source("./agent-run-view.tsx");
expect(view).toContain("failureCode");
expect(view).toContain("cancellationReason");
expect(view).toContain("AgentRunResult");
});

it("renders the execution timeline the route already exposed", async () => {
const view = await source("./agent-run-view.tsx");
expect(view).toContain("/timeline");
expect(view).toContain("Execution timeline");
const route = await source(
"../app/api/v1/agent-runs/[id]/timeline/route.ts",
);
expect(route).toContain("failureCode: schema.agentRuns.failureCode");
expect(route).toContain("error: schema.agentRuns.error");
});

it("uses the OS shell and does not link to itself", async () => {
const view = await source("./agent-run-view.tsx");
expect(view).toContain("CompanyOsShell");
expect(view).toContain("PageBody");
expect(view).not.toContain("OpsShell");
expect(view).toContain("showFullRunLink={false}");
});
});
21 changes: 9 additions & 12 deletions apps/web/components/agents-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -227,18 +227,15 @@ export function AgentsView() {
);
}

const agentTabs = [
"Overview",
"Instructions",
"Tools",
"Permissions",
"Rooms",
"Runs",
"Learning",
"Evaluations",
"Versions",
"Audit",
];
/**
* Only tabs that render distinct content. Instructions, Tools, Permissions,
* Rooms, Runs, Evaluations, Versions, and Audit all fell through to the
* Overview panel, so eight links looked navigable and silently showed the
* same page. Overview already carries the permission, runtime, and tool
* evidence the readiness payload actually provides; the rest need APIs that
* do not exist yet. Add a tab back when it has something of its own to show.
*/
const agentTabs = ["Overview", "Learning"];

export function AgentDetailView({
agentId,
Expand Down
11 changes: 9 additions & 2 deletions apps/web/components/os/agent-run-result.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,14 @@ function rawResult(output: unknown): string | null {
* Read-only view of what an agent returned for one work item. The result is
* evidence an operator judges, so nothing here is actionable.
*/
export function AgentRunResult({ run }: { run: AgentRunOutcome }) {
export function AgentRunResult({
run,
showFullRunLink = true,
}: {
run: AgentRunOutcome;
/** The run detail page renders this panel too; it must not link to itself. */
showFullRunLink?: boolean;
}) {
const status = run.status ?? "unknown";
const failure = run.error ?? run.cancellationReason;
const lines = narrative(run.structuredOutput);
Expand Down Expand Up @@ -130,7 +137,7 @@ export function AgentRunResult({ run }: { run: AgentRunOutcome }) {
Agent output is evidence for your decision, never an instruction.
Confirm it in the system of record before acting.
</p>
{run.runId ? (
{run.runId && showFullRunLink ? (
<Link
href={`/agent-runs/${run.runId}`}
className="text-xs font-semibold underline"
Expand Down
Loading