Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2029840671 | ||
|
|
91c78d626f |
6 changed files with 122 additions and 21 deletions
9
TODO.md
9
TODO.md
|
|
@ -44,15 +44,6 @@ Pick anything from here when relevant. Cross-cutting design notes live in
|
||||||
|
|
||||||
## UI / UX
|
## UI / UX
|
||||||
|
|
||||||
- **Dashboard: show per-agent applied config.** Surface
|
|
||||||
`/var/lib/hyperhive/applied/<name>/agent.nix` (the file the
|
|
||||||
container actually builds from) as a collapsible `<details>`
|
|
||||||
block on each container row, alongside the journald viewer.
|
|
||||||
Backend: new `GET /api/agent-config/{name}` returns the file
|
|
||||||
contents (text/plain). Frontend: lazy-fetch on expand, render
|
|
||||||
inside a `<pre>` with the same theming as the journal panel.
|
|
||||||
Useful for spot-checking what `request_apply_commit` produced
|
|
||||||
without ssh-ing in.
|
|
||||||
- **xterm.js terminal** embedded per-agent, attached to a PTY exposed by
|
- **xterm.js terminal** embedded per-agent, attached to a PTY exposed by
|
||||||
the harness. Pairs well with the unprivileged-container work — would let
|
the harness. Pairs well with the unprivileged-container work — would let
|
||||||
the operator drop into the container without `nixos-container root-login`.
|
the operator drop into the container without `nixos-container root-login`.
|
||||||
|
|
|
||||||
|
|
@ -164,6 +164,21 @@
|
||||||
if (!(f instanceof HTMLFormElement) || !f.hasAttribute('data-async')) return;
|
if (!(f instanceof HTMLFormElement) || !f.hasAttribute('data-async')) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (f.dataset.confirm && !confirm(f.dataset.confirm)) return;
|
if (f.dataset.confirm && !confirm(f.dataset.confirm)) return;
|
||||||
|
if (f.dataset.prompt) {
|
||||||
|
const ans = prompt(f.dataset.prompt, '');
|
||||||
|
if (ans === null) return; // operator hit Cancel
|
||||||
|
// Drop into a hidden input named after `data-prompt-field` (or
|
||||||
|
// 'note' by default) so the value rides along on the POST.
|
||||||
|
const field = f.dataset.promptField || 'note';
|
||||||
|
let input = f.querySelector(`input[name="${field}"]`);
|
||||||
|
if (!input) {
|
||||||
|
input = document.createElement('input');
|
||||||
|
input.type = 'hidden';
|
||||||
|
input.name = field;
|
||||||
|
f.append(input);
|
||||||
|
}
|
||||||
|
input.value = ans;
|
||||||
|
}
|
||||||
const btn = f.querySelector('button[type="submit"], button:not([type]), .btn-inline');
|
const btn = f.querySelector('button[type="submit"], button:not([type]), .btn-inline');
|
||||||
const original = btn ? btn.innerHTML : '';
|
const original = btn ? btn.innerHTML : '';
|
||||||
if (btn) { btn.disabled = true; btn.innerHTML = '<span class="spinner">◐</span>'; }
|
if (btn) { btn.disabled = true; btn.innerHTML = '<span class="spinner">◐</span>'; }
|
||||||
|
|
@ -290,6 +305,9 @@
|
||||||
// narrows to the harness service (or empty = full machine).
|
// narrows to the harness service (or empty = full machine).
|
||||||
const journalUnit = c.is_manager ? 'hive-m1nd.service' : 'hive-ag3nt.service';
|
const journalUnit = c.is_manager ? 'hive-m1nd.service' : 'hive-ag3nt.service';
|
||||||
li.append(buildJournalDetails(c.container, journalUnit));
|
li.append(buildJournalDetails(c.container, journalUnit));
|
||||||
|
// Per-container applied config viewer. Shows the agent.nix
|
||||||
|
// the container is actually built against.
|
||||||
|
li.append(buildConfigDetails(c.name));
|
||||||
|
|
||||||
ul.append(li);
|
ul.append(li);
|
||||||
}
|
}
|
||||||
|
|
@ -348,6 +366,48 @@
|
||||||
return details;
|
return details;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Per-container applied-config viewer. Lazy-fetches on expand;
|
||||||
|
// refresh button re-fetches. Read-only — the file is hive-c0re's
|
||||||
|
// applied repo, mutated only via the approval flow.
|
||||||
|
function buildConfigDetails(agentName) {
|
||||||
|
const details = el('details', {
|
||||||
|
class: 'journal',
|
||||||
|
'data-restore-key': 'agent-config:' + agentName,
|
||||||
|
});
|
||||||
|
const summary = el('summary', {}, '↳ agent.nix · ' + agentName);
|
||||||
|
const body = el('div', { class: 'journal-body' });
|
||||||
|
const controls = el('div', { class: 'journal-controls' });
|
||||||
|
const refresh = el('button', { type: 'button', class: 'btn btn-restart journal-refresh' },
|
||||||
|
'↻ refresh');
|
||||||
|
const pre = el('pre', { class: 'journal-output' }, 'fetching…');
|
||||||
|
let fetching = false;
|
||||||
|
async function fetchConfig() {
|
||||||
|
if (fetching) return;
|
||||||
|
fetching = true;
|
||||||
|
pre.textContent = 'fetching…';
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/agent-config/' + agentName);
|
||||||
|
const text = await resp.text();
|
||||||
|
if (!resp.ok) {
|
||||||
|
pre.textContent = 'error: ' + resp.status + '\n' + text;
|
||||||
|
} else {
|
||||||
|
pre.textContent = text || '(empty)';
|
||||||
|
pre.scrollTop = 0;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
pre.textContent = 'fetch failed: ' + err;
|
||||||
|
} finally {
|
||||||
|
fetching = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
details.addEventListener('toggle', () => { if (details.open) fetchConfig(); });
|
||||||
|
refresh.addEventListener('click', (e) => { e.preventDefault(); fetchConfig(); });
|
||||||
|
controls.append(refresh);
|
||||||
|
body.append(controls, pre);
|
||||||
|
details.append(summary, body);
|
||||||
|
return details;
|
||||||
|
}
|
||||||
|
|
||||||
function renderTombstones(s) {
|
function renderTombstones(s) {
|
||||||
const root = $('tombstones-section');
|
const root = $('tombstones-section');
|
||||||
root.innerHTML = '';
|
root.innerHTML = '';
|
||||||
|
|
@ -571,11 +631,21 @@
|
||||||
'new sub-agent — container will be created on approve'),
|
'new sub-agent — container will be created on approve'),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
// Deny prompts the operator for an optional reason; the
|
||||||
|
// submit handler stashes it into a hidden `note` input that
|
||||||
|
// rides along on the POST and is surfaced to the manager via
|
||||||
|
// HelperEvent::ApprovalResolved { note }.
|
||||||
|
const denyForm = el('form', {
|
||||||
|
method: 'POST', action: '/deny/' + a.id,
|
||||||
|
class: 'inline', 'data-async': '',
|
||||||
|
'data-prompt': 'reason for denying (optional, sent to manager):',
|
||||||
|
});
|
||||||
|
denyForm.append(el('button', { type: 'submit', class: 'btn btn-deny' }, 'DENY'));
|
||||||
row.append(
|
row.append(
|
||||||
' ',
|
' ',
|
||||||
form('/approve/' + a.id, 'btn-approve', '◆ APPR0VE'),
|
form('/approve/' + a.id, 'btn-approve', '◆ APPR0VE'),
|
||||||
' ',
|
' ',
|
||||||
form('/deny/' + a.id, 'btn-deny', 'DENY'),
|
denyForm,
|
||||||
);
|
);
|
||||||
li.append(row);
|
li.append(row);
|
||||||
if (a.diff_html) {
|
if (a.diff_html) {
|
||||||
|
|
|
||||||
|
|
@ -178,17 +178,17 @@ pub async fn destroy(coord: &Coordinator, name: &str, purge: bool) -> Result<()>
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn deny(coord: &Coordinator, id: i64) -> Result<()> {
|
pub fn deny(coord: &Coordinator, id: i64, note: Option<&str>) -> Result<()> {
|
||||||
let approval = coord.approvals.get(id)?;
|
let approval = coord.approvals.get(id)?;
|
||||||
coord.approvals.mark_denied(id)?;
|
coord.approvals.mark_denied(id, note)?;
|
||||||
tracing::info!(%id, "approval denied");
|
tracing::info!(%id, note, "approval denied");
|
||||||
if let Some(a) = approval {
|
if let Some(a) = approval {
|
||||||
coord.notify_manager(&HelperEvent::ApprovalResolved {
|
coord.notify_manager(&HelperEvent::ApprovalResolved {
|
||||||
id: a.id,
|
id: a.id,
|
||||||
agent: a.agent,
|
agent: a.agent,
|
||||||
commit_ref: a.commit_ref,
|
commit_ref: a.commit_ref,
|
||||||
status: ApprovalStatus::Denied,
|
status: ApprovalStatus::Denied,
|
||||||
note: None,
|
note: note.map(String::from),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|
|
||||||
|
|
@ -142,12 +142,12 @@ impl Approvals {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn mark_denied(&self, id: i64) -> Result<()> {
|
pub fn mark_denied(&self, id: i64, note: Option<&str>) -> Result<()> {
|
||||||
let conn = self.conn.lock().unwrap();
|
let conn = self.conn.lock().unwrap();
|
||||||
let affected = conn.execute(
|
let affected = conn.execute(
|
||||||
"UPDATE approvals SET status = 'denied', resolved_at = ?1
|
"UPDATE approvals SET status = 'denied', resolved_at = ?1, note = ?2
|
||||||
WHERE id = ?2 AND status = 'pending'",
|
WHERE id = ?3 AND status = 'pending'",
|
||||||
params![now_unix(), id],
|
params![now_unix(), note, id],
|
||||||
)?;
|
)?;
|
||||||
if affected == 0 {
|
if affected == 0 {
|
||||||
bail!("approval {id} not pending");
|
bail!("approval {id} not pending");
|
||||||
|
|
|
||||||
|
|
@ -54,6 +54,7 @@ pub async fn serve(port: u16, coord: Arc<Coordinator>) -> Result<()> {
|
||||||
.route("/cancel-question/{id}", post(post_cancel_question))
|
.route("/cancel-question/{id}", post(post_cancel_question))
|
||||||
.route("/purge-tombstone/{name}", post(post_purge_tombstone))
|
.route("/purge-tombstone/{name}", post(post_purge_tombstone))
|
||||||
.route("/api/journal/{name}", get(get_journal))
|
.route("/api/journal/{name}", get(get_journal))
|
||||||
|
.route("/api/agent-config/{name}", get(get_agent_config))
|
||||||
.route("/request-spawn", post(post_request_spawn))
|
.route("/request-spawn", post(post_request_spawn))
|
||||||
.route("/messages/stream", get(messages_stream))
|
.route("/messages/stream", get(messages_stream))
|
||||||
.with_state(AppState { coord });
|
.with_state(AppState { coord });
|
||||||
|
|
@ -409,8 +410,23 @@ async fn post_approve(State(state): State<AppState>, AxumPath(id): AxumPath<i64>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn post_deny(State(state): State<AppState>, AxumPath(id): AxumPath<i64>) -> Response {
|
#[derive(Deserialize, Default)]
|
||||||
match actions::deny(&state.coord, id) {
|
struct DenyForm {
|
||||||
|
#[serde(default)]
|
||||||
|
note: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn post_deny(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
AxumPath(id): AxumPath<i64>,
|
||||||
|
Form(form): Form<DenyForm>,
|
||||||
|
) -> Response {
|
||||||
|
let note = form
|
||||||
|
.note
|
||||||
|
.as_deref()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|s| !s.is_empty());
|
||||||
|
match actions::deny(&state.coord, id, note) {
|
||||||
Ok(()) => Redirect::to("/").into_response(),
|
Ok(()) => Redirect::to("/").into_response(),
|
||||||
Err(e) => error_response(&format!("deny {id} failed: {e:#}")),
|
Err(e) => error_response(&format!("deny {id} failed: {e:#}")),
|
||||||
}
|
}
|
||||||
|
|
@ -548,6 +564,30 @@ async fn get_journal(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Show the current `agent.nix` from the applied repo — the file
|
||||||
|
/// the container actually builds against. Read-only; the manager
|
||||||
|
/// can't influence what this returns (that path goes through the
|
||||||
|
/// approval queue).
|
||||||
|
async fn get_agent_config(AxumPath(name): AxumPath<String>) -> Response {
|
||||||
|
let logical = strip_container_prefix(&name);
|
||||||
|
// Constrain to managed containers — same shape as the journal
|
||||||
|
// endpoint, prevents arbitrary filesystem reads.
|
||||||
|
let live = lifecycle::list().await.unwrap_or_default();
|
||||||
|
let prefixed = if logical == lifecycle::MANAGER_NAME {
|
||||||
|
logical.clone()
|
||||||
|
} else {
|
||||||
|
format!("{}{logical}", lifecycle::AGENT_PREFIX)
|
||||||
|
};
|
||||||
|
if !live.iter().any(|c| c == &prefixed) {
|
||||||
|
return error_response(&format!("agent-config: no managed container {prefixed:?}"));
|
||||||
|
}
|
||||||
|
let path = Coordinator::agent_applied_dir(&logical).join("agent.nix");
|
||||||
|
match std::fs::read_to_string(&path) {
|
||||||
|
Ok(body) => ([("content-type", "text/plain; charset=utf-8")], body).into_response(),
|
||||||
|
Err(e) => error_response(&format!("read {}: {e}", path.display())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async fn post_purge_tombstone(
|
async fn post_purge_tombstone(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
AxumPath(name): AxumPath<String>,
|
AxumPath(name): AxumPath<String>,
|
||||||
|
|
|
||||||
|
|
@ -144,7 +144,7 @@ async fn dispatch(req: &HostRequest, coord: Arc<Coordinator>) -> HostResponse {
|
||||||
HostResponse::success()
|
HostResponse::success()
|
||||||
}
|
}
|
||||||
HostRequest::Deny { id } => {
|
HostRequest::Deny { id } => {
|
||||||
actions::deny(&coord, *id)?;
|
actions::deny(&coord, *id, None)?;
|
||||||
HostResponse::success()
|
HostResponse::success()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue