Add governed reusable workflow definitions

This commit is contained in:
2026-07-28 15:04:32 +02:00
parent 6737b60c11
commit 1ec8336c02
16 changed files with 1986 additions and 38 deletions

View File

@@ -6,6 +6,8 @@ import type {
} from "@govoplan/core-webui/definition-graph";
export type WorkflowStatus = "draft" | "active" | "archived";
export type DefinitionScopeType = "system" | "tenant" | "group" | "user";
export type DefinitionKind = "flow" | "template";
export type WorkflowGraphNode = DefinitionGraphNode;
export type WorkflowGraph = DefinitionGraph & {
schema_version: 1;
@@ -36,7 +38,7 @@ export type WorkflowRevision = {
export type WorkflowDefinition = {
id: string;
tenant_id: string;
tenant_id: string | null;
key: string;
name: string;
description?: string | null;
@@ -49,6 +51,32 @@ export type WorkflowDefinition = {
created_at: string;
updated_at: string;
revision: WorkflowRevision;
governance: WorkflowGovernance;
};
export type WorkflowActionDecision = {
allowed: boolean;
reason?: string | null;
source_path: Array<Record<string, unknown>>;
requirements: string[];
details: Record<string, unknown>;
};
export type WorkflowGovernance = {
scope_type: DefinitionScopeType;
scope_id?: string | null;
definition_kind: DefinitionKind;
inherit_to_lower_scopes: boolean;
allow_start: boolean;
allow_reuse: boolean;
allow_automation: boolean;
derived_from_definition_id?: string | null;
derived_from_revision?: number | null;
derived_from_hash?: string | null;
derivation_provenance: Record<string, unknown>;
actions: Record<string, WorkflowActionDecision>;
automation_runtime_available: boolean;
automation_runtime_reason?: string | null;
};
export type WorkflowDefinitionPayload = {
@@ -56,6 +84,13 @@ export type WorkflowDefinitionPayload = {
description?: string | null;
graph: WorkflowGraph;
metadata: Record<string, unknown>;
scope_type: DefinitionScopeType;
scope_id?: string | null;
definition_kind: DefinitionKind;
inherit_to_lower_scopes: boolean;
allow_start: boolean;
allow_reuse: boolean;
allow_automation: boolean;
};
export async function listWorkflowNodeTypes(
@@ -99,6 +134,31 @@ export function updateWorkflowDefinition(
);
}
export function deriveWorkflowDefinition(
settings: ApiSettings,
definitionId: string,
payload: {
key?: string | null;
name: string;
description?: string | null;
source_revision?: number | null;
metadata: Record<string, unknown>;
scope_type: DefinitionScopeType;
scope_id?: string | null;
definition_kind: DefinitionKind;
inherit_to_lower_scopes: boolean;
allow_start: boolean;
allow_reuse: boolean;
allow_automation: boolean;
}
): Promise<WorkflowDefinition> {
return apiFetch(
settings,
`/api/v1/workflow/definitions/${encodeURIComponent(definitionId)}/derive`,
{ method: "POST", body: JSON.stringify(payload) }
);
}
export async function listWorkflowRevisions(
settings: ApiSettings,
definitionId: string

View File

@@ -8,21 +8,26 @@ import {
import {
Archive,
CheckCircle2,
CopyPlus,
GitFork,
Plus,
RefreshCw,
RotateCcw,
Save,
Settings2,
Trash2
} from "lucide-react";
import { ReactFlowProvider } from "@xyflow/react";
import {
Button,
ConfirmDialog,
Dialog,
DismissibleAlert,
FormField,
IconButton,
LoadingFrame,
StatusBadge,
ToggleSwitch,
hasScope,
isApiError,
useUnsavedChanges,
@@ -35,6 +40,7 @@ import {
archiveWorkflowDefinition,
createWorkflowDefinition,
deleteWorkflowDefinition,
deriveWorkflowDefinition,
listWorkflowDefinitions,
listWorkflowNodeTypes,
listWorkflowRevisions,
@@ -93,13 +99,23 @@ export default function WorkflowPage({
const [success, setSuccess] = useState("");
const [diagnostics, setDiagnostics] = useState<WorkflowDiagnostic[]>([]);
const [deleteOpen, setDeleteOpen] = useState(false);
const [definitionSettingsOpen, setDefinitionSettingsOpen] = useState(false);
const [deriveOpen, setDeriveOpen] = useState(false);
const canWrite = hasScope(auth, "workflow:definition:write")
|| hasScope(auth, "workflow:instance:admin");
const canEdit = canWrite && (
!draft?.id || draft.governance?.actions.edit?.allowed !== false
);
const canReuse = Boolean(
draft?.id
&& canWrite
&& draft.governance?.actions.derive?.allowed
);
const dirty = Boolean(draft)
&& workflowFingerprint(draft) !== workflowFingerprint(savedDraft);
const displayedGraph = historicalRevision?.graph ?? draft?.graph ?? null;
const readOnly = !canWrite || historicalRevision !== null;
const readOnly = !canEdit || historicalRevision !== null;
const selectedNode = useMemo(
() => displayedGraph?.nodes.find((node) => node.id === selectedNodeId) ?? null,
[displayedGraph, selectedNodeId]
@@ -205,7 +221,7 @@ export default function WorkflowPage({
}, [savedDraft]);
const saveDraft = useCallback(async (): Promise<boolean> => {
if (!draft || !canWrite || !draft.name.trim()) {
if (!draft || !canEdit || !draft.name.trim()) {
setError(
!draft?.name.trim()
? "Workflow name is required."
@@ -236,7 +252,7 @@ export default function WorkflowPage({
} finally {
setWorking(false);
}
}, [applyDefinition, canWrite, draft, settings]);
}, [applyDefinition, canEdit, draft, settings]);
useUnsavedDraftGuard({
dirty,
@@ -413,6 +429,9 @@ export default function WorkflowPage({
<small>
{definition.key} · revision {definition.current_revision}
</small>
<small>
{definition.governance.scope_type} · {definition.governance.definition_kind}
</small>
</span>
<StatusBadge
status={definition.status}
@@ -502,7 +521,7 @@ export default function WorkflowPage({
setHistoricalRevision(null);
setDiagnostics([]);
}}
disabled={!canWrite}
disabled={!canEdit}
>
<RotateCcw size={16} /> Restore
</Button>
@@ -510,10 +529,25 @@ export default function WorkflowPage({
<Button onClick={() => void validate()} disabled={working}>
<CheckCircle2 size={16} /> Validate
</Button>
<IconButton
label="Definition settings"
icon={<Settings2 size={16} />}
variant="ghost"
onClick={() => setDefinitionSettingsOpen(true)}
/>
{draft.id ? (
<IconButton
label="Reuse as scoped copy"
icon={<CopyPlus size={16} />}
variant="ghost"
onClick={() => setDeriveOpen(true)}
disabled={!canReuse}
/>
) : null}
{draft.id && draft.status !== "archived" ? (
<Button
onClick={() => void archiveDefinition()}
disabled={!canWrite || dirty || working || readOnly}
disabled={!canEdit || dirty || working || readOnly}
>
<Archive size={16} /> Archive
</Button>
@@ -522,7 +556,18 @@ export default function WorkflowPage({
<Button
variant="primary"
onClick={() => void activate()}
disabled={!canWrite || dirty || working || readOnly}
disabled={
!canEdit
|| dirty
|| working
|| readOnly
|| draft.definitionKind === "template"
}
disabledReason={
draft.definitionKind === "template"
? "Templates cannot be activated or started."
: undefined
}
>
<CheckCircle2 size={16} /> Activate
</Button>
@@ -530,7 +575,7 @@ export default function WorkflowPage({
<Button
variant="primary"
onClick={() => void saveDraft()}
disabled={!canWrite || !dirty || working || readOnly}
disabled={!canEdit || !dirty || working || readOnly}
>
<Save size={16} /> Save
</Button>
@@ -540,7 +585,7 @@ export default function WorkflowPage({
icon={<Trash2 size={16} />}
variant="danger"
onClick={() => setDeleteOpen(true)}
disabled={!canWrite || working}
disabled={!canEdit || working}
/>
) : null}
</span>
@@ -674,10 +719,287 @@ export default function WorkflowPage({
onCancel={() => setDeleteOpen(false)}
onConfirm={() => void removeDefinition()}
/>
<WorkflowDefinitionSettingsDialog
open={definitionSettingsOpen}
draft={draft}
editable={canEdit}
onChange={(patch) => setDraft((current) => current ? { ...current, ...patch } : current)}
onClose={() => setDefinitionSettingsOpen(false)}
/>
<DeriveWorkflowDialog
open={deriveOpen}
settings={settings}
definition={draft?.id ? {
id: draft.id,
name: draft.name,
revision: draft.currentRevision ?? 1
} : null}
onClose={() => setDeriveOpen(false)}
onDerived={(definition) => {
applyDefinition(definition);
setDefinitions((current) => [
definition,
...current.filter((item) => item.id !== definition.id)
]);
setDeriveOpen(false);
setSuccess("Created a pinned scoped copy.");
}}
/>
</main>
);
}
function WorkflowDefinitionSettingsDialog({
open,
draft,
editable,
onChange,
onClose
}: {
open: boolean;
draft: WorkflowDraft | null;
editable: boolean;
onChange: (patch: Partial<WorkflowDraft>) => void;
onClose: () => void;
}) {
const provenance = draft?.governance?.actions.edit?.source_path ?? [];
return (
<Dialog
open={open}
title="Definition settings"
className="workflow-definition-dialog"
onClose={onClose}
footer={<Button onClick={onClose}>Close</Button>}
>
{draft ? (
<div className="workflow-definition-fields">
<FormField
label="Scope"
help="The scope determines ownership, visibility, and the Policy inheritance path."
>
<select
value={draft.scopeType}
disabled={!editable || Boolean(draft.id)}
onChange={(event) => onChange({
scopeType: event.target.value as WorkflowDraft["scopeType"],
scopeId: ""
})}
>
<option value="system">System</option>
<option value="tenant">Tenant</option>
<option value="group">Group</option>
<option value="user">User</option>
</select>
</FormField>
<FormField label="Scope ID">
<input
value={draft.scopeId}
disabled={
!editable
|| Boolean(draft.id)
|| draft.scopeType === "system"
|| draft.scopeType === "tenant"
}
placeholder={
draft.scopeType === "user"
? "Current user when empty"
: "Required for group"
}
onChange={(event) => onChange({ scopeId: event.target.value })}
/>
</FormField>
<FormField
label="Definition kind"
help="Templates can be reused or derived, but cannot be activated or started."
>
<select
value={draft.definitionKind}
disabled={!editable || Boolean(draft.id)}
onChange={(event) => onChange({
definitionKind: event.target.value as WorkflowDraft["definitionKind"],
status: "draft"
})}
>
<option value="flow">Complete flow</option>
<option value="template">Template</option>
</select>
</FormField>
<div className="workflow-definition-toggles">
<ToggleSwitch
label="Visible to lower scopes"
checked={draft.inheritToLowerScopes}
disabled={!editable}
onChange={(value) => onChange({ inheritToLowerScopes: value })}
/>
<ToggleSwitch
label="Allow starts"
checked={draft.allowStart}
disabled={!editable}
onChange={(value) => onChange({ allowStart: value })}
/>
<ToggleSwitch
label="Allow reuse"
checked={draft.allowReuse}
disabled={!editable}
onChange={(value) => onChange({ allowReuse: value })}
/>
<ToggleSwitch
label="Allow automation"
checked={draft.allowAutomation}
disabled={!editable}
onChange={(value) => onChange({ allowAutomation: value })}
/>
</div>
{draft.governance?.automation_runtime_reason ? (
<DismissibleAlert
tone="warning"
resetKey={draft.governance.automation_runtime_reason}
>
{draft.governance.automation_runtime_reason}
</DismissibleAlert>
) : null}
{draft.governance?.derived_from_definition_id ? (
<section className="workflow-provenance">
<strong>Derived from</strong>
<span>
{draft.governance.derived_from_definition_id}
{" · revision "}
{draft.governance.derived_from_revision}
</span>
<code>{draft.governance.derived_from_hash}</code>
</section>
) : null}
{provenance.length ? (
<section className="workflow-provenance">
<strong>Effective Policy path</strong>
{provenance.map((item, index) => (
<span key={`${String(item.path ?? item.scope_type)}-${index}`}>
{String(item.label ?? item.path ?? item.scope_type)}
</span>
))}
{!editable && draft.governance?.actions.edit?.reason ? (
<small>{draft.governance.actions.edit.reason}</small>
) : null}
</section>
) : null}
</div>
) : null}
</Dialog>
);
}
function DeriveWorkflowDialog({
open,
settings,
definition,
onClose,
onDerived
}: {
open: boolean;
settings: ApiSettings;
definition: { id: string; name: string; revision: number } | null;
onClose: () => void;
onDerived: (definition: WorkflowDefinition) => void;
}) {
const [name, setName] = useState("");
const [kind, setKind] = useState<"flow" | "template">("flow");
const [scopeType, setScopeType] = useState<"tenant" | "group" | "user">("tenant");
const [scopeId, setScopeId] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
useEffect(() => {
if (!open) return;
setName(`${definition?.name ?? "Workflow"} copy`);
setKind("flow");
setScopeType("tenant");
setScopeId("");
setError("");
}, [open, definition?.id]);
const derive = async () => {
if (!definition || !name.trim()) return;
setBusy(true);
setError("");
try {
onDerived(await deriveWorkflowDefinition(settings, definition.id, {
name: name.trim(),
source_revision: definition.revision,
metadata: {},
scope_type: scopeType,
scope_id: scopeId.trim() || null,
definition_kind: kind,
inherit_to_lower_scopes: false,
allow_start: true,
allow_reuse: false,
allow_automation: false
}));
} catch (deriveError) {
setError(apiErrorMessage(deriveError));
} finally {
setBusy(false);
}
};
return (
<Dialog
open={open}
title="Reuse as scoped copy"
className="workflow-definition-dialog"
closeDisabled={busy}
onClose={onClose}
footer={(
<>
<Button onClick={onClose} disabled={busy}>Cancel</Button>
<Button
variant="primary"
onClick={() => void derive()}
disabled={busy || !definition || !name.trim()}
>
<CopyPlus size={16} /> Create copy
</Button>
</>
)}
>
<div className="workflow-definition-fields">
{error ? <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert> : null}
<FormField label="Name">
<input value={name} onChange={(event) => setName(event.target.value)} />
</FormField>
<FormField label="Target scope">
<select
value={scopeType}
onChange={(event) => setScopeType(event.target.value as typeof scopeType)}
>
<option value="tenant">Tenant</option>
<option value="group">Group</option>
<option value="user">User</option>
</select>
</FormField>
{scopeType !== "tenant" ? (
<FormField label="Scope ID">
<input
value={scopeId}
placeholder={scopeType === "user" ? "Current user when empty" : "Group ID"}
onChange={(event) => setScopeId(event.target.value)}
/>
</FormField>
) : null}
<FormField label="Copy kind">
<select value={kind} onChange={(event) => setKind(event.target.value as typeof kind)}>
<option value="flow">Complete flow</option>
<option value="template">Template</option>
</select>
</FormField>
<small>
The source graph revision, node-library version, content hash, and
effective ancestor limits are retained as provenance.
</small>
</div>
</Dialog>
);
}
function startPaletteDrag(
event: DragEvent<HTMLButtonElement>,
type: string

View File

@@ -1,7 +1,10 @@
import { createDefinitionGraphNode } from "@govoplan/core-webui/definition-graph";
import type {
DefinitionKind,
DefinitionScopeType,
WorkflowDefinition,
WorkflowDefinitionPayload,
WorkflowGovernance,
WorkflowGraph,
WorkflowGraphNode,
WorkflowNodeType,
@@ -17,6 +20,14 @@ export type WorkflowDraft = {
activeRevision?: number | null;
metadata: Record<string, unknown>;
graph: WorkflowGraph;
scopeType: DefinitionScopeType;
scopeId: string;
definitionKind: DefinitionKind;
inheritToLowerScopes: boolean;
allowStart: boolean;
allowReuse: boolean;
allowAutomation: boolean;
governance: WorkflowGovernance | null;
};
const inputPort = [{
@@ -92,6 +103,14 @@ export function sampleWorkflowDraft(): WorkflowDraft {
description: "",
status: "draft",
metadata: {},
scopeType: "tenant",
scopeId: "",
definitionKind: "flow",
inheritToLowerScopes: false,
allowStart: true,
allowReuse: false,
allowAutomation: false,
governance: null,
graph: {
schema_version: 1,
nodes: [
@@ -153,7 +172,15 @@ export function draftFromDefinition(
currentRevision: definition.current_revision,
activeRevision: definition.active_revision,
metadata: structuredClone(definition.metadata),
graph: structuredClone(definition.revision.graph)
graph: structuredClone(definition.revision.graph),
scopeType: definition.governance.scope_type,
scopeId: definition.governance.scope_id ?? "",
definitionKind: definition.governance.definition_kind,
inheritToLowerScopes: definition.governance.inherit_to_lower_scopes,
allowStart: definition.governance.allow_start,
allowReuse: definition.governance.allow_reuse,
allowAutomation: definition.governance.allow_automation,
governance: definition.governance
};
}
@@ -164,7 +191,14 @@ export function workflowPayload(
name: draft.name.trim(),
description: draft.description.trim() || null,
graph: draft.graph,
metadata: draft.metadata
metadata: draft.metadata,
scope_type: draft.scopeType,
scope_id: draft.scopeId.trim() || null,
definition_kind: draft.definitionKind,
inherit_to_lower_scopes: draft.inheritToLowerScopes,
allow_start: draft.allowStart,
allow_reuse: draft.allowReuse,
allow_automation: draft.allowAutomation
};
}
@@ -176,7 +210,14 @@ export function workflowFingerprint(
name: draft.name,
description: draft.description,
graph: draft.graph,
metadata: draft.metadata
metadata: draft.metadata,
scopeType: draft.scopeType,
scopeId: draft.scopeId,
definitionKind: draft.definitionKind,
inheritToLowerScopes: draft.inheritToLowerScopes,
allowStart: draft.allowStart,
allowReuse: draft.allowReuse,
allowAutomation: draft.allowAutomation
});
}

View File

@@ -9,6 +9,53 @@
background: var(--bg);
}
.workflow-definition-dialog {
width: min(620px, calc(100vw - 32px));
}
.workflow-definition-fields {
display: grid;
gap: 12px;
}
.workflow-definition-fields input,
.workflow-definition-fields select {
width: 100%;
}
.workflow-definition-toggles {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px 18px;
}
.workflow-provenance {
display: grid;
gap: 6px;
padding: 10px;
border: var(--border-line);
border-radius: var(--radius-sm);
background: var(--panel-soft);
}
.workflow-provenance span,
.workflow-provenance small {
color: var(--muted);
font-size: 12px;
}
.workflow-provenance code {
overflow: hidden;
font-size: 11px;
text-overflow: ellipsis;
}
@media (max-width: 640px) {
.workflow-definition-toggles {
grid-template-columns: 1fr;
}
}
.workflow-page *,
.workflow-page *::before,
.workflow-page *::after {