Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a7d31046a6 | ||
|
|
86a1591cfc |
5 changed files with 277 additions and 0 deletions
|
|
@ -2045,6 +2045,58 @@ body.logs-shell {
|
||||||
font-size: 0.85em;
|
font-size: 0.85em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ─── tool-groups (permissions) table ──────────────────────────────
|
||||||
|
Agents × tool-groups matrix in the SYST3M tab. Horizontally
|
||||||
|
scrollable on narrow viewports. */
|
||||||
|
.tg-table-wrap {
|
||||||
|
overflow-x: auto;
|
||||||
|
margin-top: 0.5em;
|
||||||
|
}
|
||||||
|
.tg-table {
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 0.82em;
|
||||||
|
min-width: 100%;
|
||||||
|
}
|
||||||
|
.tg-table th,
|
||||||
|
.tg-table td {
|
||||||
|
padding: 0.35em 0.6em;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
text-align: center;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
.tg-table thead th {
|
||||||
|
background: var(--bg-elev);
|
||||||
|
color: var(--muted);
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.tg-agent-col {
|
||||||
|
text-align: left !important;
|
||||||
|
min-width: 8em;
|
||||||
|
}
|
||||||
|
.tg-agent-name {
|
||||||
|
color: var(--fg);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.tg-default-label {
|
||||||
|
margin-left: 0.4em;
|
||||||
|
font-size: 0.85em;
|
||||||
|
}
|
||||||
|
.tg-cb {
|
||||||
|
cursor: pointer;
|
||||||
|
width: 1em;
|
||||||
|
height: 1em;
|
||||||
|
accent-color: var(--purple);
|
||||||
|
}
|
||||||
|
.tg-save-btn {
|
||||||
|
font-size: 0.78em;
|
||||||
|
padding: 0.2em 0.6em;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.tg-row:hover td {
|
||||||
|
background: var(--bg-elev);
|
||||||
|
}
|
||||||
|
|
||||||
/* ─── scheduled prompts tab ────────────────────────────────────────
|
/* ─── scheduled prompts tab ────────────────────────────────────────
|
||||||
Creation form at the top, list of queued schedule cards below.
|
Creation form at the top, list of queued schedule cards below.
|
||||||
Cards show: id + source + due-in + cancel-all in the header,
|
Cards show: id + source + due-in + cancel-all in the header,
|
||||||
|
|
|
||||||
|
|
@ -171,6 +171,18 @@
|
||||||
<div id="tombstones-section">
|
<div id="tombstones-section">
|
||||||
<p class="meta">loading…</p>
|
<p class="meta">loading…</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- T00L GR0UPS: per-agent tool-group permission matrix. Rows = agents,
|
||||||
|
cols = tool groups fetched from GET /api/tool-groups.
|
||||||
|
Checking / unchecking and saving POSTs to
|
||||||
|
/api/tool-groups/{agent}; a rebuild is queued automatically.
|
||||||
|
Absent agents default to the role default (shown in parens). -->
|
||||||
|
<h2>◆ T00L GR0UPS ◆</h2>
|
||||||
|
<div class="divider">══════════════════════════════════════════════════════════════</div>
|
||||||
|
<p class="meta">per-agent tool-group permissions. columns are filled from the backend — adding a new group requires no UI change. agents without an explicit entry use the role default (agents: messaging, meta, inbox, execution; manager: all). saving queues a rebuild.</p>
|
||||||
|
<div id="tool-groups-section">
|
||||||
|
<p class="meta">loading…</p>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- P33RS: peer hive link cards. Rendered from state.peer_hives;
|
<!-- P33RS: peer hive link cards. Rendered from state.peer_hives;
|
||||||
|
|
|
||||||
|
|
@ -1204,6 +1204,133 @@ window.marked = marked;
|
||||||
root.append(ul);
|
root.append(ul);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── tool-groups (permissions) table ─────────────────────────────────────
|
||||||
|
// Fetched from GET /api/tool-groups on system tab activation and after
|
||||||
|
// each save. Groups (columns) come from the backend so the UI doesn't
|
||||||
|
// need updating when a new group is added.
|
||||||
|
|
||||||
|
async function fetchAndRenderToolGroups() {
|
||||||
|
const root = $('tool-groups-section');
|
||||||
|
if (!root) return;
|
||||||
|
root.innerHTML = '';
|
||||||
|
root.append(el('p', { class: 'meta' }, 'loading…'));
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/tool-groups');
|
||||||
|
if (!resp.ok) throw new Error('HTTP ' + resp.status);
|
||||||
|
const data = await resp.json();
|
||||||
|
renderToolGroups(root, data);
|
||||||
|
} catch (err) {
|
||||||
|
root.innerHTML = '';
|
||||||
|
root.append(el('p', { class: 'meta' }, 'fetch failed: ' + err));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderToolGroups(root, data) {
|
||||||
|
root.innerHTML = '';
|
||||||
|
const { groups, assignments } = data;
|
||||||
|
if (!groups || !groups.length) {
|
||||||
|
root.append(el('p', { class: 'meta' }, '(no tool groups defined)'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Agent names: union of live containers + keys already in assignments,
|
||||||
|
// sorted alphabetically.
|
||||||
|
const agentNames = [...new Set([
|
||||||
|
...Array.from(containersState.keys()),
|
||||||
|
...Object.keys(assignments),
|
||||||
|
])].sort();
|
||||||
|
|
||||||
|
if (!agentNames.length) {
|
||||||
|
root.append(el('p', { class: 'meta' }, '(no agents)'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const wrap = el('div', { class: 'tg-table-wrap' });
|
||||||
|
const table = el('table', { class: 'tg-table' });
|
||||||
|
|
||||||
|
// Header row.
|
||||||
|
const thead = el('thead');
|
||||||
|
const hrow = el('tr');
|
||||||
|
hrow.append(el('th', { class: 'tg-agent-col' }, 'agent'));
|
||||||
|
for (const g of groups) {
|
||||||
|
hrow.append(el('th', { class: 'tg-group-col', title: g }, g));
|
||||||
|
}
|
||||||
|
hrow.append(el('th', { class: 'tg-save-col' }, ''));
|
||||||
|
thead.append(hrow);
|
||||||
|
table.append(thead);
|
||||||
|
|
||||||
|
const tbody = el('tbody');
|
||||||
|
for (const name of agentNames) {
|
||||||
|
// Explicit assignment or empty = using role default.
|
||||||
|
const assigned = assignments[name] || [];
|
||||||
|
const hasExplicit = Object.prototype.hasOwnProperty.call(assignments, name);
|
||||||
|
const tr = el('tr', { class: 'tg-row' });
|
||||||
|
|
||||||
|
// Agent name cell.
|
||||||
|
const nameTd = el('td', { class: 'tg-agent-col' });
|
||||||
|
nameTd.append(el('span', { class: 'tg-agent-name' }, name));
|
||||||
|
if (!hasExplicit) {
|
||||||
|
nameTd.append(el('span', { class: 'meta tg-default-label' }, '(default)'));
|
||||||
|
}
|
||||||
|
tr.append(nameTd);
|
||||||
|
|
||||||
|
// One checkbox per group.
|
||||||
|
const checkboxes = [];
|
||||||
|
for (const g of groups) {
|
||||||
|
const checked = assigned.includes(g);
|
||||||
|
const td = el('td', { class: 'tg-group-col' });
|
||||||
|
const cb = el('input', {
|
||||||
|
type: 'checkbox',
|
||||||
|
class: 'tg-cb',
|
||||||
|
'data-group': g,
|
||||||
|
'aria-label': g,
|
||||||
|
});
|
||||||
|
cb.checked = checked;
|
||||||
|
td.append(cb);
|
||||||
|
tr.append(td);
|
||||||
|
checkboxes.push(cb);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save button cell.
|
||||||
|
const saveTd = el('td', { class: 'tg-save-col' });
|
||||||
|
const saveBtn = el('button', { type: 'button', class: 'btn tg-save-btn' }, 'save');
|
||||||
|
saveBtn.addEventListener('click', async () => {
|
||||||
|
const selectedGroups = checkboxes
|
||||||
|
.filter((cb) => cb.checked)
|
||||||
|
.map((cb) => cb.dataset.group);
|
||||||
|
saveBtn.disabled = true;
|
||||||
|
saveBtn.textContent = '…';
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/tool-groups/' + encodeURIComponent(name), {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ groups: selectedGroups }),
|
||||||
|
});
|
||||||
|
if (!r.ok) {
|
||||||
|
const txt = await r.text();
|
||||||
|
saveBtn.textContent = 'err';
|
||||||
|
saveBtn.title = txt;
|
||||||
|
} else {
|
||||||
|
saveBtn.textContent = '✓';
|
||||||
|
setTimeout(() => fetchAndRenderToolGroups(), 800);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
saveBtn.textContent = 'err';
|
||||||
|
saveBtn.title = String(err);
|
||||||
|
} finally {
|
||||||
|
saveBtn.disabled = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
saveTd.append(saveBtn);
|
||||||
|
tr.append(saveTd);
|
||||||
|
|
||||||
|
tbody.append(tr);
|
||||||
|
}
|
||||||
|
table.append(tbody);
|
||||||
|
wrap.append(table);
|
||||||
|
root.append(wrap);
|
||||||
|
}
|
||||||
|
|
||||||
// Derived question state — cold-loaded from /api/state, then mutated
|
// Derived question state — cold-loaded from /api/state, then mutated
|
||||||
// live by `question_added` / `question_resolved` dashboard events.
|
// live by `question_added` / `question_resolved` dashboard events.
|
||||||
const QUESTION_HISTORY_LIMIT = 20;
|
const QUESTION_HISTORY_LIMIT = 20;
|
||||||
|
|
@ -3387,6 +3514,9 @@ window.marked = marked;
|
||||||
// Schedules pane has no SSE channel for mutations, so re-fetch
|
// Schedules pane has no SSE channel for mutations, so re-fetch
|
||||||
// on activation so the operator never lands on stale data.
|
// on activation so the operator never lands on stale data.
|
||||||
if (target === 'schedules') refreshSchedules();
|
if (target === 'schedules') refreshSchedules();
|
||||||
|
// Capabilities table is on the system pane; fetch on each activation
|
||||||
|
// so it stays fresh without an SSE channel.
|
||||||
|
if (target === 'system') fetchAndRenderToolGroups();
|
||||||
}
|
}
|
||||||
function syncTabFromHash() {
|
function syncTabFromHash() {
|
||||||
const h = (window.location.hash || '#swarm').replace(/^#/, '');
|
const h = (window.location.hash || '#swarm').replace(/^#/, '');
|
||||||
|
|
|
||||||
|
|
@ -78,6 +78,8 @@ pub async fn serve(port: u16, coord: Arc<Coordinator>) -> Result<()> {
|
||||||
.route("/retry-reminder/{id}", post(post_retry_reminder))
|
.route("/retry-reminder/{id}", post(post_retry_reminder))
|
||||||
.route("/request-spawn", post(post_request_spawn))
|
.route("/request-spawn", post(post_request_spawn))
|
||||||
.route("/api/topology/set-parent", post(post_set_parent))
|
.route("/api/topology/set-parent", post(post_set_parent))
|
||||||
|
.route("/api/tool-groups", get(get_tool_groups))
|
||||||
|
.route("/api/tool-groups/{agent}", post(post_tool_groups))
|
||||||
.route("/op-send", post(post_op_send))
|
.route("/op-send", post(post_op_send))
|
||||||
.route("/meta-update", post(post_meta_update))
|
.route("/meta-update", post(post_meta_update))
|
||||||
.route("/api/schedules", get(api_schedules).post(post_schedule_new))
|
.route("/api/schedules", get(api_schedules).post(post_schedule_new))
|
||||||
|
|
@ -2475,6 +2477,57 @@ async fn post_set_parent(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── tool-group (capabilities) endpoints ──────────────────────────────────
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct ToolGroupsSnapshot {
|
||||||
|
/// Ordered list of all known tool-group names. Drives the column
|
||||||
|
/// headers in the capabilities table — the UI does not hard-code them.
|
||||||
|
groups: Vec<&'static str>,
|
||||||
|
/// Per-agent assignment map. Absent agents use the role default
|
||||||
|
/// (agents: messaging+meta+inbox+execution; manager: all groups).
|
||||||
|
assignments: std::collections::BTreeMap<String, Vec<String>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_tool_groups(State(_state): State<AppState>) -> axum::Json<ToolGroupsSnapshot> {
|
||||||
|
let groups = hive_sh4re::ToolGroup::ALL
|
||||||
|
.iter()
|
||||||
|
.map(|g| g.as_str())
|
||||||
|
.collect();
|
||||||
|
let assignments = crate::tool_groups::read();
|
||||||
|
axum::Json(ToolGroupsSnapshot { groups, assignments })
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct SetToolGroupsBody {
|
||||||
|
groups: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn post_tool_groups(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
AxumPath(name): AxumPath<String>,
|
||||||
|
axum::Json(body): axum::Json<SetToolGroupsBody>,
|
||||||
|
) -> Response {
|
||||||
|
let logical = strip_container_prefix(&name);
|
||||||
|
if let Some(reject) = guard_agent_name(&state, &logical).await {
|
||||||
|
return reject;
|
||||||
|
}
|
||||||
|
if let Err(e) = crate::tool_groups::set_groups(&logical, &body.groups) {
|
||||||
|
return error_response(&format!("set tool-groups for {logical}: {e}"));
|
||||||
|
}
|
||||||
|
// Trigger a rebuild so the new HIVE_TOOL_GROUPS env var takes effect.
|
||||||
|
state.coord.rebuild_queue.enqueue(
|
||||||
|
crate::rebuild_queue::QueueKind::Rebuild,
|
||||||
|
logical.clone(),
|
||||||
|
crate::rebuild_queue::QueueSource::Manual,
|
||||||
|
"tool-group change via capabilities UI".to_owned(),
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
state.coord.emit_rebuild_queue_snapshot();
|
||||||
|
tracing::info!(agent = %logical, groups = ?body.groups, "operator: set tool-groups via dashboard");
|
||||||
|
(StatusCode::OK, "ok").into_response()
|
||||||
|
}
|
||||||
|
|
||||||
async fn post_rebuild(State(state): State<AppState>, AxumPath(name): AxumPath<String>) -> Response {
|
async fn post_rebuild(State(state): State<AppState>, AxumPath(name): AxumPath<String>) -> Response {
|
||||||
let logical = strip_container_prefix(&name);
|
let logical = strip_container_prefix(&name);
|
||||||
if let Some(reject) = guard_agent_name(&state, &logical).await {
|
if let Some(reject) = guard_agent_name(&state, &logical).await {
|
||||||
|
|
|
||||||
|
|
@ -789,6 +789,36 @@ impl ToolGroup {
|
||||||
Self::Diagnostics,
|
Self::Diagnostics,
|
||||||
Self::Execution,
|
Self::Execution,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/// Every known tool group in a stable order. Use this to enumerate
|
||||||
|
/// columns in the capabilities UI or any other place that needs the
|
||||||
|
/// full list without hard-coding it at the call site.
|
||||||
|
pub const ALL: &'static [Self] = &[
|
||||||
|
Self::Messaging,
|
||||||
|
Self::Meta,
|
||||||
|
Self::Inbox,
|
||||||
|
Self::Lifecycle,
|
||||||
|
Self::Approvals,
|
||||||
|
Self::Scheduling,
|
||||||
|
Self::Diagnostics,
|
||||||
|
Self::Execution,
|
||||||
|
];
|
||||||
|
|
||||||
|
/// The snake_case wire name for this group (matches `serde(rename_all =
|
||||||
|
/// "snake_case")` serialisation).
|
||||||
|
#[must_use]
|
||||||
|
pub fn as_str(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Messaging => "messaging",
|
||||||
|
Self::Meta => "meta",
|
||||||
|
Self::Inbox => "inbox",
|
||||||
|
Self::Lifecycle => "lifecycle",
|
||||||
|
Self::Approvals => "approvals",
|
||||||
|
Self::Scheduling => "scheduling",
|
||||||
|
Self::Diagnostics => "diagnostics",
|
||||||
|
Self::Execution => "execution",
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Schedule row shape on the wire — mirror of
|
/// Schedule row shape on the wire — mirror of
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue