feat(task-022): add admin workflow builder ui
This commit is contained in:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user