Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1656b265ed | ||
|
|
2950a7f9ee | ||
|
|
b714ba15c2 |
4 changed files with 214 additions and 3 deletions
|
|
@ -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=<name>&new_parent=<target>`)
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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 → <pick> picker. Inline `<select>` + button pair
|
||||
sitting alongside the bulk action buttons in the selection bar.
|
||||
The select inherits the terminal-y monospace look so it doesn't
|
||||
read as system-chrome popping out of the swarm aesthetic. Only
|
||||
surfaces when exactly one agent is selected. */
|
||||
.move-picker {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3em;
|
||||
margin-left: 0.6em;
|
||||
}
|
||||
.move-picker-select {
|
||||
font-family: inherit;
|
||||
font-size: 0.75em;
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
border: 1px solid var(--mauve);
|
||||
border-radius: 2px;
|
||||
padding: 0.1em 0.3em;
|
||||
max-width: 16em;
|
||||
}
|
||||
.move-picker-select:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
.move-picker .btn-move {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -864,6 +864,146 @@ window.marked = marked;
|
|||
confirm: (names) => `PURGE ${names.length} agent${names.length === 1 ? '' : 's'} (${names.join(', ')})? containers, config history, claude creds, and notes are all WIPED. no undo.`,
|
||||
disabledTitle: why('PURG3', managerNames.map((n) => `\`${n}\` is the manager`)),
|
||||
});
|
||||
|
||||
// #486 — move agent(s) in the topology tree. Two affordances:
|
||||
//
|
||||
// ⇡ M0V3 → ROOT promote selected agent(s) to top-level (parent=null)
|
||||
// ⇢ M0V3 → [sel] reparent the single selected agent under a picked
|
||||
// parent (cycle-safe — the dropdown filters out self
|
||||
// and own descendants on the client side; the
|
||||
// backend rechecks via `topology::set_parent`).
|
||||
//
|
||||
// Backend lives at POST /api/topology/set-parent (dashboard.rs#2170),
|
||||
// form-encoded `child=<name>&new_parent=<target-or-empty>`. The
|
||||
// backend re-emits container snapshots on success, so the tree
|
||||
// repaints without a separate refresh.
|
||||
addMoveActions(actions, selected, containers);
|
||||
}
|
||||
|
||||
// #486 — render the M0V3 affordances inside the selection bar. Split
|
||||
// into its own helper because the picker variant needs a select + button
|
||||
// pair, not the single-button shape addBulkButton ships.
|
||||
//
|
||||
// No client-side manager special-case (mara on #695): backend
|
||||
// `topology::set_parent` refuses to move the manager and surfaces the
|
||||
// refusal as a per-agent failure in the bulk-action error roll-up. Same
|
||||
// pattern as #443 ST0P (which also doesn't special-case manager).
|
||||
function addMoveActions(parent, selected, containers) {
|
||||
// M0V3 → ROOT: parent=null for every selected agent. Only meaningful
|
||||
// when at least one selected agent currently has a non-null parent;
|
||||
// otherwise it's a no-op for everything.
|
||||
const someNotAtRoot = selected.some((c) => c.parent);
|
||||
addBulkButton(parent, 'btn-move', '⇡ M0V3 → ROOT', someNotAtRoot, selected, {
|
||||
action: '/api/topology/set-parent',
|
||||
perAgentBodyFor: (name) => ({ child: name, new_parent: '' }),
|
||||
confirm: (names) => `promote ${names.length} agent${names.length === 1 ? '' : 's'} (${names.join(', ')}) to top-level (parent → root)?`,
|
||||
disabledTitle: !someNotAtRoot
|
||||
? '⇡ M0V3 → ROOT not available — all selected agents are already at root'
|
||||
: null,
|
||||
});
|
||||
|
||||
// M0V3 → <pick>: inline `<select>` 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 = '<span class="spinner">◐</span> ⇢ 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/<name>, /rebuild/<name>).
|
||||
// `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,
|
||||
|
|
|
|||
|
|
@ -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.<name>.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 = {
|
||||
|
|
|
|||
Loading…
Reference in a new issue