Add governed reusable workflow definitions
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user