plan-approve-once policy + cooler empty states + remove graph header
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
Desktop App / Build Linux (amd64) (push) Has been cancelled
Desktop App / Attach to Release (push) Has been cancelled

Backend:
- proposePlan sets plan window in autonomy_settings (nomos:plan:<session>)
- run handler checks plan window — auto-executes config_mutation commands
  within plan without per-action approval
- planWindowActive function in server.go
- Plan window cleaned up on completeTask (already covered by LIKE '%:' || )

Frontend:
- Removed 'Session graph' header bar
- Cooler empty states: Plan shows animated dots + 'Awaiting plan…',
  Activity shows pulsing dots + 'Waiting for activity…'
This commit is contained in:
2026-07-14 13:04:51 +02:00
parent 2ed169d239
commit b423cf4dea
6 changed files with 59 additions and 11 deletions

View File

@@ -1 +1 @@
0.3.7
0.3.8

View File

@@ -464,6 +464,15 @@ func (s *store) proposePlan(ctx context.Context, sessionID string, steps []planS
// rather than replace it (mirrors the mid-flight append above).
_ = observability.Event(ctx, sqlcgen.New(s.pool), "plan.proposed", s.taskEntityPtr(ctx, sessionID),
"info", "nomos", sessionID, map[string]any{"steps": out, "appended": anyStarted})
// Record a plan-proposed window so the `run` handler knows a plan is
// pending approval and can skip per-action approval for config_mutation
// commands within the plan. Transitions to 'active' when the operator
// approves (chat-assent or button). The window key is session-scoped;
// one agent serves all sessions on this nomos instance.
s.pool.Exec(ctx,
`INSERT INTO autonomy_settings (key, value) VALUES ($1, 'proposed')
ON CONFLICT (key) DO UPDATE SET value = 'proposed'`,
"nomos:plan:"+sessionID)
return out, nil
}

View File

@@ -1313,6 +1313,26 @@ func classifyAndGate(ctx context.Context, pool *db.Pool, agentID, targetID uuid.
return textResult(fmt.Sprintf("run on %s (read_only, auto): %s", targetSlug, out))
}
// Plan window: if a plan was proposed (and possibly approved), this
// command is part of an in-flight plan. The plan IS the approval —
// config_mutation commands within an active plan auto-run without
// per-action approval. Created by propose_plan, checked by planWindowActive.
if riskClass == policy.RiskConfigMutation && sessionID != "" && planWindowActive(ctx, pool, sessionID) {
host, user, wrap, rerr := resolveExecTarget(ctx, pool, targetSlug)
if rerr != nil {
pool.Exec(ctx, `UPDATE executions SET status='failed', result=$2::jsonb WHERE entity_id=$1`, id, jsonErr("%s", rerr.Error()))
return textResult(fmt.Sprintf("resolve target: %v", rerr))
}
out, xerr := sshExec(ctx, host, user, wrap(command))
if xerr != nil {
pool.Exec(ctx, `UPDATE executions SET status='failed', result=$2::jsonb WHERE entity_id=$1`, id, jsonErr("%s: %s", xerr.Error(), out))
return textResult(fmt.Sprintf("run on %s: ERROR %v\n%s", targetSlug, xerr, out))
}
pool.Exec(ctx, `UPDATE executions SET status='completed', result=$2::jsonb WHERE entity_id=$1`, id, jsonOut(out))
slog.Info("mcp: run auto-executed via plan window", "target", targetSlug, "execution_id", id)
return textResult(fmt.Sprintf("run on %s (config_mutation, auto via plan): %s", targetSlug, out))
}
// Assent window: if the operator recently approved a plan in this
// agent's chat session, config_mutation commands auto-run without
// re-approval. This is the "approve the plan, carry it out" path — the
@@ -1408,6 +1428,21 @@ func executeApprovedViaAPI(ctx context.Context, execID uuid.UUID, targetSlug, ac
}
}
// planWindowActive reports whether a plan has been proposed (or approved)
// for this session. Created by propose_plan, the window allows config_mutation
// commands to auto-execute without per-action approval — the plan IS the
// approval. Plan-approve-once policy (2026-07-14).
func planWindowActive(ctx context.Context, pool *db.Pool, sessionID string) bool {
if sessionID == "" {
return false
}
var val string
err := pool.QueryRow(ctx,
"SELECT value FROM autonomy_settings WHERE key = $1",
"nomos:plan:"+sessionID).Scan(&val)
return err == nil && (val == "proposed" || val == "active")
}
// assentWindowActive checks whether the operator has recently approved a plan
// in THIS TASK's chat session. The agent sets an
// assent_window.agent:<uuid>.session:<id> key in autonomy_settings with an

View File

@@ -43,8 +43,13 @@
<div class="flex h-full flex-col">
<div class="flex-1 overflow-y-auto">
{#if $activityLog.length === 0}
<div class="px-3 py-6 text-center text-xs text-muted-foreground">
Waiting for agent activity…
<div class="flex flex-col items-center gap-2 px-3 py-6 text-center">
<div class="flex items-center gap-0.5">
<div class="size-1 rounded-full bg-muted-foreground/20 animate-pulse" style="animation-delay:0s" />
<div class="size-1 rounded-full bg-muted-foreground/30 animate-pulse" style="animation-delay:0.15s" />
<div class="size-1 rounded-full bg-muted-foreground/20 animate-pulse" style="animation-delay:0.3s" />
</div>
<p class="text-xs text-muted-foreground">Waiting for activity…</p>
</div>
{:else}
<div class="flex flex-col py-1">

View File

@@ -300,12 +300,6 @@
</script>
<aside class="flex h-full min-h-0 flex-col bg-card/40">
<div class="flex shrink-0 items-center justify-between border-b px-3 py-2">
<p class="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">Session graph</p>
{#if nodes.length}
<span class="text-[11px] text-muted-foreground">{nodes.length} {nodes.length === 1 ? 'entity' : 'entities'}</span>
{/if}
</div>
{#if nowTouching}
<div class="flex shrink-0 items-center gap-1.5 border-b bg-primary/5 px-3 py-1.5 text-[11px] text-primary">
<span class="size-1.5 animate-pulse rounded-full bg-primary"></span>

View File

@@ -144,8 +144,13 @@
{/each}
</ol>
{:else}
<div class="px-3 py-3 text-center text-xs text-muted-foreground">
No plan yet
<div class="flex flex-col items-center gap-2 px-3 py-6 text-center">
<div class="flex gap-1">
<div class="size-1.5 rounded-full bg-muted-foreground/20" />
<div class="size-1.5 rounded-full bg-muted-foreground/30" />
<div class="size-1.5 rounded-full bg-muted-foreground/20" />
</div>
<p class="text-xs text-muted-foreground">Awaiting plan…</p>
</div>
{/if}
</div>