Task 007 add agent job admin runner follow-up

This commit is contained in:
2026-05-21 19:33:24 +03:00
parent 4fd837349f
commit 8aa92ea728
16 changed files with 826 additions and 29 deletions
+1 -1
View File
@@ -6,7 +6,7 @@
"build": "next build",
"start": "next start",
"test:roles": "node tests/role-navigation.test.mjs",
"test:ui": "node tests/role-navigation.test.mjs && node tests/article_dashboard.test.mjs && node tests/article_form_validation.test.mjs && node tests/admin_script_versions.model.test.mjs",
"test:ui": "node tests/role-navigation.test.mjs && node tests/article_dashboard.test.mjs && node tests/article_form_validation.test.mjs && node tests/admin_script_versions.model.test.mjs && node tests/agent_jobs.model.test.mjs",
"typecheck": "tsc --noEmit"
},
"dependencies": {
@@ -0,0 +1,3 @@
import AdminJobsPage from "@/pages/admin-jobs";
export default AdminJobsPage;
@@ -0,0 +1,33 @@
import type { AgentJobSummary } from "@pipeline/shared";
export type AgentJobRow = {
id: string;
type: string;
status: string;
attempt: number;
parentJobId: string | null;
workspacePath: string | null;
stdout: string;
stderr: string;
exitCode: number | null;
durationMs: number | null;
outputFiles: string;
error: string;
};
export function buildAgentJobRows(jobs: readonly AgentJobSummary[]): AgentJobRow[] {
return jobs.map((job) => ({
id: job.id,
type: job.job_type,
status: job.status,
attempt: job.attempt,
parentJobId: job.parent_job_id ?? null,
workspacePath: job.workspace_path ?? null,
stdout: job.stdout,
stderr: job.stderr,
exitCode: job.exit_code ?? null,
durationMs: job.duration_ms ?? null,
outputFiles: job.output_files.map((file) => file.path).join(", "),
error: job.error_category ?? job.error_message ?? "",
}));
}
@@ -0,0 +1,51 @@
import type { AgentJobRow } from "./model";
type AgentJobsTableProps = {
rows: readonly AgentJobRow[];
};
export function AgentJobsTable({ rows }: AgentJobsTableProps) {
if (rows.length === 0) {
return <p className="emptyState">No agent jobs yet.</p>;
}
return (
<table className="table">
<thead>
<tr>
<th>Job</th>
<th>Status</th>
<th>Attempt</th>
<th>Runtime</th>
<th>Workspace</th>
<th>Logs</th>
<th>Outputs</th>
</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr key={row.id}>
<td>
<div>{row.type}</div>
<small>{row.id}</small>
{row.parentJobId ? <div>retry of {row.parentJobId}</div> : null}
</td>
<td>{row.status}</td>
<td>{row.attempt}</td>
<td>
<div>exit: {row.exitCode ?? "pending"}</div>
<div>{row.durationMs ?? 0} ms</div>
{row.error ? <div>{row.error}</div> : null}
</td>
<td>{row.workspacePath ?? "pending"}</td>
<td>
<pre className="logBlock">{row.stdout || "stdout pending"}</pre>
{row.stderr ? <pre className="logBlock">{row.stderr}</pre> : null}
</td>
<td>{row.outputFiles || "none"}</td>
</tr>
))}
</tbody>
</table>
);
}
@@ -0,0 +1,28 @@
import Link from "next/link";
import { AgentJobsTable } from "@/features/agent-jobs/ui";
import { buildAgentJobRows } from "@/features/agent-jobs/model";
import { fetchAgentJobs } from "@/shared/pipeline-api";
import { RoleNavigation } from "@/widgets/role-navigation";
export default async function AdminJobsPage() {
const jobs = await fetchAgentJobs();
const rows = buildAgentJobRows(jobs.jobs);
return (
<main>
<header className="pageHeader">
<div>
<h1>Agent Jobs</h1>
<p>Monitor runner status, logs, outputs, and retry lineage.</p>
</div>
<Link href="/">Back to dashboard</Link>
<span className="roleBadge">Admin demo</span>
</header>
<RoleNavigation role="ADMIN" />
<section className="panel">
<AgentJobsTable rows={rows} />
</section>
</main>
);
}
+42
View File
@@ -2,6 +2,9 @@ import type {
ArticleCreateRequest,
ArticleCreateResponse,
ArticleDetailResponse,
AgentJobListResponse,
AgentJobResponse,
AgentJobTestCodexRequest,
ArticleListResponse,
CurrentUserResponse,
ScriptConfigVersionCreateRequest,
@@ -149,6 +152,45 @@ export function fetchCurrentUser(): Promise<CurrentUserResponse> {
return apiGet<CurrentUserResponse>("/api/me");
}
export function fetchAgentJobs(
userEmail = DEMO_ADMIN_EMAIL,
): Promise<AgentJobListResponse> {
return apiGet<AgentJobListResponse>("/api/agent-jobs", { userEmail });
}
export function createTestCodexJob(
request: AgentJobTestCodexRequest = { fake_result: "valid" },
userEmail = DEMO_ADMIN_EMAIL,
): Promise<AgentJobResponse> {
return apiPost<AgentJobTestCodexRequest, AgentJobResponse>(
"/api/agent-jobs/test-codex",
request,
{ userEmail },
);
}
export function retryAgentJob(
jobId: string,
userEmail = DEMO_ADMIN_EMAIL,
): Promise<AgentJobResponse> {
return apiPost<unknown, AgentJobResponse>(
`/api/agent-jobs/${jobId}/retry`,
undefined,
{ userEmail },
);
}
export function cancelAgentJob(
jobId: string,
userEmail = DEMO_ADMIN_EMAIL,
): Promise<AgentJobResponse> {
return apiPost<unknown, AgentJobResponse>(
`/api/agent-jobs/${jobId}/cancel`,
undefined,
{ userEmail },
);
}
export function fetchTargetSites(): Promise<TargetSiteConfigResponse[]> {
return apiGet<TargetSiteConfigResponse[]>("/api/sites");
}
@@ -38,6 +38,12 @@ export const ROLE_NAVIGATION_ITEMS = [
href: "/admin/scripts",
roles: ["ADMIN"],
},
{
id: "agent-jobs",
label: "Agent jobs",
href: "/admin/jobs",
roles: ["ADMIN"],
},
] as const satisfies readonly NavigationItem[];
export function getRoleNavigationItems(role: Role): NavigationItem[] {
@@ -0,0 +1,49 @@
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/agent-jobs/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 { buildAgentJobRows } = moduleExports;
const rows = buildAgentJobRows([
{
id: "11111111-1111-1111-1111-111111111111",
article_id: null,
parent_job_id: "00000000-0000-0000-0000-000000000000",
attempt: 2,
job_type: "TEST_CODEX",
agent_profile: "fake-codex",
status: "SUCCEEDED",
workspace_path: "/tmp/job",
input_files: [{ path: "inputs/job.json" }],
output_files: [{ path: "outputs/result.json" }],
error_category: null,
error_message: null,
stdout: "done",
stderr: "",
exit_code: 0,
duration_ms: 5,
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, 1);
assert.equal(rows[0].status, "SUCCEEDED");
assert.equal(rows[0].attempt, 2);
assert.equal(rows[0].parentJobId, "00000000-0000-0000-0000-000000000000");
assert.equal(rows[0].outputFiles, "outputs/result.json");
assert.equal(rows[0].stdout, "done");
@@ -36,5 +36,9 @@ assert.equal(
adminItems.some((item) => item.id === "script-versions"),
true,
);
assert.equal(
adminItems.some((item) => item.id === "agent-jobs"),
true,
);
console.log("role navigation hides Admin-only items for Editors");