feat: surface blast radius on approval cards
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled

Pending-approval cards showed target and risk but not what else the
action would affect — the operator approved config_mutation/destructive
commands blind to downstream impact, even though the graph-walk
(blast_radius() SQL, GetBlastRadius endpoint) already existed and was
just never wired into the approval path.

Fetch it once per pending approval and render "Affects N downstream: …"
on both the normal and destructive approval cards, reusing the existing
fetchBlastRadius() API client function which was already written but
unused anywhere.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-10 20:52:05 +02:00
parent ac48390796
commit 682326382e

View File

@@ -1,15 +1,30 @@
<script lang="ts"> <script lang="ts">
import type { PendingApproval } from '$lib/stores/chat' import type { PendingApproval } from '$lib/stores/chat'
import { decideApproval, getExecution, type Execution } from '$lib/api' import { decideApproval, getExecution, fetchBlastRadius, type Execution } from '$lib/api'
import { Button } from '$lib/components/ui/button' import { Button } from '$lib/components/ui/button'
import { SvelteMap } from 'svelte/reactivity' import { SvelteMap } from 'svelte/reactivity'
import CheckIcon from '@lucide/svelte/icons/check' import CheckIcon from '@lucide/svelte/icons/check'
import XIcon from '@lucide/svelte/icons/x' import XIcon from '@lucide/svelte/icons/x'
import ShieldCheckIcon from '@lucide/svelte/icons/shield-check' import ShieldCheckIcon from '@lucide/svelte/icons/shield-check'
import LoaderCircleIcon from '@lucide/svelte/icons/loader-circle' import LoaderCircleIcon from '@lucide/svelte/icons/loader-circle'
import NetworkIcon from '@lucide/svelte/icons/network'
let { approvals }: { approvals: PendingApproval[] } = $props() let { approvals }: { approvals: PendingApproval[] } = $props()
// Downstream entities the target affects, keyed by executionId — fetched
// once per approval so the operator sees the graph-walk impact ("this
// affects 3 downstream") before deciding, not after. depth 0 is the target
// itself, excluded here since it's already shown as "on {target}".
const blastRadius = new SvelteMap<string, string[]>()
const blastRadiusFetched = new Set<string>()
async function loadBlastRadius(a: PendingApproval) {
if (blastRadiusFetched.has(a.executionId) || a.target === 'unknown') return
blastRadiusFetched.add(a.executionId)
const items = await fetchBlastRadius(a.target)
const affected = items.filter((i) => i.depth > 0).map((i) => i.entity.slug)
if (affected.length) blastRadius.set(a.executionId, affected)
}
// Per-execution UI phase, keyed by executionId. A resolved phase hides the // Per-execution UI phase, keyed by executionId. A resolved phase hides the
// action buttons permanently so the banner clears after a click and can // action buttons permanently so the banner clears after a click and can
// never re-POST /decision. // never re-POST /decision.
@@ -125,7 +140,10 @@
$effect(() => { $effect(() => {
for (const a of approvals) { for (const a of approvals) {
if (!phase.get(a.executionId)) void watchExternal(a.executionId) if (!phase.get(a.executionId)) {
void watchExternal(a.executionId)
void loadBlastRadius(a)
}
} }
}) })
</script> </script>
@@ -194,6 +212,7 @@
</span> </span>
</div> </div>
{:else if approval.destructive} {:else if approval.destructive}
{@const affected = blastRadius.get(approval.executionId)}
<div class="my-2 flex flex-col gap-1 rounded-lg border border-destructive/50 bg-destructive/10 px-3 py-2"> <div class="my-2 flex flex-col gap-1 rounded-lg border border-destructive/50 bg-destructive/10 px-3 py-2">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<ShieldCheckIcon class="size-4 shrink-0 text-destructive" /> <ShieldCheckIcon class="size-4 shrink-0 text-destructive" />
@@ -211,9 +230,17 @@
{#if approval.command} {#if approval.command}
<code class="ml-6 block truncate text-xs text-destructive/80">{approval.command}</code> <code class="ml-6 block truncate text-xs text-destructive/80">{approval.command}</code>
{/if} {/if}
{#if affected}
<div class="ml-6 flex items-start gap-1.5 text-xs text-destructive/90">
<NetworkIcon class="mt-0.5 size-3 shrink-0" />
<span>Affects {affected.length} downstream: {affected.join(', ')}</span>
</div>
{/if}
</div> </div>
{:else} {:else}
<div class="my-2 flex items-center gap-2 rounded-lg border border-warning/40 bg-warning/5 px-3 py-2"> {@const affected = blastRadius.get(approval.executionId)}
<div class="my-2 flex flex-col gap-1 rounded-lg border border-warning/40 bg-warning/5 px-3 py-2">
<div class="flex items-center gap-2">
<ShieldCheckIcon class="size-4 shrink-0 text-warning" /> <ShieldCheckIcon class="size-4 shrink-0 text-warning" />
<span class="flex-1 text-xs text-muted-foreground">{approval.action} on {approval.target} requires approval</span> <span class="flex-1 text-xs text-muted-foreground">{approval.action} on {approval.target} requires approval</span>
<Button size="sm" variant="default" class="h-7 px-2.5 text-xs" onclick={() => decide(approval, 'approve')}> <Button size="sm" variant="default" class="h-7 px-2.5 text-xs" onclick={() => decide(approval, 'approve')}>
@@ -223,5 +250,12 @@
<XIcon class="size-3" /><span class="ml-1">Deny</span> <XIcon class="size-3" /><span class="ml-1">Deny</span>
</Button> </Button>
</div> </div>
{#if affected}
<div class="ml-6 flex items-start gap-1.5 text-xs text-warning">
<NetworkIcon class="mt-0.5 size-3 shrink-0" />
<span>Affects {affected.length} downstream: {affected.join(', ')}</span>
</div>
{/if}
</div>
{/if} {/if}
{/each} {/each}