fix: pct_create false-success, VMID collision, and stuck approval banner
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled

Real production failure when the operator clicked Approve in chat: nothing
provisioned, banner never cleared, execution marked completed.

Three root causes:
- sshExec swallowed non-zero exits when the command produced output, so a
  `pct create` that printed "CT 132 already exists" and failed was reported
  as success and a bogus lxc entity was registered. Now any non-zero exit
  returns an error (with output) so the execution is correctly marked failed.
- The LLM reused VMID 132 (belongs to lxc:rclone; VMIDs are cluster-wide).
  pct_create now checks in-use VMIDs via `pvesh get /cluster/resources` and
  falls back to `pvesh get /cluster/nextid` when the requested id is taken.
- InlineApproval.svelte reset its state on every prop change (done was also
  compared against the wrong string), so the banner never cleared and each
  click re-POSTed /decision. Rewritten to track outcome per executionId,
  clear on success, and block resubmits.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-10 00:44:51 +02:00
parent b37f85ae08
commit 8ed2b88495
2 changed files with 75 additions and 31 deletions

View File

@@ -2,6 +2,7 @@
import type { PendingApproval } from '$lib/stores/chat'
import { decideApproval } 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'
@@ -9,34 +10,51 @@
let { approvals }: { approvals: PendingApproval[] } = $props()
let pending = $state(false)
let done = $state<string | null>(null)
let doneId = $state('')
// Per-execution outcome, keyed by executionId. A resolved entry hides the
// action buttons for that approval permanently so the banner clears after a
// click and can never re-POST /decision. 'pending' guards against double
// submits (the old component reset its state on every prop change, so the
// banner never cleared and each click fired another decision).
type State = 'pending' | 'approved' | 'denied' | 'failed'
const outcome = new SvelteMap<string, State>()
async function decide(approval: PendingApproval, decision: 'approve' | 'deny') {
pending = true
doneId = approval.executionId
const result = await decideApproval(approval.executionId, decision)
pending = false
done = result ? decision : 'failed'
const id = approval.executionId
const cur = outcome.get(id)
if (cur === 'pending' || cur === 'approved' || cur === 'denied') return // no resubmit
outcome.set(id, 'pending')
const ok = await decideApproval(id, decision)
outcome.set(id, ok ? (decision === 'approve' ? 'approved' : 'denied') : 'failed')
}
$effect(() => {
done = null
doneId = ''
pending = false
void approvals
})
</script>
{#each approvals.filter(a => !done || a.executionId !== doneId) as approval (approval.executionId)}
{#if !done || approval.executionId !== doneId}
{#each approvals as approval (approval.executionId)}
{@const state = outcome.get(approval.executionId)}
{#if state === 'approved'}
<div class="my-2 flex items-center gap-2 rounded-lg border border-success/40 bg-success/5 px-3 py-2 text-xs text-success">
<CheckIcon class="size-4" />
<span>Approved — provisioning. Track progress in the Executions view.</span>
</div>
{:else if state === 'denied'}
<div class="my-2 flex items-center gap-2 rounded-lg border border-destructive/40 bg-destructive/5 px-3 py-2 text-xs text-destructive">
<XIcon class="size-4" />
<span>Denied.</span>
</div>
{:else if state === 'failed'}
<div class="my-2 flex items-center gap-2 rounded-lg border border-destructive/40 bg-destructive/5 px-3 py-2 text-xs text-destructive">
<XIcon class="size-4" />
<span class="flex-1">Decision failed to send.</span>
<Button size="sm" variant="outline" class="h-7 px-2.5 text-xs" onclick={() => { outcome.delete(approval.executionId); decide(approval, 'approve') }}>
Retry
</Button>
</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>
{#if pending && doneId === approval.executionId}
{#if state === 'pending'}
<LoaderCircleIcon class="size-4 animate-spin text-muted-foreground" />
{:else}
<Button size="sm" variant="default" class="h-7 px-2.5 text-xs" onclick={() => decide(approval, 'approve')}>
@@ -49,15 +67,5 @@
</Button>
{/if}
</div>
{:else if done === 'approved'}
<div class="my-2 flex items-center gap-2 rounded-lg border border-success/40 bg-success/5 px-3 py-2 text-xs text-success">
<CheckIcon class="size-4" />
<span>Approved. The action is running.</span>
</div>
{:else}
<div class="my-2 flex items-center gap-2 rounded-lg border border-destructive/40 bg-destructive/5 px-3 py-2 text-xs text-destructive">
<XIcon class="size-4" />
<span>Denied.</span>
</div>
{/if}
{/each}