fix: make execution names unique, move approval bar above input
- Execution entity name now includes UUID suffix: 'pct_create on host:strong (abc12345)' so the (type,name) UNIQUE constraint doesn't block subsequent executions for the same target+action. Dedup now uses JOIN + LIKE prefix match to find only pending_approval executions. - Move persistent approval bar from top of messages area to just above the chat input box (bottom-fixed position, above the textarea form).
This commit is contained in:
@@ -284,18 +284,17 @@ func newServer(pool *db.Pool, agentID uuid.UUID) *mcp.Server {
|
|||||||
// Deduplicate: if a pending execution already exists for the same
|
// Deduplicate: if a pending execution already exists for the same
|
||||||
// target+action, return the existing one instead of creating a
|
// target+action, return the existing one instead of creating a
|
||||||
// duplicate. Prevents the LLM from re-requesting the same gated
|
// duplicate. Prevents the LLM from re-requesting the same gated
|
||||||
// action in a tool-calling loop. Uses the entities type+name
|
// action in a tool-calling loop. Only blocks when a pending
|
||||||
// UNIQUE constraint as the dedup key (one execution per
|
// execution exists; completed/failed ones don't block.
|
||||||
// action:target pair).
|
|
||||||
if action == "systemctl" || action == "apt_upgrade" || action == "pct_create" {
|
if action == "systemctl" || action == "apt_upgrade" || action == "pct_create" {
|
||||||
execName := action + " on " + targetSlug
|
execNamePrefix := action + " on " + targetSlug
|
||||||
var existingID, existingStatus string
|
var existingID string
|
||||||
err := pool.QueryRow(ctx, `
|
err := pool.QueryRow(ctx, `
|
||||||
SELECT e.id::text, COALESCE(ex.status,'') FROM entities e
|
SELECT e.id::text FROM entities e
|
||||||
LEFT JOIN executions ex ON ex.entity_id = e.id
|
JOIN executions ex ON ex.entity_id = e.id
|
||||||
WHERE e.type = 'execution' AND e.name = $1
|
WHERE e.type = 'execution' AND e.name LIKE $1 AND ex.status = 'pending_approval'
|
||||||
ORDER BY e.created_at DESC LIMIT 1`, execName).Scan(&existingID, &existingStatus)
|
ORDER BY e.created_at DESC LIMIT 1`, execNamePrefix+"%").Scan(&existingID)
|
||||||
if err == nil && existingID != "" && existingStatus != "completed" && existingStatus != "failed" {
|
if err == nil && existingID != "" {
|
||||||
return textResult(fmt.Sprintf("%s on %s is already queued for approval — execution %s. Wait for operator approval. Do not re-request.",
|
return textResult(fmt.Sprintf("%s on %s is already queued for approval — execution %s. Wait for operator approval. Do not re-request.",
|
||||||
action, targetSlug, existingID)), nil
|
action, targetSlug, existingID)), nil
|
||||||
}
|
}
|
||||||
@@ -304,12 +303,10 @@ func newServer(pool *db.Pool, agentID uuid.UUID) *mcp.Server {
|
|||||||
id, _ := uuid.NewV7()
|
id, _ := uuid.NewV7()
|
||||||
correlationID := uuid.New().String()
|
correlationID := uuid.New().String()
|
||||||
|
|
||||||
// Write execution record. If the (type,name) UNIQUE constraint
|
execName := action + " on " + targetSlug + " (" + id.String()[:8] + ")"
|
||||||
// fires (dedup race), the INSERT silently does nothing and the
|
|
||||||
// existing record wins.
|
|
||||||
execSlug := "exec:" + targetSlug + ":" + id.String()[:8]
|
execSlug := "exec:" + targetSlug + ":" + id.String()[:8]
|
||||||
_, err := pool.Exec(ctx, `INSERT INTO entities (id, slug, type, name, attributes) VALUES ($1, $2, 'execution', $3, '{}') ON CONFLICT (type, name) DO NOTHING`,
|
_, err := pool.Exec(ctx, `INSERT INTO entities (id, slug, type, name, attributes) VALUES ($1, $2, 'execution', $3, '{}')`,
|
||||||
id, execSlug, action+" on "+targetSlug)
|
id, execSlug, execName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return textResult(fmt.Sprintf("error: failed to create execution: %v", err)), nil
|
return textResult(fmt.Sprintf("error: failed to create execution: %v", err)), nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -130,38 +130,6 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
<div class="flex min-w-0 flex-1 flex-col">
|
<div class="flex min-w-0 flex-1 flex-col">
|
||||||
{#if pendingApprovals.length > 0}
|
|
||||||
<div class="shrink-0 border-b border-warning/30 bg-warning/5 px-4 py-2">
|
|
||||||
{#each pendingApprovals as a (a.executionId)}
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<ShieldCheckIcon class="size-4 shrink-0 text-warning" />
|
|
||||||
<span class="flex-1 text-xs font-medium">
|
|
||||||
{a.action} on {a.target}
|
|
||||||
</span>
|
|
||||||
{#if approving === a.executionId}
|
|
||||||
<LoaderCircleIcon class="size-4 animate-spin text-muted-foreground" />
|
|
||||||
{:else}
|
|
||||||
<Button size="sm" variant="default" class="h-7 px-2.5 text-xs" disabled={approving !== null} onclick={() => approveOne(a)}>
|
|
||||||
<CheckIcon class="size-3" />
|
|
||||||
<span class="ml-1">Approve</span>
|
|
||||||
</Button>
|
|
||||||
<Button size="sm" variant="outline" class="h-7 px-2.5 text-xs" disabled={approving !== null} onclick={() => denyOne(a)}>
|
|
||||||
<XIcon class="size-3" />
|
|
||||||
<span class="ml-1">Deny</span>
|
|
||||||
</Button>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
{#if pendingApprovals.length > 1}
|
|
||||||
<div class="mt-1">
|
|
||||||
<Button size="sm" variant="default" class="h-6 px-2 text-xs" disabled={approving !== null} onclick={approveAll}>
|
|
||||||
<CheckIcon class="size-3" />
|
|
||||||
<span class="ml-1">Approve all</span>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
<div class="min-h-0 flex-1 overflow-y-auto">
|
<div class="min-h-0 flex-1 overflow-y-auto">
|
||||||
<div class="mx-auto flex max-w-3xl flex-col gap-5 p-4">
|
<div class="mx-auto flex max-w-3xl flex-col gap-5 p-4">
|
||||||
{#if $messages.length === 0}
|
{#if $messages.length === 0}
|
||||||
@@ -215,6 +183,37 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
{#if pendingApprovals.length > 0}
|
||||||
|
<div class="shrink-0 border-t border-warning/30 bg-warning/5 px-4 py-2">
|
||||||
|
{#each pendingApprovals as a (a.executionId)}
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<ShieldCheckIcon class="size-4 shrink-0 text-warning" />
|
||||||
|
<span class="flex-1 text-xs font-medium">
|
||||||
|
{a.action} on {a.target}
|
||||||
|
</span>
|
||||||
|
{#if approving === a.executionId}
|
||||||
|
<LoaderCircleIcon class="size-4 animate-spin text-muted-foreground" />
|
||||||
|
{:else}
|
||||||
|
<Button size="sm" variant="default" class="h-7 px-2.5 text-xs" disabled={approving !== null} onclick={() => approveOne(a)}>
|
||||||
|
<CheckIcon class="size-3" />
|
||||||
|
<span class="ml-1">Approve</span>
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="outline" class="h-7 px-2.5 text-xs" disabled={approving !== null} onclick={() => denyOne(a)}>
|
||||||
|
<XIcon class="size-3" />
|
||||||
|
<span class="ml-1">Deny</span>
|
||||||
|
</Button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
{#if pendingApprovals.length > 1}
|
||||||
|
<Button size="sm" variant="default" class="mt-1 h-6 px-2 text-xs" disabled={approving !== null} onclick={approveAll}>
|
||||||
|
<CheckIcon class="size-3" />
|
||||||
|
<span class="ml-1">Approve all</span>
|
||||||
|
</Button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<div class="border-t bg-card/50 p-3">
|
<div class="border-t bg-card/50 p-3">
|
||||||
<form
|
<form
|
||||||
class="mx-auto flex max-w-3xl items-end gap-2"
|
class="mx-auto flex max-w-3xl items-end gap-2"
|
||||||
|
|||||||
Reference in New Issue
Block a user