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
@@ -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;
}