feat(task-022): add admin workflow builder ui

This commit is contained in:
2026-05-22 04:05:47 +03:00
parent cbd24d4a1c
commit baff9211e8
11 changed files with 2105 additions and 15 deletions
+9
View File
@@ -1,6 +1,15 @@
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
outputFileTracingRoot: repoRoot,
turbopack: {
root: repoRoot,
},
};
export default nextConfig;
+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 && node tests/agent_jobs.model.test.mjs && node tests/boundary_questions.model.test.mjs && node tests/plan_review.model.test.mjs && node tests/research_manifest.model.test.mjs && node tests/evidence_matrix.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/admin_workflows.model.test.mjs && node tests/agent_jobs.model.test.mjs && node tests/boundary_questions.model.test.mjs && node tests/plan_review.model.test.mjs && node tests/research_manifest.model.test.mjs && node tests/evidence_matrix.model.test.mjs",
"typecheck": "tsc --noEmit"
},
"dependencies": {
@@ -0,0 +1,3 @@
import AdminWorkflowsPage from "@/pages/admin-workflows";
export { AdminWorkflowsPage as default };
@@ -0,0 +1,363 @@
import type {
Role,
WorkflowStageCreateRequest,
WorkflowStagePart,
WorkflowStageReorderRequest,
WorkflowStageSummary,
WorkflowStageUpdateRequest,
WorkflowTemplateAuditEventSummary,
WorkflowTemplateCreateRequest,
WorkflowTemplateSummary,
WorkflowTemplateUpdateRequest,
} from "@pipeline/shared";
export type WorkflowDraft = {
name: string;
slug: string;
description: string;
};
export type WorkflowStageDraft = {
stableKey: string;
displayName: string;
description: string;
position: number;
ownerRole: Role;
runnerProfileKey: string;
requiredInputs: string[];
expectedOutputs: string[];
acceptanceCriteria: string[];
requiresHumanApproval: boolean;
retryPolicy: Record<string, unknown>;
parts: WorkflowStagePart[];
};
export type WorkflowStageFormPart = {
key: string;
type: string;
title: string;
payloadJson: string;
acceptanceCriteriaText: string;
};
export type WorkflowStageFormDraft = Omit<WorkflowStageDraft, "parts"> & {
requiredInputsText: string;
expectedOutputsText: string;
acceptanceCriteriaText: string;
retryPolicyJson: string;
parts: WorkflowStageFormPart[];
};
export type WorkflowStagePositionItem = {
id: string;
position: number;
};
export type AdminWorkflowRow = {
id: string;
name: string;
slug: string;
status: string;
version: number;
updatedAt: string;
stageCount: number;
isDraft: boolean;
isActive: boolean;
isArchived: boolean;
};
export type AdminWorkflowAuditRow = {
id: string;
eventType: string;
createdAt: string;
actorUserId: string | null;
payload: string;
};
export type StageDraftValidationErrors = Partial<
Record<
| "stableKey"
| "displayName"
| "description"
| "runnerProfileKey"
| "retryPolicyJson"
| "parts",
string
>
>;
export function defaultWorkflowDraft(): WorkflowDraft {
return {
name: "",
slug: "",
description: "",
};
}
export function buildCreateWorkflowPayload(
draft: WorkflowDraft,
): WorkflowTemplateCreateRequest {
return {
name: draft.name,
slug: draft.slug,
description: draft.description,
};
}
export function buildUpdateWorkflowPayload(
draft: WorkflowDraft,
): WorkflowTemplateUpdateRequest {
return {
name: draft.name,
slug: draft.slug,
description: draft.description,
};
}
export function defaultWorkflowStagePart(): WorkflowStagePart {
return {
key: "stage-part",
type: "instruction",
title: "Stage instruction",
payload: {
prompt: "",
config: {},
},
acceptance_criteria: [],
};
}
export function defaultWorkflowStageDraft(position = 1): WorkflowStageDraft {
return {
stableKey: "",
displayName: "",
description: "",
position,
ownerRole: "EDITOR",
runnerProfileKey: "",
requiredInputs: [],
expectedOutputs: [],
acceptanceCriteria: [],
requiresHumanApproval: false,
retryPolicy: {
max_attempts: 1,
backoff_seconds: 0,
},
parts: [],
};
}
export function buildCreateWorkflowStagePayload(
draft: WorkflowStageDraft,
): WorkflowStageCreateRequest {
return {
stable_key: draft.stableKey,
display_name: draft.displayName,
description: draft.description,
position: draft.position,
owner_role: draft.ownerRole,
runner_profile_key: draft.runnerProfileKey,
required_inputs: draft.requiredInputs,
expected_outputs: draft.expectedOutputs,
acceptance_criteria: draft.acceptanceCriteria,
requires_human_approval: draft.requiresHumanApproval,
retry_policy: draft.retryPolicy,
parts: draft.parts,
};
}
export function buildUpdateWorkflowStagePayload(
draft: WorkflowStageDraft,
): WorkflowStageUpdateRequest {
return buildCreateWorkflowStagePayload(draft);
}
export function moveWorkflowStage<TStage extends WorkflowStagePositionItem>(
stages: readonly TStage[],
stageId: string,
targetIndex: number,
): TStage[] {
const currentIndex = stages.findIndex((stage) => stage.id === stageId);
if (currentIndex === -1) {
return stages.map((stage, index) => ({
...stage,
position: index + 1,
}));
}
const nextStages = [...stages];
const [stage] = nextStages.splice(currentIndex, 1);
const clampedIndex = Math.max(0, Math.min(targetIndex, nextStages.length));
nextStages.splice(clampedIndex, 0, stage);
return nextStages.map((nextStage, index) => ({
...nextStage,
position: index + 1,
}));
}
export function buildReorderWorkflowStagesPayload(
stages: readonly WorkflowStagePositionItem[],
): WorkflowStageReorderRequest {
return {
stage_ids: stages.map((stage) => stage.id),
};
}
export function buildAdminWorkflowRows(
workflows: readonly WorkflowTemplateSummary[],
): AdminWorkflowRow[] {
return workflows.map((workflow) => ({
id: workflow.id,
name: workflow.name,
slug: workflow.slug,
status: workflow.status,
version: workflow.version,
updatedAt: workflow.updated_at,
stageCount: workflow.stages?.length ?? 0,
isDraft: workflow.status === "DRAFT",
isActive: workflow.status === "ACTIVE",
isArchived: workflow.status === "ARCHIVED",
}));
}
export function buildAdminWorkflowAuditRows(
events: readonly WorkflowTemplateAuditEventSummary[],
): AdminWorkflowAuditRow[] {
return events.map((event) => ({
id: event.id,
eventType: event.event_type,
createdAt: event.created_at,
actorUserId: event.actor_user_id ?? null,
payload: JSON.stringify(event.payload ?? {}),
}));
}
export function workflowDraftFromSummary(
workflow: WorkflowTemplateSummary,
): WorkflowDraft {
return {
name: workflow.name,
slug: workflow.slug,
description: workflow.description,
};
}
export function workflowStageDraftFromSummary(
stage: WorkflowStageSummary,
): WorkflowStageDraft {
return {
stableKey: stage.stable_key,
displayName: stage.display_name,
description: stage.description,
position: stage.position,
ownerRole: stage.owner_role,
runnerProfileKey: stage.runner_profile_key,
requiredInputs: stage.required_inputs ?? [],
expectedOutputs: stage.expected_outputs ?? [],
acceptanceCriteria: stage.acceptance_criteria ?? [],
requiresHumanApproval: stage.requires_human_approval ?? false,
retryPolicy: stage.retry_policy ?? {},
parts: stage.parts ?? [],
};
}
export function workflowStageFormDraftFromSummary(
stage: WorkflowStageSummary,
): WorkflowStageFormDraft {
const draft = workflowStageDraftFromSummary(stage);
return workflowStageDraftToFormDraft(draft);
}
export function workflowStageDraftToFormDraft(
draft: WorkflowStageDraft,
): WorkflowStageFormDraft {
return {
...draft,
requiredInputsText: draft.requiredInputs.join("\n"),
expectedOutputsText: draft.expectedOutputs.join("\n"),
acceptanceCriteriaText: draft.acceptanceCriteria.join("\n"),
retryPolicyJson: JSON.stringify(draft.retryPolicy, null, 2),
parts: draft.parts.map((part) => ({
key: part.key,
type: part.type,
title: part.title,
payloadJson: JSON.stringify(part.payload ?? {}, null, 2),
acceptanceCriteriaText: (part.acceptance_criteria ?? []).join("\n"),
})),
};
}
export function defaultWorkflowStageFormDraft(
position = 1,
): WorkflowStageFormDraft {
return workflowStageDraftToFormDraft(defaultWorkflowStageDraft(position));
}
export function workflowStageFormDraftToStageDraft(
draft: WorkflowStageFormDraft,
): WorkflowStageDraft | null {
const retryPolicy = parseJsonObject(draft.retryPolicyJson);
if (retryPolicy === null) {
return null;
}
const parts: WorkflowStagePart[] = [];
for (const part of draft.parts) {
const payload = parseJsonObject(part.payloadJson);
if (payload === null) {
return null;
}
parts.push({
key: part.key,
type: part.type,
title: part.title,
payload,
acceptance_criteria: splitLines(part.acceptanceCriteriaText),
});
}
return {
stableKey: draft.stableKey,
displayName: draft.displayName,
description: draft.description,
position: draft.position,
ownerRole: draft.ownerRole,
runnerProfileKey: draft.runnerProfileKey,
requiredInputs: splitLines(draft.requiredInputsText),
expectedOutputs: splitLines(draft.expectedOutputsText),
acceptanceCriteria: splitLines(draft.acceptanceCriteriaText),
requiresHumanApproval: draft.requiresHumanApproval,
retryPolicy,
parts,
};
}
export function splitLines(value: string): string[] {
return value
.split(/\r?\n/)
.map((item) => item.trim())
.filter(Boolean);
}
export function parseJsonObject(value: string): Record<string, unknown> | null {
if (!value.trim()) {
return {};
}
try {
const parsed = JSON.parse(value);
if (
parsed &&
typeof parsed === "object" &&
!Array.isArray(parsed) &&
parsed !== null
) {
return parsed as Record<string, unknown>;
}
} catch {
return null;
}
return null;
}
@@ -0,0 +1,976 @@
"use client";
import { useMemo, useState, type FormEvent } from "react";
import type {
Role,
WorkflowStageSummary,
WorkflowTemplateAuditEventSummary,
WorkflowTemplateSummary,
} from "@pipeline/shared";
import {
ApiError,
DEMO_ADMIN_EMAIL,
activateWorkflowTemplate,
addWorkflowStage,
archiveWorkflowTemplate,
createWorkflowTemplate,
deleteWorkflowStage,
fetchWorkflowTemplate,
fetchWorkflowTemplateAuditEvents,
fetchWorkflowTemplates,
reorderWorkflowStages,
updateWorkflowStage,
updateWorkflowTemplate,
} from "@/shared/pipeline-api";
import {
buildAdminWorkflowAuditRows,
buildAdminWorkflowRows,
buildCreateWorkflowPayload,
buildCreateWorkflowStagePayload,
buildReorderWorkflowStagesPayload,
buildUpdateWorkflowPayload,
buildUpdateWorkflowStagePayload,
defaultWorkflowDraft,
defaultWorkflowStageFormDraft,
moveWorkflowStage,
parseJsonObject,
workflowDraftFromSummary,
workflowStageFormDraftFromSummary,
workflowStageFormDraftToStageDraft,
type StageDraftValidationErrors,
type WorkflowDraft,
type WorkflowStageFormDraft,
type WorkflowStageFormPart,
} from "./model";
type AdminWorkflowBuilderProps = {
initialWorkflows: readonly WorkflowTemplateSummary[];
initialWorkflow?: WorkflowTemplateSummary | null;
initialAuditEvents?: readonly WorkflowTemplateAuditEventSummary[];
};
const EMPTY_AUDIT_EVENTS: readonly WorkflowTemplateAuditEventSummary[] = [];
function formatDate(value: string): string {
return new Date(value).toLocaleString();
}
function sortedStages(
stages: readonly WorkflowStageSummary[] | undefined,
): WorkflowStageSummary[] {
return [...(stages ?? [])].sort((left, right) => left.position - right.position);
}
function createDefaultPart(index: number): WorkflowStageFormPart {
return {
key: `stage-part-${index}`,
type: "instruction",
title: "Stage instruction",
payloadJson: JSON.stringify({ prompt: "", config: {} }, null, 2),
acceptanceCriteriaText: "",
};
}
function errorMessage(error: unknown, fallback: string): string {
if (error instanceof ApiError) {
return error.message || fallback;
}
return fallback;
}
export function AdminWorkflowBuilder({
initialWorkflows,
initialWorkflow = null,
initialAuditEvents = EMPTY_AUDIT_EVENTS,
}: AdminWorkflowBuilderProps) {
const [workflows, setWorkflows] = useState<readonly WorkflowTemplateSummary[]>(
initialWorkflows,
);
const [selectedWorkflow, setSelectedWorkflow] =
useState<WorkflowTemplateSummary | null>(initialWorkflow);
const [auditEvents, setAuditEvents] =
useState<readonly WorkflowTemplateAuditEventSummary[]>(initialAuditEvents);
const [createDraft, setCreateDraft] = useState<WorkflowDraft>(
defaultWorkflowDraft(),
);
const [metadataDraft, setMetadataDraft] = useState<WorkflowDraft>(
initialWorkflow ? workflowDraftFromSummary(initialWorkflow) : defaultWorkflowDraft(),
);
const [editingStageId, setEditingStageId] = useState<string | null>(null);
const [stageDraft, setStageDraft] = useState<WorkflowStageFormDraft>(
defaultWorkflowStageFormDraft(sortedStages(initialWorkflow?.stages).length + 1),
);
const [stageErrors, setStageErrors] = useState<StageDraftValidationErrors>({});
const [actionMessage, setActionMessage] = useState("");
const [formError, setFormError] = useState("");
const [pendingAction, setPendingAction] = useState("");
const rows = useMemo(() => buildAdminWorkflowRows(workflows), [workflows]);
const auditRows = useMemo(
() => buildAdminWorkflowAuditRows(auditEvents),
[auditEvents],
);
const stages = useMemo(
() => sortedStages(selectedWorkflow?.stages),
[selectedWorkflow],
);
const canEditSelected = selectedWorkflow?.status === "DRAFT";
const isBusy = pendingAction !== "";
function clearMessages() {
setActionMessage("");
setFormError("");
}
function setActiveWorkflow(
workflow: WorkflowTemplateSummary,
nextAuditEvents: readonly WorkflowTemplateAuditEventSummary[] = auditEvents,
) {
const nextStages = sortedStages(workflow.stages);
setSelectedWorkflow(workflow);
setMetadataDraft(workflowDraftFromSummary(workflow));
setAuditEvents(nextAuditEvents);
setEditingStageId(null);
setStageDraft(defaultWorkflowStageFormDraft(nextStages.length + 1));
setStageErrors({});
}
function replaceWorkflowInList(workflow: WorkflowTemplateSummary) {
setWorkflows((current) => {
const exists = current.some((item) => item.id === workflow.id);
if (!exists) {
return [workflow, ...current];
}
return current.map((item) => (item.id === workflow.id ? workflow : item));
});
}
async function refreshWorkflowList(selectedWorkflowId?: string) {
const listResponse = await fetchWorkflowTemplates(DEMO_ADMIN_EMAIL);
setWorkflows(listResponse.workflows);
if (selectedWorkflowId) {
await loadWorkflow(selectedWorkflowId, false);
}
}
async function loadWorkflow(workflowId: string, markPending = true) {
if (markPending) {
clearMessages();
setPendingAction(`load-${workflowId}`);
}
try {
const [workflowResponse, auditResponse] = await Promise.all([
fetchWorkflowTemplate(workflowId, DEMO_ADMIN_EMAIL),
fetchWorkflowTemplateAuditEvents(workflowId, DEMO_ADMIN_EMAIL),
]);
setActiveWorkflow(workflowResponse.workflow, auditResponse.events ?? []);
} catch (error) {
setFormError(errorMessage(error, "Unable to load workflow"));
} finally {
if (markPending) {
setPendingAction("");
}
}
}
async function handleRefresh() {
clearMessages();
setPendingAction("refresh-workflows");
try {
await refreshWorkflowList(selectedWorkflow?.id);
setActionMessage("Workflow list refreshed");
} catch (error) {
setFormError(errorMessage(error, "Unable to refresh workflow list"));
} finally {
setPendingAction("");
}
}
function updateCreateDraft<K extends keyof WorkflowDraft>(
field: K,
value: WorkflowDraft[K],
) {
setCreateDraft((current) => ({ ...current, [field]: value }));
}
function updateMetadataDraft<K extends keyof WorkflowDraft>(
field: K,
value: WorkflowDraft[K],
) {
setMetadataDraft((current) => ({ ...current, [field]: value }));
}
function updateStageDraft<K extends keyof WorkflowStageFormDraft>(
field: K,
value: WorkflowStageFormDraft[K],
) {
setStageDraft((current) => ({ ...current, [field]: value }));
}
function updateStagePart<K extends keyof WorkflowStageFormPart>(
index: number,
field: K,
value: WorkflowStageFormPart[K],
) {
setStageDraft((current) => ({
...current,
parts: current.parts.map((part, partIndex) =>
partIndex === index ? { ...part, [field]: value } : part,
),
}));
}
function addStagePart() {
setStageDraft((current) => ({
...current,
parts: [...current.parts, createDefaultPart(current.parts.length + 1)],
}));
}
function removeStagePart(index: number) {
setStageDraft((current) => ({
...current,
parts: current.parts.filter((_, partIndex) => partIndex !== index),
}));
}
function startNewStage() {
setEditingStageId(null);
setStageErrors({});
setStageDraft(defaultWorkflowStageFormDraft(stages.length + 1));
}
function startEditStage(stage: WorkflowStageSummary) {
setEditingStageId(stage.id);
setStageErrors({});
setStageDraft(workflowStageFormDraftFromSummary(stage));
}
function validateStageDraft(): ReturnType<typeof workflowStageFormDraftToStageDraft> {
const nextErrors: StageDraftValidationErrors = {};
if (!stageDraft.stableKey.trim()) {
nextErrors.stableKey = "Stable key is required";
}
if (!stageDraft.displayName.trim()) {
nextErrors.displayName = "Display name is required";
}
if (!stageDraft.description.trim()) {
nextErrors.description = "Description is required";
}
if (!stageDraft.runnerProfileKey.trim()) {
nextErrors.runnerProfileKey = "Runner profile key is required";
}
if (parseJsonObject(stageDraft.retryPolicyJson) === null) {
nextErrors.retryPolicyJson = "Invalid JSON object";
}
const hasInvalidPartPayload = stageDraft.parts.some(
(part) => parseJsonObject(part.payloadJson) === null,
);
const hasInvalidPartFields = stageDraft.parts.some(
(part) => !part.key.trim() || !part.type.trim() || !part.title.trim(),
);
if (hasInvalidPartPayload || hasInvalidPartFields) {
nextErrors.parts = hasInvalidPartPayload
? "Each part payload must be a JSON object"
: "Each part needs key, type, and title";
}
if (Object.keys(nextErrors).length > 0) {
setStageErrors(nextErrors);
return null;
}
const nextDraft = workflowStageFormDraftToStageDraft(stageDraft);
if (!nextDraft) {
setStageErrors({ parts: "Stage draft contains invalid structured data" });
return null;
}
setStageErrors({});
return nextDraft;
}
async function handleCreateWorkflow(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
clearMessages();
setPendingAction("create-workflow");
try {
const response = await createWorkflowTemplate(
buildCreateWorkflowPayload(createDraft),
DEMO_ADMIN_EMAIL,
);
setCreateDraft(defaultWorkflowDraft());
replaceWorkflowInList(response.workflow);
setActiveWorkflow(response.workflow, []);
setActionMessage("Workflow draft created");
await refreshWorkflowList(response.workflow.id);
} catch (error) {
setFormError(errorMessage(error, "Unable to create workflow draft"));
} finally {
setPendingAction("");
}
}
async function handleSaveMetadata(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!selectedWorkflow) {
return;
}
clearMessages();
setPendingAction("save-metadata");
try {
const response = await updateWorkflowTemplate(
selectedWorkflow.id,
buildUpdateWorkflowPayload(metadataDraft),
DEMO_ADMIN_EMAIL,
);
replaceWorkflowInList(response.workflow);
const auditResponse = await fetchWorkflowTemplateAuditEvents(
response.workflow.id,
DEMO_ADMIN_EMAIL,
);
setActiveWorkflow(response.workflow, auditResponse.events ?? []);
setActionMessage("Workflow metadata saved");
} catch (error) {
setFormError(errorMessage(error, "Unable to save workflow metadata"));
} finally {
setPendingAction("");
}
}
async function handleSaveStage(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!selectedWorkflow) {
return;
}
const validDraft = validateStageDraft();
if (!validDraft) {
return;
}
clearMessages();
setPendingAction("save-stage");
try {
if (editingStageId) {
await updateWorkflowStage(
selectedWorkflow.id,
editingStageId,
buildUpdateWorkflowStagePayload(validDraft),
DEMO_ADMIN_EMAIL,
);
setActionMessage("Stage updated");
} else {
await addWorkflowStage(
selectedWorkflow.id,
buildCreateWorkflowStagePayload(validDraft),
DEMO_ADMIN_EMAIL,
);
setActionMessage("Stage added");
}
await refreshWorkflowList(selectedWorkflow.id);
} catch (error) {
setFormError(errorMessage(error, "Unable to save workflow stage"));
} finally {
setPendingAction("");
}
}
async function handleDeleteStage(stageId: string) {
if (!selectedWorkflow) {
return;
}
clearMessages();
setPendingAction(`delete-stage-${stageId}`);
try {
const response = await deleteWorkflowStage(
selectedWorkflow.id,
stageId,
DEMO_ADMIN_EMAIL,
);
replaceWorkflowInList(response.workflow);
const auditResponse = await fetchWorkflowTemplateAuditEvents(
response.workflow.id,
DEMO_ADMIN_EMAIL,
);
setActiveWorkflow(response.workflow, auditResponse.events ?? []);
setActionMessage("Stage deleted");
} catch (error) {
setFormError(errorMessage(error, "Unable to delete workflow stage"));
} finally {
setPendingAction("");
}
}
async function handleReorderStage(stageId: string, targetIndex: number) {
if (!selectedWorkflow) {
return;
}
const movedStages = moveWorkflowStage(stages, stageId, targetIndex);
clearMessages();
setPendingAction(`reorder-${stageId}`);
try {
const response = await reorderWorkflowStages(
selectedWorkflow.id,
buildReorderWorkflowStagesPayload(movedStages),
DEMO_ADMIN_EMAIL,
);
replaceWorkflowInList(response.workflow);
const auditResponse = await fetchWorkflowTemplateAuditEvents(
response.workflow.id,
DEMO_ADMIN_EMAIL,
);
setActiveWorkflow(response.workflow, auditResponse.events ?? []);
setActionMessage("Stage order saved");
} catch (error) {
setFormError(errorMessage(error, "Unable to reorder workflow stages"));
} finally {
setPendingAction("");
}
}
async function handleActivateWorkflow(workflowId: string) {
clearMessages();
setPendingAction(`activate-${workflowId}`);
try {
const response = await activateWorkflowTemplate(workflowId, DEMO_ADMIN_EMAIL);
replaceWorkflowInList(response.workflow);
const auditResponse = await fetchWorkflowTemplateAuditEvents(
response.workflow.id,
DEMO_ADMIN_EMAIL,
);
setActiveWorkflow(response.workflow, auditResponse.events ?? []);
setActionMessage("Workflow activated");
await refreshWorkflowList(response.workflow.id);
} catch (error) {
setFormError(errorMessage(error, "Unable to activate workflow"));
} finally {
setPendingAction("");
}
}
async function handleArchiveWorkflow(workflowId: string) {
clearMessages();
setPendingAction(`archive-${workflowId}`);
try {
const response = await archiveWorkflowTemplate(workflowId, DEMO_ADMIN_EMAIL);
replaceWorkflowInList(response.workflow);
const auditResponse = await fetchWorkflowTemplateAuditEvents(
response.workflow.id,
DEMO_ADMIN_EMAIL,
);
setActiveWorkflow(response.workflow, auditResponse.events ?? []);
setActionMessage("Workflow archived");
await refreshWorkflowList(response.workflow.id);
} catch (error) {
setFormError(errorMessage(error, "Unable to archive workflow"));
} finally {
setPendingAction("");
}
}
return (
<>
{formError ? <p className="formError">Error: {formError}</p> : null}
{actionMessage ? <p className="formSuccess">{actionMessage}</p> : null}
<section className="panel">
<div className="sectionHeader">
<div>
<h2>Workflow templates</h2>
<p>{rows.length} templates</p>
</div>
<button type="button" onClick={handleRefresh} disabled={isBusy}>
{pendingAction === "refresh-workflows" ? "Refreshing..." : "Refresh"}
</button>
</div>
<form className="articleForm" onSubmit={handleCreateWorkflow}>
<h3>Create draft</h3>
<label htmlFor="workflowName">Name</label>
<input
id="workflowName"
value={createDraft.name}
onChange={(event) => updateCreateDraft("name", event.target.value)}
placeholder="Article production workflow"
required
/>
<label htmlFor="workflowSlug">Slug</label>
<input
id="workflowSlug"
value={createDraft.slug}
onChange={(event) => updateCreateDraft("slug", event.target.value)}
placeholder="article-production-v1"
required
/>
<label htmlFor="workflowDescription">Description</label>
<textarea
id="workflowDescription"
value={createDraft.description}
onChange={(event) => updateCreateDraft("description", event.target.value)}
required
/>
<div className="formActions">
<button type="submit" disabled={isBusy}>
{pendingAction === "create-workflow" ? "Creating..." : "Create draft"}
</button>
</div>
</form>
{rows.length === 0 ? (
<p className="emptyState">No workflow templates yet.</p>
) : (
<table className="table">
<thead>
<tr>
<th>Name</th>
<th>Status</th>
<th>Version</th>
<th>Stages</th>
<th>Updated</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr key={row.id}>
<td>
<strong>{row.name}</strong>
<div>{row.slug}</div>
</td>
<td>{row.status}</td>
<td>{row.version}</td>
<td>{row.stageCount}</td>
<td>{formatDate(row.updatedAt)}</td>
<td>
<button
type="button"
onClick={() => loadWorkflow(row.id)}
disabled={isBusy}
>
Open
</button>
{row.isDraft ? (
<button
type="button"
onClick={() => handleActivateWorkflow(row.id)}
disabled={isBusy}
className="tableAction"
>
Activate
</button>
) : null}
{!row.isArchived ? (
<button
type="button"
onClick={() => handleArchiveWorkflow(row.id)}
disabled={isBusy}
className="tableAction"
>
Archive
</button>
) : null}
</td>
</tr>
))}
</tbody>
</table>
)}
</section>
<section className="panel">
<div className="sectionHeader">
<div>
<h2>Workflow editor</h2>
<p>
{selectedWorkflow
? `${selectedWorkflow.name} - ${selectedWorkflow.status}`
: "Select a workflow"}
</p>
</div>
</div>
{!selectedWorkflow ? (
<p className="emptyState">Open a workflow template to edit stages.</p>
) : (
<>
<form className="articleForm" onSubmit={handleSaveMetadata}>
<h3>Metadata</h3>
{!canEditSelected ? (
<p className="emptyState">Only draft templates can be edited.</p>
) : null}
<label htmlFor="metadataName">Name</label>
<input
id="metadataName"
value={metadataDraft.name}
onChange={(event) => updateMetadataDraft("name", event.target.value)}
disabled={!canEditSelected || isBusy}
required
/>
<label htmlFor="metadataSlug">Slug</label>
<input
id="metadataSlug"
value={metadataDraft.slug}
onChange={(event) => updateMetadataDraft("slug", event.target.value)}
disabled={!canEditSelected || isBusy}
required
/>
<label htmlFor="metadataDescription">Description</label>
<textarea
id="metadataDescription"
value={metadataDraft.description}
onChange={(event) =>
updateMetadataDraft("description", event.target.value)
}
disabled={!canEditSelected || isBusy}
required
/>
<div className="formActions">
<button type="submit" disabled={!canEditSelected || isBusy}>
{pendingAction === "save-metadata" ? "Saving..." : "Save metadata"}
</button>
</div>
</form>
<h3>Stages</h3>
{stages.length === 0 ? (
<p className="emptyState">No stages yet.</p>
) : (
<table className="table">
<thead>
<tr>
<th>Order</th>
<th>Stage</th>
<th>Owner</th>
<th>Runner</th>
<th>Approval</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{stages.map((stage, index) => (
<tr key={stage.id}>
<td>{stage.position}</td>
<td>
<strong>{stage.display_name}</strong>
<div>{stage.stable_key}</div>
<div>{stage.description}</div>
</td>
<td>{stage.owner_role}</td>
<td>{stage.runner_profile_key}</td>
<td>{stage.requires_human_approval ? "Required" : "None"}</td>
<td>
<button
type="button"
onClick={() => startEditStage(stage)}
disabled={isBusy}
>
Edit
</button>
<button
type="button"
onClick={() => handleReorderStage(stage.id, index - 1)}
disabled={!canEditSelected || isBusy || index === 0}
className="tableAction"
>
Up
</button>
<button
type="button"
onClick={() => handleReorderStage(stage.id, index + 1)}
disabled={!canEditSelected || isBusy || index === stages.length - 1}
className="tableAction"
>
Down
</button>
<button
type="button"
onClick={() => handleDeleteStage(stage.id)}
disabled={!canEditSelected || isBusy}
className="tableAction"
>
Delete
</button>
</td>
</tr>
))}
</tbody>
</table>
)}
<form className="articleForm" onSubmit={handleSaveStage}>
<div className="sectionHeader">
<div>
<h3>{editingStageId ? "Edit stage" : "Add stage"}</h3>
<p>Structured inputs, outputs, retry policy, and parts.</p>
</div>
<button type="button" onClick={startNewStage} disabled={isBusy}>
New stage
</button>
</div>
<label htmlFor="stageStableKey">Stable key</label>
<input
id="stageStableKey"
value={stageDraft.stableKey}
onChange={(event) => updateStageDraft("stableKey", event.target.value)}
disabled={!canEditSelected || isBusy}
/>
{stageErrors.stableKey ? (
<p className="fieldError">{stageErrors.stableKey}</p>
) : null}
<label htmlFor="stageDisplayName">Display name</label>
<input
id="stageDisplayName"
value={stageDraft.displayName}
onChange={(event) => updateStageDraft("displayName", event.target.value)}
disabled={!canEditSelected || isBusy}
/>
{stageErrors.displayName ? (
<p className="fieldError">{stageErrors.displayName}</p>
) : null}
<label htmlFor="stageDescription">Description</label>
<textarea
id="stageDescription"
value={stageDraft.description}
onChange={(event) => updateStageDraft("description", event.target.value)}
disabled={!canEditSelected || isBusy}
/>
{stageErrors.description ? (
<p className="fieldError">{stageErrors.description}</p>
) : null}
<label htmlFor="stagePosition">Position</label>
<input
id="stagePosition"
type="number"
min={1}
value={stageDraft.position}
onChange={(event) =>
updateStageDraft("position", Number(event.target.value) || 1)
}
disabled={!canEditSelected || isBusy}
/>
<label htmlFor="stageOwnerRole">Owner role</label>
<select
id="stageOwnerRole"
value={stageDraft.ownerRole}
onChange={(event) =>
updateStageDraft("ownerRole", event.target.value as Role)
}
disabled={!canEditSelected || isBusy}
>
<option value="EDITOR">EDITOR</option>
<option value="ADMIN">ADMIN</option>
</select>
<label htmlFor="stageRunnerProfile">Runner profile key</label>
<input
id="stageRunnerProfile"
value={stageDraft.runnerProfileKey}
onChange={(event) =>
updateStageDraft("runnerProfileKey", event.target.value)
}
disabled={!canEditSelected || isBusy}
/>
{stageErrors.runnerProfileKey ? (
<p className="fieldError">{stageErrors.runnerProfileKey}</p>
) : null}
<label htmlFor="stageRequiredInputs">Required inputs</label>
<textarea
id="stageRequiredInputs"
value={stageDraft.requiredInputsText}
onChange={(event) =>
updateStageDraft("requiredInputsText", event.target.value)
}
disabled={!canEditSelected || isBusy}
/>
<label htmlFor="stageExpectedOutputs">Expected outputs</label>
<textarea
id="stageExpectedOutputs"
value={stageDraft.expectedOutputsText}
onChange={(event) =>
updateStageDraft("expectedOutputsText", event.target.value)
}
disabled={!canEditSelected || isBusy}
/>
<label htmlFor="stageAcceptanceCriteria">Acceptance criteria</label>
<textarea
id="stageAcceptanceCriteria"
value={stageDraft.acceptanceCriteriaText}
onChange={(event) =>
updateStageDraft("acceptanceCriteriaText", event.target.value)
}
disabled={!canEditSelected || isBusy}
/>
<label htmlFor="stageRequiresApproval">
<input
id="stageRequiresApproval"
type="checkbox"
checked={stageDraft.requiresHumanApproval}
onChange={(event) =>
updateStageDraft("requiresHumanApproval", event.target.checked)
}
disabled={!canEditSelected || isBusy}
/>
Requires human approval
</label>
<label htmlFor="stageRetryPolicy">Retry policy JSON</label>
<textarea
id="stageRetryPolicy"
className="codeArea"
value={stageDraft.retryPolicyJson}
onChange={(event) =>
updateStageDraft("retryPolicyJson", event.target.value)
}
disabled={!canEditSelected || isBusy}
/>
{stageErrors.retryPolicyJson ? (
<p className="fieldError">{stageErrors.retryPolicyJson}</p>
) : null}
<div className="sectionHeader">
<div>
<h3>Stage parts</h3>
<p>{stageDraft.parts.length} structured parts</p>
</div>
<button
type="button"
onClick={addStagePart}
disabled={!canEditSelected || isBusy}
>
Add part
</button>
</div>
{stageErrors.parts ? <p className="fieldError">{stageErrors.parts}</p> : null}
{stageDraft.parts.length === 0 ? (
<p className="emptyState">No structured parts yet.</p>
) : null}
{stageDraft.parts.map((part, index) => (
<div
key={`${part.key}-${index}`}
style={{
display: "grid",
gap: 8,
borderTop: "1px solid #d0d5dd",
paddingTop: 12,
}}
>
<label htmlFor={`stagePartKey-${index}`}>Part key</label>
<input
id={`stagePartKey-${index}`}
value={part.key}
onChange={(event) => updateStagePart(index, "key", event.target.value)}
disabled={!canEditSelected || isBusy}
/>
<label htmlFor={`stagePartType-${index}`}>Part type</label>
<input
id={`stagePartType-${index}`}
value={part.type}
onChange={(event) => updateStagePart(index, "type", event.target.value)}
disabled={!canEditSelected || isBusy}
/>
<label htmlFor={`stagePartTitle-${index}`}>Part title</label>
<input
id={`stagePartTitle-${index}`}
value={part.title}
onChange={(event) => updateStagePart(index, "title", event.target.value)}
disabled={!canEditSelected || isBusy}
/>
<label htmlFor={`stagePartPayload-${index}`}>Payload JSON</label>
<textarea
id={`stagePartPayload-${index}`}
className="codeArea"
value={part.payloadJson}
onChange={(event) =>
updateStagePart(index, "payloadJson", event.target.value)
}
disabled={!canEditSelected || isBusy}
/>
<label htmlFor={`stagePartCriteria-${index}`}>
Part acceptance criteria
</label>
<textarea
id={`stagePartCriteria-${index}`}
value={part.acceptanceCriteriaText}
onChange={(event) =>
updateStagePart(index, "acceptanceCriteriaText", event.target.value)
}
disabled={!canEditSelected || isBusy}
/>
<div className="formActions">
<button
type="button"
onClick={() => removeStagePart(index)}
disabled={!canEditSelected || isBusy}
>
Remove part
</button>
</div>
</div>
))}
<div className="formActions">
<button type="submit" disabled={!canEditSelected || isBusy}>
{pendingAction === "save-stage" ? "Saving..." : "Save stage"}
</button>
</div>
</form>
<h3>Audit trail</h3>
{auditRows.length === 0 ? (
<p className="emptyState">No audit events yet.</p>
) : (
<table className="table">
<thead>
<tr>
<th>Event</th>
<th>Created</th>
<th>Actor</th>
<th>Payload</th>
</tr>
</thead>
<tbody>
{auditRows.map((event) => (
<tr key={event.id}>
<td>{event.eventType}</td>
<td>{formatDate(event.createdAt)}</td>
<td>{event.actorUserId ?? "none"}</td>
<td>{event.payload}</td>
</tr>
))}
</tbody>
</table>
)}
</>
)}
</section>
</>
);
}
@@ -0,0 +1,65 @@
import Link from "next/link";
import type {
WorkflowTemplateAuditEventSummary,
WorkflowTemplateSummary,
} from "@pipeline/shared";
import { AdminWorkflowBuilder } from "@/features/admin-workflows/ui";
import {
DEMO_ADMIN_EMAIL,
fetchWorkflowTemplate,
fetchWorkflowTemplateAuditEvents,
fetchWorkflowTemplates,
} from "@/shared/pipeline-api";
import { RoleNavigation } from "@/widgets/role-navigation";
type SearchParams = {
workflowId?: string;
};
type AdminWorkflowsPageProps = {
searchParams?: SearchParams;
};
export default async function AdminWorkflowsPage({
searchParams,
}: AdminWorkflowsPageProps) {
const workflowListResponse = await fetchWorkflowTemplates(DEMO_ADMIN_EMAIL);
const selectedWorkflowId = searchParams?.workflowId;
let selectedWorkflow: WorkflowTemplateSummary | null = null;
let auditEvents: WorkflowTemplateAuditEventSummary[] = [];
if (selectedWorkflowId) {
try {
const [workflowResponse, auditResponse] = await Promise.all([
fetchWorkflowTemplate(selectedWorkflowId, DEMO_ADMIN_EMAIL),
fetchWorkflowTemplateAuditEvents(selectedWorkflowId, DEMO_ADMIN_EMAIL),
]);
selectedWorkflow = workflowResponse.workflow;
auditEvents = auditResponse.events ?? [];
} catch {
selectedWorkflow = null;
auditEvents = [];
}
}
return (
<main>
<header className="pageHeader">
<div>
<h1>Admin Workflows</h1>
<p>Create and manage reusable ordered workflow templates.</p>
</div>
<Link href="/">Back to dashboard</Link>
<span className="roleBadge">Admin demo</span>
</header>
<RoleNavigation role="ADMIN" />
<AdminWorkflowBuilder
initialWorkflows={workflowListResponse.workflows}
initialWorkflow={selectedWorkflow}
initialAuditEvents={auditEvents}
/>
</main>
);
}
+136
View File
@@ -48,6 +48,15 @@ import type {
ScriptConfigVersionResponse,
TargetSiteConfigResponse,
TargetSiteConfigCreateRequest,
WorkflowStageCreateRequest,
WorkflowStageReorderRequest,
WorkflowStageResponse,
WorkflowStageUpdateRequest,
WorkflowTemplateAuditEventListResponse,
WorkflowTemplateCreateRequest,
WorkflowTemplateListResponse,
WorkflowTemplateResponse,
WorkflowTemplateUpdateRequest,
} from "@pipeline/shared";
export const DEMO_EDITOR_EMAIL = "editor@example.com";
@@ -184,6 +193,13 @@ export function apiPatch<TRequest, TResponse>(
);
}
export function apiDelete<TResponse>(
path: string,
options?: ApiRequestConfig,
): Promise<TResponse> {
return requestJson<TResponse>(path, { method: "DELETE" }, options?.userEmail);
}
export function fetchCurrentUser(
userEmail = DEMO_EDITOR_EMAIL,
): Promise<CurrentUserResponse> {
@@ -259,6 +275,126 @@ export function createTargetSite(
);
}
export function fetchWorkflowTemplates(
userEmail = DEMO_ADMIN_EMAIL,
): Promise<WorkflowTemplateListResponse> {
return apiGet<WorkflowTemplateListResponse>("/api/admin/workflows", {
userEmail,
});
}
export function fetchWorkflowTemplate(
workflowId: string,
userEmail = DEMO_ADMIN_EMAIL,
): Promise<WorkflowTemplateResponse> {
return apiGet<WorkflowTemplateResponse>(`/api/admin/workflows/${workflowId}`, {
userEmail,
});
}
export function createWorkflowTemplate(
request: WorkflowTemplateCreateRequest,
userEmail = DEMO_ADMIN_EMAIL,
): Promise<WorkflowTemplateResponse> {
return apiPost<WorkflowTemplateCreateRequest, WorkflowTemplateResponse>(
"/api/admin/workflows",
request,
{ userEmail },
);
}
export function updateWorkflowTemplate(
workflowId: string,
request: WorkflowTemplateUpdateRequest,
userEmail = DEMO_ADMIN_EMAIL,
): Promise<WorkflowTemplateResponse> {
return apiPatch<WorkflowTemplateUpdateRequest, WorkflowTemplateResponse>(
`/api/admin/workflows/${workflowId}`,
request,
{ userEmail },
);
}
export function addWorkflowStage(
workflowId: string,
request: WorkflowStageCreateRequest,
userEmail = DEMO_ADMIN_EMAIL,
): Promise<WorkflowStageResponse> {
return apiPost<WorkflowStageCreateRequest, WorkflowStageResponse>(
`/api/admin/workflows/${workflowId}/stages`,
request,
{ userEmail },
);
}
export function updateWorkflowStage(
workflowId: string,
stageId: string,
request: WorkflowStageUpdateRequest,
userEmail = DEMO_ADMIN_EMAIL,
): Promise<WorkflowStageResponse> {
return apiPatch<WorkflowStageUpdateRequest, WorkflowStageResponse>(
`/api/admin/workflows/${workflowId}/stages/${stageId}`,
request,
{ userEmail },
);
}
export function deleteWorkflowStage(
workflowId: string,
stageId: string,
userEmail = DEMO_ADMIN_EMAIL,
): Promise<WorkflowTemplateResponse> {
return apiDelete<WorkflowTemplateResponse>(
`/api/admin/workflows/${workflowId}/stages/${stageId}`,
{ userEmail },
);
}
export function reorderWorkflowStages(
workflowId: string,
request: WorkflowStageReorderRequest,
userEmail = DEMO_ADMIN_EMAIL,
): Promise<WorkflowTemplateResponse> {
return apiPost<WorkflowStageReorderRequest, WorkflowTemplateResponse>(
`/api/admin/workflows/${workflowId}/stages/reorder`,
request,
{ userEmail },
);
}
export function activateWorkflowTemplate(
workflowId: string,
userEmail = DEMO_ADMIN_EMAIL,
): Promise<WorkflowTemplateResponse> {
return apiPost<unknown, WorkflowTemplateResponse>(
`/api/admin/workflows/${workflowId}/activate`,
undefined,
{ userEmail },
);
}
export function archiveWorkflowTemplate(
workflowId: string,
userEmail = DEMO_ADMIN_EMAIL,
): Promise<WorkflowTemplateResponse> {
return apiPost<unknown, WorkflowTemplateResponse>(
`/api/admin/workflows/${workflowId}/archive`,
undefined,
{ userEmail },
);
}
export function fetchWorkflowTemplateAuditEvents(
workflowId: string,
userEmail = DEMO_ADMIN_EMAIL,
): Promise<WorkflowTemplateAuditEventListResponse> {
return apiGet<WorkflowTemplateAuditEventListResponse>(
`/api/admin/workflows/${workflowId}/audit`,
{ userEmail },
);
}
export function fetchScriptConfigVersions(
siteId: string,
userEmail = DEMO_EDITOR_EMAIL,
@@ -38,6 +38,12 @@ export const ROLE_NAVIGATION_ITEMS = [
href: "/admin/scripts",
roles: ["ADMIN"],
},
{
id: "workflows",
label: "Workflows",
href: "/admin/workflows",
roles: ["ADMIN"],
},
{
id: "agent-jobs",
label: "Agent jobs",
@@ -0,0 +1,411 @@
import assert from "node:assert/strict";
import { existsSync, 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 failures = [];
function loadTsModule(sourcePath, requireOverrides = {}) {
const source = readFileSync(sourcePath, "utf8");
const compiled = ts.transpileModule(source, {
compilerOptions: {
module: ts.ModuleKind.CommonJS,
target: ts.ScriptTarget.ES2022,
},
});
const moduleExports = {};
const module = { exports: moduleExports };
const localRequire = (specifier) => {
if (specifier in requireOverrides) {
return requireOverrides[specifier];
}
throw new Error(`Unexpected runtime import in model test: ${specifier}`);
};
new Function("exports", "module", "require", compiled.outputText)(
moduleExports,
module,
localRequire,
);
return module.exports;
}
function loadExpectedModule(sourcePath, label, expectedExports = []) {
if (!existsSync(sourcePath)) {
const expected = expectedExports.join(", ");
failures.push(
`${label} is missing at ${path.relative(process.cwd(), sourcePath)}; expected exports: ${expected}`,
);
return null;
}
try {
return loadTsModule(sourcePath);
} catch (error) {
failures.push(`${label} failed to load: ${error.message}`);
return null;
}
}
function expectFunction(moduleExports, exportName, label) {
if (!moduleExports) {
return null;
}
const value = moduleExports?.[exportName];
if (typeof value !== "function") {
failures.push(`${label} must export ${exportName}()`);
return null;
}
return value;
}
async function recordAssertion(label, assertion) {
try {
await assertion();
} catch (error) {
failures.push(`${label}: ${error.message}`);
}
}
const workflowModelPath = path.resolve(
testDir,
"../src/features/admin-workflows/model.ts",
);
const apiPath = path.resolve(testDir, "../src/shared/pipeline-api.ts");
const navigationModelPath = path.resolve(
testDir,
"../src/widgets/role-navigation/model.ts",
);
const expectedWorkflowModelExports = [
"defaultWorkflowDraft",
"buildCreateWorkflowPayload",
"defaultWorkflowStageDraft",
"buildCreateWorkflowStagePayload",
"moveWorkflowStage",
"buildReorderWorkflowStagesPayload",
];
const workflowDraft = {
name: "Article production workflow",
slug: "article-production-v1",
description: "Reusable editorial workflow for longform article production.",
};
const workflowId = "11111111-1111-1111-1111-111111111111";
const intakeStageId = "22222222-2222-2222-2222-222222222222";
const draftStageId = "33333333-3333-3333-3333-333333333333";
const workflowPart = {
key: "required-context-checklist",
type: "checklist",
title: "Required context checklist",
payload: {
prompt: "Identify missing audience, keyword, and source constraints.",
config: {
required_fields: ["audience", "primary_keyword", "source_rules"],
block_on_missing: true,
},
},
acceptance_criteria: [
"Missing required context is listed explicitly.",
"No free-text parsing is needed to read the checklist.",
],
};
const intakeStageDraft = {
stableKey: "intake-boundary-questions",
displayName: "Boundary question intake",
description: "Collect required editorial context before planning.",
position: 1,
ownerRole: "EDITOR",
runnerProfileKey: "boundary-question-agent-v1",
requiredInputs: ["article_brief", "target_site"],
expectedOutputs: ["answered_boundary_questions"],
acceptanceCriteria: ["All required boundary questions are answered."],
requiresHumanApproval: true,
retryPolicy: { max_attempts: 1, backoff_seconds: 0 },
parts: [workflowPart],
};
const expectedIntakeStagePayload = {
stable_key: "intake-boundary-questions",
display_name: "Boundary question intake",
description: "Collect required editorial context before planning.",
position: 1,
owner_role: "EDITOR",
runner_profile_key: "boundary-question-agent-v1",
required_inputs: ["article_brief", "target_site"],
expected_outputs: ["answered_boundary_questions"],
acceptance_criteria: ["All required boundary questions are answered."],
requires_human_approval: true,
retry_policy: { max_attempts: 1, backoff_seconds: 0 },
parts: [workflowPart],
};
const workflowModelExports = loadExpectedModule(
workflowModelPath,
"features/admin-workflows/model",
expectedWorkflowModelExports,
);
const apiExports = loadExpectedModule(apiPath, "shared/pipeline-api");
const navigationExports = loadExpectedModule(
navigationModelPath,
"widgets/role-navigation/model",
);
const defaultWorkflowDraft = expectFunction(
workflowModelExports,
"defaultWorkflowDraft",
"features/admin-workflows/model",
);
const buildCreateWorkflowPayload = expectFunction(
workflowModelExports,
"buildCreateWorkflowPayload",
"features/admin-workflows/model",
);
const defaultWorkflowStageDraft = expectFunction(
workflowModelExports,
"defaultWorkflowStageDraft",
"features/admin-workflows/model",
);
const buildCreateWorkflowStagePayload = expectFunction(
workflowModelExports,
"buildCreateWorkflowStagePayload",
"features/admin-workflows/model",
);
const moveWorkflowStage = expectFunction(
workflowModelExports,
"moveWorkflowStage",
"features/admin-workflows/model",
);
const buildReorderWorkflowStagesPayload = expectFunction(
workflowModelExports,
"buildReorderWorkflowStagesPayload",
"features/admin-workflows/model",
);
const createWorkflowTemplate = expectFunction(
apiExports,
"createWorkflowTemplate",
"shared/pipeline-api",
);
const addWorkflowStage = expectFunction(
apiExports,
"addWorkflowStage",
"shared/pipeline-api",
);
const reorderWorkflowStages = expectFunction(
apiExports,
"reorderWorkflowStages",
"shared/pipeline-api",
);
const getRoleNavigationItems = expectFunction(
navigationExports,
"getRoleNavigationItems",
"widgets/role-navigation/model",
);
if (
defaultWorkflowDraft &&
buildCreateWorkflowPayload &&
defaultWorkflowStageDraft &&
buildCreateWorkflowStagePayload &&
moveWorkflowStage &&
buildReorderWorkflowStagesPayload
) {
await recordAssertion("workflow draft payload maps model state to API request", () => {
const createPayload = buildCreateWorkflowPayload({
...defaultWorkflowDraft(),
...workflowDraft,
});
assert.deepEqual(createPayload, workflowDraft);
});
await recordAssertion("workflow stage payload preserves structured parts", () => {
const stagePayload = buildCreateWorkflowStagePayload({
...defaultWorkflowStageDraft(),
...intakeStageDraft,
});
assert.deepEqual(stagePayload, expectedIntakeStagePayload);
});
await recordAssertion("workflow stage reorder returns ordered IDs for API", () => {
const stages = [
{ id: intakeStageId, stableKey: "intake-boundary-questions", position: 1 },
{ id: draftStageId, stableKey: "draft-assembly", position: 2 },
];
const reorderedStages = moveWorkflowStage(stages, draftStageId, 0);
assert.deepEqual(
reorderedStages.map((stage) => ({ id: stage.id, position: stage.position })),
[
{ id: draftStageId, position: 1 },
{ id: intakeStageId, position: 2 },
],
);
assert.deepEqual(buildReorderWorkflowStagesPayload(reorderedStages), {
stage_ids: [draftStageId, intakeStageId],
});
});
}
if (createWorkflowTemplate && addWorkflowStage && reorderWorkflowStages) {
await recordAssertion("workflow API client posts create/add/reorder requests", async () => {
const originalFetch = globalThis.fetch;
const requests = [];
const responses = [
{
workflow: {
id: workflowId,
...workflowDraft,
version: 1,
status: "DRAFT",
stages: [],
created_at: "2026-05-22T00:00:00Z",
updated_at: "2026-05-22T00:00:00Z",
},
},
{
stage: {
id: intakeStageId,
workflow_id: workflowId,
...expectedIntakeStagePayload,
},
},
{
stage: {
id: draftStageId,
workflow_id: workflowId,
...expectedIntakeStagePayload,
stable_key: "draft-assembly",
display_name: "Draft assembly",
position: 2,
},
},
{
workflow: {
id: workflowId,
...workflowDraft,
version: 1,
status: "DRAFT",
stages: [
{ id: draftStageId, stable_key: "draft-assembly", position: 1 },
{
id: intakeStageId,
stable_key: "intake-boundary-questions",
position: 2,
},
],
created_at: "2026-05-22T00:00:00Z",
updated_at: "2026-05-22T00:00:00Z",
},
},
];
globalThis.fetch = async (url, options) => {
requests.push({
path: new URL(String(url)).pathname,
method: options.method,
userEmail: options.headers["X-Demo-User-Email"],
body: options.body ? JSON.parse(options.body) : undefined,
});
return {
ok: true,
status: 200,
statusText: "OK",
text: async () => JSON.stringify(responses.shift()),
};
};
try {
const created = await createWorkflowTemplate(workflowDraft);
const intakeStage = await addWorkflowStage(
created.workflow.id,
expectedIntakeStagePayload,
);
const draftStage = await addWorkflowStage(created.workflow.id, {
...expectedIntakeStagePayload,
stable_key: "draft-assembly",
display_name: "Draft assembly",
position: 2,
});
const reordered = await reorderWorkflowStages(created.workflow.id, {
stage_ids: [draftStage.stage.id, intakeStage.stage.id],
});
assert.equal(created.workflow.status, "DRAFT");
assert.deepEqual(
reordered.workflow.stages.map((stage) => stage.id),
[draftStageId, intakeStageId],
);
} finally {
globalThis.fetch = originalFetch;
}
assert.deepEqual(requests, [
{
path: "/api/admin/workflows",
method: "POST",
userEmail: "admin@example.com",
body: workflowDraft,
},
{
path: `/api/admin/workflows/${workflowId}/stages`,
method: "POST",
userEmail: "admin@example.com",
body: expectedIntakeStagePayload,
},
{
path: `/api/admin/workflows/${workflowId}/stages`,
method: "POST",
userEmail: "admin@example.com",
body: {
...expectedIntakeStagePayload,
stable_key: "draft-assembly",
display_name: "Draft assembly",
position: 2,
},
},
{
path: `/api/admin/workflows/${workflowId}/stages/reorder`,
method: "POST",
userEmail: "admin@example.com",
body: { stage_ids: [draftStageId, intakeStageId] },
},
]);
});
}
if (getRoleNavigationItems) {
await recordAssertion("Admin navigation exposes Workflows builder route", () => {
const adminItems = getRoleNavigationItems("ADMIN");
assert.deepEqual(
adminItems.find((item) => item.id === "workflows"),
{
id: "workflows",
label: "Workflows",
href: "/admin/workflows",
roles: ["ADMIN"],
},
);
});
await recordAssertion("Editor navigation does not expose Workflows", () => {
const editorItems = getRoleNavigationItems("EDITOR");
assert.equal(
editorItems.some(
(item) =>
item.id === "workflows" ||
item.href === "/admin/workflows" ||
item.label === "Workflows",
),
false,
);
});
}
if (failures.length > 0) {
assert.fail(
`Admin workflow builder frontend pre-requirements missing:\n- ${failures.join("\n- ")}`,
);
}