Add durable reconciliation decisions

This commit is contained in:
2026-08-06 12:42:20 +02:00
parent 3dae80b6b8
commit b14c693bde
14 changed files with 2037 additions and 19 deletions
+85
View File
@@ -205,6 +205,7 @@ export type TabularSource = {
columns: TabularSourceColumn[];
schema_version: string;
fingerprint: string;
decisions_included: boolean;
row_count?: number | null;
byte_count?: number | null;
updated_at?: string | null;
@@ -217,6 +218,38 @@ export type TabularSourceCatalogue = {
sources: TabularSource[];
};
export type ReconciliationDecisionAction = "accept" | "reject" | "correct" | "defer";
export type ReconciliationDecision = {
ref: string;
id: string;
revision: number;
key_hash: string;
input_hash: string;
action: ReconciliationDecisionAction;
reason: string;
correction?: Record<string, unknown> | null;
actor_ref: string;
decided_at: string;
};
export type ReconciliationDecisionSet = {
ref: string;
id: string;
pipeline_id: string;
name: string;
node_id?: string | null;
resource_revision: number;
etag: string;
fingerprint: string;
current_decisions: ReconciliationDecision[];
history: ReconciliationDecision[];
created_by?: string | null;
updated_by?: string | null;
created_at: string;
updated_at: string;
};
export type PipelineRun = {
ref: string;
pipeline_id: string;
@@ -363,6 +396,58 @@ export function createDataflowSourceSnapshot(
});
}
export async function listDataflowDecisionSets(
settings: ApiSettings,
pipelineId: string
): Promise<ReconciliationDecisionSet[]> {
const response = await apiFetch<{ decision_sets: ReconciliationDecisionSet[] }>(
settings,
`/api/v1/dataflow/pipelines/${encodeURIComponent(pipelineId)}/decision-sets`
);
return response.decision_sets;
}
export function createDataflowDecisionSet(
settings: ApiSettings,
pipelineId: string,
payload: { name: string; node_id?: string | null }
): Promise<ReconciliationDecisionSet> {
return apiFetch(
settings,
`/api/v1/dataflow/pipelines/${encodeURIComponent(pipelineId)}/decision-sets`,
{ method: "POST", body: JSON.stringify(payload) }
);
}
export function getDataflowDecisionSet(
settings: ApiSettings,
decisionSetId: string
): Promise<ReconciliationDecisionSet> {
return apiFetch(
settings,
`/api/v1/dataflow/decision-sets/${encodeURIComponent(decisionSetId)}`
);
}
export function recordDataflowDecision(
settings: ApiSettings,
decisionSetId: string,
payload: {
base_revision: number;
key_hash: string;
input_hash: string;
action: ReconciliationDecisionAction;
reason: string;
correction?: Record<string, unknown> | null;
}
): Promise<ReconciliationDecisionSet> {
return apiFetch(
settings,
`/api/v1/dataflow/decision-sets/${encodeURIComponent(decisionSetId)}/decisions`,
{ method: "POST", body: JSON.stringify(payload) }
);
}
export async function listDataflowPipelines(settings: ApiSettings): Promise<Pipeline[]> {
const response = await apiFetch<{ pipelines: Pipeline[] }>(settings, "/api/v1/dataflow/pipelines");
return response.pipelines;
+445 -1
View File
@@ -13,6 +13,7 @@ import {
Code2,
CopyPlus,
DatabaseZap,
ListChecks,
Network,
Play,
Plus,
@@ -51,6 +52,7 @@ import { useLocation } from "react-router";
import {
compileDataflowSql,
cancelDataflowPipelineRun,
createDataflowDecisionSet,
createDataflowTrigger,
createDataflowPipeline,
createDataflowSourceSnapshot,
@@ -58,7 +60,9 @@ import {
deleteDataflowTrigger,
dataflowScopeReferenceProvider,
deriveDataflowPipeline,
getDataflowDecisionSet,
listDataflowNodeTypes,
listDataflowDecisionSets,
listDataflowPipelineRuns,
listDataflowPipelineDeployments,
listDataflowPipelines,
@@ -66,6 +70,7 @@ import {
listDataflowTriggers,
previewDataflowPipeline,
promoteDataflowPipeline,
recordDataflowDecision,
runDataflowPipeline,
renderDataflowSql,
updateDataflowPipeline,
@@ -84,6 +89,8 @@ import {
type PipelinePreview,
type PipelineDeployment,
type PipelineRun,
type ReconciliationDecisionAction,
type ReconciliationDecisionSet,
type TabularSource
} from "../../api/dataflow";
import DataflowCanvas, { updateGraphNode } from "./DataflowCanvas";
@@ -141,6 +148,7 @@ export default function DataflowPage({ settings, auth }: { settings: ApiSettings
const [definitionSettingsOpen, setDefinitionSettingsOpen] = useState(false);
const [deriveOpen, setDeriveOpen] = useState(false);
const [triggersOpen, setTriggersOpen] = useState(false);
const [decisionReviewOpen, setDecisionReviewOpen] = useState(false);
const [nodeLibrary, setNodeLibrary] = useState<NodeTypeDefinition[]>(FALLBACK_NODE_LIBRARY);
const [sources, setSources] = useState<TabularSource[]>([]);
const [sourceCatalogueAvailable, setSourceCatalogueAvailable] = useState(false);
@@ -932,6 +940,16 @@ export default function DataflowPage({ settings, auth }: { settings: ApiSettings
diagnostics={diagnostics}
nodeDiagnostics={nodeDiagnostics}
selectedNodeId={selectedNodeId}
decisionReviewDisabledReason={
!draft.id
? "Save the pipeline before recording review decisions."
: dirty
? "Save the current pipeline revision before recording review decisions."
: !canEdit
? editBlockedReason
: undefined
}
onReviewDecisions={() => setDecisionReviewOpen(true)}
onClose={() => setResultOpen(false)}
/>
) : null}
@@ -1068,6 +1086,22 @@ export default function DataflowPage({ settings, auth }: { settings: ApiSettings
editable={canManageTriggers}
onClose={() => setTriggersOpen(false)}
/>
<ReconciliationDecisionDialog
open={decisionReviewOpen}
settings={settings}
pipeline={draft?.id ? { id: draft.id, name: draft.name } : null}
node={selectedPreviewNode(draft?.graph.nodes ?? [], preview, selectedNodeId)}
preview={preview}
editable={canEdit && !dirty}
onClose={() => setDecisionReviewOpen(false)}
onChanged={() => {
void listDataflowSources(settings).then((catalogue) => {
setSources(catalogue.sources);
setSourceCatalogueAvailable(catalogue.available);
setSourceCatalogueWritable(catalogue.writable);
}).catch((sourceError) => setError(apiErrorMessage(sourceError)));
}}
/>
</main>
);
}
@@ -2448,6 +2482,380 @@ function SourceSnapshotDialog({
);
}
type ReviewableReconciliationRow = {
keyHash: string;
inputHash: string;
row: Record<string, unknown>;
};
function ReconciliationDecisionDialog({
open,
settings,
pipeline,
node,
preview,
editable,
onClose,
onChanged
}: {
open: boolean;
settings: ApiSettings;
pipeline: { id: string; name: string } | null;
node: PipelineGraphNode | null;
preview: PipelinePreview | null;
editable: boolean;
onClose: () => void;
onChanged: () => void;
}) {
const [decisionSets, setDecisionSets] = useState<ReconciliationDecisionSet[]>([]);
const [selectedSetId, setSelectedSetId] = useState("");
const [selectedKeyHash, setSelectedKeyHash] = useState("");
const [newSetName, setNewSetName] = useState("");
const [action, setAction] = useState<ReconciliationDecisionAction>("accept");
const [reason, setReason] = useState("");
const [correctionText, setCorrectionText] = useState("{}");
const [loading, setLoading] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [success, setSuccess] = useState("");
const rows = useMemo<ReviewableReconciliationRow[]>(() => {
const result = preview?.node_preview ?? preview;
if (!result || node?.type !== "reconcile.compare") return [];
return result.rows.flatMap((row) => {
const keyHash = row._reconciliation_key_hash;
const inputHash = row._reconciliation_input_hash;
if (!isSha256Hex(keyHash) || !isSha256Hex(inputHash)) return [];
return [{ keyHash, inputHash, row }];
});
}, [node?.type, preview]);
const selectedSet = decisionSets.find((item) => item.id === selectedSetId) ?? null;
const selectedRow = rows.find((item) => item.keyHash === selectedKeyHash) ?? null;
const currentDecision = selectedSet?.current_decisions.find(
(item) => item.key_hash === selectedRow?.keyHash
) ?? null;
const exactDecision = currentDecision?.input_hash === selectedRow?.inputHash
? currentDecision
: null;
useEffect(() => {
if (!open) return;
setSelectedKeyHash((current) => rows.some((item) => item.keyHash === current)
? current
: rows[0]?.keyHash ?? "");
}, [open, rows]);
useEffect(() => {
if (!open || !pipeline) {
setDecisionSets([]);
setSelectedSetId("");
return;
}
let cancelled = false;
setLoading(true);
setError("");
void listDataflowDecisionSets(settings, pipeline.id)
.then((items) => {
if (cancelled) return;
const relevant = items.filter((item) => !item.node_id || item.node_id === node?.id);
setDecisionSets(relevant);
setSelectedSetId((current) => relevant.some((item) => item.id === current)
? current
: relevant[0]?.id ?? "");
})
.catch((loadError) => {
if (!cancelled) setError(apiErrorMessage(loadError));
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [node?.id, open, pipeline?.id, settings]);
useEffect(() => {
if (!open || !selectedSetId) return;
let cancelled = false;
setLoading(true);
void getDataflowDecisionSet(settings, selectedSetId)
.then((item) => {
if (cancelled) return;
setDecisionSets((current) => current.map(
(candidate) => candidate.id === item.id ? item : candidate
));
})
.catch((loadError) => {
if (!cancelled) setError(apiErrorMessage(loadError));
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [open, selectedSetId, settings]);
useEffect(() => {
setAction(exactDecision?.action ?? "accept");
setReason(exactDecision?.reason ?? "");
setCorrectionText(JSON.stringify(exactDecision?.correction ?? {}, null, 2));
setError("");
setSuccess("");
}, [exactDecision?.id, selectedKeyHash, selectedSetId]);
const createSet = async () => {
if (!pipeline || !node || !newSetName.trim() || !editable) return;
setBusy(true);
setError("");
setSuccess("");
try {
const created = await createDataflowDecisionSet(settings, pipeline.id, {
name: newSetName.trim(),
node_id: node.id
});
setDecisionSets((current) => [created, ...current.filter((item) => item.id !== created.id)]);
setSelectedSetId(created.id);
setNewSetName("");
setSuccess("Decision set created. It is now available as a governed Dataflow source.");
onChanged();
} catch (createError) {
setError(apiErrorMessage(createError));
} finally {
setBusy(false);
}
};
const saveDecision = async () => {
if (!selectedSet || !selectedRow || !editable) return;
if (reason.trim().length < 3) {
setError("Record a reason of at least three characters.");
return;
}
let correction: Record<string, unknown> | null = null;
if (action === "correct") {
try {
const parsed = JSON.parse(correctionText) as unknown;
if (!isRecord(parsed) || !Object.keys(parsed).length) {
setError("Correct decisions require a JSON object with at least one corrected field.");
return;
}
correction = parsed;
} catch {
setError("Correction must be a valid JSON object.");
return;
}
}
setBusy(true);
setError("");
setSuccess("");
try {
const updated = await recordDataflowDecision(settings, selectedSet.id, {
base_revision: selectedSet.resource_revision,
key_hash: selectedRow.keyHash,
input_hash: selectedRow.inputHash,
action,
reason: reason.trim(),
correction
});
setDecisionSets((current) => current.map((item) => item.id === updated.id ? updated : item));
setSuccess(`Recorded decision revision ${updated.resource_revision}.`);
onChanged();
} catch (saveError) {
setError(apiErrorMessage(saveError));
} finally {
setBusy(false);
}
};
const reviewedCount = selectedSet
? rows.filter((row) => selectedSet.current_decisions.some(
(decision) => decision.key_hash === row.keyHash && decision.input_hash === row.inputHash
)).length
: 0;
const staleCount = selectedSet
? rows.filter((row) => selectedSet.current_decisions.some(
(decision) => decision.key_hash === row.keyHash && decision.input_hash !== row.inputHash
)).length
: 0;
return (
<Dialog
open={open}
title={`Review reconciliation${node ? `: ${node.label}` : ""}`}
className="dataflow-decision-dialog"
bodyClassName="dataflow-decision-dialog-body"
closeDisabled={busy}
onClose={onClose}
footer={(
<>
<span className="dataflow-decision-history-summary">
{selectedSet ? `${selectedSet.history.length} immutable decision revision(s)` : "No decision set selected"}
</span>
<Button onClick={onClose} disabled={busy}>Close</Button>
</>
)}
>
<div className="dataflow-decision-shell">
{error ? <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert> : null}
{success ? <DismissibleAlert tone="success" resetKey={success}>{success}</DismissibleAlert> : null}
<div className="dataflow-decision-set-toolbar">
<FormField
label="Decision set"
help="The current projection is consumable as a source; every prior decision remains in immutable history."
documentation={DATAFLOW_FIELDS_DOCUMENTATION}
>
<select
value={selectedSetId}
onChange={(event) => setSelectedSetId(event.target.value)}
disabled={loading || !decisionSets.length}
>
{!decisionSets.length ? <option value="">No decision sets</option> : null}
{decisionSets.map((item) => (
<option key={item.id} value={item.id}>{item.name}</option>
))}
</select>
</FormField>
<FormField
label="New decision set"
help="Create one set per review purpose or review round."
documentation={DATAFLOW_FIELDS_DOCUMENTATION}
>
<div className="dataflow-decision-create">
<input
value={newSetName}
onChange={(event) => setNewSetName(event.target.value)}
placeholder={`${pipeline?.name ?? "Pipeline"} review`}
disabled={!editable || busy}
/>
<Button
variant="primary"
onClick={() => void createSet()}
disabled={!editable || busy || !newSetName.trim()}
>
<Plus size={16} /> Create
</Button>
</div>
</FormField>
<div className="dataflow-decision-counts" aria-label="Review progress">
<span><strong>{reviewedCount}</strong> reviewed</span>
<span className={staleCount ? "is-warning" : ""}><strong>{staleCount}</strong> stale</span>
<span><strong>{Math.max(0, rows.length - reviewedCount - staleCount)}</strong> open</span>
</div>
</div>
{!rows.length ? (
<div className="dataflow-results-empty">
Run a preview of a reconciliation comparison that emits stable key and input hashes.
</div>
) : (
<div className="dataflow-decision-layout">
<div className="dataflow-decision-rows" role="listbox" aria-label="Reconciliation rows">
<div className="dataflow-decision-row-heading">
<span>Record</span><span>Comparison</span><span>Decision</span>
</div>
{rows.map((item) => {
const decision = selectedSet?.current_decisions.find(
(candidate) => candidate.key_hash === item.keyHash
);
const stale = Boolean(decision && decision.input_hash !== item.inputHash);
const statusLabel = stale ? "stale" : decision?.action ?? "open";
return (
<button
key={item.keyHash}
type="button"
role="option"
aria-selected={item.keyHash === selectedKeyHash}
className={item.keyHash === selectedKeyHash ? "is-selected" : ""}
onClick={() => setSelectedKeyHash(item.keyHash)}
>
<span title={item.keyHash}>{reviewRowLabel(item.row)}</span>
<span>{formatCell(item.row._reconciliation_status)}</span>
<span className={`dataflow-decision-state is-${statusLabel}`}>{statusLabel}</span>
</button>
);
})}
</div>
<div className="dataflow-decision-editor">
{selectedRow && selectedSet ? (
<>
<div className="dataflow-decision-editor-heading">
<div>
<strong>{reviewRowLabel(selectedRow.row)}</strong>
<small title={selectedRow.keyHash}>{selectedRow.keyHash}</small>
</div>
{currentDecision && !exactDecision ? (
<StatusBadge status="warning" label="Prior decision is stale" />
) : exactDecision ? (
<StatusBadge status="success" label={`Current: ${exactDecision.action}`} />
) : (
<StatusBadge status="inactive" label="Unreviewed" />
)}
</div>
<FormField label="Decision" documentation={DATAFLOW_FIELDS_DOCUMENTATION}>
<SegmentedControl<ReconciliationDecisionAction>
ariaLabel="Reconciliation decision"
options={[
{ id: "accept", label: "Accept" },
{ id: "reject", label: "Reject" },
{ id: "correct", label: "Correct" },
{ id: "defer", label: "Defer" }
]}
value={action}
onChange={setAction}
disabled={!editable || busy}
/>
</FormField>
<FormField
label="Reason"
help="The reason is retained with the actor, exact input hash, and immutable revision."
documentation={DATAFLOW_FIELDS_DOCUMENTATION}
>
<textarea
rows={4}
value={reason}
onChange={(event) => setReason(event.target.value)}
disabled={!editable || busy}
/>
</FormField>
{action === "correct" ? (
<FormField
label="Corrected fields (JSON)"
help="Corrections are annotations. A downstream governed transform decides whether to apply them."
documentation={DATAFLOW_FIELDS_DOCUMENTATION}
>
<textarea
className="dataflow-json-editor"
value={correctionText}
onChange={(event) => setCorrectionText(event.target.value)}
spellCheck={false}
disabled={!editable || busy}
/>
</FormField>
) : null}
<div className="dataflow-decision-editor-actions">
<Button
variant="primary"
onClick={() => void saveDecision()}
disabled={!editable || busy || reason.trim().length < 3}
>
<Save size={16} /> {busy ? "Recording..." : "Record revision"}
</Button>
</div>
</>
) : (
<div className="dataflow-results-empty">
{loading ? "Loading decision sets..." : "Create or select a decision set to review this row."}
</div>
)}
</div>
</div>
)}
</div>
</Dialog>
);
}
function ResultPanel({
tab,
onTabChange,
@@ -2456,6 +2864,8 @@ function ResultPanel({
diagnostics,
nodeDiagnostics,
selectedNodeId,
decisionReviewDisabledReason,
onReviewDecisions,
onClose
}: {
tab: ResultTab;
@@ -2465,6 +2875,8 @@ function ResultPanel({
diagnostics: DataflowDiagnostic[];
nodeDiagnostics: NodePreviewDiagnostic[];
selectedNodeId: string | null;
decisionReviewDisabledReason?: string;
onReviewDecisions: () => void;
onClose: () => void;
}) {
const outputNodeId = nodes.find((node) => node.type === "output")?.id ?? "";
@@ -2491,7 +2903,18 @@ function ResultPanel({
</span>
) : null}
</div>
<Button variant="ghost" onClick={onClose}>Close</Button>
<div className="dataflow-results-actions">
{tab === "preview" && previewNode?.type === "reconcile.compare" ? (
<Button
onClick={onReviewDecisions}
disabled={!preview || Boolean(decisionReviewDisabledReason)}
disabledReason={decisionReviewDisabledReason}
>
<ListChecks size={16} /> Review decisions
</Button>
) : null}
<Button variant="ghost" onClick={onClose}>Close</Button>
</div>
</div>
{tab === "preview" ? (
<PreviewTable preview={preview} />
@@ -2625,6 +3048,27 @@ function formatCell(value: unknown): string {
return String(value);
}
function selectedPreviewNode(
nodes: PipelineGraphNode[],
preview: PipelinePreview | null,
selectedNodeId: string | null
): PipelineGraphNode | null {
const nodeId = preview?.node_preview?.node_id ?? selectedNodeId;
return nodes.find((node) => node.id === nodeId) ?? null;
}
function isSha256Hex(value: unknown): value is string {
return typeof value === "string" && /^[0-9a-f]{64}$/.test(value);
}
function reviewRowLabel(row: Record<string, unknown>): string {
const values = Object.entries(row)
.filter(([key, value]) => !key.startsWith("_") && value !== null && value !== undefined)
.slice(0, 3)
.map(([key, value]) => `${key}: ${formatCell(value)}`);
return values.join(" · ") || "Reconciliation row";
}
function sourceNameFromFile(value: string): string {
const normalized = value
.normalize("NFKD")
+204
View File
@@ -736,6 +736,210 @@
width: min(720px, calc(100vw - 32px));
}
.dataflow-decision-dialog {
width: min(1080px, calc(100vw - 32px));
height: min(760px, calc(100vh - 48px));
}
.dataflow-decision-dialog-body {
overflow: hidden;
padding: 14px;
}
.dataflow-decision-shell {
display: flex;
height: 100%;
min-height: 0;
flex-direction: column;
gap: 12px;
}
.dataflow-decision-set-toolbar {
display: grid;
grid-template-columns: minmax(190px, 0.8fr) minmax(300px, 1.2fr) auto;
align-items: end;
gap: 12px;
}
.dataflow-decision-set-toolbar select,
.dataflow-decision-set-toolbar input,
.dataflow-decision-editor textarea {
width: 100%;
}
.dataflow-decision-create,
.dataflow-decision-counts,
.dataflow-results-actions,
.dataflow-decision-editor-actions {
display: flex;
align-items: center;
gap: 8px;
}
.dataflow-decision-create input {
min-width: 0;
flex: 1 1 auto;
}
.dataflow-decision-create .btn {
flex: 0 0 auto;
}
.dataflow-decision-counts {
min-height: 34px;
color: var(--muted);
font-size: 11px;
white-space: nowrap;
}
.dataflow-decision-counts strong {
color: var(--text-strong);
}
.dataflow-decision-counts .is-warning strong {
color: var(--warning-deep);
}
.dataflow-decision-layout {
display: grid;
grid-template-columns: minmax(420px, 1.1fr) minmax(340px, 0.9fr);
min-height: 0;
flex: 1 1 auto;
overflow: hidden;
border: var(--border-line);
border-radius: var(--radius-sm);
}
.dataflow-decision-rows,
.dataflow-decision-editor {
min-width: 0;
min-height: 0;
overflow: auto;
}
.dataflow-decision-rows {
border-right: var(--border-line);
}
.dataflow-decision-row-heading,
.dataflow-decision-rows > button {
display: grid;
grid-template-columns: minmax(180px, 1fr) minmax(90px, 0.45fr) 90px;
align-items: center;
gap: 10px;
width: 100%;
min-height: 42px;
border: 0;
border-bottom: var(--border-line);
padding: 7px 10px;
text-align: left;
}
.dataflow-decision-row-heading {
position: sticky;
z-index: 2;
top: 0;
background: var(--panel-header);
color: var(--muted);
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
}
.dataflow-decision-rows > button {
background: transparent;
color: var(--text);
cursor: pointer;
font-size: 12px;
}
.dataflow-decision-rows > button:hover,
.dataflow-decision-rows > button.is-selected {
background: var(--primary-soft);
}
.dataflow-decision-rows > button.is-selected {
box-shadow: inset 3px 0 0 var(--accent);
}
.dataflow-decision-rows > button > span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.dataflow-decision-state {
color: var(--muted);
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
}
.dataflow-decision-state.is-accept,
.dataflow-decision-state.is-correct {
color: var(--success-text);
}
.dataflow-decision-state.is-reject,
.dataflow-decision-state.is-stale {
color: var(--danger-text);
}
.dataflow-decision-state.is-defer {
color: var(--warning-deep);
}
.dataflow-decision-editor {
display: grid;
grid-auto-rows: max-content;
align-content: start;
gap: 14px;
padding: 14px;
}
.dataflow-decision-editor-heading {
display: flex;
align-items: start;
justify-content: space-between;
gap: 12px;
padding-bottom: 10px;
border-bottom: var(--border-line);
}
.dataflow-decision-editor-heading > div {
min-width: 0;
}
.dataflow-decision-editor-heading strong,
.dataflow-decision-editor-heading small {
display: block;
}
.dataflow-decision-editor-heading small {
margin-top: 4px;
overflow: hidden;
color: var(--muted);
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
font-size: 9px;
text-overflow: ellipsis;
white-space: nowrap;
}
.dataflow-decision-editor .dataflow-json-editor {
min-height: 140px;
}
.dataflow-decision-editor-actions {
justify-content: flex-end;
}
.dataflow-decision-history-summary {
margin-right: auto;
color: var(--muted);
font-size: 11px;
}
.dataflow-run-dialog {
width: min(860px, calc(100vw - 32px));
}