diff --git a/frontend/src/lib/components/assets/AssetGraph/AssetGraphCanvas.svelte b/frontend/src/lib/components/assets/AssetGraph/AssetGraphCanvas.svelte index a63bdca62b..53097f5379 100644 --- a/frontend/src/lib/components/assets/AssetGraph/AssetGraphCanvas.svelte +++ b/frontend/src/lib/components/assets/AssetGraph/AssetGraphCanvas.svelte @@ -323,8 +323,44 @@ // own adjacency (boundedCascade.buildLineageDownstreamMap). const hasLineageDownstream = new Set(buildLineageDownstreamMap(g).keys()) + // Producers that declare `// data_test` checks, keyed by runnable id — the + // asset node uses this (plus the producer's run state) to render the + // data-test outcome badge. Tests only assert on ducklake `// materialize` + // targets (v1), so guard status is a ducklake-only concept below. + const producerHasTests = new Set() + // Producer → its declared `// materialize` target, so a multi-output + // producer's guard badge lands only on the table its tests assert on — + // not on its other ducklake outputs. Mirrors the write-edge badge anchor. + const guardMaterializeTarget = new Map< + string, + NonNullable + >() + for (const r of g.runnables) { + if (r.data_tests && r.data_tests.length > 0) producerHasTests.add(`${r.usage_kind}:${r.path}`) + if (r.materialize_target) + guardMaterializeTarget.set(`${r.usage_kind}:${r.path}`, r.materialize_target) + } + for (const a of g.assets) { const assetId = `asset:${a.kind}:${a.path}` + // Guard/outcome badge inputs: is a producer of this (ducklake) asset + // test-guarded, and did that guarded producer's latest run fail? + let dataTestGuarded = false + let producerFailed = false + if (a.kind === 'ducklake') { + for (const p of producersByAsset.get(`${a.kind}:${a.path}`) ?? []) { + const rid = `${p.kind}:${p.path}` + if (!producerHasTests.has(rid)) continue + // Tests assert on the producer's `// materialize` target; when the + // producer declares one, only that table is guarded (a multi-output + // producer must not badge its other ducklake writes). No declared + // target → single-output producer, so its lone ducklake write is it. + const mt = guardMaterializeTarget.get(rid) + if (mt && !(mt.kind === a.kind && mt.path === a.path)) continue + dataTestGuarded = true + if (runStates?.get(rid)?.status === 'failure') producerFailed = true + } + } nodes.push({ id: assetId, type: 'asset', @@ -337,7 +373,9 @@ pathPrefix, defaultPathSuffix, producers: producersByAsset.get(`${a.kind}:${a.path}`) ?? [], - onRunProducer + onRunProducer, + dataTestGuarded, + producerFailed } }) } diff --git a/frontend/src/lib/components/assets/AssetGraph/AssetNode.svelte b/frontend/src/lib/components/assets/AssetGraph/AssetNode.svelte index 7d784b74b7..762bdbc0fc 100644 --- a/frontend/src/lib/components/assets/AssetGraph/AssetNode.svelte +++ b/frontend/src/lib/components/assets/AssetGraph/AssetNode.svelte @@ -5,9 +5,19 @@ import { formatShortAssetPath, type AssetKind } from '$lib/components/assets/lib' import { NODE } from '$lib/components/graph/util' import PipelineInsertMenu, { type PipelineInsertPick } from './PipelineInsertMenu.svelte' - import { ArrowUpRight, Code2, GitFork, History, Play, Loader2, Plus } from 'lucide-svelte' + import { + ArrowUpRight, + Code2, + GitFork, + History, + Play, + Loader2, + Plus, + ShieldCheck, + ShieldAlert + } from 'lucide-svelte' import type { ScriptLang } from '$lib/gen' - import { workspaceStore } from '$lib/stores' + import { enterpriseLicense, workspaceStore } from '$lib/stores' import { sendUserToast } from '$lib/utils' import { PIPELINE_LANGUAGES } from './pipelineLanguages' import type { PipelineOutputKind } from './pipelineTemplates' @@ -55,6 +65,15 @@ // cached draft content). Without this callback, the play button // is hidden — runs only make sense in editor contexts. onRunProducer?: (producer: AssetProducer) => Promise + // True when a producer materializing this asset declares `// data_test` + // checks — the asset's write is guarded. Drives the data-test outcome + // badge, whose meaning differs by edition (EE rolls a failing write + // back; CE publishes it anyway). + dataTestGuarded?: boolean + // True when the latest observed run of a producer materializing this + // asset failed. Escalates the guard badge from "protected" to a + // failed-run outcome (rolled-back on EE, published-anyway on CE). + producerFailed?: boolean } // SvelteFlow injects this on the node component when the user clicks // the node. Combined with our own `hovered` state to drive the @@ -109,6 +128,37 @@ } let showAdd = $derived(data.onAddScript != undefined) + + // Data-test outcome badge. Only guarded assets show it. The write's fate on a + // failing test differs by edition — surface which one applies so a shared + // parent/fork table name can't hide a silently-published bad version. + let isEE = $derived(!!$enterpriseLicense) + let showGuardBadge = $derived(data.dataTestGuarded === true) + let guardFailed = $derived(data.producerFailed === true) + let GuardIcon = $derived(isEE ? ShieldCheck : ShieldAlert) + // Filled + colored when the last run failed (the actionable state); a quiet + // ring at rest so the badge doesn't shout on every healthy guarded asset. + let guardClass = $derived( + guardFailed + ? isEE + ? 'bg-amber-500 text-white border-amber-600' + : 'bg-red-500 text-white border-red-600' + : isEE + ? 'bg-surface-secondary text-emerald-600 dark:text-emerald-400 border-emerald-500/60' + : 'bg-surface-secondary text-amber-600 dark:text-amber-400 border-amber-500/60' + ) + // Failed copy speaks to the edition's write policy, not the failure cause: + // `producerFailed` is a generic job failure (could be a runtime/worker error, + // not a data-test violation), so we don't assert "failed its data tests". + let guardTitle = $derived( + guardFailed + ? isEE + ? 'Last run failed — Enterprise rolls a failed materialize back, so the previous version is left live.' + : 'Last run failed — Community Edition does not roll a failed materialize back (Enterprise does), so a failing write may be left live. Verify the table.' + : isEE + ? 'Guarded by data tests: a failing write is rolled back, keeping the previous version live.' + : 'Guarded by data tests, but Community Edition does not block on failure — a failing write is still published. Rollback is Enterprise-only.' + ) + {#if data.fork_materialization === 'deferred'} - + + parent {:else if data.fork_materialization === 'fork'} - + + fork {/if} +
+ +
+ {/if} {#if showActions} + + {/if} {#if mode === 'edit' && saveErrors.size > 0}