diff --git a/apps/web/src/app/(workspace)/workspace/reports/[track]/ReportAuditView.test.tsx b/apps/web/src/app/(workspace)/workspace/reports/[track]/ReportAuditView.test.tsx
index 5e481d2..11e3064 100644
--- a/apps/web/src/app/(workspace)/workspace/reports/[track]/ReportAuditView.test.tsx
+++ b/apps/web/src/app/(workspace)/workspace/reports/[track]/ReportAuditView.test.tsx
@@ -93,4 +93,36 @@ describe("ReportAuditView", () => {
expect(markup).toContain("尚未进入当前公开快照");
expect(markup).not.toContain("匹配任务");
});
+
+ it("renders sanitized producer context when a completed job exposes runtime tails", () => {
+ const markup = renderToStaticMarkup(
+ ,
+ );
+
+ expect(markup).toContain("Producer context");
+ expect(markup).toContain("Completed");
+ expect(markup).toContain("Runtime output tail");
+ expect(markup).toContain("State history");
+ expect(markup).toContain("<local-path>");
+ expect(markup).toContain("token=<redacted>");
+ expect(markup).toContain("<runtime-url>");
+ expect(markup).not.toContain("C:\\runtime\\private");
+ expect(markup).not.toContain("abc123");
+ expect(markup).not.toContain("localhost:8780");
+ });
});
diff --git a/apps/web/src/app/(workspace)/workspace/reports/[track]/ReportAuditView.tsx b/apps/web/src/app/(workspace)/workspace/reports/[track]/ReportAuditView.tsx
index 3f719f6..8080af3 100644
--- a/apps/web/src/app/(workspace)/workspace/reports/[track]/ReportAuditView.tsx
+++ b/apps/web/src/app/(workspace)/workspace/reports/[track]/ReportAuditView.tsx
@@ -3,7 +3,9 @@ import { ReportEvidenceStack } from "@/components/report-evidence-stack";
import { MetricTooltip } from "@/components/metric-tooltip";
import { type Locale } from "@/components/language-picker";
import { type AttackDefenseRowViewModel } from "@/lib/workspace-source";
+import { formatFullTime } from "@/lib/format";
import { riskLabel } from "@/lib/risk-report";
+import { sanitizeRuntimeText } from "@/lib/runtime-text";
import { WORKSPACE_COPY } from "@/lib/workspace-copy";
export type ReportProvenance = {
@@ -26,12 +28,24 @@ export type ReportJobContext = {
aucLabel?: string;
};
+export type ReportProducerContext = {
+ status?: string | null;
+ updatedAt?: string | null;
+ stdoutTail?: string | null;
+ stderrTail?: string | null;
+ stateHistory?: Array<{
+ state: string;
+ timestamp?: string | null;
+ }>;
+};
+
type ReportAuditViewProps = {
locale: Locale;
rows: AttackDefenseRowViewModel[];
provenance: ReportProvenance;
historyPlaceholder: string;
jobContext?: ReportJobContext;
+ producerContext?: ReportProducerContext;
highlightedRowKeys?: string[];
};
@@ -39,12 +53,31 @@ function hasValue(value?: string | null): boolean {
return !!value && value.trim().length > 0 && value.trim() !== "—";
}
+function displayStatus(value?: string | null) {
+ if (!value) {
+ return undefined;
+ }
+
+ const normalized = value.trim();
+ if (!normalized) {
+ return undefined;
+ }
+
+ return normalized.charAt(0).toUpperCase() + normalized.slice(1);
+}
+
+function sanitizedTail(value?: string | null) {
+ const sanitized = sanitizeRuntimeText(value);
+ return sanitized?.trim() ? sanitized : undefined;
+}
+
export function ReportAuditView({
locale,
rows,
provenance,
historyPlaceholder,
jobContext,
+ producerContext,
highlightedRowKeys = [],
}: ReportAuditViewProps) {
const copy = WORKSPACE_COPY[locale].reports;
@@ -69,6 +102,11 @@ export function ReportAuditView({
historyTitle: "历史对照",
historyPlaceholder:
"历史对照数据将在后续版本接入,敬请期待。",
+ producerTitle: "生产者上下文",
+ producerStatus: "状态",
+ producerUpdated: "更新时间",
+ outputTail: "Runtime 输出尾部",
+ stateHistory: "状态历史",
noProvenanceData: "暂无溯源数据。",
}
: {
@@ -90,10 +128,26 @@ export function ReportAuditView({
historyTitle: "History comparison",
historyPlaceholder:
"Historical comparison data will be available in a future release.",
+ producerTitle: "Producer context",
+ producerStatus: "Status",
+ producerUpdated: "Updated",
+ outputTail: "Runtime output tail",
+ stateHistory: "State history",
noProvenanceData: "No provenance data available.",
};
const defendedRows = rows.filter((row) => row.defense !== "none" && row.defense !== "None").length;
const undefendedRows = rows.length - defendedRows;
+ const producerStatus = displayStatus(producerContext?.status);
+ const stdoutTail = sanitizedTail(producerContext?.stdoutTail);
+ const stderrTail = sanitizedTail(producerContext?.stderrTail);
+ const stateHistory = producerContext?.stateHistory ?? [];
+ const hasProducerContext = !!producerContext && (
+ hasValue(producerStatus)
+ || hasValue(producerContext.updatedAt)
+ || hasValue(stdoutTail)
+ || hasValue(stderrTail)
+ || stateHistory.length > 0
+ );
return (
@@ -249,6 +303,55 @@ export function ReportAuditView({
{historyPlaceholder}
+ {hasProducerContext ? (
+
+
{t.producerTitle}
+
+ {producerStatus ? (
+
+
- {t.producerStatus}
+ - {producerStatus}
+
+ ) : null}
+ {producerContext?.updatedAt ? (
+
+
- {t.producerUpdated}
+ - {formatFullTime(producerContext.updatedAt, locale)}
+
+ ) : null}
+
+ {stateHistory.length > 0 ? (
+
+
+ {t.stateHistory}
+
+
+ {stateHistory.map((entry, index) => (
+
+ {displayStatus(sanitizeRuntimeText(entry.state)) ?? "Unknown"}
+ {entry.timestamp ? (
+ {formatFullTime(entry.timestamp, locale)}
+ ) : null}
+
+ ))}
+
+
+ ) : null}
+ {stdoutTail || stderrTail ? (
+
+
+ {t.outputTail}
+
+
+ {[stdoutTail, stderrTail].filter(Boolean).join("\n")}
+
+
+ ) : null}
+
+ ) : null}
diff --git a/apps/web/src/app/(workspace)/workspace/reports/[track]/page.test.tsx b/apps/web/src/app/(workspace)/workspace/reports/[track]/page.test.tsx
index 806f9e8..579ace8 100644
--- a/apps/web/src/app/(workspace)/workspace/reports/[track]/page.test.tsx
+++ b/apps/web/src/app/(workspace)/workspace/reports/[track]/page.test.tsx
@@ -68,6 +68,51 @@ describe("TrackReportPage", () => {
expect(markup).toContain("recon_artifact_mainline");
});
+ it("hydrates sanitized producer context from the completed job detail facade", async () => {
+ vi.stubGlobal("fetch", vi.fn(async (input: RequestInfo | URL) => {
+ const url = String(input);
+ if (url.includes("/api/v1/audit/jobs/job_demo_004")) {
+ return Response.json({
+ job: {
+ status: "completed",
+ updated_at: "2026-05-15T08:12:30Z",
+ stdout_tail: "saved C:\\runtime\\private\\score.json\nreported token=abc123",
+ stderr_tail: "upload to http://localhost:8780 failed",
+ state_history: [
+ { state: "queued", timestamp: "2026-05-15T08:12:00Z" },
+ { state: "completed", timestamp: "2026-05-15T08:12:30Z" },
+ ],
+ },
+ });
+ }
+
+ return new Response(null, { status: 404 });
+ }));
+
+ const markup = renderToStaticMarkup(
+ await renderTrackReportPage({
+ locale: "en-US",
+ params: { track: "black-box" },
+ searchParams: {
+ view: "audit",
+ job: "job_demo_004",
+ contract: "recon_artifact_mainline",
+ model: "stable-diffusion-v1-4",
+ auc: "0.849",
+ },
+ }),
+ );
+
+ expect(markup).toContain("Producer context");
+ expect(markup).toContain("Runtime output tail");
+ expect(markup).toContain("<local-path>");
+ expect(markup).toContain("token=<redacted>");
+ expect(markup).toContain("<runtime-url>");
+ expect(markup).not.toContain("C:\\runtime\\private");
+ expect(markup).not.toContain("abc123");
+ expect(markup).not.toContain("localhost:8780");
+ });
+
it("keeps completed job context safe when no snapshot row matches", async () => {
const markup = renderToStaticMarkup(
await renderTrackReportPage({
diff --git a/apps/web/src/app/(workspace)/workspace/reports/[track]/track-report-page.tsx b/apps/web/src/app/(workspace)/workspace/reports/[track]/track-report-page.tsx
index 72b4e97..72b83a8 100644
--- a/apps/web/src/app/(workspace)/workspace/reports/[track]/track-report-page.tsx
+++ b/apps/web/src/app/(workspace)/workspace/reports/[track]/track-report-page.tsx
@@ -15,8 +15,7 @@ import { type EvidenceSummaryPayload } from "@/lib/audit-client";
import { backendBaseUrl } from "@/lib/api-proxy";
import { fetchWithTimeout } from "@/lib/fetch-timeout";
import { resolveLocaleFromHeaderStore } from "@/lib/locale";
-import { WORKSPACE_COPY } from "@/lib/workspace-copy";
-import { ReportAuditView, type ReportProvenance } from "./ReportAuditView";
+import { ReportAuditView, type ReportProducerContext, type ReportProvenance } from "./ReportAuditView";
import { ReportDisplayView } from "./ReportDisplayView";
const DEFAULT_SERVER_FETCH_TIMEOUT_MS = 600;
@@ -94,12 +93,6 @@ function pageTitle(locale: Locale, label: string) {
return locale === "zh-CN" ? `${label}报告详情` : `${label} report details`;
}
-function pageDescription(locale: Locale, label: string) {
- return locale === "zh-CN"
- ? "同一审计线路的展示视图与长期审计视图共用这一个入口,默认保持展示形态。"
- : `This route keeps both the display view and the long-term audit view for the ${label.toLowerCase()} track.`;
-}
-
function toggleLabel(locale: Locale, view: ViewMode) {
if (locale === "zh-CN") {
return view === "display" ? "展示视图" : "审计视图";
@@ -107,7 +100,7 @@ function toggleLabel(locale: Locale, view: ViewMode) {
return view === "display" ? "Display view" : "Audit view";
}
-function historyPlaceholder(locale: Locale, label: string) {
+function historyPlaceholder(locale: Locale) {
return locale === "zh-CN"
? "该审计线路的历史对比功能将在后续版本接入,当前为占位区域。"
: "Historical comparison for this track will be available in a future release. This area is reserved.";
@@ -204,6 +197,73 @@ function buildJobContext(searchParams?: TrackReportPageSearchParams) {
};
}
+function readJobEnvelope(payload: unknown) {
+ if (!payload || typeof payload !== "object") {
+ return null;
+ }
+
+ const record = payload as Record;
+ const job = record.job;
+ if (job && typeof job === "object") {
+ return job as Record;
+ }
+ return record;
+}
+
+function readStateHistory(value: unknown): ReportProducerContext["stateHistory"] {
+ if (!Array.isArray(value)) {
+ return [];
+ }
+
+ return value.flatMap((entry) => {
+ if (!entry || typeof entry !== "object") {
+ return [];
+ }
+
+ const record = entry as Record;
+ if (typeof record.state !== "string") {
+ return [];
+ }
+
+ return [{
+ state: record.state,
+ timestamp: typeof record.timestamp === "string" ? record.timestamp : null,
+ }];
+ });
+}
+
+async function fetchProducerContext(jobContext: ReturnType): Promise {
+ if (!jobContext?.jobId) {
+ return undefined;
+ }
+
+ try {
+ const response = await fetchWithTimeout(
+ new URL(`/api/v1/audit/jobs/${encodeURIComponent(jobContext.jobId)}`, backendBaseUrl()),
+ { cache: "no-store" },
+ { timeoutMs: DEFAULT_SERVER_FETCH_TIMEOUT_MS },
+ );
+ if (!response.ok) {
+ return undefined;
+ }
+
+ const job = readJobEnvelope(await response.json());
+ if (!job) {
+ return undefined;
+ }
+
+ return {
+ status: typeof job.status === "string" ? job.status : null,
+ updatedAt: typeof job.updated_at === "string" ? job.updated_at : null,
+ stdoutTail: typeof job.stdout_tail === "string" ? job.stdout_tail : null,
+ stderrTail: typeof job.stderr_tail === "string" ? job.stderr_tail : null,
+ stateHistory: readStateHistory(job.state_history),
+ };
+ } catch {
+ return undefined;
+ }
+}
+
function findHighlightedRows(rows: AttackDefenseRowViewModel[], jobContext: ReturnType) {
if (!jobContext) {
return [];
@@ -243,9 +303,11 @@ export async function renderTrackReportPage({
const rows = filterRows(table?.rows ?? [], track);
const highlightedRowKeys = findHighlightedRows(rows, jobContext);
const catalogEntries = catalog?.tracks.find((item) => item.track === track)?.entries ?? [];
- const provenance = await fetchTrackProvenance(pickPrimaryEntry(catalogEntries));
+ const [provenance, producerContext] = await Promise.all([
+ fetchTrackProvenance(pickPrimaryEntry(catalogEntries)),
+ fetchProducerContext(jobContext),
+ ]);
const label = trackLabel(resolvedLocale, track);
- const copy = WORKSPACE_COPY[resolvedLocale].reports;
const displayHref = `/workspace/reports/${track}?view=display`;
const auditHref = `/workspace/reports/${track}?view=audit`;
@@ -290,8 +352,9 @@ export async function renderTrackReportPage({
locale={resolvedLocale}
rows={rows}
provenance={provenance}
- historyPlaceholder={historyPlaceholder(resolvedLocale, label)}
+ historyPlaceholder={historyPlaceholder(resolvedLocale)}
jobContext={jobContext}
+ producerContext={producerContext}
highlightedRowKeys={highlightedRowKeys}
/>
) : (
diff --git a/docs/platform-roadmap.md b/docs/platform-roadmap.md
index 16c77ea..6f5f644 100644
--- a/docs/platform-roadmap.md
+++ b/docs/platform-roadmap.md
@@ -18,9 +18,9 @@ This roadmap tracks product-facing Platform work. It avoids private deployment d
| Docker images | Active | GHCR publishes web and API images with immutable `sha-` tags |
| Deployment traceability | Active | Gateway health exposes redacted build revision and snapshot status |
| Workspace observability | Active | Shell status drawer and Settings show data mode, snapshot state, and build revision |
-| Reports | Active | Evidence stack, provenance, track review links, charts, PDF and CSV export |
+| Reports | Active | Evidence stack, provenance, track review links, job-linked producer context, charts, PDF and CSV export |
| Demo mode | Active | Snapshot-backed demo data keeps the workspace reviewable offline |
-| Runtime response facade | Active | Audit job API responses are normalized through a public-safe redaction layer before rendering |
+| Runtime response facade | Active | Audit job API responses and report-side producer details are normalized through a public-safe redaction layer before rendering |
| Workspace controls | Active | Audit filters and create-audit wizard expose named controls, step state, and reduced-motion behavior |
| Public API docs | Active | Docs record snapshot-backed API contracts, optional evidence fields, and request-time fallback boundaries |
@@ -29,7 +29,7 @@ This roadmap tracks product-facing Platform work. It avoids private deployment d
| Priority | Track | Work |
| --- | --- | --- |
| P1 | Reports | Improve printable report pagination, table wrapping, and long-evidence layout |
-| P1 | Audit loop | Highlight completed job context inside matching report rows when admitted into the snapshot |
+| P1 | Audit loop | Improve report-side producer context layout for print/export and disconnected Runtime states |
| P2 | Deployment | Add optional image provenance verification helpers for GHCR and local archive deployments |
| P2 | Account | Polish account security state for linked providers, verified email, and password access |
| P2 | Accessibility | Add menu roles, chart text summaries, and stronger focus handling in shared primitives |