dashboard: forge-linked config + approval card + 3-way diff base
- forge nix option moves to hyperhive.forge.enable, defaults true;
hive-c0re imports the forge module so it's on by default.
- drop the agent.nix container-row viewer + /api/agent-config; link
to the agent-configs forge repo instead.
- restructure pending approvals into a card (identity header /
what-changed body / decision actions) with a link to the proposal
commit on the forge.
- diff opens in the side panel with a 3-way base toggle: vs applied
(running) / vs last-approved / vs previous proposal, served by the
new /api/approval-diff/{id}?base= endpoint.
This commit is contained in:
parent
0c62bbf1cd
commit
49f4e9cc89
5 changed files with 305 additions and 134 deletions
|
|
@ -593,14 +593,23 @@
|
||||||
}
|
}
|
||||||
li.append(actions);
|
li.append(actions);
|
||||||
|
|
||||||
|
// ── line 3: drill-ins ────────────────────────────────────────
|
||||||
|
const drill = el('div', { class: 'drill-ins' });
|
||||||
// Per-container journald viewer. Opens the side panel and
|
// Per-container journald viewer. Opens the side panel and
|
||||||
// fetches the last N lines; refresh re-fetches; unit selector
|
// fetches the last N lines; refresh re-fetches; unit selector
|
||||||
// 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(buildJournalTrigger(c.container, journalUnit));
|
drill.append(buildJournalTrigger(c.container, journalUnit));
|
||||||
// Per-container applied config viewer. Shows the agent.nix
|
// Applied config now lives on the forge — link to the
|
||||||
// the container is actually built against.
|
// agent-configs mirror repo instead of a one-file viewer.
|
||||||
li.append(buildConfigTrigger(c.name));
|
if (s && s.forge_present) {
|
||||||
|
drill.append(el('a', {
|
||||||
|
class: 'panel-trigger', target: '_blank', rel: 'noopener',
|
||||||
|
href: `http://${hostname}:3000/agent-configs/${c.name}`,
|
||||||
|
title: 'applied config repo on the hive forge',
|
||||||
|
}, '↳ config repo ↗'));
|
||||||
|
}
|
||||||
|
li.append(drill);
|
||||||
|
|
||||||
ul.append(li);
|
ul.append(li);
|
||||||
}
|
}
|
||||||
|
|
@ -660,46 +669,6 @@
|
||||||
return trigger;
|
return trigger;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Per-container applied-config viewer. Returns an inline trigger;
|
|
||||||
// the click opens the side panel and fetches agent.nix. Read-only —
|
|
||||||
// the file is hive-c0re's applied repo, mutated only via approvals.
|
|
||||||
function buildConfigTrigger(agentName) {
|
|
||||||
const trigger = el('button', { type: 'button', class: 'panel-trigger' },
|
|
||||||
'↳ agent.nix · ' + agentName);
|
|
||||||
trigger.addEventListener('click', () => {
|
|
||||||
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)';
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
pre.textContent = 'fetch failed: ' + err;
|
|
||||||
} finally {
|
|
||||||
fetching = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
refresh.addEventListener('click', (e) => { e.preventDefault(); fetchConfig(); });
|
|
||||||
controls.append(refresh);
|
|
||||||
body.append(controls, pre);
|
|
||||||
Panel.open('agent.nix · ' + agentName, body);
|
|
||||||
fetchConfig();
|
|
||||||
});
|
|
||||||
return trigger;
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderTombstones(s) {
|
function renderTombstones(s) {
|
||||||
const root = $('tombstones-section');
|
const root = $('tombstones-section');
|
||||||
root.innerHTML = '';
|
root.innerHTML = '';
|
||||||
|
|
@ -1097,6 +1066,68 @@
|
||||||
}
|
}
|
||||||
renderApprovals();
|
renderApprovals();
|
||||||
}
|
}
|
||||||
|
// Classify each unified-diff line by its leading char so
|
||||||
|
// `.diff-add` / `.diff-del` / `.diff-hunk` / `.diff-file` /
|
||||||
|
// `.diff-ctx` colour the output. Built as text-only spans (no
|
||||||
|
// innerHTML) so there's no HTML-escape surface.
|
||||||
|
function buildDiffPre(text) {
|
||||||
|
const pre = el('pre', { class: 'diff' });
|
||||||
|
for (const raw of String(text).split('\n')) {
|
||||||
|
let cls = 'diff-ctx';
|
||||||
|
if (raw.startsWith('--- ') || raw.startsWith('+++ ')) cls = 'diff-file';
|
||||||
|
else if (raw.startsWith('@')) cls = 'diff-hunk';
|
||||||
|
else if (raw.startsWith('+')) cls = 'diff-add';
|
||||||
|
else if (raw.startsWith('-')) cls = 'diff-del';
|
||||||
|
const span = document.createElement('span');
|
||||||
|
span.className = cls;
|
||||||
|
span.textContent = raw + '\n';
|
||||||
|
pre.appendChild(span);
|
||||||
|
}
|
||||||
|
return pre;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open an approval's diff in the side panel with a 3-way base
|
||||||
|
// toggle: vs applied (running tree), vs last-approved, vs previous
|
||||||
|
// proposal. `applied` uses the diff already shipped on the approval
|
||||||
|
// for instant paint; the other two fetch /api/approval-diff.
|
||||||
|
function openDiffPanel(a) {
|
||||||
|
const bases = [
|
||||||
|
['applied', 'vs applied'],
|
||||||
|
['approved', 'vs last-approved'],
|
||||||
|
['previous', 'vs previous proposal'],
|
||||||
|
];
|
||||||
|
const tabs = el('div', { class: 'diff-base-tabs' });
|
||||||
|
const host = el('div', { class: 'diff-host' });
|
||||||
|
async function selectBase(base) {
|
||||||
|
for (const btn of tabs.children) {
|
||||||
|
btn.classList.toggle('active', btn.dataset.base === base);
|
||||||
|
}
|
||||||
|
if (base === 'applied' && a.diff != null) {
|
||||||
|
host.replaceChildren(buildDiffPre(a.diff));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
host.replaceChildren(el('div', { class: 'meta' }, 'loading…'));
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/approval-diff/' + a.id + '?base=' + base);
|
||||||
|
const text = await resp.text();
|
||||||
|
host.replaceChildren(resp.ok
|
||||||
|
? buildDiffPre(text)
|
||||||
|
: el('div', { class: 'meta' }, 'error: ' + text));
|
||||||
|
} catch (e) {
|
||||||
|
host.replaceChildren(el('div', { class: 'meta' }, 'error: ' + e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const [base, label] of bases) {
|
||||||
|
const btn = el('button',
|
||||||
|
{ type: 'button', class: 'diff-base-tab', 'data-base': base }, label);
|
||||||
|
btn.addEventListener('click', () => selectBase(base));
|
||||||
|
tabs.append(btn);
|
||||||
|
}
|
||||||
|
const wrap = el('div', { class: 'diff-panel' }, tabs, host);
|
||||||
|
Panel.open('diff · ' + a.agent + ' #' + a.id, wrap);
|
||||||
|
selectBase('applied');
|
||||||
|
}
|
||||||
|
|
||||||
function renderApprovals() {
|
function renderApprovals() {
|
||||||
const root = $('approvals-section');
|
const root = $('approvals-section');
|
||||||
root.innerHTML = '';
|
root.innerHTML = '';
|
||||||
|
|
@ -1158,34 +1189,56 @@
|
||||||
root.append(el('p', { class: 'empty' }, 'queue empty'));
|
root.append(el('p', { class: 'empty' }, 'queue empty'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// forge link base — only when the hive-forge container is up.
|
||||||
|
const fs = window.__hyperhive_state;
|
||||||
|
const hostname = (fs && fs.hostname) || window.location.hostname;
|
||||||
|
const forgeBase = (fs && fs.forge_present) ? `http://${hostname}:3000` : null;
|
||||||
|
|
||||||
const ul = el('ul', { class: 'approvals' });
|
const ul = el('ul', { class: 'approvals' });
|
||||||
for (const a of pending) {
|
for (const a of pending) {
|
||||||
const li = el('li');
|
const isApply = a.kind === 'apply_commit';
|
||||||
const row = el('div', { class: 'row' });
|
const li = el('li', { class: 'approval-card' });
|
||||||
if (a.kind === 'apply_commit') {
|
|
||||||
row.append(
|
// ── identity header ──────────────────────────────────────────
|
||||||
el('span', { class: 'glyph' }, '→'), ' ',
|
const head = el('div', { class: 'approval-head' },
|
||||||
el('span', { class: 'id' }, '#' + a.id), ' ',
|
el('span', { class: 'glyph' }, isApply ? '→' : '⊕'),
|
||||||
el('span', { class: 'agent' }, a.agent), ' ',
|
el('span', { class: 'id' }, '#' + a.id),
|
||||||
el('span', { class: 'kind' }, 'apply'), ' ',
|
el('span', { class: 'agent' }, a.agent),
|
||||||
el('code', {}, a.sha_short || ''),
|
el('span', { class: 'kind' + (isApply ? '' : ' kind-spawn') },
|
||||||
|
isApply ? 'apply' : 'spawn'),
|
||||||
);
|
);
|
||||||
} else {
|
if (isApply && a.sha_short) head.append(el('code', {}, a.sha_short));
|
||||||
row.append(
|
li.append(head);
|
||||||
el('span', { class: 'glyph' }, '⊕'), ' ',
|
|
||||||
el('span', { class: 'id' }, '#' + a.id), ' ',
|
// ── what-changed body ────────────────────────────────────────
|
||||||
el('span', { class: 'agent' }, a.agent), ' ',
|
const body = el('div', { class: 'approval-body' });
|
||||||
el('span', { class: 'kind kind-spawn' }, 'spawn'), ' ',
|
|
||||||
el('span', { class: 'meta' },
|
|
||||||
'new sub-agent — container will be created on approve'),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (a.description) {
|
if (a.description) {
|
||||||
li.append(el('div', { class: 'approval-description' }, a.description));
|
body.append(el('div', { class: 'approval-description' }, a.description));
|
||||||
}
|
}
|
||||||
// Deny prompts the operator for an optional reason; the
|
if (isApply) {
|
||||||
// submit handler stashes it into a hidden `note` input that
|
const drill = el('div', { class: 'drill-ins' });
|
||||||
// rides along on the POST and is surfaced to the manager via
|
const diffBtn = el('button', { type: 'button', class: 'panel-trigger' },
|
||||||
|
'↳ view diff');
|
||||||
|
diffBtn.addEventListener('click', () => openDiffPanel(a));
|
||||||
|
drill.append(diffBtn);
|
||||||
|
if (forgeBase && a.sha_short) {
|
||||||
|
drill.append(el('a', {
|
||||||
|
class: 'panel-trigger', target: '_blank', rel: 'noopener',
|
||||||
|
href: `${forgeBase}/agent-configs/${a.agent}/commit/${a.sha_short}`,
|
||||||
|
title: 'this proposal commit on the hive forge',
|
||||||
|
}, '↳ commit on forge ↗'));
|
||||||
|
}
|
||||||
|
body.append(drill);
|
||||||
|
} else {
|
||||||
|
body.append(el('span', { class: 'meta' },
|
||||||
|
'new sub-agent — container will be created on approve'));
|
||||||
|
}
|
||||||
|
li.append(body);
|
||||||
|
|
||||||
|
// ── decision actions ─────────────────────────────────────────
|
||||||
|
// 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 }.
|
// HelperEvent::ApprovalResolved { note }.
|
||||||
const denyForm = el('form', {
|
const denyForm = el('form', {
|
||||||
method: 'POST', action: '/deny/' + a.id,
|
method: 'POST', action: '/deny/' + a.id,
|
||||||
|
|
@ -1193,39 +1246,11 @@
|
||||||
'data-prompt': 'reason for denying (optional, sent to manager):',
|
'data-prompt': 'reason for denying (optional, sent to manager):',
|
||||||
});
|
});
|
||||||
denyForm.append(el('button', { type: 'submit', class: 'btn btn-deny' }, 'DENY'));
|
denyForm.append(el('button', { type: 'submit', class: 'btn btn-deny' }, 'DENY'));
|
||||||
row.append(
|
li.append(el('div', { class: 'approval-actions' },
|
||||||
' ',
|
|
||||||
form('/approve/' + a.id, 'btn-approve', '◆ APPR0VE', null, {}, { noRefresh: true }),
|
form('/approve/' + a.id, 'btn-approve', '◆ APPR0VE', null, {}, { noRefresh: true }),
|
||||||
' ',
|
|
||||||
denyForm,
|
denyForm,
|
||||||
);
|
));
|
||||||
li.append(row);
|
|
||||||
if (a.diff) {
|
|
||||||
const trigger = el('button', { type: 'button', class: 'panel-trigger' },
|
|
||||||
'diff vs applied');
|
|
||||||
trigger.addEventListener('click', () => {
|
|
||||||
// Server ships the raw unified diff; classify each line by
|
|
||||||
// its leading char so `.diff-add` / `.diff-del` /
|
|
||||||
// `.diff-hunk` / `.diff-file` / `.diff-ctx` colour the
|
|
||||||
// output. Building spans here (instead of innerHTML-ing
|
|
||||||
// pre-rendered markup) keeps the snapshot wire format
|
|
||||||
// text-only and one less HTML-escape surface server-side.
|
|
||||||
const pre = el('pre', { class: 'diff' });
|
|
||||||
for (const raw of a.diff.split('\n')) {
|
|
||||||
let cls = 'diff-ctx';
|
|
||||||
if (raw.startsWith('--- ') || raw.startsWith('+++ ')) cls = 'diff-file';
|
|
||||||
else if (raw.startsWith('@')) cls = 'diff-hunk';
|
|
||||||
else if (raw.startsWith('+')) cls = 'diff-add';
|
|
||||||
else if (raw.startsWith('-')) cls = 'diff-del';
|
|
||||||
const span = document.createElement('span');
|
|
||||||
span.className = cls;
|
|
||||||
span.textContent = raw + '\n';
|
|
||||||
pre.appendChild(span);
|
|
||||||
}
|
|
||||||
Panel.open('diff · ' + a.agent + ' #' + a.id, pre);
|
|
||||||
});
|
|
||||||
li.append(trigger);
|
|
||||||
}
|
|
||||||
ul.append(li);
|
ul.append(li);
|
||||||
}
|
}
|
||||||
root.append(ul);
|
root.append(ul);
|
||||||
|
|
|
||||||
|
|
@ -226,9 +226,66 @@ code {
|
||||||
border-radius: 2px;
|
border-radius: 2px;
|
||||||
font-size: 0.9em;
|
font-size: 0.9em;
|
||||||
}
|
}
|
||||||
.approvals .row { display: flex; align-items: center; flex-wrap: wrap; gap: 0.4em; }
|
/* Pending approval: a card with three stacked sections — identity
|
||||||
.approvals form.inline { display: inline; margin-left: 0.4em; }
|
header, what-changed body, decision actions. */
|
||||||
.approval-description { font-size: 0.85em; color: var(--fg-dim, #888); margin: 0.2em 0 0.4em 1.2em; }
|
.approvals { list-style: none; padding: 0; margin: 0.4em 0 0; }
|
||||||
|
.approval-card {
|
||||||
|
background: var(--bg-elev);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-left: 3px solid var(--purple);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 0.6em 0.8em;
|
||||||
|
margin-bottom: 0.6em;
|
||||||
|
}
|
||||||
|
.approval-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.3em;
|
||||||
|
}
|
||||||
|
.approval-body {
|
||||||
|
margin: 0.45em 0;
|
||||||
|
padding-left: 1.3em;
|
||||||
|
}
|
||||||
|
.approval-description {
|
||||||
|
font-size: 0.9em;
|
||||||
|
color: var(--fg);
|
||||||
|
white-space: pre-wrap;
|
||||||
|
margin-bottom: 0.35em;
|
||||||
|
}
|
||||||
|
.approval-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5em;
|
||||||
|
padding-top: 0.45em;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.approval-actions form.inline { display: inline; }
|
||||||
|
/* Inline drill-in triggers (logs / config repo / view diff). */
|
||||||
|
.drill-ins {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.15em 1.1em;
|
||||||
|
margin-top: 0.4em;
|
||||||
|
}
|
||||||
|
.drill-ins .panel-trigger { margin-top: 0; }
|
||||||
|
/* Diff side-panel: base-toggle tabs above the diff host. */
|
||||||
|
.diff-panel { display: flex; flex-direction: column; gap: 0.6em; }
|
||||||
|
.diff-base-tabs { display: flex; flex-wrap: wrap; gap: 0.4em; }
|
||||||
|
.diff-base-tab {
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: var(--muted);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.85em;
|
||||||
|
padding: 0.2em 0.7em;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.diff-base-tab:hover { color: var(--fg); }
|
||||||
|
.diff-base-tab.active {
|
||||||
|
color: var(--purple);
|
||||||
|
border-color: var(--purple);
|
||||||
|
background: rgba(203, 166, 247, 0.08);
|
||||||
|
}
|
||||||
.approval-tabs {
|
.approval-tabs {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.4em;
|
gap: 0.4em;
|
||||||
|
|
@ -738,6 +795,7 @@ footer a { color: var(--purple); }
|
||||||
margin-top: 0.5em;
|
margin-top: 0.5em;
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
.panel-trigger:hover { color: var(--cyan); }
|
.panel-trigger:hover { color: var(--cyan); }
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -54,11 +54,11 @@ 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/approval-diff/{id}", get(get_approval_diff))
|
||||||
.route("/api/state-file", get(get_state_file))
|
.route("/api/state-file", get(get_state_file))
|
||||||
.route("/api/reminders", get(api_reminders))
|
.route("/api/reminders", get(api_reminders))
|
||||||
.route("/cancel-reminder/{id}", post(post_cancel_reminder))
|
.route("/cancel-reminder/{id}", post(post_cancel_reminder))
|
||||||
.route("/retry-reminder/{id}", post(post_retry_reminder))
|
.route("/retry-reminder/{id}", post(post_retry_reminder))
|
||||||
.route("/api/agent-config/{name}", get(get_agent_config))
|
|
||||||
.route("/request-spawn", post(post_request_spawn))
|
.route("/request-spawn", post(post_request_spawn))
|
||||||
.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))
|
||||||
|
|
@ -195,6 +195,10 @@ struct StateSnapshot {
|
||||||
/// Inputs in `meta/flake.lock` the operator can selectively
|
/// Inputs in `meta/flake.lock` the operator can selectively
|
||||||
/// `nix flake update`. Hyperhive first, then `agent-<n>` rows.
|
/// `nix flake update`. Hyperhive first, then `agent-<n>` rows.
|
||||||
meta_inputs: Vec<MetaInputView>,
|
meta_inputs: Vec<MetaInputView>,
|
||||||
|
/// Whether the hive-forge container is up. When true the dashboard
|
||||||
|
/// links each container's config + each approval's commit into the
|
||||||
|
/// forge's `agent-configs` repos.
|
||||||
|
forge_present: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `OpQuestion` + computed `question_refs` / `answer_refs`. Built
|
/// `OpQuestion` + computed `question_refs` / `answer_refs`. Built
|
||||||
|
|
@ -381,6 +385,7 @@ async fn api_state(headers: HeaderMap, State(state): State<AppState>) -> axum::J
|
||||||
question_history,
|
question_history,
|
||||||
tombstones,
|
tombstones,
|
||||||
port_conflicts,
|
port_conflicts,
|
||||||
|
forge_present: crate::forge::is_present().await,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -932,30 +937,6 @@ 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())),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
struct StateFileQuery {
|
struct StateFileQuery {
|
||||||
path: String,
|
path: String,
|
||||||
|
|
@ -1801,26 +1782,118 @@ pub(crate) async fn approval_diff(agent: &str, approval_id: i64) -> String {
|
||||||
return format!("(no applied git repo at {})", applied.display());
|
return format!("(no applied git repo at {})", applied.display());
|
||||||
}
|
}
|
||||||
let proposal_ref = format!("refs/tags/proposal/{approval_id}");
|
let proposal_ref = format!("refs/tags/proposal/{approval_id}");
|
||||||
match git_diff_main_to(&applied, &proposal_ref).await {
|
match git_diff_refs(&applied, "refs/heads/main", &proposal_ref).await {
|
||||||
Ok(s) if s.is_empty() => "(proposal matches currently-deployed tree)".to_owned(),
|
Ok(s) if s.is_empty() => "(proposal matches currently-deployed tree)".to_owned(),
|
||||||
Ok(s) => s,
|
Ok(s) => s,
|
||||||
Err(e) => format!("(error: {e:#})"),
|
Err(e) => format!("(error: {e:#})"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn git_diff_main_to(applied_dir: &Path, target_ref: &str) -> Result<String> {
|
async fn git_diff_refs(applied_dir: &Path, base_ref: &str, target_ref: &str) -> Result<String> {
|
||||||
let out = lifecycle::git_command()
|
let out = lifecycle::git_command()
|
||||||
.current_dir(applied_dir)
|
.current_dir(applied_dir)
|
||||||
.args(["diff", &format!("refs/heads/main..{target_ref}")])
|
.args(["diff", &format!("{base_ref}..{target_ref}")])
|
||||||
.output()
|
.output()
|
||||||
.await
|
.await
|
||||||
.with_context(|| format!("spawn `git diff` in {}", applied_dir.display()))?;
|
.with_context(|| format!("spawn `git diff` in {}", applied_dir.display()))?;
|
||||||
if !out.status.success() {
|
if !out.status.success() {
|
||||||
anyhow::bail!(
|
anyhow::bail!(
|
||||||
"git diff main..{target_ref} failed: {}",
|
"git diff {base_ref}..{target_ref} failed: {}",
|
||||||
String::from_utf8_lossy(&out.stderr).trim()
|
String::from_utf8_lossy(&out.stderr).trim()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
Ok(String::from_utf8_lossy(&out.stdout).into_owned())
|
Ok(String::from_utf8_lossy(&out.stdout).into_owned())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Numeric ids of `<prefix>/<n>` tags in the applied repo (e.g.
|
||||||
|
/// `proposal/3` → `3`). Unparseable suffixes are skipped. Used to
|
||||||
|
/// resolve the `approved` / `previous` diff bases for an approval.
|
||||||
|
async fn tag_ids(applied_dir: &Path, prefix: &str) -> Vec<i64> {
|
||||||
|
let Ok(out) = lifecycle::git_command()
|
||||||
|
.current_dir(applied_dir)
|
||||||
|
.args(["tag", "-l", &format!("{prefix}/*")])
|
||||||
|
.output()
|
||||||
|
.await
|
||||||
|
else {
|
||||||
|
return Vec::new();
|
||||||
|
};
|
||||||
|
if !out.status.success() {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
let strip = format!("{prefix}/");
|
||||||
|
String::from_utf8_lossy(&out.stdout)
|
||||||
|
.lines()
|
||||||
|
.filter_map(|l| l.trim().strip_prefix(&strip))
|
||||||
|
.filter_map(|s| s.parse::<i64>().ok())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct DiffBaseQuery {
|
||||||
|
/// `applied` (running tree — default), `approved` (most recent
|
||||||
|
/// earlier approved proposal), or `previous` (the prior queued
|
||||||
|
/// proposal for this agent).
|
||||||
|
base: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// On-demand unified diff for one `ApplyCommit` approval against a
|
||||||
|
/// chosen base. `applied` = `applied/main` (what's running);
|
||||||
|
/// `approved` = the most recent earlier `approved/<n>` tag (the last
|
||||||
|
/// proposal the operator OK'd, even if its build then failed);
|
||||||
|
/// `previous` = the prior queued `proposal/<n>` (the incremental
|
||||||
|
/// delta when the manager chains proposals). Returns the raw diff
|
||||||
|
/// text — the dashboard classifies lines client-side.
|
||||||
|
async fn get_approval_diff(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
AxumPath(id): AxumPath<i64>,
|
||||||
|
axum::extract::Query(q): axum::extract::Query<DiffBaseQuery>,
|
||||||
|
) -> Response {
|
||||||
|
let base = q.base.as_deref().unwrap_or("applied");
|
||||||
|
let approval = match state.coord.approvals.get(id) {
|
||||||
|
Ok(Some(a)) => a,
|
||||||
|
Ok(None) => return error_response(&format!("approval {id} not found")),
|
||||||
|
Err(e) => return error_response(&format!("approval {id}: {e:#}")),
|
||||||
|
};
|
||||||
|
if !matches!(approval.kind, hive_sh4re::ApprovalKind::ApplyCommit) {
|
||||||
|
return error_response("spawn approvals carry no commit to diff");
|
||||||
|
}
|
||||||
|
let applied = Coordinator::agent_applied_dir(&approval.agent);
|
||||||
|
if !applied.join(".git").exists() {
|
||||||
|
return plain_text(format!("(no applied git repo at {})", applied.display()));
|
||||||
|
}
|
||||||
|
let target = format!("refs/tags/proposal/{id}");
|
||||||
|
let base_ref = match base {
|
||||||
|
"applied" => Some("refs/heads/main".to_owned()),
|
||||||
|
"approved" => {
|
||||||
|
let ids = tag_ids(&applied, "approved").await;
|
||||||
|
ids.into_iter()
|
||||||
|
.filter(|&n| n != id)
|
||||||
|
.max()
|
||||||
|
.map(|n| format!("refs/tags/approved/{n}"))
|
||||||
|
}
|
||||||
|
"previous" => {
|
||||||
|
let ids = tag_ids(&applied, "proposal").await;
|
||||||
|
ids.into_iter()
|
||||||
|
.filter(|&n| n < id)
|
||||||
|
.max()
|
||||||
|
.map(|n| format!("refs/tags/proposal/{n}"))
|
||||||
|
}
|
||||||
|
other => return error_response(&format!("unknown diff base {other:?}")),
|
||||||
|
};
|
||||||
|
let Some(base_ref) = base_ref else {
|
||||||
|
return plain_text(match base {
|
||||||
|
"approved" => "(no earlier approved proposal to diff against)".to_owned(),
|
||||||
|
_ => "(no previous proposal to diff against)".to_owned(),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
match git_diff_refs(&applied, &base_ref, &target).await {
|
||||||
|
Ok(s) if s.is_empty() => plain_text("(identical — no changes vs this base)".to_owned()),
|
||||||
|
Ok(s) => plain_text(s),
|
||||||
|
Err(e) => error_response(&format!("git diff: {e:#}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn plain_text(body: String) -> Response {
|
||||||
|
(StatusCode::OK, body).into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,11 @@ let
|
||||||
cfg = config.services.hive-c0re;
|
cfg = config.services.hive-c0re;
|
||||||
in
|
in
|
||||||
{
|
{
|
||||||
|
# The forge is part of the standard install — hive-c0re mirrors
|
||||||
|
# every agent's applied config repo into it. On by default; opt out
|
||||||
|
# with `hyperhive.forge.enable = false`.
|
||||||
|
imports = [ ./hive-forge.nix ];
|
||||||
|
|
||||||
options.services.hive-c0re = {
|
options.services.hive-c0re = {
|
||||||
enable = lib.mkEnableOption "hive-c0re — hyperhive coordinator daemon";
|
enable = lib.mkEnableOption "hive-c0re — hyperhive coordinator daemon";
|
||||||
package = lib.mkOption {
|
package = lib.mkOption {
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@
|
||||||
...
|
...
|
||||||
}:
|
}:
|
||||||
let
|
let
|
||||||
cfg = config.services.hive-forge;
|
cfg = config.hyperhive.forge;
|
||||||
in
|
in
|
||||||
{
|
{
|
||||||
# Private Forgejo for hyperhive agents, wrapped in a nixos-container
|
# Private Forgejo for hyperhive agents, wrapped in a nixos-container
|
||||||
|
|
@ -24,8 +24,18 @@ in
|
||||||
# and survives container restart / host reboot. To wipe, destroy the
|
# and survives container restart / host reboot. To wipe, destroy the
|
||||||
# container.
|
# container.
|
||||||
|
|
||||||
options.services.hive-forge = {
|
options.hyperhive.forge = {
|
||||||
enable = lib.mkEnableOption "hive-forge — private Forgejo (in a nixos-container) for hyperhive agents";
|
enable = lib.mkOption {
|
||||||
|
type = lib.types.bool;
|
||||||
|
default = true;
|
||||||
|
description = ''
|
||||||
|
Run hive-forge — a private Forgejo (in a nixos-container) for
|
||||||
|
hyperhive agents. On by default: hive-c0re mirrors every
|
||||||
|
agent's applied config repo into the forge's `agent-configs`
|
||||||
|
org, so the forge is part of the standard install. Set
|
||||||
|
`hyperhive.forge.enable = false` to opt out.
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
httpPort = lib.mkOption {
|
httpPort = lib.mkOption {
|
||||||
type = lib.types.port;
|
type = lib.types.port;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue