feat(dataflow): govern reusable definition updates
Module Package Release / publish-packages (push) Successful in 12s

This commit is contained in:
2026-08-21 18:27:09 +02:00
parent a86220db27
commit c1111c605f
17 changed files with 1338 additions and 55 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@govoplan/dataflow-webui",
"version": "0.1.19",
"version": "0.1.20",
"private": true,
"type": "module",
"main": "src/index.ts",
+23 -1
View File
@@ -95,6 +95,11 @@ export type PipelineGovernance = {
derived_from_pipeline_id?: string | null;
derived_from_revision?: number | null;
derived_from_hash?: string | null;
source_available: boolean;
source_name?: string | null;
source_current_revision?: number | null;
source_current_hash?: string | null;
update_available: boolean;
derivation_provenance: Record<string, unknown>;
actions: Record<string, DefinitionActionDecision>;
};
@@ -505,6 +510,23 @@ export function deriveDataflowPipeline(
);
}
export function rebaseDataflowPipeline(
settings: ApiSettings,
pipelineId: string,
payload: {
expected_revision: number;
source_revision: number;
source_hash: string;
reason: string;
}
): Promise<Pipeline> {
return apiFetch(
settings,
`/api/v1/dataflow/pipelines/${encodeURIComponent(pipelineId)}/rebase`,
{ method: "POST", body: JSON.stringify(payload) }
);
}
export function dataflowScopeReferenceProvider(
settings: ApiSettings,
scopeType: "user" | "group"
@@ -564,7 +586,7 @@ export function deleteDataflowTrigger(
export function validateDataflowPipeline(
settings: ApiSettings,
payload: { graph?: PipelineGraph; sql_text?: string; source_nodes?: PipelineGraphNode[] }
payload: { pipeline_id?: string | null; graph?: PipelineGraph; sql_text?: string; source_nodes?: PipelineGraphNode[] }
): Promise<PipelineValidation> {
return apiFetch<PipelineValidation>(settings, "/api/v1/dataflow/validate", {
method: "POST",
+230 -3
View File
@@ -13,6 +13,7 @@ import {
Code2,
CopyPlus,
DatabaseZap,
GitCompareArrows,
ListChecks,
Network,
Play,
@@ -84,6 +85,7 @@ import {
listDataflowTriggers,
previewDataflowPipeline,
promoteDataflowPipeline,
rebaseDataflowPipeline,
recordDataflowDecision,
runDataflowPipeline,
renderDataflowSql,
@@ -161,6 +163,7 @@ export default function DataflowPage({ settings, auth }: { settings: ApiSettings
const [runOpen, setRunOpen] = useState(false);
const [definitionSettingsOpen, setDefinitionSettingsOpen] = useState(false);
const [deriveOpen, setDeriveOpen] = useState(false);
const [rebaseOpen, setRebaseOpen] = useState(false);
const [triggersOpen, setTriggersOpen] = useState(false);
const [decisionReviewOpen, setDecisionReviewOpen] = useState(false);
const [nodeLibrary, setNodeLibrary] = useState<NodeTypeDefinition[]>(FALLBACK_NODE_LIBRARY);
@@ -183,6 +186,14 @@ export default function DataflowPage({ settings, auth }: { settings: ApiSettings
&& canWrite
&& draft.governance?.actions.derive?.allowed
);
const canRebase = Boolean(
draft?.id
&& draft.governance?.update_available
&& draft.governance.source_current_revision
&& draft.governance.source_current_hash
&& canEdit
&& !dirty
);
const canStartSavedRun = Boolean(
draft?.id
&& draft.definitionKind === "flow"
@@ -411,10 +422,15 @@ export default function DataflowPage({ settings, auth }: { settings: ApiSettings
setSuccess("");
try {
const response = await validateDataflowPipeline(settings, draft.editorMode === "sql"
? { graph: draft.graph, sql_text: draft.sqlText, source_nodes: sourceNodes(draft.graph) }
: { graph: draft.graph });
? { pipeline_id: draft.id, graph: draft.graph, sql_text: draft.sqlText, source_nodes: sourceNodes(draft.graph) }
: { pipeline_id: draft.id, graph: draft.graph });
setDiagnostics(response.diagnostics);
if (response.valid) setSuccess("Pipeline definition is valid.");
if (response.valid) {
if (response.graph && draft.editorMode === "graph") {
updateDraft({ graph: response.graph });
}
setSuccess("Pipeline definition is valid.");
}
setResultOpen(true);
setResultTab("diagnostics");
} catch (validationError) {
@@ -766,6 +782,26 @@ export default function DataflowPage({ settings, auth }: { settings: ApiSettings
}
/>
) : null}
{draft.governance?.derived_from_pipeline_id ? (
<IconButton
label="Review source update"
icon={<GitCompareArrows size={16} />}
variant="ghost"
onClick={() => setRebaseOpen(true)}
disabled={!canRebase}
disabledReason={
dirty
? DATAFLOW_I18N.saveFirst
: !canEdit
? editBlockedReason
: !draft.governance.source_available
? "The source definition is no longer available."
: !draft.governance.update_available
? "This copy already pins the current source revision."
: undefined
}
/>
) : null}
{draft.id ? (
<IconButton
label="Automation triggers"
@@ -899,6 +935,10 @@ export default function DataflowPage({ settings, auth }: { settings: ApiSettings
<NodeInspector
node={selectedNode}
nodeLibrary={nodeLibrary}
reusablePipelines={pipelines.filter((pipeline) => (
pipeline.id !== draft.id
&& pipeline.governance.actions.reuse?.allowed
))}
sources={sources}
sourceCatalogueAvailable={sourceCatalogueAvailable}
readOnly={!canEdit}
@@ -1060,6 +1100,32 @@ export default function DataflowPage({ settings, auth }: { settings: ApiSettings
setSuccess("Created a pinned scoped copy.");
}}
/>
<RebasePipelineDialog
open={rebaseOpen}
settings={settings}
pipeline={draft?.id && draft.currentRevision && draft.governance ? {
id: draft.id,
name: draft.name,
currentRevision: draft.currentRevision,
governance: draft.governance
} : null}
onClose={() => setRebaseOpen(false)}
onRebased={(pipeline) => {
const next = draftFromPipeline(pipeline);
setPipelines((current) => [
pipeline,
...current.filter((item) => item.id !== pipeline.id)
]);
setDraft(next);
setSavedDraft(structuredClone(next));
setSelectedNodeId(next.graph.nodes[0]?.id ?? null);
setRebaseOpen(false);
setPreview(null);
setDiagnostics([]);
setNodeDiagnostics([]);
setSuccess(`Adopted source revision ${pipeline.governance.derived_from_revision} as draft revision ${pipeline.current_revision}.`);
}}
/>
<DataflowTriggersDialog
open={triggersOpen}
settings={settings}
@@ -1231,6 +1297,20 @@ function DefinitionSettingsDialog({
{draft.governance.derived_from_revision}
</span>
<code>{draft.governance.derived_from_hash}</code>
{!draft.governance.source_available ? (
<StatusBadge status="warning" label="Source unavailable" />
) : draft.governance.update_available ? (
<>
<StatusBadge status="warning" label="Source update available" />
<span>
{draft.governance.source_name ?? "Source definition"}
{" · revision "}
{draft.governance.source_current_revision}
</span>
</>
) : (
<StatusBadge status="success" label="Source revision current" />
)}
</ContentSection>
) : null}
{provenance.length ? (
@@ -1430,6 +1510,153 @@ function DerivePipelineDialog({
);
}
function RebasePipelineDialog({
open,
settings,
pipeline,
onClose,
onRebased
}: {
open: boolean;
settings: ApiSettings;
pipeline: {
id: string;
name: string;
currentRevision: number;
governance: Pipeline["governance"];
} | null;
onClose: () => void;
onRebased: (pipeline: Pipeline) => void;
}) {
const { requestDiscard } = useUnsavedChanges();
const [reason, setReason] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const governance = pipeline?.governance;
const sourceRevision = governance?.source_current_revision ?? null;
const sourceHash = governance?.source_current_hash ?? null;
const dirty = Boolean(open && reason);
useEffect(() => {
if (!open) return;
setReason("");
setError("");
}, [open, pipeline?.id, sourceRevision]);
const resetDraft = () => {
setReason("");
setError("");
};
const rebase = async (): Promise<boolean> => {
if (
!pipeline
|| !sourceRevision
|| !sourceHash
|| !reason.trim()
|| !governance?.update_available
) return false;
setBusy(true);
setError("");
try {
onRebased(await rebaseDataflowPipeline(settings, pipeline.id, {
expected_revision: pipeline.currentRevision,
source_revision: sourceRevision,
source_hash: sourceHash,
reason: reason.trim()
}));
return true;
} catch (rebaseError) {
setError(apiErrorMessage(rebaseError));
return false;
} finally {
setBusy(false);
}
};
useUnsavedDraftGuard({
dirty,
title: "Unapplied source update",
message: "Apply the reviewed source update or discard the review reason before leaving.",
onSave: rebase,
onDiscard: resetDraft
});
const close = () => {
if (busy) return;
if (dirty) requestDiscard(onClose);
else onClose();
};
return (
<Dialog
open={open}
title="Review source update"
className="dataflow-definition-dialog"
closeDisabled={busy}
onClose={close}
footer={(
<>
<Button onClick={close} disabled={busy}>Cancel</Button>
<Button
variant="primary"
onClick={() => void rebase()}
disabled={
busy
|| !pipeline
|| !sourceRevision
|| !sourceHash
|| !reason.trim()
|| !governance?.update_available
}
>
<GitCompareArrows size={16} /> Adopt source revision
</Button>
</>
)}
>
<div className="dataflow-definition-fields">
{error ? <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert> : null}
<ContentSection spacing="none" surface="subtle" density="compact" layout="stack">
<strong>{governance?.source_name ?? "Source definition"}</strong>
<span>
Pinned revision {governance?.derived_from_revision ?? "—"}
{" → source revision "}
{sourceRevision ?? "—"}
</span>
{sourceHash ? <code>{sourceHash}</code> : null}
</ContentSection>
<DismissibleAlert
tone="warning"
resetKey={`${pipeline?.id ?? "none"}:${sourceRevision ?? "none"}`}
>
Adopting the update replaces the copy's current graph with the exact
reviewed source revision and returns the copy to draft. Existing
revisions, run evidence and rebase provenance remain immutable.
</DismissibleAlert>
<FormField
label="Review reason"
help="Record what was reviewed and why this source revision is appropriate for the scoped copy."
interfaceId="dataflow.field.rebase-reason"
helpContextId="dataflow.field.rebase-reason"
helpModuleId="dataflow"
helpTopicId="dataflow.reference.fields-and-consequences"
documentation={DATAFLOW_FIELDS_DOCUMENTATION}
>
<textarea
value={reason}
onChange={(event) => setReason(event.target.value)}
rows={4}
maxLength={4000}
disabled={busy}
required
/>
</FormField>
</div>
</Dialog>
);
}
function DataflowTriggersDialog({
open,
settings,
+90 -29
View File
@@ -9,6 +9,7 @@ import {
} from "@govoplan/core-webui";
import type {
NodeTypeDefinition,
Pipeline,
PipelineGraphNode,
TabularSource
} from "../../api/dataflow";
@@ -28,6 +29,7 @@ function NodeFormField({ documentation, ...props }: NodeFormFieldProps) {
type NodeInspectorProps = {
node: PipelineGraphNode | null;
nodeLibrary: NodeTypeDefinition[];
reusablePipelines: Pipeline[];
sources: TabularSource[];
sourceCatalogueAvailable: boolean;
readOnly: boolean;
@@ -41,6 +43,7 @@ type NodeInspectorProps = {
export default function NodeInspector({
node,
nodeLibrary,
reusablePipelines,
sources,
sourceCatalogueAvailable,
readOnly,
@@ -57,7 +60,6 @@ export default function NodeInspector({
const [rankSortText, setRankSortText] = useState("");
const [rulesText, setRulesText] = useState("");
const [parametersText, setParametersText] = useState("");
const [subflowGraphText, setSubflowGraphText] = useState("");
const [localError, setLocalError] = useState("");
useEffect(() => {
@@ -68,7 +70,6 @@ export default function NodeInspector({
setRankSortText(node ? sortFieldsToText(node.config.order_by) : "");
setRulesText(node ? JSON.stringify(node.config.rules ?? [], null, 2) : "");
setParametersText(node ? JSON.stringify(node.config.parameters ?? {}, null, 2) : "");
setSubflowGraphText(node ? JSON.stringify(node.config.graph ?? {}, null, 2) : "");
setLocalError("");
}, [node?.id]);
@@ -84,6 +85,9 @@ export default function NodeInspector({
}
const definition = nodeLibrary.find((item) => item.type === node.type);
const selectedReusable = reusablePipelines.find(
(item) => `pipeline:${item.id}` === textValue(node.config.template_ref)
);
const updateConfig = (patch: Record<string, unknown>) => {
onChange({ ...node, config: { ...node.config, ...patch } });
};
@@ -263,16 +267,33 @@ export default function NodeInspector({
</>
) : null}
{node.type === "source.inline" ? (
<NodeFormField label="Rows">
<textarea
className="dataflow-json-editor"
value={rowsText}
onChange={(event) => setRowsText(event.target.value)}
onBlur={commitRows}
spellCheck={false}
disabled={readOnly}
/>
</NodeFormField>
<>
<NodeFormField label="Rows">
<textarea
className="dataflow-json-editor"
value={rowsText}
onChange={(event) => setRowsText(event.target.value)}
onBlur={commitRows}
spellCheck={false}
disabled={readOnly}
/>
</NodeFormField>
<NodeFormField
label="Reusable input binding"
help="A reusable definition must mark exactly one typed inline source as the rows supplied by its caller."
interfaceId="dataflow.field.reusable-input-binding"
helpContextId="dataflow.field.reusable-input-binding"
helpModuleId="dataflow"
helpTopicId="dataflow.reference.nodes-and-expressions"
>
<input
type="checkbox"
checked={node.config.input_binding === true}
onChange={(event) => updateConfig({ input_binding: event.target.checked })}
disabled={readOnly}
/>
</NodeFormField>
</>
) : null}
{node.type === "filter" ? (
<>
@@ -783,19 +804,62 @@ export default function NodeInspector({
) : null}
{node.type === "subflow" ? (
<>
<NodeFormField label="Template reference">
<input
<NodeFormField
label="Reusable definition"
interfaceId="dataflow.field.subflow-reference"
helpContextId="dataflow.field.subflow-reference"
helpModuleId="dataflow"
helpTopicId="dataflow.reference.nodes-and-expressions"
>
<select
value={textValue(node.config.template_ref)}
onChange={(event) => updateConfig({ template_ref: event.target.value })}
onChange={(event) => {
const selected = reusablePipelines.find(
(item) => `pipeline:${item.id}` === event.target.value
);
updateConfig({
template_ref: event.target.value,
template_version: selected ? String(selected.current_revision) : "",
template_hash: "",
graph: { schema_version: 1, nodes: [], edges: [] },
input_schema: [],
output_schema: []
});
}}
disabled={readOnly}
/>
>
<option value="">Choose a reusable definition</option>
{reusablePipelines.map((pipeline) => (
<option key={pipeline.id} value={`pipeline:${pipeline.id}`}>
{pipeline.name} · revision {pipeline.current_revision}
</option>
))}
</select>
</NodeFormField>
<NodeFormField label="Template version">
<input
<NodeFormField
label="Template version"
interfaceId="dataflow.field.subflow-revision"
helpContextId="dataflow.field.subflow-revision"
helpModuleId="dataflow"
helpTopicId="dataflow.reference.nodes-and-expressions"
>
<select
value={textValue(node.config.template_version)}
onChange={(event) => updateConfig({ template_version: event.target.value })}
disabled={readOnly}
/>
>
{textValue(node.config.template_version)
&& textValue(node.config.template_version) !== String(selectedReusable?.current_revision ?? "") ? (
<option value={textValue(node.config.template_version)}>
Pinned revision {textValue(node.config.template_version)}
</option>
) : null}
{selectedReusable ? (
<option value={String(selectedReusable.current_revision)}>
Current revision {selectedReusable.current_revision}
</option>
) : null}
</select>
</NodeFormField>
<NodeFormField label="Parameters">
<textarea
@@ -807,16 +871,13 @@ export default function NodeInspector({
disabled={readOnly}
/>
</NodeFormField>
<NodeFormField label="Pinned graph">
<textarea
className="dataflow-json-editor"
value={subflowGraphText}
onChange={(event) => setSubflowGraphText(event.target.value)}
onBlur={() => commitJsonConfig("graph", subflowGraphText, "object")}
spellCheck={false}
disabled={readOnly}
/>
</NodeFormField>
{Array.isArray(node.config.input_schema) && Array.isArray(node.config.output_schema) ? (
<NodeFormField label="Pinned contracts">
<code>
{node.config.input_schema.length} input · {node.config.output_schema.length} output fields
</code>
</NodeFormField>
) : null}
</>
) : null}
</div>
+1 -1
View File
@@ -272,7 +272,7 @@ export const FALLBACK_NODE_LIBRARY: NodeTypeDefinition[] = [
"transform",
"Transform",
"Reusable subflow",
"Run a pinned parameterized template snapshot.",
"Run a Policy-authorized, server-resolved immutable definition revision.",
"boxes",
input,
output,
+38
View File
@@ -56,6 +56,25 @@ const en = {
"New pipeline": "New pipeline",
"Definition settings": "Definition settings",
"Reuse as scoped copy": "Reuse as scoped copy",
"Review source update": "Review source update",
"Adopt source revision": "Adopt source revision",
"Source unavailable": "Source unavailable",
"Source update available": "Source update available",
"Source definition": "Source definition",
"Source revision current": "Source revision current",
"Unapplied source update": "Unapplied source update",
"Apply the reviewed source update or discard the review reason before leaving.": "Apply the reviewed source update or discard the review reason before leaving.",
"The source definition is no longer available.": "The source definition is no longer available.",
"This copy already pins the current source revision.": "This copy already pins the current source revision.",
"Adopting the update replaces the copy's current graph with the exact reviewed source revision and returns the copy to draft. Existing revisions, run evidence and rebase provenance remain immutable.": "Adopting the update replaces the copy's current graph with the exact reviewed source revision and returns the copy to draft. Existing revisions, run evidence and rebase provenance remain immutable.",
"Review reason": "Review reason",
"Record what was reviewed and why this source revision is appropriate for the scoped copy.": "Record what was reviewed and why this source revision is appropriate for the scoped copy.",
"Reusable input binding": "Reusable input binding",
"A reusable definition must mark exactly one typed inline source as the rows supplied by its caller.": "A reusable definition must mark exactly one typed inline source as the rows supplied by its caller.",
"Reusable definition": "Reusable definition",
"Choose a reusable definition": "Choose a reusable definition",
"Template version": "Template version",
"Pinned contracts": "Pinned contracts",
"Automation triggers": "Automation triggers",
"Discard changes": "Discard changes",
"Delete pipeline": "Delete pipeline",
@@ -136,6 +155,25 @@ const de: Record<keyof typeof en, string> = {
"New pipeline": "Neuer Datenfluss",
"Definition settings": "Definitionseinstellungen",
"Reuse as scoped copy": "Als eingegrenzte Kopie verwenden",
"Review source update": "Aktualisierung der Quelle prüfen",
"Adopt source revision": "Quellrevision übernehmen",
"Source unavailable": "Quelle nicht verfügbar",
"Source update available": "Aktualisierung der Quelle verfügbar",
"Source definition": "Quelldefinition",
"Source revision current": "Quellrevision aktuell",
"Unapplied source update": "Nicht übernommene Quellenaktualisierung",
"Apply the reviewed source update or discard the review reason before leaving.": "Übernehmen Sie die geprüfte Quellenaktualisierung oder verwerfen Sie die Prüfbegründung, bevor Sie den Dialog verlassen.",
"The source definition is no longer available.": "Die Quelldefinition ist nicht mehr verfügbar.",
"This copy already pins the current source revision.": "Diese Kopie ist bereits an die aktuelle Quellrevision gebunden.",
"Adopting the update replaces the copy's current graph with the exact reviewed source revision and returns the copy to draft. Existing revisions, run evidence and rebase provenance remain immutable.": "Die Übernahme ersetzt den aktuellen Graphen der Kopie durch die exakt geprüfte Quellrevision und setzt die Kopie auf Entwurf zurück. Bestehende Revisionen, Ausführungsnachweise und die Herkunft der Übernahme bleiben unveränderlich.",
"Review reason": "Prüfbegründung",
"Record what was reviewed and why this source revision is appropriate for the scoped copy.": "Dokumentieren Sie, was geprüft wurde und warum diese Quellrevision für die eingegrenzte Kopie geeignet ist.",
"Reusable input binding": "Wiederverwendbare Eingabebindung",
"A reusable definition must mark exactly one typed inline source as the rows supplied by its caller.": "Eine wiederverwendbare Definition muss genau eine typisierte Inline-Quelle als die vom Aufrufer gelieferten Zeilen kennzeichnen.",
"Reusable definition": "Wiederverwendbare Definition",
"Choose a reusable definition": "Wiederverwendbare Definition auswählen",
"Template version": "Vorlagenversion",
"Pinned contracts": "Gebundene Verträge",
"Automation triggers": "Automatisierungsauslöser",
"Discard changes": "Änderungen verwerfen",
"Delete pipeline": "Datenfluss löschen",