feat(task-018): add observability retry cancel and audit trail
This commit is contained in:
@@ -4,11 +4,16 @@ type DynamicArticleDetailPageProps = {
|
||||
params: {
|
||||
articleId: string;
|
||||
};
|
||||
searchParams?: {
|
||||
role?: string;
|
||||
};
|
||||
};
|
||||
|
||||
export default async function DynamicArticleDetailPage({
|
||||
params,
|
||||
searchParams,
|
||||
}: DynamicArticleDetailPageProps) {
|
||||
const { articleId } = params;
|
||||
return <ArticleDetailPage articleId={articleId} />;
|
||||
const viewerRoleHint = searchParams?.role === "admin" ? "admin" : "editor";
|
||||
return <ArticleDetailPage articleId={articleId} viewerRoleHint={viewerRoleHint} />;
|
||||
}
|
||||
|
||||
@@ -344,6 +344,24 @@ main {
|
||||
padding: 10px 0;
|
||||
}
|
||||
|
||||
.timelineMeta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.timelineSource {
|
||||
border: 1px solid #d0d5dd;
|
||||
border-radius: 6px;
|
||||
min-height: 24px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0 8px;
|
||||
background: #ffffff;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.draftEditor {
|
||||
margin-top: 28px;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type {
|
||||
ScriptConfigVersionAuditEventSummary,
|
||||
ScriptConfigVersionCreateRequest,
|
||||
ScriptConfigVersionSummary,
|
||||
} from "@pipeline/shared";
|
||||
@@ -25,6 +26,16 @@ export type NewAdminScriptVersionDraft = {
|
||||
rollbackTargetVersionId: string;
|
||||
};
|
||||
|
||||
export type AdminScriptAuditRow = {
|
||||
id: string;
|
||||
eventType: string;
|
||||
version: number | null;
|
||||
createdAt: string;
|
||||
actorUserId: string | null;
|
||||
diff: string;
|
||||
rollbackTargetVersionId: string | null;
|
||||
};
|
||||
|
||||
export type DraftValidationErrors = Partial<Record<keyof NewAdminScriptVersionDraft, string>>;
|
||||
|
||||
const DEFAULT_DIFF = '{\n "summary": "manual publish pipeline change"\n}';
|
||||
@@ -95,3 +106,19 @@ export function parseJsonObject(value: string): Record<string, unknown> | null {
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function buildAdminScriptAuditRows(
|
||||
events: readonly ScriptConfigVersionAuditEventSummary[],
|
||||
): AdminScriptAuditRow[] {
|
||||
return events
|
||||
.map((event) => ({
|
||||
id: event.id,
|
||||
eventType: event.event_type,
|
||||
version: event.version ?? null,
|
||||
createdAt: event.created_at,
|
||||
actorUserId: event.actor_user_id ?? null,
|
||||
diff: JSON.stringify(event.diff ?? {}),
|
||||
rollbackTargetVersionId: event.rollback_target_version_id ?? null,
|
||||
}))
|
||||
.sort((left, right) => left.createdAt.localeCompare(right.createdAt));
|
||||
}
|
||||
|
||||
@@ -2,10 +2,11 @@
|
||||
|
||||
import { useMemo, useState, type FormEvent } from "react";
|
||||
|
||||
import type { ScriptConfigVersionSummary, TargetSiteConfig } from "@pipeline/shared";
|
||||
import type { ScriptConfigVersionAuditEventSummary, ScriptConfigVersionSummary, TargetSiteConfig } from "@pipeline/shared";
|
||||
|
||||
import { ApiError, DEMO_ADMIN_EMAIL, activateScriptConfigVersion, createScriptConfigVersion, fetchScriptConfigVersions, rollbackScriptConfigVersion } from "@/shared/pipeline-api";
|
||||
import { ApiError, DEMO_ADMIN_EMAIL, activateScriptConfigVersion, createScriptConfigVersion, fetchScriptConfigAuditEvents, fetchScriptConfigVersions, rollbackScriptConfigVersion } from "@/shared/pipeline-api";
|
||||
import {
|
||||
buildAdminScriptAuditRows,
|
||||
buildAdminScriptVersionRows,
|
||||
buildCreateScriptVersionPayload,
|
||||
defaultScriptVersionDraft,
|
||||
@@ -17,6 +18,7 @@ import {
|
||||
type AdminScriptVersionsPanelProps = {
|
||||
site: TargetSiteConfig;
|
||||
initialVersions: readonly ScriptConfigVersionSummary[];
|
||||
initialAuditEvents: readonly ScriptConfigVersionAuditEventSummary[];
|
||||
};
|
||||
|
||||
function parseOptionalError(value: string): string | null {
|
||||
@@ -29,10 +31,14 @@ function parseOptionalError(value: string): string | null {
|
||||
export function AdminScriptVersionsPanel({
|
||||
site,
|
||||
initialVersions,
|
||||
initialAuditEvents,
|
||||
}: AdminScriptVersionsPanelProps) {
|
||||
const [versions, setVersions] = useState<readonly ScriptConfigVersionSummary[]>(
|
||||
initialVersions,
|
||||
);
|
||||
const [auditEvents, setAuditEvents] = useState<readonly ScriptConfigVersionAuditEventSummary[]>(
|
||||
initialAuditEvents,
|
||||
);
|
||||
const [draft, setDraft] = useState<NewAdminScriptVersionDraft>(
|
||||
defaultScriptVersionDraft(),
|
||||
);
|
||||
@@ -46,17 +52,25 @@ export function AdminScriptVersionsPanel({
|
||||
buildAdminScriptVersionRows(versions),
|
||||
[versions],
|
||||
);
|
||||
const auditRows = useMemo(
|
||||
() => buildAdminScriptAuditRows(auditEvents),
|
||||
[auditEvents],
|
||||
);
|
||||
|
||||
const hasVersions = rows.length > 0;
|
||||
const canSubmit = !isSubmitting;
|
||||
const rollbackCandidates = rows.filter((row) => row.id !== site.active_script_config_version_id);
|
||||
|
||||
async function refreshVersions() {
|
||||
const latest = await fetchScriptConfigVersions(
|
||||
const [latest, audit] = await Promise.all([
|
||||
fetchScriptConfigVersions(
|
||||
site.id,
|
||||
DEMO_ADMIN_EMAIL,
|
||||
);
|
||||
),
|
||||
fetchScriptConfigAuditEvents(site.id, DEMO_ADMIN_EMAIL),
|
||||
]);
|
||||
setVersions(latest.versions);
|
||||
setAuditEvents(audit.events ?? []);
|
||||
}
|
||||
|
||||
function updateField<K extends keyof NewAdminScriptVersionDraft>(
|
||||
@@ -295,6 +309,36 @@ export function AdminScriptVersionsPanel({
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
|
||||
<h3>Audit trail</h3>
|
||||
{auditRows.length === 0 ? (
|
||||
<p className="emptyState">No audit events yet.</p>
|
||||
) : (
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Event</th>
|
||||
<th>Version</th>
|
||||
<th>Created</th>
|
||||
<th>Actor</th>
|
||||
<th>Diff</th>
|
||||
<th>Rollback target</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{auditRows.map((event) => (
|
||||
<tr key={event.id}>
|
||||
<td>{event.eventType}</td>
|
||||
<td>{event.version ?? "—"}</td>
|
||||
<td>{new Date(event.createdAt).toLocaleString()}</td>
|
||||
<td>{event.actorUserId ?? "—"}</td>
|
||||
<td>{event.diff}</td>
|
||||
<td>{event.rollbackTargetVersionId ?? "—"}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,42 @@
|
||||
import type { AgentJobSummary, ArticleDetailResponse, WorkflowEventSummary } from "@pipeline/shared";
|
||||
import type {
|
||||
AgentJobSummary,
|
||||
ArticleDetailResponse,
|
||||
ObservabilityTimelineEventSummary,
|
||||
Role,
|
||||
} from "@pipeline/shared";
|
||||
|
||||
export type ArticleTimelineItem = Pick<
|
||||
WorkflowEventSummary,
|
||||
"id" | "event_type" | "to_status" | "from_status" | "actor_user_id" | "created_at"
|
||||
> & {
|
||||
export type ArticleTimelineItem = {
|
||||
id: string;
|
||||
articleId: string;
|
||||
entryType: string;
|
||||
source: string;
|
||||
eventType: string;
|
||||
fromStatus: string | null;
|
||||
toStatus: string | null;
|
||||
actorUserId: string | null;
|
||||
jobId: string | null;
|
||||
jobType: string | null;
|
||||
jobStatus: string | null;
|
||||
retryEligible: boolean;
|
||||
cancelEligible: boolean;
|
||||
safeFailureSummary: string | null;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
export type JobDetailRow = {
|
||||
jobId: string;
|
||||
jobType: string;
|
||||
status: string;
|
||||
attempt: number;
|
||||
errorCategory: string | null;
|
||||
errorMessage: string | null;
|
||||
lastSuccessfulStep: string | null;
|
||||
retryEligible: boolean;
|
||||
retryBlockReason: string | null;
|
||||
cancelEligible: boolean;
|
||||
safeFailureSummary: string | null;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
};
|
||||
|
||||
export type DetailSummary = {
|
||||
@@ -15,35 +47,53 @@ export type DetailSummary = {
|
||||
briefDescription: string;
|
||||
targetSite: string;
|
||||
updatedAt: string;
|
||||
viewerRole: Role;
|
||||
viewerEmail: string;
|
||||
timeline: ArticleTimelineItem[];
|
||||
productionArtifacts: ProductionArtifactRow[];
|
||||
jobDetails: JobDetailRow[];
|
||||
};
|
||||
|
||||
export type ProductionArtifactRow = {
|
||||
jobId: string;
|
||||
artifactKey: string;
|
||||
artifactLabel: string;
|
||||
status: string;
|
||||
attempt: number;
|
||||
jobType: string;
|
||||
usedEvidenceIds: string[];
|
||||
unsupportedClaims: string[];
|
||||
errorMessage: string | null;
|
||||
export type JobControlState = {
|
||||
showRetry: boolean;
|
||||
showCancel: boolean;
|
||||
showAdminLogs: boolean;
|
||||
showSafeSummary: boolean;
|
||||
};
|
||||
|
||||
export function buildArticleTimeline(detail: ArticleDetailResponse): ArticleTimelineItem[] {
|
||||
return (detail.workflow_events ?? []).map((event) => ({
|
||||
id: event.id,
|
||||
event_type: event.event_type,
|
||||
to_status: event.to_status,
|
||||
from_status: event.from_status,
|
||||
actor_user_id: event.actor_user_id,
|
||||
created_at: event.created_at,
|
||||
articleId: event.article_id,
|
||||
}));
|
||||
const timeline = detail.timeline ?? [];
|
||||
if (timeline.length > 0) {
|
||||
return timeline
|
||||
.map((entry) => normalizeTimelineEntry(entry))
|
||||
.sort((left, right) => left.createdAt.localeCompare(right.createdAt));
|
||||
}
|
||||
|
||||
return (detail.workflow_events ?? [])
|
||||
.map((event) => ({
|
||||
id: event.id,
|
||||
articleId: event.article_id,
|
||||
entryType: "WORKFLOW_EVENT",
|
||||
source: event.actor_user_id ? "USER" : "SYSTEM",
|
||||
eventType: event.event_type,
|
||||
fromStatus: event.from_status ?? null,
|
||||
toStatus: event.to_status ?? null,
|
||||
actorUserId: event.actor_user_id ?? null,
|
||||
jobId: null,
|
||||
jobType: null,
|
||||
jobStatus: null,
|
||||
retryEligible: false,
|
||||
cancelEligible: false,
|
||||
safeFailureSummary: null,
|
||||
createdAt: event.created_at,
|
||||
}))
|
||||
.sort((left, right) => left.createdAt.localeCompare(right.createdAt));
|
||||
}
|
||||
|
||||
export function buildDetailSummary(detail: ArticleDetailResponse): DetailSummary {
|
||||
export function buildDetailSummary(
|
||||
detail: ArticleDetailResponse,
|
||||
options: { viewerRole: Role; viewerEmail: string },
|
||||
): DetailSummary {
|
||||
const { viewerRole, viewerEmail } = options;
|
||||
return {
|
||||
articleId: detail.article.id,
|
||||
status: detail.article.status,
|
||||
@@ -52,8 +102,47 @@ export function buildDetailSummary(detail: ArticleDetailResponse): DetailSummary
|
||||
briefDescription: detail.article.brief_description,
|
||||
targetSite: detail.target_site?.name ?? "Unknown",
|
||||
updatedAt: detail.article.updated_at,
|
||||
viewerRole,
|
||||
viewerEmail,
|
||||
timeline: buildArticleTimeline(detail),
|
||||
productionArtifacts: buildProductionArtifactRows(detail.agent_jobs ?? []),
|
||||
jobDetails: buildJobDetailRows(detail.agent_jobs ?? []),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildJobDetailRows(jobs: readonly AgentJobSummary[]): JobDetailRow[] {
|
||||
return jobs
|
||||
.map((job) => {
|
||||
const payload = (job.payload ?? {}) as Record<string, unknown>;
|
||||
const lastSuccessfulStep = stringValue(payload.last_successful_step)
|
||||
|| stringValue(payload.artifact_label)
|
||||
|| stringValue(payload.heading)
|
||||
|| null;
|
||||
return {
|
||||
jobId: job.id,
|
||||
jobType: job.job_type,
|
||||
status: job.status,
|
||||
attempt: job.attempt ?? 1,
|
||||
errorCategory: job.error_category ?? null,
|
||||
errorMessage: job.error_message ?? null,
|
||||
lastSuccessfulStep,
|
||||
retryEligible: Boolean(job.retry_eligible),
|
||||
retryBlockReason: job.retry_block_reason ?? null,
|
||||
cancelEligible: Boolean(job.cancel_eligible),
|
||||
safeFailureSummary: job.safe_failure_summary ?? null,
|
||||
stdout: job.stdout ?? "",
|
||||
stderr: job.stderr ?? "",
|
||||
};
|
||||
})
|
||||
.sort((left, right) => left.jobType.localeCompare(right.jobType));
|
||||
}
|
||||
|
||||
export function buildJobControlState(job: JobDetailRow, viewerRole: Role): JobControlState {
|
||||
const isAdmin = viewerRole === "ADMIN";
|
||||
return {
|
||||
showRetry: isAdmin && job.retryEligible,
|
||||
showCancel: isAdmin && job.cancelEligible,
|
||||
showAdminLogs: isAdmin,
|
||||
showSafeSummary: !isAdmin,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -71,60 +160,26 @@ function resolvePublishingValidationLabel(detail: ArticleDetailResponse): string
|
||||
return "Best-effort content-shape validation only.";
|
||||
}
|
||||
|
||||
export function buildProductionArtifactRows(
|
||||
jobs: readonly AgentJobSummary[],
|
||||
): ProductionArtifactRow[] {
|
||||
return jobs
|
||||
.filter((job) => job.job_type === "SECTION_SCAFFOLD")
|
||||
.map((job) => {
|
||||
const payload = (job.payload ?? {}) as Record<string, unknown>;
|
||||
const artifactKey = stringValue(payload.artifact_key) || `job:${job.id}`;
|
||||
const artifactLabel =
|
||||
stringValue(payload.artifact_label)
|
||||
|| stringValue(payload.heading)
|
||||
|| artifactKey;
|
||||
return {
|
||||
jobId: job.id,
|
||||
artifactKey,
|
||||
artifactLabel,
|
||||
status: job.status,
|
||||
attempt: job.attempt ?? 1,
|
||||
jobType: job.job_type,
|
||||
usedEvidenceIds: stringArray(payload.used_evidence_ids),
|
||||
unsupportedClaims: unsupportedClaims(payload.unsupported_claims),
|
||||
errorMessage: job.error_message ?? null,
|
||||
};
|
||||
})
|
||||
.sort((left, right) => left.artifactLabel.localeCompare(right.artifactLabel));
|
||||
function normalizeTimelineEntry(entry: ObservabilityTimelineEventSummary): ArticleTimelineItem {
|
||||
return {
|
||||
id: entry.id,
|
||||
articleId: entry.article_id,
|
||||
entryType: entry.entry_type,
|
||||
source: entry.source,
|
||||
eventType: entry.event_type,
|
||||
fromStatus: entry.from_status ?? null,
|
||||
toStatus: entry.to_status ?? null,
|
||||
actorUserId: entry.actor_user_id ?? null,
|
||||
jobId: entry.job_id ?? null,
|
||||
jobType: entry.job_type ?? null,
|
||||
jobStatus: entry.job_status ?? null,
|
||||
retryEligible: Boolean(entry.retry_eligible),
|
||||
cancelEligible: Boolean(entry.cancel_eligible),
|
||||
safeFailureSummary: entry.safe_failure_summary ?? null,
|
||||
createdAt: entry.created_at,
|
||||
};
|
||||
}
|
||||
|
||||
function stringValue(value: unknown): string {
|
||||
return typeof value === "string" ? value : "";
|
||||
}
|
||||
|
||||
function stringArray(value: unknown): string[] {
|
||||
if (!Array.isArray(value)) {
|
||||
return [];
|
||||
}
|
||||
return value.filter((item): item is string => typeof item === "string");
|
||||
}
|
||||
|
||||
function unsupportedClaims(value: unknown): string[] {
|
||||
if (!Array.isArray(value)) {
|
||||
return [];
|
||||
}
|
||||
const claims: string[] = [];
|
||||
for (const item of value) {
|
||||
if (typeof item === "string") {
|
||||
claims.push(item);
|
||||
continue;
|
||||
}
|
||||
if (typeof item === "object" && item !== null && "claim_text" in item) {
|
||||
const claimText = item.claim_text;
|
||||
if (typeof claimText === "string") {
|
||||
claims.push(claimText);
|
||||
}
|
||||
}
|
||||
}
|
||||
return claims;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import Link from "next/link";
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
|
||||
import { ApiError, cancelAgentJob, retryAgentJob } from "@/shared/pipeline-api";
|
||||
import { buildJobControlState } from "./model";
|
||||
import type { DetailSummary } from "./model";
|
||||
|
||||
type DetailShellProps = {
|
||||
@@ -7,8 +13,46 @@ type DetailShellProps = {
|
||||
};
|
||||
|
||||
export function ArticleDetailShell({ summary }: DetailShellProps) {
|
||||
const router = useRouter();
|
||||
const [actionError, setActionError] = useState("");
|
||||
const [busyJobId, setBusyJobId] = useState("");
|
||||
const timeline = summary.timeline;
|
||||
const productionArtifacts = summary.productionArtifacts;
|
||||
const jobDetails = summary.jobDetails;
|
||||
const isAdmin = summary.viewerRole === "ADMIN";
|
||||
|
||||
async function handleRetry(jobId: string) {
|
||||
setActionError("");
|
||||
setBusyJobId(jobId);
|
||||
try {
|
||||
await retryAgentJob(jobId, summary.viewerEmail);
|
||||
router.refresh();
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError) {
|
||||
setActionError(error.message || "Unable to retry job.");
|
||||
} else {
|
||||
setActionError("Unable to retry job.");
|
||||
}
|
||||
} finally {
|
||||
setBusyJobId("");
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCancel(jobId: string) {
|
||||
setActionError("");
|
||||
setBusyJobId(jobId);
|
||||
try {
|
||||
await cancelAgentJob(jobId, summary.viewerEmail);
|
||||
router.refresh();
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError) {
|
||||
setActionError(error.message || "Unable to cancel job.");
|
||||
} else {
|
||||
setActionError("Unable to cancel job.");
|
||||
}
|
||||
} finally {
|
||||
setBusyJobId("");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section>
|
||||
@@ -64,49 +108,93 @@ export function ArticleDetailShell({ summary }: DetailShellProps) {
|
||||
<ul className="timeline">
|
||||
{timeline.map((event) => (
|
||||
<li key={event.id}>
|
||||
<strong>{event.event_type}</strong> from {event.from_status ?? "—"} to{" "}
|
||||
{event.to_status ?? "—"} at {new Date(event.created_at).toLocaleString()}
|
||||
{event.actor_user_id ? <span> • actor {event.actor_user_id}</span> : null}
|
||||
<div className="timelineMeta">
|
||||
<strong>{event.eventType}</strong>
|
||||
<span className="timelineSource">{event.source}</span>
|
||||
<span>{new Date(event.createdAt).toLocaleString()}</span>
|
||||
</div>
|
||||
<div>
|
||||
from {event.fromStatus ?? "—"} to {event.toStatus ?? "—"}
|
||||
{event.actorUserId ? <span> • actor {event.actorUserId}</span> : null}
|
||||
{event.jobType ? <span> • {event.jobType}</span> : null}
|
||||
{event.jobStatus ? <span> • {event.jobStatus}</span> : null}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
{timeline.length === 0 ? <li>No workflow events yet.</li> : null}
|
||||
</ul>
|
||||
|
||||
<h3>Production artifacts</h3>
|
||||
<h3>Job failures and controls</h3>
|
||||
{actionError ? <p className="formError">Error: {actionError}</p> : null}
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Artifact</th>
|
||||
<th>Job type</th>
|
||||
<th>Status</th>
|
||||
<th>Attempt</th>
|
||||
<th>Used evidence IDs</th>
|
||||
<th>Unsupported claims</th>
|
||||
<th>Error category</th>
|
||||
<th>Error message</th>
|
||||
<th>Last successful step</th>
|
||||
<th>Controls</th>
|
||||
<th>{isAdmin ? "Logs" : "Safe summary"}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{productionArtifacts.map((artifact) => (
|
||||
<tr key={artifact.jobId}>
|
||||
<td>
|
||||
<div>{artifact.artifactLabel}</div>
|
||||
<div>{artifact.artifactKey}</div>
|
||||
</td>
|
||||
<td>{artifact.status}</td>
|
||||
<td>{artifact.attempt}</td>
|
||||
<td>
|
||||
{artifact.usedEvidenceIds.length
|
||||
? artifact.usedEvidenceIds.join(", ")
|
||||
: "—"}
|
||||
</td>
|
||||
<td>
|
||||
{artifact.unsupportedClaims.length
|
||||
? artifact.unsupportedClaims.join("; ")
|
||||
: artifact.errorMessage ?? "—"}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{productionArtifacts.length === 0 ? (
|
||||
{jobDetails.map((job) => {
|
||||
const isBusy = busyJobId === job.jobId;
|
||||
const controls = buildJobControlState(job, summary.viewerRole);
|
||||
return (
|
||||
<tr key={job.jobId}>
|
||||
<td>
|
||||
<div>{job.jobType}</div>
|
||||
<div>attempt {job.attempt}</div>
|
||||
</td>
|
||||
<td>{job.status}</td>
|
||||
<td>{job.errorCategory ?? "—"}</td>
|
||||
<td>
|
||||
{job.errorMessage ?? "—"}
|
||||
{!job.errorMessage && job.retryBlockReason ? (
|
||||
<div>{job.retryBlockReason}</div>
|
||||
) : null}
|
||||
</td>
|
||||
<td>{job.lastSuccessfulStep ?? "—"}</td>
|
||||
<td>
|
||||
{controls.showRetry ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRetry(job.jobId)}
|
||||
disabled={isBusy}
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
) : null}
|
||||
{controls.showCancel ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleCancel(job.jobId)}
|
||||
disabled={isBusy}
|
||||
className="tableAction"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
) : null}
|
||||
{!controls.showRetry && !controls.showCancel ? "—" : null}
|
||||
</td>
|
||||
<td>
|
||||
{controls.showAdminLogs ? (
|
||||
<>
|
||||
<div>{job.stdout || "stdout: —"}</div>
|
||||
<div>{job.stderr || "stderr: —"}</div>
|
||||
</>
|
||||
) : (
|
||||
job.safeFailureSummary ?? "—"
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
{jobDetails.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={5}>No production artifacts yet.</td>
|
||||
<td colSpan={7}>No agent jobs yet.</td>
|
||||
</tr>
|
||||
) : null}
|
||||
</tbody>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import Link from "next/link";
|
||||
|
||||
import { DEMO_ADMIN_EMAIL, fetchScriptConfigVersions, fetchTargetSitesAsUser, fetchTargetSite } from "@/shared/pipeline-api";
|
||||
import { DEMO_ADMIN_EMAIL, fetchScriptConfigAuditEvents, fetchScriptConfigVersions, fetchTargetSitesAsUser, fetchTargetSite } from "@/shared/pipeline-api";
|
||||
import { RoleNavigation } from "@/widgets/role-navigation";
|
||||
import { AdminScriptVersionsPanel } from "@/features/admin-scripts/ui";
|
||||
|
||||
@@ -40,10 +40,13 @@ export default async function AdminScriptsPage({
|
||||
|
||||
const selectedResponse = await fetchTargetSite(selectedSite.id, DEMO_ADMIN_EMAIL);
|
||||
const activeSite = selectedResponse.site;
|
||||
const versionsResponse = await fetchScriptConfigVersions(
|
||||
selectedSite.id,
|
||||
DEMO_ADMIN_EMAIL,
|
||||
);
|
||||
const [versionsResponse, auditResponse] = await Promise.all([
|
||||
fetchScriptConfigVersions(
|
||||
selectedSite.id,
|
||||
DEMO_ADMIN_EMAIL,
|
||||
),
|
||||
fetchScriptConfigAuditEvents(selectedSite.id, DEMO_ADMIN_EMAIL),
|
||||
]);
|
||||
|
||||
return (
|
||||
<main>
|
||||
@@ -77,6 +80,7 @@ export default async function AdminScriptsPage({
|
||||
<AdminScriptVersionsPanel
|
||||
site={activeSite}
|
||||
initialVersions={versionsResponse.versions}
|
||||
initialAuditEvents={auditResponse.events ?? []}
|
||||
/>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
@@ -9,22 +9,30 @@ import { DraftEditorPanel } from "@/features/article-draft/ui";
|
||||
import { ArticleReviewPanel } from "@/features/article-review/ui";
|
||||
import { MediaLibraryPanel } from "@/features/article-assets/ui";
|
||||
import { FinalApprovalPanel } from "@/features/final-approval/ui";
|
||||
import { fetchArticleDetail, fetchDrafts } from "@/shared/pipeline-api";
|
||||
import { DEMO_ADMIN_EMAIL, DEMO_EDITOR_EMAIL, fetchArticleDetail, fetchCurrentUser, fetchDrafts } from "@/shared/pipeline-api";
|
||||
import { ApiError } from "@/shared/pipeline-api";
|
||||
|
||||
type ArticleDetailPageProps = {
|
||||
articleId: string;
|
||||
viewerRoleHint?: "admin" | "editor";
|
||||
};
|
||||
|
||||
export default async function ArticleDetailPage({
|
||||
articleId,
|
||||
viewerRoleHint,
|
||||
}: ArticleDetailPageProps) {
|
||||
const selectedEmail = viewerRoleHint === "admin" ? DEMO_ADMIN_EMAIL : DEMO_EDITOR_EMAIL;
|
||||
try {
|
||||
const [detail, draftsResponse] = await Promise.all([
|
||||
fetchArticleDetail(articleId),
|
||||
fetchDrafts(articleId),
|
||||
const [currentUserResponse, detail, draftsResponse] = await Promise.all([
|
||||
fetchCurrentUser(selectedEmail),
|
||||
fetchArticleDetail(articleId, selectedEmail),
|
||||
fetchDrafts(articleId, selectedEmail),
|
||||
]);
|
||||
const summary = buildDetailSummary(detail);
|
||||
const viewer = currentUserResponse.user;
|
||||
const summary = buildDetailSummary(detail, {
|
||||
viewerRole: viewer.role,
|
||||
viewerEmail: selectedEmail,
|
||||
});
|
||||
|
||||
return (
|
||||
<main>
|
||||
@@ -35,7 +43,7 @@ export default async function ArticleDetailPage({
|
||||
</div>
|
||||
<Link href="/">Back to dashboard</Link>
|
||||
</header>
|
||||
<RoleNavigation role="EDITOR" />
|
||||
<RoleNavigation role={summary.viewerRole} />
|
||||
<section className="panel">
|
||||
<ArticleDetailShell summary={summary} />
|
||||
<MediaLibraryPanel assets={detail.assets ?? []} />
|
||||
|
||||
@@ -43,6 +43,7 @@ import type {
|
||||
SeoReviewReportResponse,
|
||||
SeoReviewRunResponse,
|
||||
ScriptConfigVersionCreateRequest,
|
||||
ScriptConfigVersionAuditEventListResponse,
|
||||
ScriptConfigVersionListResponse,
|
||||
ScriptConfigVersionResponse,
|
||||
TargetSiteConfigResponse,
|
||||
@@ -183,8 +184,10 @@ export function apiPatch<TRequest, TResponse>(
|
||||
);
|
||||
}
|
||||
|
||||
export function fetchCurrentUser(): Promise<CurrentUserResponse> {
|
||||
return apiGet<CurrentUserResponse>("/api/me");
|
||||
export function fetchCurrentUser(
|
||||
userEmail = DEMO_EDITOR_EMAIL,
|
||||
): Promise<CurrentUserResponse> {
|
||||
return apiGet<CurrentUserResponse>("/api/me", { userEmail });
|
||||
}
|
||||
|
||||
export function fetchAgentJobs(
|
||||
@@ -266,6 +269,16 @@ export function fetchScriptConfigVersions(
|
||||
);
|
||||
}
|
||||
|
||||
export function fetchScriptConfigAuditEvents(
|
||||
siteId: string,
|
||||
userEmail = DEMO_EDITOR_EMAIL,
|
||||
): Promise<ScriptConfigVersionAuditEventListResponse> {
|
||||
return apiGet<ScriptConfigVersionAuditEventListResponse>(
|
||||
`/api/sites/${siteId}/publishing-config/audit`,
|
||||
{ userEmail },
|
||||
);
|
||||
}
|
||||
|
||||
export function createScriptConfigVersion(
|
||||
siteId: string,
|
||||
request: ScriptConfigVersionCreateRequest,
|
||||
@@ -308,8 +321,9 @@ export function fetchArticles(): Promise<ArticleListResponse> {
|
||||
|
||||
export function fetchArticleDetail(
|
||||
articleId: string,
|
||||
userEmail = DEMO_EDITOR_EMAIL,
|
||||
): Promise<ArticleDetailResponse> {
|
||||
return apiGet<ArticleDetailResponse>(`/api/articles/${articleId}`);
|
||||
return apiGet<ArticleDetailResponse>(`/api/articles/${articleId}`, { userEmail });
|
||||
}
|
||||
|
||||
export function createArticle(
|
||||
@@ -455,8 +469,11 @@ export function assembleDraft(articleId: string): Promise<DraftAssembleResponse>
|
||||
);
|
||||
}
|
||||
|
||||
export function fetchDrafts(articleId: string): Promise<DraftListResponse> {
|
||||
return apiGet<DraftListResponse>(`/api/articles/${articleId}/drafts`);
|
||||
export function fetchDrafts(
|
||||
articleId: string,
|
||||
userEmail = DEMO_EDITOR_EMAIL,
|
||||
): Promise<DraftListResponse> {
|
||||
return apiGet<DraftListResponse>(`/api/articles/${articleId}/drafts`, { userEmail });
|
||||
}
|
||||
|
||||
export function fetchDraft(
|
||||
|
||||
@@ -20,6 +20,7 @@ new Function("exports", compiled.outputText)(moduleExports);
|
||||
|
||||
const {
|
||||
buildAdminScriptVersionRows,
|
||||
buildAdminScriptAuditRows,
|
||||
defaultScriptVersionDraft,
|
||||
buildCreateScriptVersionPayload,
|
||||
parseJsonObject,
|
||||
@@ -53,3 +54,21 @@ assert.equal(payload.rollback_target_version_id, null);
|
||||
assert.deepEqual(parseJsonObject('{"a":1}'), { a: 1 });
|
||||
assert.equal(parseJsonObject("[]"), null);
|
||||
assert.deepEqual(parseJsonObject(""), {});
|
||||
|
||||
const auditRows = buildAdminScriptAuditRows([
|
||||
{
|
||||
id: "evt-1",
|
||||
target_site_id: "site-1",
|
||||
version_id: "ver-1",
|
||||
event_type: "SCRIPT_CONFIG_VERSION_ROLLBACK",
|
||||
actor_user_id: "user-1",
|
||||
payload: { action: "rollback" },
|
||||
created_at: "2026-05-21T12:00:00Z",
|
||||
version: 2,
|
||||
diff: { summary: "rollback event" },
|
||||
rollback_target_version_id: "ver-0",
|
||||
},
|
||||
]);
|
||||
assert.equal(auditRows.length, 1);
|
||||
assert.equal(auditRows[0].eventType, "SCRIPT_CONFIG_VERSION_ROLLBACK");
|
||||
assert.equal(auditRows[0].rollbackTargetVersionId, "ver-0");
|
||||
|
||||
@@ -15,100 +15,14 @@ const compiled = ts.transpileModule(source, {
|
||||
const moduleExports = {};
|
||||
new Function("exports", compiled.outputText)(moduleExports);
|
||||
|
||||
const { buildDetailSummary, buildProductionArtifactRows } = moduleExports;
|
||||
const { buildArticleTimeline, buildJobDetailRows, buildDetailSummary, buildJobControlState } = moduleExports;
|
||||
|
||||
const rows = buildProductionArtifactRows([
|
||||
{
|
||||
id: "job-section-1",
|
||||
article_id: "a1",
|
||||
parent_job_id: null,
|
||||
attempt: 1,
|
||||
job_type: "SECTION_SCAFFOLD",
|
||||
agent_profile: "fake-section-scaffold",
|
||||
status: "SUCCEEDED",
|
||||
workspace_path: null,
|
||||
input_files: [],
|
||||
output_files: [],
|
||||
payload: {
|
||||
artifact_key: "section:1",
|
||||
artifact_label: "Context and scope",
|
||||
used_evidence_ids: ["e1", "e2"],
|
||||
unsupported_claims: [],
|
||||
},
|
||||
error_category: null,
|
||||
error_message: null,
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
exit_code: 0,
|
||||
duration_ms: 3,
|
||||
queued_at: "2026-05-21T00:00:00Z",
|
||||
started_at: "2026-05-21T00:00:01Z",
|
||||
finished_at: "2026-05-21T00:00:02Z",
|
||||
},
|
||||
{
|
||||
id: "job-section-2",
|
||||
article_id: "a1",
|
||||
parent_job_id: null,
|
||||
attempt: 1,
|
||||
job_type: "SECTION_SCAFFOLD",
|
||||
agent_profile: "fake-section-scaffold",
|
||||
status: "FAILED",
|
||||
workspace_path: null,
|
||||
input_files: [],
|
||||
output_files: [],
|
||||
payload: {
|
||||
artifact_key: "section:2",
|
||||
artifact_label: "Implementation workflow",
|
||||
used_evidence_ids: ["e2"],
|
||||
unsupported_claims: [{ claim_text: "Unverified claim" }],
|
||||
},
|
||||
error_category: "UNSUPPORTED_CLAIMS_FOUND",
|
||||
error_message: "Unsupported claims introduced during scaffolding: 1",
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
exit_code: 0,
|
||||
duration_ms: 3,
|
||||
queued_at: "2026-05-21T00:00:00Z",
|
||||
started_at: "2026-05-21T00:00:01Z",
|
||||
finished_at: "2026-05-21T00:00:02Z",
|
||||
},
|
||||
{
|
||||
id: "job-non-artifact",
|
||||
article_id: "a1",
|
||||
parent_job_id: null,
|
||||
attempt: 1,
|
||||
job_type: "TEST_CODEX",
|
||||
agent_profile: "fake-codex",
|
||||
status: "SUCCEEDED",
|
||||
workspace_path: null,
|
||||
input_files: [],
|
||||
output_files: [],
|
||||
payload: {},
|
||||
error_category: null,
|
||||
error_message: null,
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
exit_code: 0,
|
||||
duration_ms: 0,
|
||||
queued_at: "2026-05-21T00:00:00Z",
|
||||
started_at: "2026-05-21T00:00:01Z",
|
||||
finished_at: "2026-05-21T00:00:02Z",
|
||||
},
|
||||
]);
|
||||
|
||||
assert.equal(rows.length, 2);
|
||||
assert.equal(rows[0].artifactLabel, "Context and scope");
|
||||
assert.equal(rows[0].status, "SUCCEEDED");
|
||||
assert.deepEqual(rows[0].usedEvidenceIds, ["e1", "e2"]);
|
||||
assert.equal(rows[1].status, "FAILED");
|
||||
assert.deepEqual(rows[1].unsupportedClaims, ["Unverified claim"]);
|
||||
|
||||
const detailSummary = buildDetailSummary({
|
||||
const detail = {
|
||||
article: {
|
||||
id: "a1",
|
||||
target_site_id: "site1",
|
||||
status: "PUBLISH_COMMIT_CREATED",
|
||||
publishing_status: "PUBLISH_COMMIT_CREATED",
|
||||
status: "PARALLEL_PRODUCTION_RUNNING",
|
||||
publishing_status: "PUBLISH_NOT_STARTED",
|
||||
brief_description: "desc",
|
||||
language: "en",
|
||||
content_type: "longform_guide",
|
||||
@@ -116,26 +30,106 @@ const detailSummary = buildDetailSummary({
|
||||
updated_at: "2026-05-21T00:00:00Z",
|
||||
},
|
||||
target_site: { name: "Demo Site" },
|
||||
workflow_events: [],
|
||||
agent_jobs: [],
|
||||
publish_commit: {
|
||||
id: "pc1",
|
||||
article_id: "a1",
|
||||
target_site_id: "site1",
|
||||
repository_url: "/tmp/repo.git",
|
||||
branch: "main",
|
||||
commit_sha: "abc123",
|
||||
status: "PUBLISH_COMMIT_CREATED",
|
||||
created_at: "2026-05-21T00:00:00Z",
|
||||
content_bundle_manifest: {
|
||||
validation: {
|
||||
label: "Best-effort content-shape validation only.",
|
||||
},
|
||||
timeline: [
|
||||
{
|
||||
id: "2",
|
||||
article_id: "a1",
|
||||
entry_type: "AGENT_JOB",
|
||||
source: "AGENT",
|
||||
event_type: "SECTION_SCAFFOLD_FAILED",
|
||||
from_status: null,
|
||||
to_status: null,
|
||||
actor_user_id: null,
|
||||
job_id: "job2",
|
||||
job_type: "SECTION_SCAFFOLD",
|
||||
job_status: "FAILED",
|
||||
retry_eligible: true,
|
||||
cancel_eligible: false,
|
||||
safe_failure_summary: "Section failed.",
|
||||
payload: {},
|
||||
created_at: "2026-05-21T00:00:02Z",
|
||||
},
|
||||
},
|
||||
});
|
||||
{
|
||||
id: "1",
|
||||
article_id: "a1",
|
||||
entry_type: "WORKFLOW_EVENT",
|
||||
source: "USER",
|
||||
event_type: "PLAN_APPROVED",
|
||||
from_status: "PLAN_REVIEW_REQUIRED",
|
||||
to_status: "RESEARCH_RUNNING",
|
||||
actor_user_id: "u1",
|
||||
job_id: null,
|
||||
job_type: null,
|
||||
job_status: null,
|
||||
retry_eligible: false,
|
||||
cancel_eligible: false,
|
||||
safe_failure_summary: null,
|
||||
payload: {},
|
||||
created_at: "2026-05-21T00:00:01Z",
|
||||
},
|
||||
],
|
||||
workflow_events: [],
|
||||
agent_jobs: [
|
||||
{
|
||||
id: "job2",
|
||||
article_id: "a1",
|
||||
parent_job_id: null,
|
||||
attempt: 1,
|
||||
job_type: "SECTION_SCAFFOLD",
|
||||
agent_profile: "fake-section-scaffold",
|
||||
status: "FAILED",
|
||||
workspace_path: null,
|
||||
input_files: [],
|
||||
output_files: [],
|
||||
payload: { last_successful_step: "Outline written" },
|
||||
error_category: "UNSUPPORTED_CLAIMS_FOUND",
|
||||
error_message: "Unsupported claims introduced during scaffolding: 1",
|
||||
stdout: "token=[REDACTED]",
|
||||
stderr: "",
|
||||
exit_code: 1,
|
||||
duration_ms: 5,
|
||||
queued_at: "2026-05-21T00:00:00Z",
|
||||
started_at: "2026-05-21T00:00:01Z",
|
||||
finished_at: "2026-05-21T00:00:02Z",
|
||||
retry_eligible: true,
|
||||
retry_block_reason: null,
|
||||
cancel_eligible: false,
|
||||
safe_failure_summary: "Section failed safely.",
|
||||
},
|
||||
],
|
||||
publish_commit: null,
|
||||
};
|
||||
|
||||
assert.equal(
|
||||
detailSummary.publishingValidationLabel,
|
||||
"Best-effort content-shape validation only.",
|
||||
const timeline = buildArticleTimeline(detail);
|
||||
assert.equal(timeline.length, 2);
|
||||
assert.equal(timeline[0].eventType, "PLAN_APPROVED");
|
||||
assert.equal(timeline[1].eventType, "SECTION_SCAFFOLD_FAILED");
|
||||
assert.equal(timeline[1].source, "AGENT");
|
||||
assert.equal(timeline[1].retryEligible, true);
|
||||
|
||||
const jobRows = buildJobDetailRows(detail.agent_jobs);
|
||||
assert.equal(jobRows.length, 1);
|
||||
assert.equal(jobRows[0].jobType, "SECTION_SCAFFOLD");
|
||||
assert.equal(jobRows[0].status, "FAILED");
|
||||
assert.equal(jobRows[0].errorCategory, "UNSUPPORTED_CLAIMS_FOUND");
|
||||
assert.equal(jobRows[0].errorMessage, "Unsupported claims introduced during scaffolding: 1");
|
||||
assert.equal(jobRows[0].lastSuccessfulStep, "Outline written");
|
||||
assert.equal(jobRows[0].retryEligible, true);
|
||||
assert.deepEqual(
|
||||
buildJobControlState(jobRows[0], "ADMIN"),
|
||||
{ showRetry: true, showCancel: false, showAdminLogs: true, showSafeSummary: false },
|
||||
);
|
||||
assert.deepEqual(
|
||||
buildJobControlState(jobRows[0], "EDITOR"),
|
||||
{ showRetry: false, showCancel: false, showAdminLogs: false, showSafeSummary: true },
|
||||
);
|
||||
|
||||
const summary = buildDetailSummary(detail, {
|
||||
viewerRole: "EDITOR",
|
||||
viewerEmail: "editor@example.com",
|
||||
});
|
||||
assert.equal(summary.viewerRole, "EDITOR");
|
||||
assert.equal(summary.viewerEmail, "editor@example.com");
|
||||
assert.equal(summary.targetSite, "Demo Site");
|
||||
assert.equal(summary.timeline.length, 2);
|
||||
assert.equal(summary.jobDetails.length, 1);
|
||||
|
||||
Reference in New Issue
Block a user