feat(task-018): add observability retry cancel and audit trail

This commit is contained in:
2026-05-22 01:23:07 +03:00
parent 318ae790ef
commit 95fec3ba26
26 changed files with 1984 additions and 275 deletions
@@ -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} />;
}
+18
View File
@@ -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;
}
+120 -32
View File
@@ -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 ?? []} />
+22 -5
View File
@@ -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");
+104 -110
View File
@@ -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);