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
+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,