feat: robust provisioning (DNS self-heal) + live execution feedback in chat
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled

Production session provisioned the container but the service never installed:
apt failed with "Temporary failure resolving deb.debian.org" — a static-IP LXC
whose assigned nameserver couldn't resolve. The operator also got zero feedback:
the approval banner just sat there with no running/complete/failed status.

Backend robustness (provisionScript):
- Wait for real DNS/connectivity inside the container before apt, and self-heal
  /etc/resolv.conf to a public resolver (1.1.1.1/8.8.8.8) if the assigned one
  is dead. `set -e` after the gate so apt/post_install failures surface.
- apt-get update/install with Acquire::Retries=3.

Frontend feedback (InlineApproval):
- After approve, poll GET /executions/{id} and show live phase: submitting →
  provisioning… → provisioned successfully / execution failed (with the error).
- add getExecution() to api.ts.

Agent guidance (SOUL.md):
- omit vmid (auto-assigned), prefer dhcp, docker-compose-plugin is not in Debian
  (use docker.io + get.docker.com), end post_install with a health check.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-10 01:15:21 +02:00
parent a1f666f68a
commit f248508919
5 changed files with 152 additions and 56 deletions

View File

@@ -230,6 +230,12 @@ export async function fetchExecutions(status?: string): Promise<Execution[]> {
return data.items ?? []
}
export async function getExecution(id: string): Promise<Execution | null> {
const res = await fetch(`${API}/executions/${id}`)
if (!res.ok) return null
return res.json()
}
export async function cancelExecution(id: string): Promise<Execution | null> {
const res = await fetch(`${API}/executions/${id}/cancel`, { method: 'POST' })
if (!res.ok) return null

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import type { PendingApproval } from '$lib/stores/chat'
import { decideApproval } from '$lib/api'
import { decideApproval, getExecution, type Execution } from '$lib/api'
import { Button } from '$lib/components/ui/button'
import { SvelteMap } from 'svelte/reactivity'
import CheckIcon from '@lucide/svelte/icons/check'
@@ -10,62 +10,87 @@
let { approvals }: { approvals: PendingApproval[] } = $props()
// 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>()
// 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.
type Phase = 'deciding' | 'running' | 'completed' | 'failed' | 'denied'
const phase = new SvelteMap<string, Phase>()
// Latest execution row (for status/result display), keyed by executionId.
const exec = new SvelteMap<string, Execution>()
const TERMINAL = new Set(['completed', 'failed', 'cancelled', 'denied'])
function errorText(e: Execution | undefined): string {
const r = e?.result as Record<string, unknown> | undefined | null
const v = r?.error
return typeof v === 'string' && v ? v : 'Execution failed.'
}
// Poll the execution until it reaches a terminal state, so the operator sees
// provisioning progress and the final outcome without leaving the chat.
async function track(id: string) {
for (let i = 0; i < 150; i++) { // ~6min ceiling at 2.5s
const e = await getExecution(id)
if (e) {
exec.set(id, e)
if (e.status === 'completed') { phase.set(id, 'completed'); return }
if (e.status === 'failed' || e.status === 'cancelled') { phase.set(id, 'failed'); return }
}
await new Promise((r) => setTimeout(r, 2500))
}
// Timed out waiting — leave whatever we last saw, mark running-stalled.
if (!TERMINAL.has(exec.get(id)?.status ?? '')) phase.set(id, 'running')
}
async function decide(approval: PendingApproval, decision: 'approve' | 'deny') {
const id = approval.executionId
const cur = outcome.get(id)
if (cur === 'pending' || cur === 'approved' || cur === 'denied') return // no resubmit
outcome.set(id, 'pending')
const cur = phase.get(id)
if (cur && cur !== 'failed') return // no resubmit once decided/in-flight
phase.set(id, 'deciding')
const ok = await decideApproval(id, decision)
outcome.set(id, ok ? (decision === 'approve' ? 'approved' : 'denied') : 'failed')
if (!ok) { phase.set(id, 'failed'); return }
if (decision === 'deny') { phase.set(id, 'denied'); return }
phase.set(id, 'running')
void track(id)
}
</script>
{#each approvals as approval (approval.executionId)}
{@const state = outcome.get(approval.executionId)}
{#if state === 'approved'}
{@const p = phase.get(approval.executionId)}
{@const e = exec.get(approval.executionId)}
{#if p === 'completed'}
<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>
<CheckIcon class="size-4 shrink-0" />
<span>Provisioned successfully{e?.duration_ms ? ` in ${Math.round(e.duration_ms / 1000)}s` : ''}. See the Executions view for details.</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>
{:else if p === 'failed'}
<div class="my-2 flex flex-col gap-1 rounded-lg border border-destructive/40 bg-destructive/5 px-3 py-2 text-xs text-destructive">
<div class="flex items-center gap-2">
<XIcon class="size-4 shrink-0" />
<span class="font-medium">Execution failed</span>
<Button size="sm" variant="outline" class="ml-auto h-6 px-2 text-xs" onclick={() => decide(approval, 'approve')}>Retry</Button>
</div>
<pre class="whitespace-pre-wrap break-words pl-6 opacity-90">{errorText(e)}</pre>
</div>
{:else if state === 'failed'}
{:else if p === '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 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>
<XIcon class="size-4" /><span>Denied.</span>
</div>
{:else if p === 'running' || p === 'deciding'}
<div class="my-2 flex items-center gap-2 rounded-lg border border-warning/40 bg-warning/5 px-3 py-2 text-xs text-muted-foreground">
<LoaderCircleIcon class="size-4 shrink-0 animate-spin text-warning" />
<span>{p === 'deciding' ? 'Submitting approval…' : `Provisioning ${approval.target} (this can take a minute)`}</span>
</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 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')}>
<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>
{/if}
<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}
{/each}