diff --git a/docs/web-ui.md b/docs/web-ui.md index ec734dfd..8248a034 100644 --- a/docs/web-ui.md +++ b/docs/web-ui.md @@ -440,6 +440,19 @@ frosted-mauve bar slides up from the bottom of the viewport - `▶ ST4RT` — stopped agents only - `↻ R3BU1LD` — always available - `DESTR0Y` / `PURG3` — sub-agents only (disabled if manager selected) + - `⇡ M0V3 → ROOT` (#486) — promote selected agents to top-level + (parent = null); disabled when all selected are already at root. + Manager included with no special-case (matches the `ST0P` policy); + the backend's `topology::set_parent` refuses to move the manager + and the refusal surfaces in the failure roll-up. + - `⇢ M0V3 → [select]` (#486) — inline picker available for any + selection size. The dropdown lists every container that isn't IN + the selection itself nor a descendant of any selected agent + (client-side BFS cycle prevention across the whole batch; the + backend re-checks per-agent). On submit POSTs to + `/api/topology/set-parent` (form-encoded `child=&new_parent=`) + once per selected agent, which writes `topology.json` and re-emits + a container snapshot so the tree repaints without a page reload. - **`✕ clear`** button + `Esc` key clear the entire selection. Stale selections (agents destroyed while selected) are pruned on diff --git a/frontend/packages/dashboard/src/dashboard.css b/frontend/packages/dashboard/src/dashboard.css index 08150b4a..20260a0e 100644 --- a/frontend/packages/dashboard/src/dashboard.css +++ b/frontend/packages/dashboard/src/dashboard.css @@ -915,6 +915,11 @@ ul form.inline { display: inline-block; } .btn-restart { color: var(--cyan); border-color: var(--cyan); font-size: 0.75em; padding: 0.15em 0.5em; margin-left: 0.6em; } .btn-stop { color: var(--pink); border-color: var(--pink); font-size: 0.75em; padding: 0.15em 0.5em; margin-left: 0.6em; } .btn-start { color: var(--green); border-color: var(--green); font-size: 0.75em; padding: 0.15em 0.5em; margin-left: 0.6em; } +/* #486 — M0V3 affordance (selection bar). Mauve picks up the same + accent the question-override / mid-status surfaces use; reads as + "structural change" rather than the destructive red / amber chrome + of destroy / rebuild. */ +.btn-move { color: var(--mauve); border-color: var(--mauve); font-size: 0.75em; padding: 0.15em 0.5em; margin-left: 0.6em; } .btn-talk { color: var(--cyan); border-color: var(--cyan); } .btn-spawn { color: var(--amber); border-color: var(--amber); } .btn-fire-now { color: var(--mauve, #cba6f7); border-color: var(--mauve, #cba6f7); } @@ -2085,3 +2090,32 @@ body.flow-shell .tabbar .tab.active.tab-link { visible (`body.has-selection`, toggled by tabs.js when the selection set is non-empty). */ body.dashboard-shell.has-selection { padding-bottom: 4.5em; } + +/* #486 — M0V3 → picker. Inline `` of candidate parents + submit + // button. Available for any selection size (mara on #695 — was + // single-agent only in v1). Picker omits each selected agent itself + // plus the union of every selected agent's descendants (cycle-safe; + // backend `topology::set_parent` re-checks). Empty candidate list ⇒ + // disable the picker. + const candidates = validReparentCandidates(selected, containers); + const wrap = el('span', { class: 'move-picker' }); + const selectTitle = selected.length === 1 + ? `change ${selected[0].name}'s parent` + : `change ${selected.length} agents' parent`; + const sel = el('select', { class: 'move-picker-select', title: selectTitle }); + sel.append(el('option', { value: '' }, '— pick parent —')); + for (const name of candidates) { + sel.append(el('option', { value: name }, name)); + } + if (!candidates.length) { + sel.disabled = true; + sel.title = selected.length === 1 + ? `no valid parents for ${selected[0].name} (every other agent is its descendant)` + : `no valid parents — every other agent is a descendant of one of the selected`; + } + const btn = el('button', { type: 'button', class: 'btn btn-move' }, '⇢ M0V3'); + btn.disabled = true; + sel.addEventListener('change', () => { btn.disabled = !sel.value; }); + btn.addEventListener('click', async () => { + const newParent = sel.value; + if (!newParent) return; + const names = selected.map((c) => c.name); + const promptMsg = names.length === 1 + ? `move ${names[0]} under ${newParent}?` + : `move ${names.length} agents (${names.join(', ')}) under ${newParent}?`; + if (!confirm(promptMsg)) return; + btn.disabled = true; + const original = btn.innerHTML; + btn.innerHTML = ' ⇢ M0V3'; + // Sequential POSTs — same shape as the bulk-button loop. Each + // call is small + backend serialises topology writes via the + // file-lock anyway. + const failures = []; + for (const name of names) { + try { + const body = new URLSearchParams({ child: name, new_parent: newParent }); + const resp = await fetch('/api/topology/set-parent', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body, + redirect: 'manual', + }); + const ok = resp.ok || resp.type === 'opaqueredirect' + || (resp.status >= 200 && resp.status < 400); + if (!ok) { + const text = await resp.text().catch(() => ''); + failures.push(`${name}: http ${resp.status}${text ? ' — ' + text.slice(0, 200) : ''}`); + } + } catch (err) { + failures.push(`${name}: ${err}`); + } + } + btn.innerHTML = original; + btn.disabled = !sel.value; + if (failures.length) { + alert(`⇢ M0V3 completed with ${failures.length} failure${failures.length === 1 ? '' : 's'}:\n\n` + failures.join('\n')); + } + }); + wrap.append(sel, btn); + parent.append(wrap); + } + + // Filter the dashboard's container list to those that are valid + // re-parent targets for the `selected` agents: anyone who isn't IN + // the selection itself, isn't a descendant of any selected agent + // (cycle prevention across the whole batch). The backend re-checks + // per-agent via `topology::set_parent`; this client-side filter is + // purely UX so the operator can't pick an obviously-invalid option. + function validReparentCandidates(selected, containers) { + // Build child map once. + const childrenOf = new Map(); + for (const c of containers) { + const p = c.parent || null; + if (!childrenOf.has(p)) childrenOf.set(p, []); + childrenOf.get(p).push(c.name); + } + // Union descendant set across every selected agent (each agent's + // descendants AND itself). + const blocked = new Set(); + for (const t of selected) { + const queue = [t.name]; + blocked.add(t.name); + while (queue.length) { + const n = queue.shift(); + for (const child of (childrenOf.get(n) || [])) { + if (blocked.has(child)) continue; + blocked.add(child); + queue.push(child); + } + } + } + return containers + .filter((c) => !blocked.has(c.name)) + .map((c) => c.name) + .sort(); } function addBulkButton(parent, btnClass, label, enabled, selected, opts) { @@ -887,10 +1027,25 @@ window.marked = marked; // Sequential POSTs to keep server-side serialisation predictable // (rebuild_queue dedups but other endpoints don't); the loop is // short — bulk selections are typically a handful of agents. + // + // Two URL shapes: + // - `opts.action` is a path prefix and the agent name gets + // appended (lifecycle endpoints: /start/, /rebuild/). + // `opts.body` is a static object applied to every POST. + // - `opts.perAgentBodyFor(name)` is set: `opts.action` is the + // full URL (no name appended) and the per-agent body comes + // from the callback. Used by /api/topology/set-parent (#486), + // where the agent name is a body field rather than a URL + // component. for (const name of names) { - const body = new URLSearchParams(opts.body || {}); + const body = opts.perAgentBodyFor + ? new URLSearchParams(opts.perAgentBodyFor(name)) + : new URLSearchParams(opts.body || {}); + const url = opts.perAgentBodyFor + ? opts.action + : opts.action + encodeURIComponent(name); try { - const resp = await fetch(opts.action + encodeURIComponent(name), { + const resp = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body, diff --git a/nix/templates/harness-base.nix b/nix/templates/harness-base.nix index d99061ff..b8ac71bb 100644 --- a/nix/templates/harness-base.nix +++ b/nix/templates/harness-base.nix @@ -1269,8 +1269,17 @@ in # `hyperhive.user.passwordlessSudo = true` is configured # (#672 fixup pulled forward into this PR to avoid the # regression argus flagged on #676). + # + # `systemd.services..path` appends `/bin` to each entry, + # so the bare prefixes here resolve to `/run/wrappers/bin` + + # `/run/current-system/sw/bin` inside the unit's PATH. Passing + # the trailing `/bin` ourselves (the natural-looking spelling) + # would yield `/run/wrappers/bin/bin` + `/run/current-system/sw/bin/bin`, + # neither of which exists — that's how #672 originally landed + # broken: every agent had a PATH pointing at non-existent dirs + # and `which sudo` kept falling back to the un-setuid binary. path = [ - "/run/wrappers/bin" + "/run/wrappers" "/run/current-system/sw" ]; environment = {