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">
|
<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,17 +230,32 @@
|
|||||||
{#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)}
|
||||||
<ShieldCheckIcon class="size-4 shrink-0 text-warning" />
|
<div class="my-2 flex flex-col gap-1 rounded-lg border border-warning/40 bg-warning/5 px-3 py-2">
|
||||||
<span class="flex-1 text-xs text-muted-foreground">{approval.action} on {approval.target} requires approval</span>
|
<div class="flex items-center gap-2">
|
||||||
<Button size="sm" variant="default" class="h-7 px-2.5 text-xs" onclick={() => decide(approval, 'approve')}>
|
<ShieldCheckIcon class="size-4 shrink-0 text-warning" />
|
||||||
<CheckIcon class="size-3" /><span class="ml-1">Approve</span>
|
<span class="flex-1 text-xs text-muted-foreground">{approval.action} on {approval.target} requires approval</span>
|
||||||
</Button>
|
<Button size="sm" variant="default" class="h-7 px-2.5 text-xs" onclick={() => decide(approval, 'approve')}>
|
||||||
<Button size="sm" variant="outline" class="h-7 px-2.5 text-xs" onclick={() => decide(approval, 'deny')}>
|
<CheckIcon class="size-3" /><span class="ml-1">Approve</span>
|
||||||
<XIcon class="size-3" /><span class="ml-1">Deny</span>
|
</Button>
|
||||||
</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>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{/each}
|
{/each}
|
||||||
|
|||||||
Reference in New Issue
Block a user