feat(task-012): orchestrate parallel section production jobs

This commit is contained in:
2026-05-21 23:10:34 +03:00
parent 7e1b05067c
commit 8ebb5ad623
15 changed files with 756 additions and 39 deletions
@@ -1,4 +1,4 @@
import type { ArticleDetailResponse, WorkflowEventSummary } from "@pipeline/shared";
import type { AgentJobSummary, ArticleDetailResponse, WorkflowEventSummary } from "@pipeline/shared";
export type ArticleTimelineItem = Pick<
WorkflowEventSummary,
@@ -15,6 +15,19 @@ export type DetailSummary = {
targetSite: string;
updatedAt: string;
timeline: ArticleTimelineItem[];
productionArtifacts: ProductionArtifactRow[];
};
export type ProductionArtifactRow = {
jobId: string;
artifactKey: string;
artifactLabel: string;
status: string;
attempt: number;
jobType: string;
usedEvidenceIds: string[];
unsupportedClaims: string[];
errorMessage: string | null;
};
export function buildArticleTimeline(detail: ArticleDetailResponse): ArticleTimelineItem[] {
@@ -38,5 +51,64 @@ export function buildDetailSummary(detail: ArticleDetailResponse): DetailSummary
targetSite: detail.target_site?.name ?? "Unknown",
updatedAt: detail.article.updated_at,
timeline: buildArticleTimeline(detail),
productionArtifacts: buildProductionArtifactRows(detail.agent_jobs ?? []),
};
}
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 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;
}
@@ -8,6 +8,7 @@ type DetailShellProps = {
export function ArticleDetailShell({ summary }: DetailShellProps) {
const timeline = summary.timeline;
const productionArtifacts = summary.productionArtifacts;
return (
<section>
@@ -66,6 +67,46 @@ export function ArticleDetailShell({ summary }: DetailShellProps) {
))}
{timeline.length === 0 ? <li>No workflow events yet.</li> : null}
</ul>
<h3>Production artifacts</h3>
<table className="table">
<thead>
<tr>
<th>Artifact</th>
<th>Status</th>
<th>Attempt</th>
<th>Used evidence IDs</th>
<th>Unsupported claims</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 ? (
<tr>
<td colSpan={5}>No production artifacts yet.</td>
</tr>
) : null}
</tbody>
</table>
</section>
);
}
@@ -0,0 +1,104 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import ts from "typescript";
const testDir = path.dirname(fileURLToPath(import.meta.url));
const sourcePath = path.resolve(testDir, "../src/features/article-detail/model.ts");
const source = readFileSync(sourcePath, "utf8");
const compiled = ts.transpileModule(source, {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 },
});
const moduleExports = {};
new Function("exports", compiled.outputText)(moduleExports);
const { buildProductionArtifactRows } = 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"]);