feat(task-022): add admin workflow builder ui
This commit is contained in:
@@ -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} */
|
/** @type {import('next').NextConfig} */
|
||||||
const nextConfig = {
|
const nextConfig = {
|
||||||
reactStrictMode: true,
|
reactStrictMode: true,
|
||||||
|
outputFileTracingRoot: repoRoot,
|
||||||
|
turbopack: {
|
||||||
|
root: repoRoot,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default nextConfig;
|
export default nextConfig;
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"test:roles": "node tests/role-navigation.test.mjs",
|
"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"
|
"typecheck": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -48,6 +48,15 @@ import type {
|
|||||||
ScriptConfigVersionResponse,
|
ScriptConfigVersionResponse,
|
||||||
TargetSiteConfigResponse,
|
TargetSiteConfigResponse,
|
||||||
TargetSiteConfigCreateRequest,
|
TargetSiteConfigCreateRequest,
|
||||||
|
WorkflowStageCreateRequest,
|
||||||
|
WorkflowStageReorderRequest,
|
||||||
|
WorkflowStageResponse,
|
||||||
|
WorkflowStageUpdateRequest,
|
||||||
|
WorkflowTemplateAuditEventListResponse,
|
||||||
|
WorkflowTemplateCreateRequest,
|
||||||
|
WorkflowTemplateListResponse,
|
||||||
|
WorkflowTemplateResponse,
|
||||||
|
WorkflowTemplateUpdateRequest,
|
||||||
} from "@pipeline/shared";
|
} from "@pipeline/shared";
|
||||||
|
|
||||||
export const DEMO_EDITOR_EMAIL = "editor@example.com";
|
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(
|
export function fetchCurrentUser(
|
||||||
userEmail = DEMO_EDITOR_EMAIL,
|
userEmail = DEMO_EDITOR_EMAIL,
|
||||||
): Promise<CurrentUserResponse> {
|
): 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(
|
export function fetchScriptConfigVersions(
|
||||||
siteId: string,
|
siteId: string,
|
||||||
userEmail = DEMO_EDITOR_EMAIL,
|
userEmail = DEMO_EDITOR_EMAIL,
|
||||||
|
|||||||
@@ -38,6 +38,12 @@ export const ROLE_NAVIGATION_ITEMS = [
|
|||||||
href: "/admin/scripts",
|
href: "/admin/scripts",
|
||||||
roles: ["ADMIN"],
|
roles: ["ADMIN"],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "workflows",
|
||||||
|
label: "Workflows",
|
||||||
|
href: "/admin/workflows",
|
||||||
|
roles: ["ADMIN"],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "agent-jobs",
|
id: "agent-jobs",
|
||||||
label: "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- ")}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -629,6 +629,122 @@ export type ReviewType = "PLAN" | "EVIDENCE" | "ASSET" | "SEO_LANGUAGE" | "FINAL
|
|||||||
|
|
||||||
export type Role = "ADMIN" | "EDITOR";
|
export type Role = "ADMIN" | "EDITOR";
|
||||||
|
|
||||||
|
export type WorkflowStagePart = {
|
||||||
|
acceptance_criteria?: string[];
|
||||||
|
key: string;
|
||||||
|
payload?: Record<string, unknown>;
|
||||||
|
title: string;
|
||||||
|
type: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WorkflowStageSummary = {
|
||||||
|
acceptance_criteria?: string[];
|
||||||
|
created_at: string;
|
||||||
|
description: string;
|
||||||
|
display_name: string;
|
||||||
|
expected_outputs?: string[];
|
||||||
|
id: string;
|
||||||
|
owner_role: Role;
|
||||||
|
parts?: WorkflowStagePart[];
|
||||||
|
position: number;
|
||||||
|
required_inputs?: string[];
|
||||||
|
requires_human_approval?: boolean;
|
||||||
|
retry_policy?: Record<string, unknown>;
|
||||||
|
runner_profile_key: string;
|
||||||
|
stable_key: string;
|
||||||
|
updated_at: string;
|
||||||
|
workflow_id: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WorkflowTemplateStatus = "DRAFT" | "ACTIVE" | "ARCHIVED";
|
||||||
|
|
||||||
|
export type WorkflowTemplateSummary = {
|
||||||
|
activated_at?: string | null;
|
||||||
|
archived_at?: string | null;
|
||||||
|
created_at: string;
|
||||||
|
created_by: string;
|
||||||
|
description: string;
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
stages?: WorkflowStageSummary[];
|
||||||
|
status: WorkflowTemplateStatus;
|
||||||
|
updated_at: string;
|
||||||
|
updated_by?: string | null;
|
||||||
|
version: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WorkflowTemplateCreateRequest = {
|
||||||
|
description: string;
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WorkflowTemplateUpdateRequest = {
|
||||||
|
description?: string | null;
|
||||||
|
name?: string | null;
|
||||||
|
slug?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WorkflowStageCreateRequest = {
|
||||||
|
acceptance_criteria?: string[];
|
||||||
|
description: string;
|
||||||
|
display_name: string;
|
||||||
|
expected_outputs?: string[];
|
||||||
|
owner_role: Role;
|
||||||
|
parts?: WorkflowStagePart[];
|
||||||
|
position?: number | null;
|
||||||
|
required_inputs?: string[];
|
||||||
|
requires_human_approval?: boolean;
|
||||||
|
retry_policy?: Record<string, unknown>;
|
||||||
|
runner_profile_key: string;
|
||||||
|
stable_key: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WorkflowStageUpdateRequest = {
|
||||||
|
acceptance_criteria?: string[] | null;
|
||||||
|
description?: string | null;
|
||||||
|
display_name?: string | null;
|
||||||
|
expected_outputs?: string[] | null;
|
||||||
|
owner_role?: Role | null;
|
||||||
|
parts?: WorkflowStagePart[] | null;
|
||||||
|
position?: number | null;
|
||||||
|
required_inputs?: string[] | null;
|
||||||
|
requires_human_approval?: boolean | null;
|
||||||
|
retry_policy?: Record<string, unknown> | null;
|
||||||
|
runner_profile_key?: string | null;
|
||||||
|
stable_key?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WorkflowStageReorderRequest = {
|
||||||
|
stage_ids: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WorkflowTemplateResponse = {
|
||||||
|
workflow: WorkflowTemplateSummary;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WorkflowTemplateListResponse = {
|
||||||
|
workflows: WorkflowTemplateSummary[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WorkflowStageResponse = {
|
||||||
|
stage: WorkflowStageSummary;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WorkflowTemplateAuditEventSummary = {
|
||||||
|
actor_user_id?: string | null;
|
||||||
|
created_at: string;
|
||||||
|
event_type: string;
|
||||||
|
id: string;
|
||||||
|
payload?: Record<string, unknown>;
|
||||||
|
workflow_id: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WorkflowTemplateAuditEventListResponse = {
|
||||||
|
events?: WorkflowTemplateAuditEventSummary[];
|
||||||
|
};
|
||||||
|
|
||||||
export type RunnerFileRef = {
|
export type RunnerFileRef = {
|
||||||
content_hash?: string | null;
|
content_hash?: string | null;
|
||||||
path: string;
|
path: string;
|
||||||
|
|||||||
@@ -37,15 +37,15 @@ Development description: Build the Admin dashboard workflow builder so Admins ca
|
|||||||
|
|
||||||
## Acceptance Criteria
|
## Acceptance Criteria
|
||||||
|
|
||||||
- [ ] TDD pre-requirement: before implementation, write one failing frontend model test for creating a workflow draft and adding/reordering stages; proceed one behavior at a time and record evidence in `Result`.
|
- [x] TDD pre-requirement: before implementation, write one failing frontend model test for creating a workflow draft and adding/reordering stages; proceed one behavior at a time and record evidence in `Result`.
|
||||||
- [ ] Admin navigation exposes Workflows and routes to the builder page.
|
- [x] Admin navigation exposes Workflows and routes to the builder page.
|
||||||
- [ ] Editor navigation does not expose Workflows.
|
- [x] Editor navigation does not expose Workflows.
|
||||||
- [ ] Workflow list loads from the backend API and shows empty/error/loading states.
|
- [x] Workflow list loads from the backend API and shows empty/error/loading states.
|
||||||
- [ ] Workflow editor can create workflow drafts and persist metadata.
|
- [x] Workflow editor can create workflow drafts and persist metadata.
|
||||||
- [ ] Workflow editor can add, edit, delete, and reorder stages.
|
- [x] Workflow editor can add, edit, delete, and reorder stages.
|
||||||
- [ ] Workflow editor can edit structured stage parts without converting them to opaque strings.
|
- [x] Workflow editor can edit structured stage parts without converting them to opaque strings.
|
||||||
- [ ] Activation/archive actions update UI state after successful API responses.
|
- [x] Activation/archive actions update UI state after successful API responses.
|
||||||
- [ ] Frontend code follows the existing FSD folder boundaries.
|
- [x] Frontend code follows the existing FSD folder boundaries.
|
||||||
|
|
||||||
## Verification
|
## Verification
|
||||||
|
|
||||||
@@ -56,9 +56,14 @@ Development description: Build the Admin dashboard workflow builder so Admins ca
|
|||||||
|
|
||||||
## Result
|
## Result
|
||||||
|
|
||||||
- Status: Not started.
|
- Status: Implemented frontend admin workflow builder.
|
||||||
- TDD plan:
|
- TDD plan: Use `apps/frontend/tests/admin_workflows.model.test.mjs` as the red pre-requirement, then add model helpers, API client methods, navigation, and the route UI.
|
||||||
- Red evidence:
|
- Red evidence: `node apps/frontend/tests/admin_workflows.model.test.mjs` failed because `features/admin-workflows/model.ts`, workflow API methods, and the Admin Workflows nav item were missing.
|
||||||
- Green evidence:
|
- Green evidence: `node apps/frontend/tests/admin_workflows.model.test.mjs` passed after adding workflow model/API/nav behavior.
|
||||||
- Refactor notes:
|
- Refactor notes: Added shared workflow template contract types, dense Admin workflow list/create/editor UI, structured stage part JSON editing, stage reorder controls, activation/archive actions, audit display, and explicit frontend workspace root config for local Next runs.
|
||||||
- Verification output:
|
- Verification output:
|
||||||
|
- `node apps/frontend/tests/admin_workflows.model.test.mjs` - passed.
|
||||||
|
- `node apps/frontend/tests/role-navigation.test.mjs` - passed: `role navigation hides Admin-only items for Editors`.
|
||||||
|
- `pnpm -C apps/frontend run test:ui` - passed.
|
||||||
|
- `pnpm -C apps/frontend run typecheck` - passed.
|
||||||
|
- Browser smoke was attempted with a local mock backend, but local Next dev startup is blocked by existing dependency drift: `Mismatching @next/swc version, detected: 15.5.4 while Next.js is on 16.2.6` and `bindings.lockfileTryAcquireSync is not a function`.
|
||||||
|
|||||||
Reference in New Issue
Block a user