refactor: retain workflow as optional editor

This commit is contained in:
2026-07-31 16:59:02 +02:00
parent 484ac43352
commit 8fd8753012
33 changed files with 450 additions and 11483 deletions
+59
View File
@@ -136,10 +136,30 @@ export type WorkflowRevision = {
view_id?: string | null;
view_revision_id?: string | null;
bpmn?: BpmnRevisionSummary | null;
contribution_origin_module_version?: string | null;
contribution_schema_version?: string | null;
contribution_hash?: string | null;
contribution_metadata: Record<string, unknown>;
created_by?: string | null;
created_at: string;
};
export type WorkflowStandardProvenance = {
kind: "baseline" | "override";
origin_module_id: string;
origin_module_version?: string | null;
definition_key: string;
contribution_schema_version?: string | null;
contribution_hash?: string | null;
baseline_definition_id: string;
latest_baseline_revision: number;
active_baseline_revision?: number | null;
pinned_baseline_revision?: number | null;
pinned_baseline_hash?: string | null;
update_available: boolean;
reset_available: boolean;
};
export type WorkflowDefinition = {
id: string;
tenant_id: string | null;
@@ -156,6 +176,7 @@ export type WorkflowDefinition = {
updated_at: string;
revision: WorkflowRevision;
governance: WorkflowGovernance;
standard?: WorkflowStandardProvenance | null;
};
export type WorkflowActionDecision = {
@@ -409,6 +430,33 @@ export function deriveWorkflowDefinition(
);
}
export function reconcileWorkflowStandards(
settings: ApiSettings
): Promise<{
discovered: number;
created: number;
updated: number;
unchanged: number;
blocked: number;
pending_tenant_scope: number;
items: Array<Record<string, unknown>>;
}> {
return apiFetch(settings, "/api/v1/workflow/standards/reconcile", {
method: "POST"
});
}
export function resetWorkflowDefinitionToStandard(
settings: ApiSettings,
definitionId: string
): Promise<WorkflowDefinition> {
return apiFetch(
settings,
`/api/v1/workflow/definitions/${encodeURIComponent(definitionId)}/reset-standard`,
{ method: "POST" }
);
}
export async function listWorkflowRevisions(
settings: ApiSettings,
definitionId: string
@@ -420,6 +468,17 @@ export async function listWorkflowRevisions(
return response.revisions;
}
export function getWorkflowRevision(
settings: ApiSettings,
definitionId: string,
revision: number
): Promise<WorkflowRevision> {
return apiFetch(
settings,
`/api/v1/workflow/definitions/${encodeURIComponent(definitionId)}/revisions/${revision}`
);
}
export function getWorkflowRevisionBpmn(
settings: ApiSettings,
definitionId: string,
+207 -5
View File
@@ -49,10 +49,13 @@ import {
createWorkflowDefinition,
deleteWorkflowDefinition,
deriveWorkflowDefinition,
getWorkflowRevision,
listWorkflowDefinitions,
listWorkflowNodeTypes,
listWorkflowRevisions,
reconcileWorkflowStandards,
renderWorkflowBpmn,
resetWorkflowDefinitionToStandard,
updateWorkflowDefinition,
validateWorkflowDefinition,
workflowScopeReferenceProvider,
@@ -118,6 +121,8 @@ export default function WorkflowPage({
const [definitionSettingsOpen, setDefinitionSettingsOpen] = useState(false);
const [deriveOpen, setDeriveOpen] = useState(false);
const [runsOpen, setRunsOpen] = useState(false);
const [resetOpen, setResetOpen] = useState(false);
const [compareOpen, setCompareOpen] = useState(false);
const bpmnFileInputRef = useRef<HTMLInputElement | null>(null);
const canWrite = hasScope(auth, "workflow:definition:write")
@@ -125,6 +130,10 @@ export default function WorkflowPage({
const canEdit = canWrite && (
!draft?.id || draft.governance?.actions.edit?.allowed !== false
);
const canActivate = Boolean(
canWrite
&& (draft?.standard?.kind === "baseline" || canEdit)
);
const canReuse = Boolean(
draft?.id
&& canWrite
@@ -142,6 +151,10 @@ export default function WorkflowPage({
() => definitions.find((item) => item.id === draft?.id) ?? null,
[definitions, draft?.id]
);
const baselineDefinition = useMemo(() => {
const baselineId = selectedDefinition?.standard?.baseline_definition_id;
return definitions.find((item) => item.id === baselineId) ?? null;
}, [definitions, selectedDefinition]);
const dirty = Boolean(draft)
&& workflowFingerprint(draft) !== workflowFingerprint(savedDraft);
const displayedGraph = historicalRevision?.graph ?? draft?.graph ?? null;
@@ -480,6 +493,42 @@ export default function WorkflowPage({
}
};
const reconcileStandards = async () => {
setWorking(true);
setError("");
setSuccess("");
try {
const result = await reconcileWorkflowStandards(settings);
await reload(draft?.id);
setSuccess(
`Module standards: ${result.created} installed, ${result.updated} updated, ${result.blocked} blocked.`
);
} catch (reconcileError) {
setError(apiErrorMessage(reconcileError));
} finally {
setWorking(false);
}
};
const resetToStandard = async () => {
if (!draft?.id) return;
setWorking(true);
setError("");
try {
const baseline = await resetWorkflowDefinitionToStandard(
settings,
draft.id
);
setResetOpen(false);
await reload(baseline.id);
setSuccess("The local override was archived and the module standard restored.");
} catch (resetError) {
setError(apiErrorMessage(resetError));
} finally {
setWorking(false);
}
};
const updateGraph = (graph: WorkflowDraft["graph"]) => {
if (graphReadOnly) return;
setDraft((current) => current ? { ...current, graph } : current);
@@ -532,6 +581,15 @@ export default function WorkflowPage({
onClick={() => void reload(draft?.id)}
disabled={loading || working}
/>
{hasScope(auth, "workflow:instance:admin") ? (
<IconButton
label="Reconcile module standards"
icon={<GitFork size={16} />}
variant="ghost"
onClick={() => void reconcileStandards()}
disabled={loading || working}
/>
) : null}
<IconButton
label="New workflow"
icon={<Plus size={17} />}
@@ -567,6 +625,14 @@ export default function WorkflowPage({
<small>
{definition.governance.scope_type} · {definition.governance.definition_kind}
</small>
{definition.standard ? (
<small>
{definition.standard.kind === "baseline"
? `Standard · ${definition.standard.origin_module_id}`
: `Override · ${definition.standard.origin_module_id}`}
{definition.standard.update_available ? " · update available" : ""}
</small>
) : null}
</span>
<StatusBadge
status={definition.status}
@@ -634,6 +700,24 @@ export default function WorkflowPage({
))}
</select>
) : null}
{draft.standard?.kind === "override"
&& draft.standard.update_available ? (
<Button
onClick={() => setCompareOpen(true)}
disabled={working}
>
<GitFork size={16} /> Compare update
</Button>
) : null}
{draft.standard?.kind === "override"
&& draft.standard.reset_available ? (
<Button
onClick={() => setResetOpen(true)}
disabled={working || dirty}
>
<RotateCcw size={16} /> Reset
</Button>
) : null}
{historicalRevision ? (
<Button
onClick={() => {
@@ -703,7 +787,9 @@ export default function WorkflowPage({
disabled={!canReuse}
/>
) : null}
{draft.id && draft.status !== "archived" ? (
{draft.id
&& draft.status !== "archived"
&& draft.standard?.kind !== "baseline" ? (
<Button
onClick={() => void archiveDefinition()}
disabled={!canEdit || dirty || working || readOnly}
@@ -711,15 +797,18 @@ export default function WorkflowPage({
<Archive size={16} /> Archive
</Button>
) : null}
{draft.id && draft.status !== "active" ? (
{draft.id && (
draft.status !== "active"
|| draft.activeRevision !== draft.currentRevision
) ? (
<Button
variant="primary"
onClick={() => void activate()}
disabled={
!canEdit
!canActivate
|| dirty
|| working
|| readOnly
|| historicalRevision !== null
|| draft.definitionKind === "template"
}
disabledReason={
@@ -740,7 +829,7 @@ export default function WorkflowPage({
>
<Save size={16} /> Save
</Button>
{draft.id ? (
{draft.id && draft.standard?.kind !== "baseline" ? (
<IconButton
label="Delete workflow"
icon={<Trash2 size={16} />}
@@ -885,6 +974,22 @@ export default function WorkflowPage({
onCancel={() => setDeleteOpen(false)}
onConfirm={() => void removeDefinition()}
/>
<ConfirmDialog
open={resetOpen}
title="Reset to module standard"
message={`Archive ${draft?.name ?? "this override"} and restore the latest module standard? Historical revisions and running instances remain unchanged.`}
confirmLabel="Reset"
busy={working}
onCancel={() => setResetOpen(false)}
onConfirm={() => void resetToStandard()}
/>
<WorkflowStandardComparisonDialog
open={compareOpen}
settings={settings}
override={selectedDefinition}
baseline={baselineDefinition}
onClose={() => setCompareOpen(false)}
/>
<WorkflowDefinitionSettingsDialog
open={definitionSettingsOpen}
settings={settings}
@@ -932,6 +1037,103 @@ export default function WorkflowPage({
);
}
function WorkflowStandardComparisonDialog({
open,
settings,
override,
baseline,
onClose
}: {
open: boolean;
settings: ApiSettings;
override: WorkflowDefinition | null;
baseline: WorkflowDefinition | null;
onClose: () => void;
}) {
const [pinned, setPinned] = useState<WorkflowRevision | null>(null);
const [latest, setLatest] = useState<WorkflowRevision | null>(null);
const [error, setError] = useState("");
const standard = override?.standard;
useEffect(() => {
if (!open || !baseline || !standard) return;
let cancelled = false;
setError("");
const pinnedRevision = standard.pinned_baseline_revision
?? standard.active_baseline_revision
?? 1;
void Promise.all([
getWorkflowRevision(settings, baseline.id, pinnedRevision),
getWorkflowRevision(
settings,
baseline.id,
standard.latest_baseline_revision
)
]).then(([pinnedItem, latestItem]) => {
if (cancelled) return;
setPinned(pinnedItem);
setLatest(latestItem);
}).catch((loadError) => {
if (!cancelled) setError(apiErrorMessage(loadError));
});
return () => {
cancelled = true;
};
}, [baseline, open, settings, standard]);
return (
<Dialog
open={open}
title="Compare module standard update"
className="workflow-standard-comparison-dialog"
onClose={onClose}
footer={<Button onClick={onClose}>Close</Button>}
>
{error ? (
<DismissibleAlert tone="danger" resetKey={error}>
{error}
</DismissibleAlert>
) : null}
<div className="workflow-standard-comparison">
<StandardRevisionColumn
label="Pinned standard"
revision={pinned}
/>
<StandardRevisionColumn
label="Local override"
revision={override?.revision ?? null}
/>
<StandardRevisionColumn
label="Latest standard"
revision={latest}
/>
</div>
</Dialog>
);
}
function StandardRevisionColumn({
label,
revision
}: {
label: string;
revision: WorkflowRevision | null;
}) {
return (
<section className="workflow-standard-revision">
<header>
<strong>{label}</strong>
<small>
{revision
? `Revision ${revision.revision} · ${revision.content_hash.slice(0, 12)}`
: "Loading..."}
</small>
</header>
<pre>{revision ? JSON.stringify(revision.graph, null, 2) : ""}</pre>
</section>
);
}
function WorkflowDefinitionSettingsDialog({
open,
settings,
+5 -1
View File
@@ -9,6 +9,7 @@ import type {
WorkflowGraphNode,
WorkflowExecutionMode,
WorkflowNodeType,
WorkflowStandardProvenance,
WorkflowStatus
} from "../../api/workflow";
@@ -32,6 +33,7 @@ export type WorkflowDraft = {
viewId: string;
viewRevisionId: string;
governance: WorkflowGovernance | null;
standard: WorkflowStandardProvenance | null;
};
const inputPort = [{
@@ -133,6 +135,7 @@ export function sampleWorkflowDraft(): WorkflowDraft {
viewId: "",
viewRevisionId: "",
governance: null,
standard: null,
graph: {
schema_version: 1,
nodes: [
@@ -247,7 +250,8 @@ export function draftFromDefinition(
executionMode: definition.revision.execution_mode,
viewId: definition.revision.view_id ?? "",
viewRevisionId: definition.revision.view_revision_id ?? "",
governance: definition.governance
governance: definition.governance,
standard: definition.standard ?? null
};
}
+58
View File
@@ -13,6 +13,64 @@
width: min(620px, calc(100vw - 32px));
}
.workflow-standard-comparison-dialog {
width: min(1180px, calc(100vw - 32px));
height: min(760px, calc(100vh - 32px));
}
.workflow-standard-comparison {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
height: 100%;
min-height: 0;
gap: 10px;
overflow: hidden;
}
.workflow-standard-revision {
display: flex;
min-width: 0;
min-height: 0;
flex-direction: column;
border: var(--border-line);
border-radius: var(--radius-sm);
background: var(--panel-soft);
overflow: hidden;
}
.workflow-standard-revision header {
display: grid;
gap: 3px;
padding: 10px;
border-bottom: var(--border-line);
}
.workflow-standard-revision small {
color: var(--muted);
font-size: 11px;
}
.workflow-standard-revision pre {
min-height: 0;
flex: 1 1 auto;
margin: 0;
padding: 10px;
overflow: auto;
font-size: 11px;
white-space: pre;
}
@media (max-width: 900px) {
.workflow-standard-comparison {
grid-template-columns: 1fr;
overflow: auto;
}
.workflow-standard-revision {
min-height: 260px;
}
}
.workflow-definition-fields {
display: grid;
gap: 12px;