diff --git a/docs/web-ui.md b/docs/web-ui.md index 8248a034..ec734dfd 100644 --- a/docs/web-ui.md +++ b/docs/web-ui.md @@ -440,19 +440,6 @@ 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 20260a0e..08150b4a 100644 --- a/frontend/packages/dashboard/src/dashboard.css +++ b/frontend/packages/dashboard/src/dashboard.css @@ -915,11 +915,6 @@ 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); } @@ -2090,32 +2085,3 @@ 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) { @@ -1027,25 +887,10 @@ 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 = opts.perAgentBodyFor - ? new URLSearchParams(opts.perAgentBodyFor(name)) - : new URLSearchParams(opts.body || {}); - const url = opts.perAgentBodyFor - ? opts.action - : opts.action + encodeURIComponent(name); + const body = new URLSearchParams(opts.body || {}); try { - const resp = await fetch(url, { + const resp = await fetch(opts.action + encodeURIComponent(name), { 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 b8ac71bb..d99061ff 100644 --- a/nix/templates/harness-base.nix +++ b/nix/templates/harness-base.nix @@ -1269,17 +1269,8 @@ 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" + "/run/wrappers/bin" "/run/current-system/sw" ]; environment = {