feat: surface blast radius on approval cards
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:
@@ -1,15 +1,30 @@
|
||||
<script lang="ts">
|
||||
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 { SvelteMap } from 'svelte/reactivity'
|
||||
import CheckIcon from '@lucide/svelte/icons/check'
|
||||
import XIcon from '@lucide/svelte/icons/x'
|
||||
import ShieldCheckIcon from '@lucide/svelte/icons/shield-check'
|
||||
import LoaderCircleIcon from '@lucide/svelte/icons/loader-circle'
|
||||
import NetworkIcon from '@lucide/svelte/icons/network'
|
||||
|
||||
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
|
||||
// action buttons permanently so the banner clears after a click and can
|
||||
// never re-POST /decision.
|
||||
@@ -125,7 +140,10 @@
|
||||
|
||||
$effect(() => {
|
||||
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>
|
||||
@@ -194,6 +212,7 @@
|
||||
</span>
|
||||
</div>
|
||||
{: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="flex items-center gap-2">
|
||||
<ShieldCheckIcon class="size-4 shrink-0 text-destructive" />
|
||||
@@ -211,17 +230,32 @@
|
||||
{#if approval.command}
|
||||
<code class="ml-6 block truncate text-xs text-destructive/80">{approval.command}</code>
|
||||
{/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>
|
||||
{:else}
|
||||
<div class="my-2 flex items-center gap-2 rounded-lg border border-warning/40 bg-warning/5 px-3 py-2">
|
||||
<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>
|
||||
<Button size="sm" variant="default" class="h-7 px-2.5 text-xs" onclick={() => decide(approval, 'approve')}>
|
||||
<CheckIcon class="size-3" /><span class="ml-1">Approve</span>
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" class="h-7 px-2.5 text-xs" onclick={() => decide(approval, 'deny')}>
|
||||
<XIcon class="size-3" /><span class="ml-1">Deny</span>
|
||||
</Button>
|
||||
{@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" />
|
||||
<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')}>
|
||||
<CheckIcon class="size-3" /><span class="ml-1">Approve</span>
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" class="h-7 px-2.5 text-xs" onclick={() => decide(approval, 'deny')}>
|
||||
<XIcon class="size-3" /><span class="ml-1">Deny</span>
|
||||
</Button>
|
||||
</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}
|
||||
{/each}
|
||||
|
||||
Reference in New Issue
Block a user