mirror of
https://github.com/mvschwarz/openrig-studio-apps.git
synced 2026-11-07 05:56:21 -10:00
Fleet Studio: fleet know-how packs, its launch rig, and a read-only Fleet page (#2)
Fleet Studio: fleet know-how packs, its launch rig and a read-only Fleet page. The page reads OpenRig daemons' open GET routes for teams, seats, usage across hosts and configured and observed subscription facts, and names any machine, rig or seat it could not read instead of showing it empty. Subscription moves still read "no record" until the move-report route lands. Not part of the curated default install. Slice 19; reviewed in two rounds by two independent pairs; integrated with main after the Observatory merge.
This commit is contained in:
1 parent
8d39bf9d90
commit
fdaefb174e
41 files changed
+1603
-219
No files matched your search
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"manifest_version": 1,
|
||||
"id": "fleet",
|
||||
"name": "FLEET",
|
||||
"summary": "Your OpenRig machines at a glance: which accounts have room, which machines report, and what your rigs are doing.",
|
||||
"category": "studio",
|
||||
"maker": {
|
||||
"name": "OpenRig",
|
||||
"url": "https://github.com/mvschwarz"
|
||||
},
|
||||
"surface": {
|
||||
"entry": "app/fleet.html",
|
||||
"path": "/surfaces/fleet.html",
|
||||
"glyph": "▦",
|
||||
"hint": "accounts, machines and rigs at a glance"
|
||||
},
|
||||
"calls": {
|
||||
"/api/fleet-view/fleet": { "required": true }
|
||||
},
|
||||
"vendor": [],
|
||||
"provider": {
|
||||
"package": "@openrig/studio-fleet-view",
|
||||
"required": true
|
||||
},
|
||||
"_note": "Read only, and it needs no other app. It shows what OpenRig's own daemons report and says when something is not reported. It never asks for a sign-in, reads a token or spends an account's allowance."
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Fleet</title>
|
||||
<style>
|
||||
/* Colour carries state only: green is working or live, amber is low or waiting, red is out or
|
||||
blocked, grey is "—" (not reported). Everything else is neutral. The layout is one column on a
|
||||
narrow screen and never scrolls sideways. */
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
--bg: #0b0e13; --panel: #121722; --line: #252c3a; --text: #f3f5f8; --muted: #929cad;
|
||||
--ok: #47d18c; --low: #ffb44a; --out: #ff795f; --none: #77808f;
|
||||
--gap: 14px;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; background: var(--bg); color: var(--text); font: 15px/1.45 system-ui, sans-serif; overflow-wrap: anywhere; }
|
||||
main { width: min(1200px, 100%); margin: 0 auto; padding: 20px 16px 40px; }
|
||||
h1 { margin: 0 0 4px; font-size: clamp(26px, 6vw, 38px); letter-spacing: -.02em; }
|
||||
h2 { margin: 28px 0 6px; font-size: 13px; color: var(--muted); letter-spacing: .1em; text-transform: uppercase; }
|
||||
.legend, .context, .meta { color: var(--muted); font-size: 13px; margin: 0 0 10px; }
|
||||
.cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: var(--gap); margin-top: 16px; }
|
||||
.card, .machine, .rig, .fold { background: var(--panel); border: 1px solid var(--line); border-radius: 10px; padding: 12px 14px; }
|
||||
.card h3 { margin: 0 0 6px; font-size: 13px; color: var(--muted); font-weight: 600; letter-spacing: .06em; text-transform: uppercase; }
|
||||
.card p { margin: 0; }
|
||||
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: var(--gap); }
|
||||
.list { display: grid; gap: 8px; }
|
||||
.row { display: flex; flex-wrap: wrap; gap: 4px 14px; align-items: baseline; }
|
||||
.name { font-weight: 650; }
|
||||
.sub { color: var(--muted); font-size: 13px; }
|
||||
.ok { color: var(--ok); } .low { color: var(--low); } .out { color: var(--out); } .none { color: var(--none); }
|
||||
.bar { display: flex; height: 6px; border-radius: 3px; overflow: hidden; background: var(--line); margin: 6px 0; }
|
||||
.bar span { display: block; height: 100%; }
|
||||
.bar .ok { background: var(--ok); } .bar .low { background: var(--low); } .bar .out { background: var(--out); } .bar .idle { background: #3a4252; } .bar .none { background: var(--none); }
|
||||
details > summary { cursor: pointer; list-style: none; }
|
||||
details > summary::-webkit-details-marker { display: none; }
|
||||
table { width: 100%; border-collapse: collapse; margin-top: 8px; font-size: 14px; }
|
||||
th, td { text-align: left; padding: 6px 6px 6px 0; border-top: 1px solid var(--line); vertical-align: top; }
|
||||
th { color: var(--muted); font-weight: 600; font-size: 12px; }
|
||||
a { color: inherit; }
|
||||
footer { margin-top: 30px; color: var(--muted); font-size: 13px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>Fleet</h1>
|
||||
<p class="legend">— means not reported here yet. Old readings show their time. An account shared by machines is counted once.</p>
|
||||
<p class="meta" id="meta">Reading…</p>
|
||||
<div id="content"></div>
|
||||
<footer>For history and deeper readings, add the Observatory and open it at <a href="/surfaces/observatory.html">/surfaces/observatory.html</a>. Read only: nothing here changes any machine.</footer>
|
||||
</main>
|
||||
|
||||
<script type="module">
|
||||
// The view: turn the provider's fleet reading into what the page answers (which accounts have room,
|
||||
// which machines report, what the rigs are doing), sorted by what needs the operator. Every function
|
||||
// here is pure and exported so it can be checked without a browser; the boot at the end runs only in
|
||||
// a page.
|
||||
|
||||
export const DASH = '—';
|
||||
export const esc = (v) => String(v ?? '').replace(/[&<>'"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', "'": ''', '"': '"' })[c]);
|
||||
const dash = () => `<span class="none">${DASH}</span>`;
|
||||
|
||||
// Age of a measurement from its own time. Not the poll time.
|
||||
export function ago(iso, now) {
|
||||
const t = Date.parse(iso ?? '');
|
||||
if (Number.isNaN(t)) return null;
|
||||
const s = Math.max(0, Math.round((now - t) / 1000));
|
||||
return s < 90 ? `${s} s ago` : s < 5400 ? `${Math.round(s / 60)} min ago` : s < 172800 ? `${Math.round(s / 3600)} h ago` : `${Math.round(s / 86400)} d ago`;
|
||||
}
|
||||
// When a reset comes, in plain words, with the exact time on hover.
|
||||
export function until(iso, now) {
|
||||
const t = Date.parse(iso ?? '');
|
||||
if (Number.isNaN(t)) return null;
|
||||
const s = Math.round((t - now) / 1000);
|
||||
if (s <= 0) return 'due now';
|
||||
return s < 5400 ? `in ${Math.max(1, Math.round(s / 60))} min` : s < 172800 ? `in ${Math.round(s / 3600)} h` : `in ${Math.round(s / 86400)} d`;
|
||||
}
|
||||
const resetText = (iso, now) => `<span title="${esc(iso)}">resets ${esc(until(iso, now) ?? iso)}</span>`;
|
||||
const machineName = (h) => h.hostId ?? (h.lastSeenAs ? h.lastSeenAs.hostId : (h.labels ?? [])[0] ?? DASH);
|
||||
|
||||
const STATE_RANK = { out: 0, low: 1, ok: 2 };
|
||||
// OpenRig's own level for a window: exhausted, nearing, ok or unknown. Nothing is computed on top.
|
||||
const levelState = (level) => (level === 'exhausted' ? 'out' : level === 'nearing' ? 'low' : level === 'ok' ? 'ok' : null);
|
||||
|
||||
export function accountsView(fleet) {
|
||||
const s = fleet.subscriptions;
|
||||
const connected = s?.status === 'ok' && s.data;
|
||||
const accounts = connected ? s.data.allowances.map((g) => {
|
||||
// One account, once: its machines are links, and its readings are never added together.
|
||||
const states = [];
|
||||
let reset = null;
|
||||
const machines = [];
|
||||
for (const o of g.observations) {
|
||||
machines.push({ host: o.host, readAt: o.readAt, observedAt: o.observedAt });
|
||||
for (const w of o.windows) {
|
||||
const st = levelState(w.level);
|
||||
if (st) states.push(st);
|
||||
if ((st === 'out' || st === 'low') && w.resetsAt && (!reset || w.resetsAt < reset)) reset = w.resetsAt;
|
||||
}
|
||||
}
|
||||
const state = states.length ? states.sort((a, b) => STATE_RANK[a] - STATE_RANK[b])[0] : null;
|
||||
return { provider: g.provider, account: g.key, plan: g.observations.map((o) => o.plan).find(Boolean) ?? null, state, reset, machines, linkage: g.linkage, observations: g.observations };
|
||||
}) : [];
|
||||
const withReading = accounts.filter((a) => a.state).sort((a, b) => STATE_RANK[a.state] - STATE_RANK[b.state]);
|
||||
const noReading = accounts.filter((a) => !a.state).map((a) => `${a.provider} ${a.account ?? DASH}`);
|
||||
// Configured accounts no machine has a reading for.
|
||||
const seen = new Set(accounts.map((a) => a.account));
|
||||
for (const h of fleet.hosts ?? []) for (const b of h.reads?.bindings?.data ?? []) {
|
||||
const label = b.account?.label ?? b.account?.id;
|
||||
if (label && !seen.has(b.account?.id) && !noReading.includes(label)) noReading.push(label);
|
||||
}
|
||||
return { connected, status: s?.status ?? 'not read', detail: s?.detail ?? null, accounts: withReading, noReading };
|
||||
}
|
||||
|
||||
export function machinesView(fleet) {
|
||||
const hosts = fleet.hosts ?? [];
|
||||
const reporting = hosts.filter((h) => h.status === 'ok').map((h) => {
|
||||
const seats = h.reads?.seats?.data ?? null;
|
||||
const rigs = h.reads?.seats?.coverage?.rigs ?? null;
|
||||
const ctx = h.reads?.health?.data?.context?.data ?? null;
|
||||
const nodes = h.reads?.health?.data?.nodes?.data ?? null;
|
||||
// Strain as OpenRig reports it: seats near their context limit, seats needing attention.
|
||||
const strain = [];
|
||||
if (ctx && ctx.critical > 0) strain.push(`${ctx.critical} seat${ctx.critical === 1 ? '' : 's'} near the context limit`);
|
||||
if (nodes && nodes.attentionRequired > 0) strain.push(`${nodes.attentionRequired} needing attention`);
|
||||
// Seats are counted only from the rigs that were read; rigs whose seats couldn't be read are named, not counted as none.
|
||||
const rigsUnread = h.reads?.seats?.coverage?.rigsFailed?.length ?? 0;
|
||||
return { name: machineName(h), version: h.healthz?.version ?? null, rigs: rigs ? rigs.length : null, seats: seats ? seats.length : null, rigsUnread, strain, restarted: h.restart?.status === 'restarted' };
|
||||
});
|
||||
const WHY = { unreachable: 'unreachable', 'unsupported-transport': 'reachable only over ssh', guarded: 'needs a token', 'http-error': 'error' };
|
||||
const notReporting = hosts.filter((h) => h.status !== 'ok').map((h) => ({ name: machineName(h), why: WHY[h.status] ?? h.status }));
|
||||
return { total: hosts.length, reporting, notReporting };
|
||||
}
|
||||
|
||||
const WORK = { running: 'working', needs_input: 'waiting', idle: 'idle' };
|
||||
export function seatState(s) {
|
||||
if (s.work?.blocked > 0) return 'blocked';
|
||||
return WORK[s.activity?.state] ?? null;
|
||||
}
|
||||
|
||||
// Rigs from the machines whose rig list was read. A machine that couldn't be read, or whose rig list failed, and a rig
|
||||
// whose seats couldn't be read are named as not read: never counted as no rigs, or a rig with no seats.
|
||||
export function rigsView(fleet) {
|
||||
const running = [], stopped = [], unread = [], machinesUnread = [];
|
||||
let machinesRead = 0;
|
||||
for (const h of fleet.hosts ?? []) {
|
||||
const sr = h.reads?.seats;
|
||||
if (h.status !== 'ok' || !sr?.coverage?.rigs) { machinesUnread.push({ name: machineName(h), why: h.status !== 'ok' ? h.status : sr?.status ?? 'not read' }); continue; }
|
||||
machinesRead++;
|
||||
const seats = sr.data ?? [];
|
||||
const failed = new Set((sr.coverage.rigsFailed ?? []).map((x) => x.rigId));
|
||||
for (const r of sr.coverage.rigs) {
|
||||
if (failed.has(r.rigId)) { unread.push({ name: r.name, machine: machineName(h) }); continue; }
|
||||
const mine = seats.filter((s) => s.rigName === r.name);
|
||||
const counts = { working: 0, waiting: 0, blocked: 0, idle: 0, unknown: 0 };
|
||||
for (const s of mine) counts[seatState(s) ?? 'unknown']++;
|
||||
const row = { name: r.name, machine: machineName(h), status: r.status, seats: mine, counts };
|
||||
(r.status === 'running' || r.status === 'partial' ? running : stopped).push(row);
|
||||
}
|
||||
}
|
||||
// What needs the operator first: blocked, then waiting, then the rest.
|
||||
running.sort((a, b) => b.counts.blocked - a.counts.blocked || b.counts.waiting - a.counts.waiting || a.name.localeCompare(b.name));
|
||||
return { running, stopped, unread, machinesUnread, machinesRead };
|
||||
}
|
||||
|
||||
export function view(fleet, now) {
|
||||
return { accounts: accountsView(fleet), machines: machinesView(fleet), rigs: rigsView(fleet), served: fleet.served ?? null, polledAt: fleet.polledAt, now };
|
||||
}
|
||||
|
||||
// ---- rendering ---------------------------------------------------------------------------------
|
||||
|
||||
const countWords = (c) => [c.working && `${c.working} working`, c.waiting && `${c.waiting} waiting`, c.blocked && `${c.blocked} blocked`, c.idle && `${c.idle} idle`, c.unknown && `${c.unknown} ${DASH}`].filter(Boolean).join(' · ') || DASH;
|
||||
|
||||
export function renderMeta(v) {
|
||||
const sv = v.served;
|
||||
if (!sv) return `Read ${esc(ago(v.polledAt, v.now) ?? DASH)}`;
|
||||
const age = ago(sv.polledAt, v.now) ?? DASH;
|
||||
return sv.stale ? `Showing the last good reading, from ${esc(age)}${sv.refreshing ? '; a fresh one is on its way' : ''}.` : `Read ${esc(age)}.`;
|
||||
}
|
||||
|
||||
export function renderCards(v) {
|
||||
const a = v.accounts, m = v.machines, r = v.rigs;
|
||||
const out = a.accounts.filter((x) => x.state === 'out'), low = a.accounts.filter((x) => x.state === 'low');
|
||||
const worst = out[0] ?? low[0];
|
||||
const cap = !a.connected
|
||||
? [`Usage: not connected`, a.status === 'not-set-up' ? 'Usage readings are turned off on this machine.' : a.status === 'absent' ? "This machine's OpenRig doesn't report usage yet." : '']
|
||||
: [out.length || low.length ? `<span class="out">${out.length} out</span> · <span class="low">${low.length} low</span>`
|
||||
: a.accounts.length ? `<span class="ok">None of the ${a.accounts.length} with a reading is low or out</span>` : `<span class="none">No account has a readable window</span>`,
|
||||
worst ? `${esc(worst.provider)} ${esc(worst.account ?? DASH)}: ${worst.state === 'out' ? 'out' : 'low'}${worst.reset ? `, ${resetText(worst.reset, v.now)}` : ''}` : '',
|
||||
a.noReading.length ? `${a.noReading.length} with no reading` : ''];
|
||||
const strained = m.reporting.filter((x) => x.strain.length);
|
||||
const mach = [`<span class="${m.reporting.length === m.total ? 'ok' : 'low'}">${m.reporting.length} of ${m.total}</span> report here`,
|
||||
strained.length ? `<span class="low">Under strain:</span> ${strained.map((x) => esc(x.name)).join(', ')}` : '',
|
||||
m.notReporting.length ? `Not reporting: ${m.notReporting.map((x) => esc(x.name)).join(', ')}` : ''];
|
||||
const all = r.running.reduce((c, x) => { for (const k in x.counts) c[k] += x.counts[k]; return c; }, { working: 0, waiting: 0, blocked: 0, idle: 0, unknown: 0 });
|
||||
const unreadCount = r.unread.length + r.machinesUnread.length;
|
||||
const teams = r.machinesRead === 0
|
||||
? [`<span class="none">Rigs not read</span>`, r.machinesUnread.length ? `Not read: ${r.machinesUnread.map((x) => esc(x.name)).join(', ')}` : '']
|
||||
: [`${r.running.length} rig${r.running.length === 1 ? '' : 's'} running${unreadCount ? ` among what was read` : ''}`,
|
||||
r.running.length ? `<span class="ok">${all.working} working</span> · <span class="low">${all.waiting} waiting</span> · <span class="out">${all.blocked} blocked</span>` : '',
|
||||
unreadCount ? `<span class="low">Not read:</span> ${[...r.unread.map((x) => `${esc(x.name)} (${esc(x.machine)})`), ...r.machinesUnread.map((x) => esc(x.name))].join(', ')}` : r.stopped.length ? `${r.stopped.length} not running` : ''];
|
||||
const card = (title, lines) => `<div class="card"><h3>${title}</h3>${lines.filter(Boolean).slice(0, 3).map((l) => `<p>${l}</p>`).join('')}</div>`;
|
||||
return `<div class="cards" id="glance">${card('Capacity', cap)}${card('Machines', mach)}${card('Teams', teams)}</div>`;
|
||||
}
|
||||
|
||||
export function renderAccounts(v) {
|
||||
const a = v.accounts;
|
||||
if (!a.connected && !a.noReading.length) return `<section id="accounts"><h2>Accounts</h2><p class="context">Usage is not connected.</p></section>`;
|
||||
const rows = a.accounts.map((x) => `<div class="fold"><div class="row"><span class="name">${esc(x.provider)} ${esc(x.account ?? DASH)}</span><span class="${x.state}">${x.state === 'out' ? 'out' : x.state === 'low' ? 'low' : 'has room'}</span>${x.reset ? `<span class="sub">${resetText(x.reset, v.now)}</span>` : ''}</div>
|
||||
<div class="sub">${x.observations.map((o) => `${esc(o.host)}: ${o.windows.map((w) => `${esc(w.window ?? DASH)} ${w.usedPercent === null || w.usedPercent === undefined ? DASH : `${esc(w.usedPercent)}% used`}`).join(', ') || DASH} (${esc(ago(o.observedAt ?? o.readAt, v.now) ?? DASH)})`).join(' · ')}</div></div>`).join('');
|
||||
const fold = a.noReading.length ? `<p class="context">No reading yet: ${a.noReading.map(esc).join(', ')}.</p>` : '';
|
||||
return `<section id="accounts"><h2>Accounts</h2>${a.connected ? '' : '<p class="context">Usage is not connected; these accounts are configured.</p>'}<div class="list">${rows}</div>${fold}${renderSeatAccounts(v)}</section>`;
|
||||
}
|
||||
|
||||
// Planned assignment, a machine's default login and the usage windows a seat was observed reporting: three facts,
|
||||
// three columns, never merged. A column nobody has a value for is left out.
|
||||
export function seatAccountsRows(fleet, now = Date.now()) {
|
||||
const rows = new Map();
|
||||
const keyOf = (host, seat) => `${host}/${seat}`;
|
||||
for (const h of fleet.hosts ?? []) for (const b of h.reads?.bindings?.data ?? []) {
|
||||
rows.set(keyOf(h.key, b.seat), { seat: b.seat, machine: machineName(h), planned: b.account?.label ?? b.account?.id ?? null, login: null, seen: null });
|
||||
}
|
||||
for (const s of fleet.subscriptions?.data?.seatAccounts ?? []) {
|
||||
const r = rows.get(keyOf(s.host, s.seat)) ?? { seat: s.seat, machine: s.host, planned: null, login: null, seen: null };
|
||||
if (s.configured?.source === 'default_login') r.login = 'default login';
|
||||
if (s.configured?.source === 'registry' && !r.planned) r.planned = s.configured.profile;
|
||||
// What the daemon observed is this seat's own sampled usage windows, never an account: when they were sampled, and
|
||||
// the other seats whose windows differ, with the daemon's own qualification.
|
||||
if (s.observed) {
|
||||
const o = s.observed;
|
||||
r.seen = o.withheld ? `not attributed${o.note ? ` (${o.note})` : ''}`
|
||||
: o.lastSampledAt ? `windows sampled ${ago(o.lastSampledAt, now) ?? o.lastSampledAt}${o.differsFrom?.length ? `; they differ from ${o.differsFrom.join(', ')}${o.note ? ` (${o.note})` : ''}` : ''}`
|
||||
: null;
|
||||
}
|
||||
rows.set(keyOf(s.host, s.seat), r);
|
||||
}
|
||||
return [...rows.values()];
|
||||
}
|
||||
|
||||
function renderSeatAccounts(v) {
|
||||
const rows = seatAccountsRows(v.fleet ?? {}, v.now);
|
||||
if (!rows.length) return '';
|
||||
const cols = [['planned', 'Planned'], ['login', 'Default login'], ['seen', 'Usage windows observed']].filter(([k]) => rows.some((r) => r[k]));
|
||||
if (!cols.length) return '';
|
||||
return `<details><summary class="sub">Seats and their accounts</summary><table><thead><tr><th>Seat</th>${cols.map(([, t]) => `<th>${t}</th>`).join('')}</tr></thead><tbody>${rows.map((r) => `<tr><td>${esc(r.seat)}<div class="sub">${esc(r.machine)}</div></td>${cols.map(([k]) => `<td>${r[k] ? esc(r[k]) : dash()}</td>`).join('')}</tr>`).join('')}</tbody></table></details>`;
|
||||
}
|
||||
|
||||
export function renderMachines(v) {
|
||||
const m = v.machines;
|
||||
const cards = m.reporting.map((x) => `<div class="machine"><div class="row"><span class="name">${esc(x.name)}</span><span class="ok">live</span>${x.version ? `<span class="sub">OpenRig ${esc(x.version)}</span>` : ''}</div>
|
||||
<div class="sub">${x.rigs ?? DASH} rig${x.rigs === 1 ? '' : 's'} · ${x.seats ?? DASH}${x.rigsUnread ? '+' : ''} seat${x.seats === 1 && !x.rigsUnread ? '' : 's'}${x.rigsUnread ? ` (${x.rigsUnread} rig${x.rigsUnread === 1 ? '' : 's'} not read)` : ''}${x.restarted ? ' · restarted since the last reading' : ''}</div>${x.strain.length ? `<div class="low">${esc(x.strain.join(' · '))}</div>` : ''}</div>`).join('');
|
||||
const fold = m.notReporting.length ? `<p class="context">Not reporting here: ${m.notReporting.map((x) => `${esc(x.name)} (${esc(x.why)})`).join(' · ')}.</p>` : '';
|
||||
return `<section id="machines"><h2>Machines</h2><div class="grid">${cards}</div>${fold}</section>`;
|
||||
}
|
||||
|
||||
const SEG = [['working', 'ok'], ['waiting', 'low'], ['blocked', 'out'], ['idle', 'idle'], ['unknown', 'none']];
|
||||
export function renderRigs(v) {
|
||||
const r = v.rigs;
|
||||
const rows = r.running.map((x) => {
|
||||
const total = x.seats.length || 1;
|
||||
const bar = SEG.map(([k, cls]) => (x.counts[k] ? `<span class="${cls}" style="width:${(100 * x.counts[k] / total).toFixed(1)}%"></span>` : '')).join('');
|
||||
const table = x.seats.length ? `<table><thead><tr><th>Seat</th><th>State</th><th>On</th></tr></thead><tbody>${x.seats.map((s) => {
|
||||
const st = seatState(s);
|
||||
const cls = st === 'working' ? 'ok' : st === 'waiting' ? 'low' : st === 'blocked' ? 'out' : st ? '' : 'none';
|
||||
const since = ago(s.activity?.observedAt, v.now);
|
||||
const on = [s.work?.inProgress ? `${s.work.inProgress} in progress` : '', s.work?.blocked ? `${s.work.blocked} blocked` : ''].filter(Boolean).join(' · ');
|
||||
return `<tr><td>${esc(s.logicalId ?? DASH)}</td><td><span class="${cls}">${esc(st ?? DASH)}</span>${since ? `<div class="sub">since ${esc(since)}</div>` : ''}</td><td>${on ? esc(on) : dash()}</td></tr>`;
|
||||
}).join('')}</tbody></table>` : '';
|
||||
return `<details class="rig"><summary><div class="row"><span class="name">${esc(x.name)}</span><span class="sub">${esc(x.machine)} · ${x.seats.length} seat${x.seats.length === 1 ? '' : 's'}</span></div><div class="bar">${bar}</div><div class="sub">${countWords(x.counts)}</div></summary>${table}</details>`;
|
||||
}).join('');
|
||||
const fold = r.stopped.length ? `<p class="context">Not running: ${r.stopped.map((x) => `${esc(x.name)} (${esc(x.machine)})`).join(', ')}.</p>` : '';
|
||||
const unreadFold = [r.unread.length ? `Seats not read: ${r.unread.map((x) => `${esc(x.name)} (${esc(x.machine)})`).join(', ')}.` : '',
|
||||
r.machinesUnread.length ? `Rigs not read on: ${r.machinesUnread.map((x) => `${esc(x.name)} (${esc(x.why)})`).join(', ')}.` : ''].filter(Boolean).map((l) => `<p class="context">${l}</p>`).join('');
|
||||
const empty = r.machinesRead === 0 ? `<p class="context">Rigs not read.</p>`
|
||||
: r.unread.length || r.machinesUnread.length ? `<p class="context">No running rig among what was read.</p>` : `<p class="context">No rig is running.</p>`;
|
||||
return `<section id="rigs"><h2>Rigs</h2>${unreadFold}<div class="list">${rows || empty}</div>${fold}</section>`;
|
||||
}
|
||||
|
||||
export function renderPage(fleet, now) {
|
||||
const v = { ...view(fleet, now), fleet };
|
||||
return { meta: renderMeta(v), body: renderCards(v) + renderAccounts(v) + renderMachines(v) + renderRigs(v) };
|
||||
}
|
||||
|
||||
if (typeof document !== 'undefined') {
|
||||
const content = document.querySelector('#content');
|
||||
const meta = document.querySelector('#meta');
|
||||
async function load() {
|
||||
try {
|
||||
const r = await fetch('/api/fleet-view/fleet');
|
||||
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
||||
const out = renderPage(await r.json(), Date.now());
|
||||
meta.innerHTML = out.meta;
|
||||
content.innerHTML = out.body;
|
||||
} catch (e) {
|
||||
meta.textContent = 'Not connected.';
|
||||
content.innerHTML = `<p class="context">Could not read the fleet (${esc(e.message)}). This is a failure to read, not an empty fleet.</p>`;
|
||||
}
|
||||
}
|
||||
load();
|
||||
// Refresh only while the page is open and visible; nothing is read when nobody is looking.
|
||||
setInterval(() => { if (!document.hidden) load(); }, 30000);
|
||||
document.addEventListener('visibilitychange', () => { if (!document.hidden) load(); });
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -119,6 +119,7 @@
|
||||
const sv = fleet.served;
|
||||
if (!sv) return `asked at ${esc(fleet.polledAt ?? '')}`;
|
||||
const age = Math.max(sv.cacheAgeSeconds ?? 0, Math.round((now - Date.parse(sv.servedAt)) / 1000) + (sv.cacheAgeSeconds ?? 0));
|
||||
if (sv.stale) return `read by the provider ${ageText(sv.polledAt, now)} <span class="stale">(the last good read, ${esc(age)} s old, past the ${esc(sv.keptForSeconds)} s keep${sv.refreshing ? '; a fresh read is on its way' : ''})</span>`;
|
||||
return `read by the provider ${ageText(sv.polledAt, now)}${sv.fromCache ? ` (a kept read, ${esc(age)} s old; reads are kept up to ${esc(sv.keptForSeconds)} s)` : ''}`;
|
||||
}
|
||||
|
||||
|
||||
+5
-1
@@ -108,7 +108,11 @@ async function readSeats(ctx, host) {
|
||||
// Seats carry their own observedAt (the activity sample time); the read as a whole has none.
|
||||
observedAt: null,
|
||||
data: seats,
|
||||
coverage: { rigsListed: rigs.length, rigsRead: rigs.length - failed.length, rigsFailed: failed, archivedRigs: "excluded (the route's default)", complete: failed.length === 0 },
|
||||
coverage: {
|
||||
rigsListed: rigs.length, rigsRead: rigs.length - failed.length, rigsFailed: failed, archivedRigs: "excluded (the route's default)", complete: failed.length === 0,
|
||||
// Each rig as the daemon's own summary describes it (whether it is running), for views that group by rig.
|
||||
rigs: rigs.map((r) => ({ key: `${host.key}/${r.name ?? r.rigId}`, rigId: r.rigId, name: r.name ?? null, status: r.status ?? null, lifecycleState: r.lifecycleState ?? null, nodeCount: r.nodeCount ?? null, runningCount: r.runningCount ?? null })),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
// A container healthcheck for a read-only provider behind the Studio. It asks the provider's ready route
|
||||
// THROUGH THE STUDIO'S PORT, so passing means the studio and that provider are both serving. It never
|
||||
// reads a daemon: whether the fleet can be read is a reading the app shows, not a reason to call the app
|
||||
// unhealthy.
|
||||
//
|
||||
// Healthy only when the ready route answers ready from the named provider. Otherwise unhealthy, with a
|
||||
// line saying what is known. When only the studio answers, that line is "shell answers; provider not
|
||||
// confirmed", which is a failure, never a pass.
|
||||
export const LABELS = {
|
||||
shellOnly: "shell answers; provider not confirmed",
|
||||
down: "studio not answering",
|
||||
};
|
||||
|
||||
async function get(fetchImpl, url, timeoutMs) {
|
||||
try {
|
||||
const r = await fetchImpl(url, { signal: AbortSignal.timeout(timeoutMs) });
|
||||
const text = await r.text();
|
||||
let body = null;
|
||||
try { body = JSON.parse(text); } catch { /* not JSON */ }
|
||||
return { ok: r.ok, status: r.status, body };
|
||||
} catch (err) {
|
||||
return { ok: false, status: null, error: String(err?.cause?.code ?? err?.message ?? err) };
|
||||
}
|
||||
}
|
||||
|
||||
export async function checkProvider({ base, readyPath, provider, readyLabel, fetch: fetchImpl = globalThis.fetch, timeoutMs = 4000 }) {
|
||||
const ready = await get(fetchImpl, `${base}${readyPath}`, timeoutMs);
|
||||
if (ready.ok && ready.body?.ready === true && ready.body?.provider === provider) return { healthy: true, label: readyLabel };
|
||||
const shell = await get(fetchImpl, `${base}/api/contract`, timeoutMs);
|
||||
const why = ready.status ? `ready verb answered HTTP ${ready.status}${ready.body?.reason ? ` (${ready.body.reason})` : ""}` : `ready verb: ${ready.error ?? "no answer"}`;
|
||||
if (shell.ok) return { healthy: false, label: LABELS.shellOnly, detail: why };
|
||||
return { healthy: false, label: LABELS.down, detail: why };
|
||||
}
|
||||
|
||||
export async function runHealthcheck(opts) {
|
||||
const r = await checkProvider(opts);
|
||||
console.log(r.detail ? `${r.label} (${r.detail})` : r.label);
|
||||
process.exit(r.healthy ? 0 : 1);
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
// The HTTP side shared by the read-only providers built on the fleet reader (the Observatory's and
|
||||
// Fleet's). Each provider names its own routes; the behaviour below is the same for both.
|
||||
//
|
||||
// Cached readings, no fan-out in the request path once a reading exists:
|
||||
// - The first request waits for the first read of the fleet (there is nothing else to show).
|
||||
// - After that, every request is answered at once from the last good read. Within the keep time it is
|
||||
// current; after it, it is served labelled stale, with its age, and ONE refresh starts in the
|
||||
// background. Nothing polls on a timer: with nobody asking, nothing is read.
|
||||
// - A read that could not reach the configured daemon, or that failed, is never kept.
|
||||
// - Every response says when its read was made, how old it is, and whether it is stale.
|
||||
//
|
||||
// One seat's drill-down reads that one seat on its own daemon, kept briefly and only while it belongs
|
||||
// to the fleet read it was made from. The ready route answers from the provider process alone: no
|
||||
// daemon call and no fleet read.
|
||||
//
|
||||
// Nothing here writes to a daemon, starts anything on one, or holds a token.
|
||||
import http from "node:http";
|
||||
import { readFleet, readSeat, READS } from "./fleet-reader.mjs";
|
||||
|
||||
export const READY_MEANING = "this provider process is loaded and serving; it says nothing about whether any OpenRig daemon can be read";
|
||||
const SEAT_KEEP_MS = 10_000;
|
||||
const SEATS_REMEMBERED = 64;
|
||||
export const ALL_READS = [...READS, "subscriptions"];
|
||||
|
||||
function json(res, status, value) {
|
||||
res.writeHead(status, { "content-type": "application/json", "cache-control": "no-store" });
|
||||
res.end(JSON.stringify(value));
|
||||
}
|
||||
|
||||
export function createFleetCache({ daemon, read = readFleet, reads = ALL_READS, ttlMs = 15_000, now = Date.now } = {}) {
|
||||
let kept = null; // { fleet, at }: the last read that reached the configured daemon
|
||||
let inFlight = null; // Promise<{ fleet, at }>
|
||||
let previous = null; // the last kept read, for the restart signal
|
||||
let last = null; // how the last fleet read came out, kept or not: what the ready route reports
|
||||
async function fresh() {
|
||||
let fleet;
|
||||
try { fleet = await read({ daemonUrl: daemon, reads, previous }); }
|
||||
catch (err) { last = { polledAt: new Date(now()).toISOString(), ok: false, status: "error", detail: String(err?.message ?? err) }; throw err; }
|
||||
const at = now();
|
||||
const status = fleet?.hosts?.[0]?.status ?? "unknown";
|
||||
last = { polledAt: fleet?.polledAt ?? new Date(at).toISOString(), ok: status === "ok", status };
|
||||
if (status === "ok") { kept = { fleet, at }; previous = fleet; }
|
||||
return { fleet, at };
|
||||
}
|
||||
function refresh() {
|
||||
inFlight ??= fresh().finally(() => { inFlight = null; });
|
||||
return inFlight;
|
||||
}
|
||||
async function get() {
|
||||
const t = now();
|
||||
let entry, fromCache, stale = false;
|
||||
if (kept) {
|
||||
entry = kept;
|
||||
fromCache = true;
|
||||
if (t - kept.at >= ttlMs) {
|
||||
stale = true;
|
||||
// Refresh outside the request: this answer does not wait for it.
|
||||
refresh().catch(() => {});
|
||||
}
|
||||
} else {
|
||||
fromCache = false;
|
||||
entry = await refresh();
|
||||
}
|
||||
const servedAt = now();
|
||||
return {
|
||||
...entry.fleet,
|
||||
served: {
|
||||
servedAt: new Date(servedAt).toISOString(), polledAt: entry.fleet.polledAt,
|
||||
cacheAgeSeconds: Math.max(0, Math.round((servedAt - entry.at) / 1000)), keptForSeconds: Math.round(ttlMs / 1000),
|
||||
fromCache, stale, refreshing: inFlight !== null,
|
||||
},
|
||||
};
|
||||
}
|
||||
get.lastRead = () => last;
|
||||
// For tests and shutdown: the refresh currently running, if any.
|
||||
get.pending = () => inFlight;
|
||||
return get;
|
||||
}
|
||||
|
||||
export function createSeatCache({ fleet, readOne = readSeat, ttlMs = SEAT_KEEP_MS, max = SEATS_REMEMBERED, now = Date.now } = {}) {
|
||||
const kept = new Map(); // key -> { value, at }, oldest first
|
||||
return async function seat(key, tenureCursor = null) {
|
||||
const id = `${key}\u0000${tenureCursor ?? ""}`;
|
||||
// The fleet read comes first, and a kept seat is used only while it belongs to that same fleet
|
||||
// read. Otherwise a seat could still read as fine after its machine has stopped answering.
|
||||
const f = await fleet();
|
||||
const hit = kept.get(id);
|
||||
if (hit && now() - hit.at < ttlMs && hit.value.fleetServed?.polledAt === f.polledAt) return { ...hit.value, fleetServed: f.served, served: { fromCache: true, cacheAgeSeconds: Math.round((now() - hit.at) / 1000), keptForSeconds: Math.round(ttlMs / 1000) } };
|
||||
const value = await readOne({ fleet: f, key, tenureCursor, now });
|
||||
value.fleetServed = f.served;
|
||||
if (value.status === "ok") {
|
||||
kept.delete(id);
|
||||
kept.set(id, { value, at: now() });
|
||||
while (kept.size > max) kept.delete(kept.keys().next().value);
|
||||
}
|
||||
return { ...value, served: { fromCache: false, cacheAgeSeconds: 0, keptForSeconds: Math.round(ttlMs / 1000) } };
|
||||
};
|
||||
}
|
||||
|
||||
// routes: { fleet: [paths answered with the whole fleet read], seat: path|null, ready: path }
|
||||
export function createReaderServer({ name, pkg = {}, routes, daemon, read = readFleet, reads = ALL_READS, ttlMs = 15_000, now = Date.now, readOne = readSeat, startedAt = new Date().toISOString() }) {
|
||||
const fleet = createFleetCache({ daemon, read, reads, ttlMs, now });
|
||||
const seat = routes.seat ? createSeatCache({ fleet, readOne, now }) : null;
|
||||
const known = new Set([...routes.fleet, routes.ready, ...(routes.seat ? [routes.seat] : [])]);
|
||||
const server = http.createServer(async (req, res) => {
|
||||
const url = new URL(req.url, "http://127.0.0.1");
|
||||
if (!known.has(url.pathname)) return json(res, 404, { error: `no ${name} route ${url.pathname}` });
|
||||
if (req.method !== "GET" && req.method !== "HEAD") {
|
||||
res.setHeader("allow", "GET, HEAD");
|
||||
return json(res, 405, { error: "read only: GET" });
|
||||
}
|
||||
if (url.pathname === routes.ready) {
|
||||
const body = {
|
||||
schema: `openrig.${name}.ready/v1`,
|
||||
ready: !server.draining,
|
||||
meaning: READY_MEANING,
|
||||
provider: pkg.name ?? null, version: pkg.version ?? null,
|
||||
startedAt, servedAt: new Date(now()).toISOString(),
|
||||
lastFleetRead: fleet.lastRead(),
|
||||
...(server.draining ? { reason: "the provider is shutting down" } : {}),
|
||||
};
|
||||
return json(res, server.draining ? 503 : 200, body);
|
||||
}
|
||||
try {
|
||||
if (seat && url.pathname === routes.seat) {
|
||||
const key = url.searchParams.get("key");
|
||||
if (!key) return json(res, 400, { error: "one seat per call: give key=<machine>/<rig>/<seat>" });
|
||||
const out = await seat(key, url.searchParams.get("cursor"));
|
||||
return json(res, out.status === "ok" ? 200 : out.status === "seat_host_unknown" || out.status === "seat_not_found" ? 404 : out.status === "seat_key_required" ? 400 : 200, out);
|
||||
}
|
||||
return json(res, 200, await fleet());
|
||||
} catch (err) {
|
||||
return json(res, 500, { error: String(err?.message ?? err) });
|
||||
}
|
||||
});
|
||||
server.draining = false;
|
||||
server.fleet = fleet;
|
||||
return server;
|
||||
}
|
||||
|
||||
export function runReaderServer(server, { port, label }) {
|
||||
for (const sig of ["SIGTERM", "SIGINT"]) process.on(sig, () => { server.draining = true; server.close(() => process.exit(0)); setTimeout(() => process.exit(0), 2000).unref(); });
|
||||
server.listen(port, "127.0.0.1", () => console.log(label));
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
#!/usr/bin/env node
|
||||
// Read-only fleet readings for the Fleet page, from OpenRig daemons' existing GET routes. The behaviour
|
||||
// (cached readings served at once and refreshed outside the request; the ready route) is shared with the
|
||||
// Observatory provider and described in providers/shared/reader-server.mjs. This file names Fleet's
|
||||
// routes and the readings Fleet shows. It needs no Observatory.
|
||||
//
|
||||
// Nothing here writes to a daemon, starts anything on one, holds a token, or asks a model provider for
|
||||
// anything: usage comes only from what OpenRig's daemons already report.
|
||||
import fs from "node:fs";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { createReaderServer, runReaderServer } from "../shared/reader-server.mjs";
|
||||
|
||||
function arg(name, fallback = "") {
|
||||
const i = process.argv.indexOf(name);
|
||||
return i >= 0 ? process.argv[i + 1] : fallback;
|
||||
}
|
||||
|
||||
const port = Number(arg("--port", "9451"));
|
||||
const daemonUrl = arg("--daemon", process.env.FLEET_DAEMON_URL || "http://127.0.0.1:7433");
|
||||
const cacheSeconds = Number(process.env.FLEET_CACHE_SECONDS ?? 15);
|
||||
const PACKAGE = (() => { try { return JSON.parse(fs.readFileSync(new URL("./package.json", import.meta.url), "utf8")); } catch { return {}; } })();
|
||||
|
||||
export const FLEET_ROUTE = "/api/fleet-view/fleet";
|
||||
export const READY_ROUTE = "/api/fleet-view/ready";
|
||||
// What Fleet shows: machines (with their health summaries), rigs and seats, configured links and usage.
|
||||
export const FLEET_READS = ["seats", "health", "bindings", "subscriptions"];
|
||||
|
||||
export function createFleetViewServer({ daemon = daemonUrl, read, ttlMs = cacheSeconds * 1000, now = Date.now } = {}) {
|
||||
return createReaderServer({
|
||||
name: "fleet-view", pkg: PACKAGE, daemon, ttlMs, now, reads: FLEET_READS,
|
||||
...(read ? { read } : {}),
|
||||
routes: { fleet: [FLEET_ROUTE], seat: null, ready: READY_ROUTE },
|
||||
});
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
runReaderServer(createFleetViewServer(), { port, label: `studio-fleet-view on 127.0.0.1:${port} · daemon ${daemonUrl} · reads kept ${cacheSeconds} s` });
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
#!/usr/bin/env node
|
||||
// The Fleet container's healthcheck: the Fleet provider's ready route, asked through the studio's port
|
||||
// (providers/shared/healthcheck-core.mjs has the rules).
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { checkProvider, runHealthcheck, LABELS as CORE } from "../shared/healthcheck-core.mjs";
|
||||
|
||||
export const PROVIDER = "@openrig/studio-fleet-view";
|
||||
export const LABELS = { ready: "provider ready: the studio and the fleet provider are serving", ...CORE };
|
||||
const opts = (o = {}) => ({ base: "http://127.0.0.1:9450", readyPath: "/api/fleet-view/ready", provider: PROVIDER, readyLabel: LABELS.ready, ...o });
|
||||
|
||||
export async function check(o = {}) { return checkProvider(opts(o)); }
|
||||
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
await runHealthcheck(opts({ base: process.env.FLEET_HEALTH_BASE || "http://127.0.0.1:9450" }));
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "@openrig/studio-fleet-view",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"license": "Apache-2.0",
|
||||
"description": "Fleet readings for the Fleet page: machines, rigs, seats and subscription usage from each OpenRig daemon, with where and when each was measured.",
|
||||
"scripts": {
|
||||
"start": "node fleet-view-server.mjs",
|
||||
"test": "node --test ../../test/fleet-app.test.mjs ../../test/fleet-studio.test.mjs"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"package": "@openrig/studio-fleet-view",
|
||||
"run": {
|
||||
"entry": "fleet-view-server.mjs",
|
||||
"args": ["--port", "{{port}}"]
|
||||
},
|
||||
"verbs": [
|
||||
"/api/fleet-view/fleet",
|
||||
"/api/fleet-view/ready"
|
||||
],
|
||||
"_note": "Read only. /api/fleet-view/fleet is the fleet as OpenRig's own daemons report it (the daemon on this machine, FLEET_DAEMON_URL, default http://127.0.0.1:7433, and each HTTP host in its host list): machines, rigs and seats, machine health summaries, configured subscription links and usage readings. A reading is served from the last good read at once and refreshed outside the request; nothing polls when nobody asks. No token is read, stored or sent, and no request is made to a model provider."
|
||||
}
|
||||
@@ -1,43 +1,15 @@
|
||||
#!/usr/bin/env node
|
||||
// The Observatory container's healthcheck. It asks the provider's ready verb THROUGH THE STUDIO'S PORT,
|
||||
// so passing means the studio and the observatory provider are both serving. It never reads a daemon:
|
||||
// whether the fleet can be read is a reading the app shows, not a reason to call the app unhealthy.
|
||||
//
|
||||
// Exit 0 only when /api/observatory/ready answers ready from this provider. Otherwise exit 1 with a
|
||||
// line saying what is known. When only the studio answers, that line is "shell answers; provider not
|
||||
// confirmed", which is a failure, never a pass.
|
||||
// The Observatory container's healthcheck: the Observatory provider's ready route, asked through the
|
||||
// studio's port (providers/shared/healthcheck-core.mjs has the rules).
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { checkProvider, runHealthcheck, LABELS as CORE } from "../shared/healthcheck-core.mjs";
|
||||
|
||||
export const PROVIDER = "@openrig/studio-observatory";
|
||||
export const LABELS = {
|
||||
ready: "provider ready: the studio and the observatory provider are serving",
|
||||
shellOnly: "shell answers; provider not confirmed",
|
||||
down: "studio not answering",
|
||||
};
|
||||
export const LABELS = { ready: "provider ready: the studio and the observatory provider are serving", ...CORE };
|
||||
const opts = (o = {}) => ({ base: "http://127.0.0.1:9440", readyPath: "/api/observatory/ready", provider: PROVIDER, readyLabel: LABELS.ready, ...o });
|
||||
|
||||
async function get(fetchImpl, url, timeoutMs) {
|
||||
try {
|
||||
const r = await fetchImpl(url, { signal: AbortSignal.timeout(timeoutMs) });
|
||||
const text = await r.text();
|
||||
let body = null;
|
||||
try { body = JSON.parse(text); } catch { /* not JSON */ }
|
||||
return { ok: r.ok, status: r.status, body };
|
||||
} catch (err) {
|
||||
return { ok: false, status: null, error: String(err?.cause?.code ?? err?.message ?? err) };
|
||||
}
|
||||
}
|
||||
|
||||
export async function check({ base = "http://127.0.0.1:9440", fetch: fetchImpl = globalThis.fetch, timeoutMs = 4000 } = {}) {
|
||||
const ready = await get(fetchImpl, `${base}/api/observatory/ready`, timeoutMs);
|
||||
if (ready.ok && ready.body?.ready === true && ready.body?.provider === PROVIDER) return { healthy: true, label: LABELS.ready };
|
||||
const shell = await get(fetchImpl, `${base}/api/contract`, timeoutMs);
|
||||
const why = ready.status ? `ready verb answered HTTP ${ready.status}${ready.body?.reason ? ` (${ready.body.reason})` : ""}` : `ready verb: ${ready.error ?? "no answer"}`;
|
||||
if (shell.ok) return { healthy: false, label: LABELS.shellOnly, detail: why };
|
||||
return { healthy: false, label: LABELS.down, detail: why };
|
||||
}
|
||||
export async function check(o = {}) { return checkProvider(opts(o)); }
|
||||
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
const r = await check({ base: process.env.OBSERVATORY_HEALTH_BASE || undefined });
|
||||
console.log(r.detail ? `${r.label} (${r.detail})` : r.label);
|
||||
process.exit(r.healthy ? 0 : 1);
|
||||
await runHealthcheck(opts({ base: process.env.OBSERVATORY_HEALTH_BASE || "http://127.0.0.1:9440" }));
|
||||
}
|
||||
@@ -1,27 +1,15 @@
|
||||
#!/usr/bin/env node
|
||||
// Read-only fleet views for the Observatory, built from OpenRig daemons' existing GET routes.
|
||||
//
|
||||
// One read of the whole fleet serves every verb: the configured daemon, plus every machine its
|
||||
// host list names. A read is kept for a short time (OBSERVATORY_CACHE_SECONDS, default 15) so a page
|
||||
// refresh, which asks for every verb at once, costs one read rather than one per verb. Concurrent
|
||||
// requests share the read in flight. Every response says when that read was made and how old it is,
|
||||
// so a kept read is never presented as a fresh one. A read that could not reach the configured daemon
|
||||
// is not kept.
|
||||
//
|
||||
// One seat's drill-down (/api/observatory/seat?key=<machine>/<rig>/<seat>) reads that one seat on
|
||||
// its own daemon. Those reads are kept for a shorter time of their own, and only a bounded number of
|
||||
// seats are remembered at once.
|
||||
//
|
||||
// GET /api/observatory/ready says only that this provider process is loaded and serving. It makes no
|
||||
// daemon call and no fleet read; it reports the last fleet read the provider already made, as that read
|
||||
// came out. A container healthcheck asks it through the studio's port, so "ready" there means the studio
|
||||
// and this provider are both answering, which /api/contract alone (the studio's runtime) cannot show.
|
||||
// The behaviour (cached readings served at once, refreshed outside the request; the seat drill-down;
|
||||
// the ready route) is shared with the Fleet provider and described in providers/shared/reader-server.mjs.
|
||||
// This file names the Observatory's routes.
|
||||
//
|
||||
// Nothing here writes to a daemon, starts anything on one, or holds a token.
|
||||
import http from "node:http";
|
||||
import fs from "node:fs";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { readFleet, readSeat, READS } from "./lib/fleet-reader.mjs";
|
||||
import { createReaderServer, runReaderServer, createFleetCache, createSeatCache, READY_MEANING, ALL_READS } from "../shared/reader-server.mjs";
|
||||
|
||||
export { createFleetCache, createSeatCache, READY_MEANING };
|
||||
|
||||
function arg(name, fallback = "") {
|
||||
const i = process.argv.indexOf(name);
|
||||
@@ -45,114 +33,15 @@ export const VERBS = [
|
||||
];
|
||||
export const FLEET_VERBS = VERBS.filter((v) => v !== "/api/observatory/seat" && v !== "/api/observatory/ready");
|
||||
const PACKAGE = (() => { try { return JSON.parse(fs.readFileSync(new URL("./package.json", import.meta.url), "utf8")); } catch { return {}; } })();
|
||||
const STARTED_AT = new Date().toISOString();
|
||||
export const READY_MEANING = "this provider process is loaded and serving; it says nothing about whether any OpenRig daemon can be read";
|
||||
const SEAT_KEEP_MS = 10_000;
|
||||
const SEATS_REMEMBERED = 64;
|
||||
const ALL_READS = [...READS, "subscriptions"];
|
||||
|
||||
function json(res, status, value) {
|
||||
res.writeHead(status, { "content-type": "application/json", "cache-control": "no-store" });
|
||||
res.end(JSON.stringify(value));
|
||||
}
|
||||
|
||||
export function createFleetCache({ daemon, read = readFleet, ttlMs = cacheSeconds * 1000, now = Date.now } = {}) {
|
||||
let kept = null; // { fleet, at }
|
||||
let inFlight = null; // Promise<{ fleet, at }>
|
||||
let previous = null; // the last read that reached the configured daemon, for the restart signal
|
||||
let last = null; // how the last fleet read came out, kept or not: what /ready reports
|
||||
async function fresh() {
|
||||
let fleet;
|
||||
try { fleet = await read({ daemonUrl: daemon, reads: ALL_READS, previous }); }
|
||||
catch (err) { last = { polledAt: new Date(now()).toISOString(), ok: false, status: "error", detail: String(err?.message ?? err) }; throw err; }
|
||||
const at = now();
|
||||
const status = fleet?.hosts?.[0]?.status ?? "unknown";
|
||||
last = { polledAt: fleet?.polledAt ?? new Date(at).toISOString(), ok: status === "ok", status };
|
||||
if (status === "ok") { kept = { fleet, at }; previous = fleet; }
|
||||
return { fleet, at };
|
||||
}
|
||||
async function get() {
|
||||
const t = now();
|
||||
let entry, fromCache;
|
||||
if (kept && t - kept.at < ttlMs) { entry = kept; fromCache = true; }
|
||||
else {
|
||||
fromCache = false;
|
||||
inFlight ??= fresh().finally(() => { inFlight = null; });
|
||||
entry = await inFlight;
|
||||
}
|
||||
const servedAt = now();
|
||||
return {
|
||||
...entry.fleet,
|
||||
served: { servedAt: new Date(servedAt).toISOString(), polledAt: entry.fleet.polledAt, cacheAgeSeconds: Math.max(0, Math.round((servedAt - entry.at) / 1000)), keptForSeconds: Math.round(ttlMs / 1000), fromCache },
|
||||
};
|
||||
}
|
||||
// What the provider already knows, without reading anything.
|
||||
get.lastRead = () => last;
|
||||
return get;
|
||||
}
|
||||
|
||||
export function createSeatCache({ fleet, readOne = readSeat, ttlMs = SEAT_KEEP_MS, max = SEATS_REMEMBERED, now = Date.now } = {}) {
|
||||
const kept = new Map(); // key -> { value, at }, oldest first
|
||||
return async function seat(key, tenureCursor = null) {
|
||||
const id = `${key}\u0000${tenureCursor ?? ""}`;
|
||||
// The fleet read comes first, and a kept seat is used only while it belongs to that same fleet
|
||||
// read. Otherwise a seat could still read as fine after its machine has stopped answering.
|
||||
const f = await fleet();
|
||||
const hit = kept.get(id);
|
||||
if (hit && now() - hit.at < ttlMs && hit.value.fleetServed?.polledAt === f.polledAt) return { ...hit.value, served: { fromCache: true, cacheAgeSeconds: Math.round((now() - hit.at) / 1000), keptForSeconds: Math.round(ttlMs / 1000) } };
|
||||
const value = await readOne({ fleet: f, key, tenureCursor, now });
|
||||
value.fleetServed = f.served;
|
||||
if (value.status === "ok") {
|
||||
kept.delete(id);
|
||||
kept.set(id, { value, at: now() });
|
||||
while (kept.size > max) kept.delete(kept.keys().next().value);
|
||||
}
|
||||
return { ...value, served: { fromCache: false, cacheAgeSeconds: 0, keptForSeconds: Math.round(ttlMs / 1000) } };
|
||||
};
|
||||
}
|
||||
|
||||
export function createObservatoryServer({ daemon = daemonUrl, read = readFleet, ttlMs = cacheSeconds * 1000, now = Date.now, readOne = readSeat } = {}) {
|
||||
const fleet = createFleetCache({ daemon, read, ttlMs, now });
|
||||
const seat = createSeatCache({ fleet, readOne, now });
|
||||
const server = http.createServer(async (req, res) => {
|
||||
const url = new URL(req.url, "http://127.0.0.1");
|
||||
if (!VERBS.includes(url.pathname)) return json(res, 404, { error: `no observatory route ${url.pathname}` });
|
||||
if (req.method !== "GET" && req.method !== "HEAD") {
|
||||
res.setHeader("allow", "GET, HEAD");
|
||||
return json(res, 405, { error: "read only: GET" });
|
||||
}
|
||||
if (url.pathname === "/api/observatory/ready") {
|
||||
const body = {
|
||||
schema: "openrig.observatory.ready/v1",
|
||||
ready: !server.draining,
|
||||
meaning: READY_MEANING,
|
||||
provider: PACKAGE.name ?? null, version: PACKAGE.version ?? null,
|
||||
startedAt: STARTED_AT, servedAt: new Date(now()).toISOString(),
|
||||
lastFleetRead: fleet.lastRead(),
|
||||
...(server.draining ? { reason: "the provider is shutting down" } : {}),
|
||||
};
|
||||
return json(res, server.draining ? 503 : 200, body);
|
||||
}
|
||||
try {
|
||||
if (url.pathname === "/api/observatory/seat") {
|
||||
const key = url.searchParams.get("key");
|
||||
if (!key) return json(res, 400, { error: "one seat per call: give key=<machine>/<rig>/<seat>" });
|
||||
const out = await seat(key, url.searchParams.get("cursor"));
|
||||
return json(res, out.status === "ok" ? 200 : out.status === "seat_host_unknown" || out.status === "seat_not_found" ? 404 : out.status === "seat_key_required" ? 400 : 200, out);
|
||||
}
|
||||
return json(res, 200, await fleet());
|
||||
} catch (err) {
|
||||
return json(res, 500, { error: String(err?.message ?? err) });
|
||||
}
|
||||
export function createObservatoryServer({ daemon = daemonUrl, read, ttlMs = cacheSeconds * 1000, now = Date.now, readOne } = {}) {
|
||||
return createReaderServer({
|
||||
name: "observatory", pkg: PACKAGE, daemon, ttlMs, now, reads: ALL_READS,
|
||||
...(read ? { read } : {}), ...(readOne ? { readOne } : {}),
|
||||
routes: { fleet: FLEET_VERBS, seat: "/api/observatory/seat", ready: "/api/observatory/ready" },
|
||||
});
|
||||
server.draining = false;
|
||||
return server;
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
const server = createObservatoryServer();
|
||||
for (const sig of ["SIGTERM", "SIGINT"]) process.on(sig, () => { server.draining = true; server.close(() => process.exit(0)); setTimeout(() => process.exit(0), 2000).unref(); });
|
||||
server.listen(port, "127.0.0.1", () => {
|
||||
console.log(`studio-observatory on 127.0.0.1:${port} · daemon ${daemonUrl} · reads kept ${cacheSeconds} s`);
|
||||
});
|
||||
runReaderServer(createObservatoryServer(), { port, label: `studio-observatory on 127.0.0.1:${port} · daemon ${daemonUrl} · reads kept ${cacheSeconds} s` });
|
||||
}
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
name: observatory-operator
|
||||
name: starter-ops
|
||||
version: "0.1"
|
||||
description: Starter operations agent; runs and shapes the Observatory, may grow its own rig, changes nothing outside it
|
||||
description: Starter operations agent; runs its rig and its apps, knows the fleet routes, may grow its own rig, changes nothing outside it
|
||||
|
||||
defaults:
|
||||
runtime: claude-code
|
||||
+40
-10
@@ -2,7 +2,7 @@
|
||||
|
||||
You are the starter operations agent for this person's OpenRig setup. You help them run it, starting
|
||||
from what this rig gives you, and you may grow this rig as their needs grow. The Observatory is one
|
||||
app you run and shape here; your other work does not depend on it.
|
||||
app you may run and shape; your other work does not depend on it.
|
||||
|
||||
This describes the role you are built for. It is not, by itself, permission on any particular
|
||||
machine: what you may do on a given machine is what its owner has actually enabled there.
|
||||
@@ -12,17 +12,45 @@ machine: what you may do on a given machine is what its owner has actually enabl
|
||||
This rig is yours. You may add to and change this rig (its members, its services and its app data)
|
||||
as normal, reviewed changes to its own files.
|
||||
|
||||
Its first app, the Observatory, is one Compose project named `openrig-observatory`: a single service,
|
||||
`studio`, and one named volume for its data. That project, its service, its volume and this rig's own
|
||||
reports are yours to set up, configure, start and recover.
|
||||
Your rig's startup context says which apps it has. When it includes the Observatory, that is one
|
||||
Compose project named `openrig-observatory`: a single service, `studio`, and one named volume for its
|
||||
data. When it includes the Fleet page, that is one Compose project named `openrig-fleet-studio`, shaped
|
||||
the same way. Your rig's app project, its service, its volume and this rig's own reports are yours to
|
||||
set up, configure, start and recover.
|
||||
|
||||
You may run:
|
||||
You may run, for the app your rig has:
|
||||
|
||||
- `docker compose -p openrig-observatory ps`, `logs`, `up -d studio`, `restart studio`
|
||||
- `docker compose -p openrig-fleet-studio ps`, `logs`, `up -d studio`, `restart studio`
|
||||
- `rig env status observatory` and `rig env logs observatory studio`
|
||||
- `rig whoami --json`, `rig ps --json` and the Observatory's own pages, to look at the fleet
|
||||
- `rig env status fleet-studio` and `rig env logs fleet-studio studio`
|
||||
- `rig whoami --json`, `rig ps --json` and your app's own pages, to look at the fleet
|
||||
- `rig watchdog register --policy periodic-reminder` for a reminder to yourself, and no other
|
||||
watchdog command
|
||||
watchdog command (your startup context shows the exact form)
|
||||
|
||||
## Routes you work with
|
||||
|
||||
These are OpenRig's own commands; `rig <command> --help` on this machine is the authority for their
|
||||
options. Use them on this person's environment the way your role allows: read freely, and change only
|
||||
what is yours.
|
||||
|
||||
- `rig ps` (`--nodes`, `--json`, `--all-hosts`): rigs, seats, their activity and context use
|
||||
- `rig queue`: work owed between seats
|
||||
- `rig provider status`: accounts, configured seats and usage readings
|
||||
- `rig auth status`: login state, without secrets
|
||||
- `rig compact-plan`: a read-only plan for compacting seats in place
|
||||
- `rig host pair <address>`: pair a second machine; the other machine's owner approves it
|
||||
- `rig up` and `rig grow`: bring up or grow **your own** rig
|
||||
- `rig watchdog`: reminders and early warnings for yourself
|
||||
- `rig context list`, `rig context get <ref>`: shipped know-how, found by purpose
|
||||
- the `openrig-upgrade` skill (`rig context get skills/core/openrig-upgrade`): upgrading in place
|
||||
|
||||
## Know-how you carry, and anyone can use
|
||||
|
||||
The practices you follow for recurring fleet work (recovering a stranded seat, reading usage honestly,
|
||||
keeping context healthy) are in this machine's context library as `fleet-ops/...` entries. Read the
|
||||
matching one with `rig context get` when the moment comes. They are not yours alone: any agent on the
|
||||
machine can find and use them the same way.
|
||||
|
||||
## Customizing the Observatory
|
||||
|
||||
@@ -60,8 +88,9 @@ Everything else is observed, never changed. Do not:
|
||||
|
||||
- start, stop, restart, remove or bring down any other Compose project, container or volume
|
||||
- run `rig up`, `rig down`, `rig seat` or any other lifecycle command for another rig, seat or daemon
|
||||
- change daemon settings, host lists, accounts, subscriptions or anyone's queue (the only exception is
|
||||
the subscription-rotation job above, and only once it has been enabled for you)
|
||||
- change daemon settings, host lists, accounts, subscriptions or anyone's queue (the only exceptions are
|
||||
the subscription-rotation job above, once it has been enabled for you, and `rig host pair` for a machine
|
||||
the person named, which its owner then approves)
|
||||
- read, copy or mount credentials, or anything under `~/.openrig`, `~/.claude` or `~/.codex`
|
||||
- publish or push the app's code upstream
|
||||
- change another rig, a host service, the OpenRig daemon, alert or retention policy, or any other
|
||||
@@ -88,7 +117,8 @@ the whole job there.
|
||||
|
||||
## Answering questions
|
||||
|
||||
- Answer "how is my fleet doing?" only from what the Observatory shows. For each machine, say
|
||||
- Answer "how is my fleet doing?" only from what your rig's app (Fleet or the Observatory) and
|
||||
OpenRig's own commands show. For each machine, say
|
||||
whether it was read, how old its readings are, and what could not be read and why.
|
||||
- Never turn a machine you could not read into "fine", "idle" or zero. Say "not read" and the reason.
|
||||
- Activity is activity. Do not call it productivity.
|
||||
@@ -0,0 +1,21 @@
|
||||
# Startup context: getting your bearings
|
||||
|
||||
1. `rig whoami --json`: confirm who and where you are, and which rig is yours.
|
||||
2. Read your rig's own startup context (it follows this one): it says which apps this rig has and
|
||||
where they are.
|
||||
3. `rig ps --nodes --json`: what is running on this machine, and what needs attention.
|
||||
4. `rig context list`: the know-how on this machine. Your rig's context says which entries to make
|
||||
sure are there.
|
||||
5. A reminder to yourself, for checking in regularly. A periodic reminder needs a small spec file
|
||||
naming you and the message:
|
||||
|
||||
```yaml
|
||||
target:
|
||||
session: <your session from rig whoami>
|
||||
message: "Check in: your rig, its apps and what needs attention."
|
||||
```
|
||||
|
||||
then `rig watchdog register --policy periodic-reminder --spec <file> --target-session <you>
|
||||
--interval-seconds 1800 --registered-by <you>`. Check `rig watchdog register --help` first.
|
||||
|
||||
What you may do on this machine is what its owner has actually enabled here.
|
||||
@@ -0,0 +1,41 @@
|
||||
# The Fleet image: the Studio SDK at a pinned public commit, plus the Fleet page and its provider
|
||||
# from the checkout this rig is started from (the build context is the repository root).
|
||||
FROM node:22.22.1-bookworm-slim@sha256:4f77a690f2f8946ab16fe1e791a3ac0667ae1c3575c3e4d0d4589e9ed5bfaf3d
|
||||
|
||||
ARG SDK_REPO=https://github.com/mvschwarz/openrig-studio.git
|
||||
ARG SDK_COMMIT=e7346bbdbd01539e6003ccccb38c44763236545b
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends git ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Fetch exactly the pinned commit, and refuse to build if something else arrived.
|
||||
RUN git init -q /studio/sdk \
|
||||
&& git -C /studio/sdk fetch -q --depth 1 "$SDK_REPO" "$SDK_COMMIT" \
|
||||
&& git -C /studio/sdk checkout -q FETCH_HEAD \
|
||||
&& test "$(git -C /studio/sdk rev-parse HEAD)" = "$SDK_COMMIT"
|
||||
|
||||
COPY apps/fleet /studio/apps/apps/fleet
|
||||
COPY providers/studio-fleet-view /studio/apps/providers/studio-fleet-view
|
||||
COPY providers/shared /studio/apps/providers/shared
|
||||
|
||||
# The SDK's install tool installs the APP, not its provider's dependencies; provisioning does
|
||||
# those separately, so this step does too. The Fleet provider has no dependencies today,
|
||||
# and the step says so rather than skipping silently.
|
||||
RUN printf '{"apps":[],"appsRoot":"/studio/apps","port":9450}\n' > /studio/studio.json \
|
||||
&& for p in $(node /studio/sdk/provision/needed-providers.mjs /studio/apps fleet); do \
|
||||
dir="/studio/apps/providers/$p"; \
|
||||
if [ -f "$dir/package-lock.json" ]; then npm ci --prefix "$dir" --omit=dev --no-audit --no-fund; \
|
||||
elif node -e "process.exit(Object.keys(require('$dir/package.json').dependencies || {}).length ? 0 : 1)"; then npm install --prefix "$dir" --omit=dev --no-audit --no-fund; \
|
||||
else echo "provider $p: no dependencies to install"; fi; \
|
||||
done \
|
||||
&& cd /studio/sdk && OPENRIG_STUDIO_DIR=/studio node tools/install-app.mjs /studio/apps/apps/fleet --enable \
|
||||
&& mkdir -p /data && chown -R node:node /studio /data
|
||||
|
||||
LABEL org.opencontainers.image.title="openrig-fleet-studio" \
|
||||
openrig.fleet-studio.sdk-commit="$SDK_COMMIT"
|
||||
|
||||
ENV OPENRIG_STUDIO_DIR=/studio
|
||||
USER node
|
||||
WORKDIR /studio/sdk
|
||||
CMD ["node", "tools/studio.mjs", "--port", "9450"]
|
||||
@@ -0,0 +1,5 @@
|
||||
# The build context is the repository root; the image needs only the Fleet page, its provider and the shared code.
|
||||
*
|
||||
!apps/fleet/**
|
||||
!providers/studio-fleet-view/**
|
||||
!providers/shared/**
|
||||
@@ -0,0 +1,38 @@
|
||||
# Fleet Studio launch rig
|
||||
|
||||
A starter operations agent for an OpenRig machine, with the fleet know-how it carries:
|
||||
|
||||
- recovering a stranded seat (a usage limit, a lost login, a restart)
|
||||
- reading subscription usage honestly
|
||||
- keeping agents' context healthy
|
||||
|
||||
The know-how is three context packs in `context/fleet-ops/`. At startup the agent adds any that are
|
||||
missing to the machine's context library, so **every** agent on the machine can find them with
|
||||
`rig context list` and read them with `rig context get fleet-ops/<name>`. You can also add them
|
||||
yourself, without the agent:
|
||||
|
||||
```sh
|
||||
rig context add rigs/fleet-studio/context/fleet-ops/recover-a-stranded-seat
|
||||
rig context add rigs/fleet-studio/context/fleet-ops/read-usage-honestly
|
||||
rig context add rigs/fleet-studio/context/fleet-ops/keep-context-healthy
|
||||
```
|
||||
|
||||
## Start it
|
||||
|
||||
From a checkout of this repository:
|
||||
|
||||
```sh
|
||||
rig up rigs/fleet-studio/rig.yaml
|
||||
```
|
||||
|
||||
The rig also runs the Fleet page in its own container (Compose project `openrig-fleet-studio`, port
|
||||
9450): open http://127.0.0.1:9450/surfaces/fleet.html. It answers which accounts have room, which
|
||||
machines report here and what the rigs are doing, from what OpenRig's own daemons report. It is read
|
||||
only, needs no other app, and never asks for a sign-in or reads a token. The Observatory (`rigs/observatory/`) is an optional,
|
||||
separate launch rig; Fleet Studio does not need it. Both rigs use the same starter operations agent
|
||||
(`rigs/agents/starter-ops/`), each with its own startup context.
|
||||
|
||||
## What it touches
|
||||
|
||||
Its own rig and its own files. It reads the rest of the environment with OpenRig's commands and
|
||||
changes nothing outside its rig; see the agent's role.
|
||||
@@ -0,0 +1,8 @@
|
||||
name: fleet-ops/keep-context-healthy
|
||||
version: "1"
|
||||
taxonomy: skills
|
||||
purpose: "Keep agents' context healthy: check each seat's context use, plan compaction safely, set an early warning, and decide when a seat should hand over to a fresh successor instead."
|
||||
files:
|
||||
- path: practice.md
|
||||
role: practice
|
||||
summary: "Check context use, plan compact-in-place with rig compact-plan, warn early with a context watchdog, and hand over when that is better."
|
||||
@@ -0,0 +1,36 @@
|
||||
# Keep context healthy
|
||||
|
||||
**When this applies:** a seat's context is filling up, its answers are getting worse, or you want warning before that
|
||||
happens.
|
||||
|
||||
## Check
|
||||
|
||||
- `rig ps --nodes --json` shows each seat's `contextUsage`: how full it is, whether the sample is fresh, and when it was
|
||||
sampled.
|
||||
- `rig health` lists findings, including context pressure, with how fresh each finding is.
|
||||
- `rig compact-plan` (`--rig <name>`, `--refresh` to re-sample first, `--json`) is a **read-only** plan of which Claude
|
||||
seats are candidates for compacting in place. It changes nothing.
|
||||
|
||||
## Act
|
||||
|
||||
1. **Compacting in place** is done inside the seat's own conversation, at a quiet moment between tasks. Agree it with
|
||||
whoever owns that seat's work.
|
||||
2. **Handing over** to a fresh successor is better when the seat's work is long-running and its context is no longer
|
||||
trustworthy. `rig seat handover <seat> --dry-run` plans it without changing anything; give the real handover a
|
||||
`--reason`, so the record says why.
|
||||
3. **An early warning:** a `context-usage-threshold` watchdog sends a message before the limit:
|
||||
`rig watchdog register --policy context-usage-threshold --target-session <seat> --interval-seconds <n>
|
||||
--registered-by <you> --threshold-mb <n> --message "<what to do>"`. Check `rig watchdog register --help` for the
|
||||
current options.
|
||||
4. A rig can also choose a compaction strategy per seat in its rig spec (`compaction_strategy`). That's a change to that
|
||||
rig, for its owner.
|
||||
|
||||
## What needs a person
|
||||
|
||||
Compacting or handing over a seat whose work belongs to someone else: ask that owner first.
|
||||
|
||||
## Traps
|
||||
|
||||
- **Compacting in the middle of a task.** Wait for a boundary.
|
||||
- **Trusting a stale sample.** Check when it was taken; `--refresh` where it matters.
|
||||
- **Starting a fresh seat when a handover would keep the work's continuity.**
|
||||
@@ -0,0 +1,8 @@
|
||||
name: fleet-ops/read-usage-honestly
|
||||
version: "1"
|
||||
taxonomy: skills
|
||||
purpose: "Read subscription usage honestly: where each machine's usage windows and resets come from, how old they are, why readings from several machines are never added together, and what unknown means."
|
||||
files:
|
||||
- path: practice.md
|
||||
role: practice
|
||||
summary: "Read usage windows and resets with rig provider status, per machine, with their age; never a model request, never a total, never money."
|
||||
@@ -0,0 +1,39 @@
|
||||
# Read subscription usage honestly
|
||||
|
||||
**When this applies:** someone asks how much of their subscription is left, when it resets, or whether a seat can keep
|
||||
working.
|
||||
|
||||
## Where the readings come from
|
||||
|
||||
- `rig provider status` (add `--json` for structure) shows accounts, which seat is configured for which account, and
|
||||
usage signals.
|
||||
- **Usage windows** (how much of each window is used, and when it resets) are only read when the machine's
|
||||
`provider.usage_windows` setting is on. It is off by default. Turning it on is the machine owner's choice:
|
||||
`rig config set provider.usage_windows true`. Ask before changing it.
|
||||
- `rig provider status --refresh` reads the current windows natively first (it can take up to about 35 seconds, and needs
|
||||
the setting on).
|
||||
- **Several machines:** newer versions offer `rig provider status --all-hosts` (or `--hosts <ids>`) to see each
|
||||
registered machine's readings side by side. Check `rig provider status --help` on your version.
|
||||
- `rig usage` is per-seat **token** telemetry over time. It is not your subscription allowance; don't present it as one.
|
||||
|
||||
## Read it honestly
|
||||
|
||||
- **Every reading has an age.** Say when it was measured, not when you looked. An old reading is old.
|
||||
- **Unknown is unknown.** A missing or unreadable window is "not known", never 0% and never "fine".
|
||||
- **Each machine's reading stays separate.** Two machines can report the same shared allowance; adding them together
|
||||
double-counts it. Show each, and say whether they agree.
|
||||
- **Configured is not observed.** Which account a seat is set up for and which account it is actually using are two
|
||||
different facts. Say which one you are quoting.
|
||||
- **No money.** Subscriptions are read as usage and resets, not as a price.
|
||||
|
||||
## What needs a person
|
||||
|
||||
Turning on usage windows, and any decision to move a seat to another account (that belongs to whoever owns account
|
||||
moves on the machine).
|
||||
|
||||
## Traps
|
||||
|
||||
- **Sending a model request to "check" usage.** It spends the allowance you are trying to read.
|
||||
- **Adding readings from several machines into one total.**
|
||||
- **Showing a missing window as zero.**
|
||||
- **Quoting a configured account as the one in use.**
|
||||
@@ -0,0 +1,8 @@
|
||||
name: fleet-ops/recover-a-stranded-seat
|
||||
version: "1"
|
||||
taxonomy: skills
|
||||
purpose: "Recover a stranded seat: an agent that stopped at a usage limit, lost its login, or was left stopped after a restart. How to recognise it, what to do with today's rig commands, and when to ask a person."
|
||||
files:
|
||||
- path: practice.md
|
||||
role: practice
|
||||
summary: "Recognise a seat stuck at a usage limit or a lost login, choose wait / sign in / resume, and keep its conversation."
|
||||
@@ -0,0 +1,49 @@
|
||||
# Recover a stranded seat
|
||||
|
||||
**When this applies:** an agent seat has stopped doing work: it hit its subscription's usage limit, its login expired
|
||||
or was lost, or it was left stopped after a machine restart.
|
||||
|
||||
## Recognise it
|
||||
|
||||
- `rig ps --nodes --json` shows each seat's `agentActivity` (`state`, `reason`, and when it was sampled) and its
|
||||
attention state. A seat waiting on a person shows `needs_input` with a reason.
|
||||
- `rig parked` diagnoses stopped seats that still owe work, for your rig (`--rig <name>`) or one seat.
|
||||
- `rig capture <session>` shows the seat's screen, where a usage-limit or sign-in message usually appears.
|
||||
- `rig provider status --json` shows accounts, which seat is configured for which account, and usage signals with
|
||||
their reset times when the machine has them (see the `fleet-ops/read-usage-honestly` practice).
|
||||
- `rig auth status` shows login state without secrets. Check `rig auth status --help` for the runtimes it covers on your
|
||||
version.
|
||||
|
||||
## Choose a path
|
||||
|
||||
**At a usage limit:**
|
||||
1. Read the reset time from `rig provider status` (or the seat's own screen). Say how old that reading is.
|
||||
2. If the reset is soon, wait. When it has passed, nudge the seat in its own conversation:
|
||||
`rig send <session> "Your usage limit has reset; carry on with your work."`
|
||||
3. If work can't wait, a different account is a decision for whoever owns account moves on this machine. Ask them;
|
||||
don't move accounts yourself unless that job has been enabled for you.
|
||||
|
||||
**After a lost or expired login:**
|
||||
1. Confirm it: `rig auth status`, and the seat's screen.
|
||||
2. Signing in needs a person. Ask them **once**, plainly: which machine, which tool, and that the seat is waiting.
|
||||
3. When they say it's done, check `rig auth status` again. For a Codex seat, `rig seat restart <seat> --keep-conversation
|
||||
--reason "<why>" --plan` shows what a restart would keep; run it again without `--plan` to pick up the new login in
|
||||
the seat's own conversation. A Claude seat usually needs the person to finish signing in inside the seat itself.
|
||||
|
||||
**After a machine restart:** `rig ps` shows which rigs are stopped. `rig up --existing <rig-name>` brings a stopped rig
|
||||
back from its saved state; seats resume their own conversations where they can.
|
||||
|
||||
## What needs a person
|
||||
|
||||
Signing in, choosing to use another account, and anything that spends money. Ask once, say exactly what is needed, then
|
||||
carry on with what you can do.
|
||||
|
||||
## Traps
|
||||
|
||||
- **Starting a fresh seat to "fix" it** (`rig seat launch`) loses its conversation. Prefer waiting, resuming, or
|
||||
restoring.
|
||||
- **Checking usage by sending a model request.** That spends the very allowance you are reading. Use the readings the
|
||||
machine already has.
|
||||
- **Treating "I asked for a switch" as "the seat moved".** Check the seat's own state afterwards.
|
||||
- **Asking the person again and again.** Once, then continue and report.
|
||||
- **Copying credentials** between seats or machines. Never.
|
||||
@@ -0,0 +1,36 @@
|
||||
# Startup context: Fleet Studio
|
||||
|
||||
## What this rig has
|
||||
|
||||
- You, the starter operations agent, and the fleet know-how in `context/fleet-ops/` under this rig's
|
||||
directory (your working directory).
|
||||
- The Fleet page, in its own container (Compose project `openrig-fleet-studio`), at
|
||||
http://127.0.0.1:9450/surfaces/fleet.html: accounts, machines and rigs at a glance, read only. It
|
||||
shows what OpenRig's daemons report and "—" for anything not reported; it never asks for a sign-in.
|
||||
You also read the environment with OpenRig's own commands (your role lists them).
|
||||
- If `rig env status fleet-studio` shows `studio: running (unhealthy)`, read
|
||||
`docker compose -p openrig-fleet-studio logs studio`, restart `studio` once, and tell the person
|
||||
what happened, what you did and anything you could not fix. Do not loop.
|
||||
- The Observatory is optional. If the person wants it, it is its own launch rig; this rig does not
|
||||
need it.
|
||||
|
||||
## First: make sure the know-how is in this machine's library
|
||||
|
||||
Check `rig context list --json` for these three entries:
|
||||
|
||||
- `fleet-ops/recover-a-stranded-seat`
|
||||
- `fleet-ops/read-usage-honestly`
|
||||
- `fleet-ops/keep-context-healthy`
|
||||
|
||||
Add only the ones that are missing, one at a time, from this rig's directory:
|
||||
|
||||
`rig context add context/fleet-ops/<name>`
|
||||
|
||||
(`rig context add` refuses a name that is already there, so adding only what is missing keeps this
|
||||
safe to repeat.) Then confirm with `rig context get fleet-ops/<name>`. Once they are in the library,
|
||||
any agent on this machine can find them by purpose with `rig context list`.
|
||||
|
||||
## Then
|
||||
|
||||
Point the person to the Fleet page, and tell them what you found: which rigs and seats are running, anything stranded or close to a
|
||||
limit, and what is not known and why. Use the matching `fleet-ops` practice when one applies.
|
||||
@@ -0,0 +1,36 @@
|
||||
# The Fleet page as one container in its own Compose project.
|
||||
#
|
||||
# - Built from this checkout's Fleet page and provider plus the Studio SDK at a pinned commit (see Dockerfile).
|
||||
# - Host networking: the studio listens on 127.0.0.1 only, and it reads the OpenRig daemon on
|
||||
# 127.0.0.1 too, so the container shares the host's loopback. This works on Linux. On macOS,
|
||||
# Docker Desktop's host networking is a separate opt-in feature and is not verified here.
|
||||
# - Its only storage is its own named volume. Nothing from the host's home, OpenRig's home,
|
||||
# ssh or any credential is mounted, and no token is passed in.
|
||||
name: openrig-fleet-studio
|
||||
|
||||
services:
|
||||
studio:
|
||||
build:
|
||||
context: ../..
|
||||
dockerfile: rigs/fleet-studio/Dockerfile
|
||||
image: openrig-fleet-studio:0.1.0
|
||||
network_mode: host
|
||||
# Docker's init runs as PID 1, forwards signals and reaps the studio's child processes.
|
||||
init: true
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
FLEET_DAEMON_URL: ${FLEET_DAEMON_URL:-http://127.0.0.1:7433}
|
||||
volumes:
|
||||
- fleet-studio-data:/data
|
||||
healthcheck:
|
||||
# The app's own health only: the studio AND the Fleet provider answering, asked through the
|
||||
# studio's port (the provider's ready verb makes no daemon call). A daemon that is down is a
|
||||
# reading the app shows, not a reason to call the app unhealthy.
|
||||
test: ["CMD", "node", "/studio/apps/providers/studio-fleet-view/healthcheck.mjs"]
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 20s
|
||||
|
||||
volumes:
|
||||
fleet-studio-data:
|
||||
@@ -0,0 +1,45 @@
|
||||
version: "0.2"
|
||||
name: fleet-studio
|
||||
summary: >
|
||||
Fleet Studio: a starter operations agent for this OpenRig setup, carrying the fleet know-how
|
||||
(recovering a stranded seat, reading usage honestly, keeping context healthy) and making it
|
||||
available to every agent on the machine. Its Fleet page shows accounts, machines and rigs at a glance;
|
||||
the Observatory is an optional addition, never required.
|
||||
|
||||
services:
|
||||
kind: compose
|
||||
compose_file: fleet-studio.compose.yaml
|
||||
project_name: openrig-fleet-studio
|
||||
down_policy: down
|
||||
# The rig comes up when the studio answers. Whether the Fleet provider behind it is serving is a
|
||||
# runtime reading, which the container healthcheck reports and the agent acts on; waiting on it here
|
||||
# would roll the whole rig back on the very failure the agent is there to handle.
|
||||
wait_for:
|
||||
- url: http://127.0.0.1:9450/api/contract
|
||||
surfaces:
|
||||
urls:
|
||||
- name: Fleet
|
||||
url: http://127.0.0.1:9450/surfaces/fleet.html
|
||||
commands:
|
||||
- name: Fleet status
|
||||
command: "rig env status fleet-studio"
|
||||
- name: Fleet logs
|
||||
command: "rig env logs fleet-studio studio --tail 100"
|
||||
|
||||
pods:
|
||||
- id: ops
|
||||
label: Operations
|
||||
members:
|
||||
- id: operator
|
||||
agent_ref: "local:../agents/starter-ops"
|
||||
runtime: claude-code
|
||||
profile: default
|
||||
cwd: "."
|
||||
startup:
|
||||
files:
|
||||
- path: fleet-studio-startup.md
|
||||
delivery_hint: send_text
|
||||
required: true
|
||||
edges: []
|
||||
|
||||
edges: []
|
||||
@@ -17,6 +17,7 @@ RUN git init -q /studio/sdk \
|
||||
|
||||
COPY apps/observatory /studio/apps/apps/observatory
|
||||
COPY providers/studio-observatory /studio/apps/providers/studio-observatory
|
||||
COPY providers/shared /studio/apps/providers/shared
|
||||
|
||||
# The SDK's install tool installs the APP, not its provider's dependencies; provisioning does
|
||||
# those separately, so this step does too. The observatory provider has no dependencies today,
|
||||
|
||||
@@ -2,3 +2,4 @@
|
||||
*
|
||||
!apps/observatory/**
|
||||
!providers/studio-observatory/**
|
||||
!providers/shared/**
|
||||
+6
-7
@@ -10,21 +10,19 @@
|
||||
|
||||
## First steps
|
||||
|
||||
1. `rig whoami --json`: confirm who and where you are.
|
||||
2. `rig env status observatory`: is the app's service up and healthy? The rig comes up as soon as the
|
||||
1. `rig env status observatory`: is the app's service up and healthy? The rig comes up as soon as the
|
||||
studio answers, so the observatory provider may not be serving yet. Look at the `studio` line:
|
||||
healthy means the studio and the provider both answer (`/api/observatory/ready`); `unhealthy` with
|
||||
the studio still answering means "shell answers; provider not confirmed". Then: read the logs,
|
||||
restart `studio` once, and report. Docker does not restart an unhealthy container by itself, and
|
||||
you restart only once: no loop.
|
||||
3. Open the hosts reading (`curl -s http://127.0.0.1:9440/api/observatory/hosts`) and tell the
|
||||
2. Open the hosts reading (`curl -s http://127.0.0.1:9440/api/observatory/hosts`) and tell the
|
||||
person:
|
||||
- which machines were read
|
||||
- which were not, and why (unreachable, needs a token, ssh only, error)
|
||||
- where to open the app
|
||||
4. Register one reminder to yourself to check the app every 30 minutes:
|
||||
`rig watchdog register --policy periodic-reminder` with yourself as the target. On each reminder,
|
||||
run step 2, check the readings' ages, and act only on the app (see your role).
|
||||
3. On each periodic reminder (your general startup context shows how to register it), run step 1,
|
||||
check the readings' ages, and act only on the app (see your role).
|
||||
|
||||
## Shaping the app
|
||||
|
||||
@@ -36,5 +34,6 @@ enabled it for you. What you may do on this machine is what its owner has actual
|
||||
## Limits to say out loud when they matter
|
||||
|
||||
- Machines reachable only over ssh are listed but not read.
|
||||
- Productivity, the oversight team's readings and subscription moves are not set up yet: only activity is shown, and nothing estimates them. Ceremony and subscriptions are shown, each with how it was read.
|
||||
- Productivity, the oversight team's readings and subscription moves are not set up yet: only activity is
|
||||
shown, and nothing estimates them. Ceremony and subscriptions are shown, each with how it was read.
|
||||
- This app reads open GET routes. It holds no token and asks for none.
|
||||
@@ -33,10 +33,15 @@ pods:
|
||||
label: Observatory
|
||||
members:
|
||||
- id: operator
|
||||
agent_ref: "local:agents/observatory-operator"
|
||||
agent_ref: "local:../agents/starter-ops"
|
||||
runtime: claude-code
|
||||
profile: default
|
||||
cwd: "."
|
||||
startup:
|
||||
files:
|
||||
- path: observatory-startup.md
|
||||
delivery_hint: send_text
|
||||
required: true
|
||||
edges: []
|
||||
|
||||
edges: []
|
||||
@@ -0,0 +1,266 @@
|
||||
// The Fleet page: a small, read-only view of the person's machines, rigs and accounts, needing no other
|
||||
// app. These pin the ways it could mislead (an unreachable machine shown as fine, a reading's age taken
|
||||
// from the poll, usage that's off shown as room, a configured account shown as the one in use, a shared
|
||||
// allowance added up), its independence from the Observatory, and the provider's cached-reading rule.
|
||||
// Stand-in daemons on loopback with invented data.
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { readFleet } from "../providers/shared/fleet-reader.mjs";
|
||||
import { createFleetCache } from "../providers/shared/reader-server.mjs";
|
||||
import { createFleetViewServer, FLEET_READS, FLEET_ROUTE, READY_ROUTE } from "../providers/studio-fleet-view/fleet-view-server.mjs";
|
||||
import { check, LABELS } from "../providers/studio-fleet-view/healthcheck.mjs";
|
||||
import { runtimeClosure, appClosure } from "./observatory-closure.test.mjs";
|
||||
import { startFakeDaemon, closedPortUrl, seat } from "./observatory-fake-daemon.mjs";
|
||||
import { loadPage } from "./observatory-page.mjs";
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
const root = path.resolve(here, "..");
|
||||
const pagePath = path.join(root, "apps/fleet/app/fleet.html");
|
||||
const NOW = Date.parse("2030-01-02T12:00:00.000Z");
|
||||
const now = () => NOW;
|
||||
const started = [];
|
||||
async function daemon(opts) { const d = await startFakeDaemon(opts); started.push(d); return d; }
|
||||
test.afterEach(async () => { await Promise.all(started.splice(0).map((d) => d.close())); });
|
||||
const text = (html) => html.replace(/<[^>]+>/g, " ").replace(/&/g, "&").replace(/'/g, "'").replace(/\s+/g, " ");
|
||||
const read = (url) => readFleet({ daemonUrl: url, reads: [...FLEET_READS], now });
|
||||
const fleetPage = () => loadPage(pagePath);
|
||||
const render = async (fleet) => { const p = await fleetPage(); const o = p.renderPage(fleet, NOW); return { ...o, all: text(o.meta + " " + o.body) }; };
|
||||
|
||||
const usage = (observations, extra = {}) => ({ items: [{ provider: "codex", key: "acct-1", linkage: "same", notes: [], observations }], hosts: [], skipped: [], registry: { state: "read" }, seatAccounts: [], asOf: "2030-01-02T11:59:50.000Z", ...extra });
|
||||
const obs = (hostId, usedPercent, level = "ok", resetsAt = "2030-01-05T00:00:00.000Z") => ({ hostId, readAt: "2030-01-02T11:59:00.000Z", row: { identity: { planType: "team" }, observedAt: "2030-01-02T11:30:00.000Z", currentAvailability: "known", windows: [{ window: "weekly", usedPercent, resetsAt, observedAt: "2030-01-02T11:30:00.000Z", freshness: "fresh", level }] } });
|
||||
|
||||
// --- independence and boundaries -----------------------------------------------------------------
|
||||
|
||||
test("the page asks only for Fleet's own route, which the app declares and the Fleet provider serves", () => {
|
||||
const html = fs.readFileSync(pagePath, "utf8");
|
||||
const app = JSON.parse(fs.readFileSync(path.join(root, "apps/fleet/app.json"), "utf8"));
|
||||
const provider = JSON.parse(fs.readFileSync(path.join(root, "providers/studio-fleet-view/provider.json"), "utf8"));
|
||||
const asked = [...new Set([...html.matchAll(/['"`](\/api\/[^'"`?$]+)['"`?]/g)].map((m) => m[1]))].sort();
|
||||
assert.deepEqual(asked, ["/api/fleet-view/fleet"]);
|
||||
assert.deepEqual(Object.keys(app.calls), asked);
|
||||
assert.equal(app.provider.package, "@openrig/studio-fleet-view");
|
||||
assert.equal(provider.package, app.provider.package);
|
||||
assert.deepEqual(provider.verbs, [FLEET_ROUTE, READY_ROUTE]);
|
||||
assert.equal(app.id, "fleet");
|
||||
assert.equal(app.surface.path, "/surfaces/fleet.html");
|
||||
});
|
||||
|
||||
test("Fleet depends on nothing of the Observatory's: no Observatory route, file or provider in what it runs or loads", () => {
|
||||
const provider = JSON.parse(fs.readFileSync(path.join(root, "providers/studio-fleet-view/provider.json"), "utf8"));
|
||||
assert.ok(provider.verbs.every((v) => !v.includes("observatory")));
|
||||
const { files, problems } = runtimeClosure(path.join(root, "providers/studio-fleet-view"));
|
||||
assert.deepEqual(problems, []);
|
||||
assert.ok(files.every((f) => !f.includes(`${path.sep}studio-observatory${path.sep}`)), "no Observatory provider file in Fleet's closure");
|
||||
const app = appClosure(path.join(root, "apps/fleet"));
|
||||
assert.deepEqual(app.problems, []);
|
||||
assert.deepEqual(app.files.map((f) => path.relative(path.join(root, "apps/fleet"), f)).sort(), ["app.json", path.join("app", "fleet.html")]);
|
||||
// The page links to the Observatory for history; it does not call it.
|
||||
assert.doesNotMatch(fs.readFileSync(pagePath, "utf8"), /fetch\([^)]*observatory/);
|
||||
});
|
||||
|
||||
test("nothing Fleet runs or loads reads a credential, asks for a sign-in, or makes a model request", () => {
|
||||
const files = [...runtimeClosure(path.join(root, "providers/studio-fleet-view")).files, ...appClosure(path.join(root, "apps/fleet")).files];
|
||||
const bad = /credentials\.json|\bauth\.json|\/v1\/messages|api\.anthropic\.com|api\.openai\.com|\boauth\b|authorization|\bbearer\b|access_?token/i;
|
||||
const hits = [];
|
||||
for (const f of files) fs.readFileSync(f, "utf8").split("\n").forEach((l, i) => { if (bad.test(l)) hits.push(`${path.relative(root, f)}:${i + 1}`); });
|
||||
assert.deepEqual(hits, []);
|
||||
assert.ok(bad.test("fetch('https://api.anthropic.com/v1/messages')") && bad.test("read ~/.claude/.credentials.json"), "the pattern catches a planted request or credential read");
|
||||
});
|
||||
|
||||
// --- what the page says --------------------------------------------------------------------------
|
||||
|
||||
test("an unreachable machine is named as not reporting, never shown as fine or empty", async () => {
|
||||
const gone = await closedPortUrl();
|
||||
const a = await daemon({ selfHostId: "box-a", hosts: [{ id: "gone", transport: "http", url: gone }] });
|
||||
const { all } = await render(await read(a.url));
|
||||
assert.match(all, /1 of 2 report here/);
|
||||
assert.match(all, /Not reporting here: gone \(unreachable\)\./);
|
||||
assert.doesNotMatch(all, /gone live/);
|
||||
});
|
||||
|
||||
test("a seat's 'since' is when its machine sampled it, not when the page asked", async () => {
|
||||
const a = await daemon({ selfHostId: "box-a", rigs: { "rig-1": { name: "alpha", nodes: [seat("dev.impl", { agentActivity: { state: "idle", reason: "no turn", evidenceSource: "pane_heuristic", sampledAt: new Date(NOW - 3 * 3600_000).toISOString() } })] } } });
|
||||
const { all } = await render(await read(a.url));
|
||||
assert.match(all, /dev\.impl idle since 3 h ago/);
|
||||
assert.doesNotMatch(all, /since 0 s ago/);
|
||||
});
|
||||
|
||||
test("usage that is off reads 'not connected', never zero or room", async () => {
|
||||
const a = await daemon({ selfHostId: "box-a" });
|
||||
const { all } = await render(await read(a.url));
|
||||
assert.match(all, /Capacity Usage: not connected Usage readings are turned off on this machine\./);
|
||||
assert.doesNotMatch(all, /\b0%|has room|No account is low or out/);
|
||||
});
|
||||
|
||||
test("a shared allowance appears once, with its machines, and is never added up", async () => {
|
||||
const b = await daemon({ selfHostId: "box-b" });
|
||||
const a = await daemon({ selfHostId: "box-a", hosts: [{ id: "second", transport: "http", url: b.url }], usageHosts: usage([obs("local", 40), obs("second", 40)]) });
|
||||
const { all } = await render(await read(a.url));
|
||||
assert.equal(all.match(/codex acct-1/g)?.length, 1, "one row for one account");
|
||||
assert.match(all, /box-a: weekly 40% used .* · box-b: weekly 40% used/);
|
||||
assert.doesNotMatch(all, /80%/);
|
||||
});
|
||||
|
||||
test("accounts are sorted out, then low, then the rest; ones without a reading fold into one named line", async () => {
|
||||
const items = [
|
||||
{ provider: "codex", key: "ok-acct", linkage: "single", notes: [], observations: [obs("local", 10, "ok")] },
|
||||
{ provider: "claude", key: "out-acct", linkage: "single", notes: [], observations: [obs("local", 100, "exhausted", "2030-01-03T00:00:00.000Z")] },
|
||||
{ provider: "codex", key: "low-acct", linkage: "single", notes: [], observations: [obs("local", 85, "nearing")] },
|
||||
{ provider: "codex", key: "blind-acct", linkage: "unknown", notes: [], observations: [{ hostId: "local", readAt: "2030-01-02T11:59:00.000Z", row: { windows: [] } }] },
|
||||
];
|
||||
const a = await daemon({ selfHostId: "box-a", usageHosts: { ...usage([]), items } });
|
||||
const { all } = await render(await read(a.url));
|
||||
const order = ["claude out-acct", "codex low-acct", "codex ok-acct"].map((k) => all.indexOf(k));
|
||||
assert.ok(order.every((i, n) => i > 0 && (n === 0 || i > order[n - 1])), `order ${order}`);
|
||||
assert.match(all, /Capacity 1 out · 1 low claude out-acct: out, resets in 12 h 2 with no reading/);
|
||||
// A configured account (the fixture's seat link) with no usage reading folds into the same line.
|
||||
assert.match(all, /No reading yet: codex blind-acct, team plan A\./);
|
||||
});
|
||||
|
||||
test("planned, default login and observed usage windows stay three separate columns; no account is ever shown as the one in use", async () => {
|
||||
const a = await daemon({
|
||||
selfHostId: "box-a",
|
||||
usageHosts: usage([obs("local", 10)], { seatAccounts: [{ hostId: "local", seats: [{ seatSession: "dev-impl@alpha", rigName: "alpha", configured: { source: "registry", profileRef: "team-a" }, observed: { windowsSampled: true, lastSampledAt: "2030-01-02T11:00:00.000Z", differsFrom: ["dev-review@alpha"], note: "possibly a different login: this seat's status line reports windows that differ from another seat's (a reset more than 60 s apart); not a verdict" } }, { seatSession: "dev-lead@alpha", rigName: "alpha", configured: { source: "registry", profileRef: "team-a" }, observed: { windowsSampled: true, lastSampledAt: "2030-01-02T11:30:00.000Z" } }, { seatSession: "dev-qa@alpha", rigName: "alpha", configured: { source: "default_login" } }] }] }),
|
||||
});
|
||||
const p = await fleetPage();
|
||||
const fleet = await read(a.url);
|
||||
const rows = p.seatAccountsRows(fleet, NOW);
|
||||
const impl = rows.find((r) => r.seat === "dev-impl@alpha");
|
||||
assert.equal(impl.planned, "team plan A");
|
||||
assert.equal(impl.seen, "windows sampled 60 min ago; they differ from dev-review@alpha (possibly a different login: this seat's status line reports windows that differ from another seat's (a reset more than 60 s apart); not a verdict)");
|
||||
assert.equal(rows.find((r) => r.seat === "dev-lead@alpha").seen, "windows sampled 30 min ago", "a sample alone names no account");
|
||||
assert.equal(rows.find((r) => r.seat === "dev-qa@alpha").login, "default login");
|
||||
const { all } = await render(fleet);
|
||||
assert.match(all, /Seat Planned Default login Usage windows observed/);
|
||||
assert.doesNotMatch(all, /current account|currently using|a different account|the planned account/i);
|
||||
});
|
||||
|
||||
test("a column with no values is cut", async () => {
|
||||
const a = await daemon({ selfHostId: "box-a" });
|
||||
const { all } = await render(await read(a.url));
|
||||
assert.match(all, /Seat Planned dev-impl@alpha/);
|
||||
assert.doesNotMatch(all, /Default login|Usage windows observed/);
|
||||
});
|
||||
|
||||
test("the honesty rule is stated once, rows show '—' in grey, and a stale reading says so", async () => {
|
||||
const html = fs.readFileSync(pagePath, "utf8");
|
||||
assert.equal(html.match(/— means not reported here yet\. Old readings show their time\. An account shared by machines is counted once\./g)?.length, 1);
|
||||
const a = await daemon({ selfHostId: "box-a", rigs: { "rig-1": { name: "alpha", nodes: [seat("dev.impl", { agentActivity: undefined, assignedWorkCount: 0, inProgressWorkCount: 0, blockedWorkCount: 0 })] } } });
|
||||
const fleet = await read(a.url);
|
||||
const { body, meta } = await render({ ...fleet, served: { polledAt: "2030-01-02T11:55:00.000Z", stale: true, refreshing: true } });
|
||||
assert.match(body, /<span class="none">—<\/span>/);
|
||||
assert.doesNotMatch(text(body), /not declared|not reported(?! here:)/);
|
||||
assert.match(meta, /Showing the last good reading, from 5 min ago; a fresh one is on its way\./);
|
||||
});
|
||||
|
||||
test("rigs: running first by what needs the operator, a state bar with words, the rest folded", async () => {
|
||||
const a = await daemon({ selfHostId: "box-a", rigs: {
|
||||
"rig-1": { name: "calm", nodes: [seat("a.one")] },
|
||||
"rig-2": { name: "busy", nodes: [seat("b.one", { agentActivity: { state: "needs_input", reason: "x", sampledAt: "2030-01-02T11:59:00.000Z" } }), seat("b.two", { blockedWorkCount: 1 })] },
|
||||
"rig-3": { name: "parked", status: "stopped", nodes: [] },
|
||||
} });
|
||||
const { body, all } = await render(await read(a.url));
|
||||
assert.ok(all.indexOf("busy box-a") < all.indexOf("calm box-a"));
|
||||
assert.match(all, /busy box-a · 2 seats 1 waiting · 1 blocked/);
|
||||
assert.match(body, /<div class="bar"><span class="low" style="width:50\.0%"><\/span><span class="out" style="width:50\.0%"><\/span><\/div>/);
|
||||
assert.match(all, /Not running: parked \(box-a\)\./);
|
||||
});
|
||||
|
||||
test("the layout fits 390 px wide: no fixed width wider than the screen, cards that wrap", () => {
|
||||
const css = fs.readFileSync(pagePath, "utf8").match(/<style>([\s\S]*?)<\/style>/)[1];
|
||||
for (const m of css.matchAll(/(min-width|width)\s*:\s*(\d+)px/g)) assert.ok(Number(m[2]) <= 358, `${m[0]} is wider than a 390 px screen with its margins`);
|
||||
assert.match(css, /grid-template-columns: repeat\(auto-fit, minmax\(240px, 1fr\)\)/);
|
||||
assert.match(css, /overflow-wrap: anywhere/);
|
||||
});
|
||||
|
||||
// --- the provider ---------------------------------------------------------------------------------
|
||||
|
||||
test("once a reading exists, a request never waits for the fan-out: the kept read is served, labelled stale, and refreshed outside", async () => {
|
||||
let reads = 0, release;
|
||||
const gate = new Promise((r) => { release = r; });
|
||||
const readFn = async () => { reads++; if (reads > 1) await gate; return { polledAt: `t${reads}`, hosts: [{ key: "box-a", status: "ok" }] }; };
|
||||
let clock = 0;
|
||||
const get = createFleetCache({ daemon: "http://x", read: readFn, ttlMs: 15_000, now: () => clock });
|
||||
await get(); // the first reading
|
||||
clock = 20_000; // past the keep time; the next read will hang until released
|
||||
const answered = await Promise.race([get(), new Promise((r) => setTimeout(() => r("waited"), 300))]);
|
||||
assert.notEqual(answered, "waited", "the request did not wait for the refresh");
|
||||
assert.equal(answered.served.stale, true);
|
||||
assert.equal(answered.served.refreshing, true);
|
||||
assert.equal(answered.polledAt, "t1");
|
||||
assert.equal(reads, 2, "one refresh started");
|
||||
await get(); // still refreshing: no second refresh
|
||||
assert.equal(reads, 2);
|
||||
release();
|
||||
await get.pending();
|
||||
clock = 21_000;
|
||||
assert.equal((await get()).polledAt, "t2", "the refreshed reading is served next");
|
||||
});
|
||||
|
||||
test("nothing is read when nobody asks: no timer polls", async () => {
|
||||
let reads = 0;
|
||||
const get = createFleetCache({ daemon: "http://x", read: async () => { reads++; return { polledAt: "t", hosts: [{ key: "a", status: "ok" }] }; }, ttlMs: 50, now: Date.now });
|
||||
await get();
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
assert.equal(reads, 1);
|
||||
});
|
||||
|
||||
test("Fleet's ready route makes no daemon call, and its healthcheck passes only on Fleet's provider", async () => {
|
||||
const a = await daemon({ selfHostId: "box-a" });
|
||||
const server = createFleetViewServer({ daemon: a.url });
|
||||
await new Promise((r) => server.listen(0, "127.0.0.1", r));
|
||||
const base = `http://127.0.0.1:${server.address().port}`;
|
||||
try {
|
||||
const body = await (await fetch(base + READY_ROUTE)).json();
|
||||
assert.equal(body.ready, true);
|
||||
assert.equal(body.provider, "@openrig/studio-fleet-view");
|
||||
assert.equal(a.calls.length, 0);
|
||||
assert.equal((await fetch(`${base}/api/observatory/hosts`)).status, 404, "no Observatory route in Fleet's provider");
|
||||
const fleet = await (await fetch(base + FLEET_ROUTE)).json();
|
||||
assert.ok(fleet.hosts[0].reads.seats && fleet.hosts[0].reads.subscriptions === undefined && fleet.subscriptions, "Fleet reads seats, health, links and usage");
|
||||
assert.equal(fleet.hosts[0].reads.ceremony, undefined, "no ceremony reading");
|
||||
// The healthcheck asks the ready route through the studio's port; here the provider stands in for it.
|
||||
const r = await check({ base: a.url });
|
||||
assert.equal(r.healthy, false, "something that is not Fleet's provider is not a pass");
|
||||
const ok = await check({ base, readyPath: READY_ROUTE });
|
||||
assert.deepEqual(ok, { healthy: true, label: LABELS.ready });
|
||||
} finally {
|
||||
await new Promise((r) => { server.closeAllConnections?.(); server.close(r); });
|
||||
}
|
||||
});
|
||||
|
||||
test("what wasn't read is named, never counted as zero rigs, a rig with no seats, or no account low", async () => {
|
||||
// The daemon is down on the first read.
|
||||
const down = await render(await readFleet({ daemonUrl: await closedPortUrl(), reads: [...FLEET_READS], now, timeoutMs: 300 }));
|
||||
assert.match(down.all, /Teams Rigs not read/);
|
||||
assert.doesNotMatch(down.all, /0 rigs running|No rig is running/);
|
||||
|
||||
// The machine answers, but its rig list fails.
|
||||
const ps = await daemon({ selfHostId: "box-a", routes: { "/api/ps": () => [500, { error: "internal" }] } });
|
||||
const psAll = (await render(await read(ps.url))).all;
|
||||
assert.match(psAll, /Rigs not read on: box-a \(http-error\)/);
|
||||
assert.doesNotMatch(psAll, /0 rigs running|No rig is running/);
|
||||
|
||||
// One rig's seats fail: it's named as not read, not shown as a rig with no seats, and the machine's seat count says so.
|
||||
const part = await daemon({ selfHostId: "box-b", rigs: { "rig-1": { name: "alpha", nodes: [seat("dev.impl")] }, "rig-2": { name: "beta", nodes: [seat("dev.qa")] } },
|
||||
routes: { nodes: (u) => (u.pathname.includes("rig-2") ? [503, { error: "unavailable" }] : [200, [{ ...seat("dev.impl"), rigId: "rig-1", rigName: "alpha" }]]) } });
|
||||
const p = await fleetPage();
|
||||
const fleet = await read(part.url);
|
||||
const rigs = p.rigsView(fleet);
|
||||
assert.deepEqual(rigs.running.map((x) => x.name), ["alpha"]);
|
||||
assert.deepEqual(rigs.unread, [{ name: "beta", machine: "box-b" }]);
|
||||
const partAll = (await render(fleet)).all;
|
||||
assert.match(partAll, /1 rig running among what was read/);
|
||||
assert.match(partAll, /Seats not read: beta \(box-b\)/);
|
||||
assert.match(partAll, /2 rigs · 1\+ seats \(1 rig not read\)/);
|
||||
|
||||
// Accounts are connected, but no allowance has a readable window: unknown, not "none low".
|
||||
const noWindow = { hostId: "local", readAt: "2030-01-02T11:59:00.000Z", row: { identity: { planType: "team" }, observedAt: null, currentAvailability: "unknown", windows: [] } };
|
||||
const acc = await daemon({ selfHostId: "box-c", usageHosts: usage([noWindow]) });
|
||||
const accAll = (await render(await read(acc.url))).all;
|
||||
assert.match(accAll, /No account has a readable window/);
|
||||
assert.doesNotMatch(accAll, /No account is low or out|None of the/);
|
||||
});
|
||||
@@ -0,0 +1,223 @@
|
||||
// Fleet Studio: the fleet-ops know-how and the launch rig that carries it.
|
||||
//
|
||||
// The know-how only helps if any agent can find it by what it is for, and if every command it tells
|
||||
// an agent to run actually exists with those options. These checks need no daemon: they read the packs,
|
||||
// the rig and the shared agent's text. A separate hermetic check runs the packs through a real daemon.
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { parseYaml } from "./observatory-yaml.mjs";
|
||||
import { loadRig, rigProblems, composeProblems } from "./observatory-rig.test.mjs";
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
const rigDir = path.resolve(here, "../rigs/fleet-studio");
|
||||
const packsDir = path.join(rigDir, "context/fleet-ops");
|
||||
const agentDir = path.resolve(here, "../rigs/agents/starter-ops");
|
||||
|
||||
export function loadPacks(dir = packsDir) {
|
||||
return fs.readdirSync(dir).sort().map((name) => {
|
||||
const manifestText = fs.readFileSync(path.join(dir, name, "manifest.yaml"), "utf8");
|
||||
return { dir: path.join(dir, name), name, manifestText, manifest: parseYaml(manifestText) };
|
||||
});
|
||||
}
|
||||
|
||||
// The rules OpenRig's own installer and library apply (name, version, taxonomy, files with path and
|
||||
// role), plus ours: every pack has a purpose written to be found, and summaries on its files.
|
||||
const TAXONOMIES = ["world", "lore", "skills", "mission"];
|
||||
export function packProblems(packs) {
|
||||
const p = [];
|
||||
for (const { dir, name, manifest: m } of packs) {
|
||||
if (!m || typeof m !== "object") { p.push(`${name}: manifest is not a mapping`); continue; }
|
||||
if (m.name !== `fleet-ops/${name}`) p.push(`${name}: name must be fleet-ops/${name}, got ${m.name}`);
|
||||
if (!/^[A-Za-z0-9][A-Za-z0-9._+-]{0,31}$/.test(String(m.version ?? ""))) p.push(`${name}: bad version`);
|
||||
if (!TAXONOMIES.includes(m.taxonomy)) p.push(`${name}: taxonomy must be one of ${TAXONOMIES.join(", ")}`);
|
||||
if (typeof m.purpose !== "string" || m.purpose.length < 40) p.push(`${name}: needs a purpose written for discovery`);
|
||||
if (!Array.isArray(m.files) || !m.files.length) p.push(`${name}: no files`);
|
||||
for (const f of m.files ?? []) {
|
||||
if (!f.path || !f.role) p.push(`${name}: a file needs path and role`);
|
||||
else if (!fs.existsSync(path.join(dir, f.path))) p.push(`${name}: ${f.path} is missing`);
|
||||
if (!f.summary) p.push(`${name}: ${f.path} has no summary`);
|
||||
}
|
||||
}
|
||||
return p;
|
||||
}
|
||||
|
||||
// What an agent following the shipped router does: list the library and match its moment against
|
||||
// each entry's purpose. These are the moments the practices exist for.
|
||||
export const SEARCHES = [
|
||||
{ words: ["usage limit"], expect: "fleet-ops/recover-a-stranded-seat" },
|
||||
{ words: ["stranded seat"], expect: "fleet-ops/recover-a-stranded-seat" },
|
||||
{ words: ["lost its login", "lost login", "login"], expect: "fleet-ops/recover-a-stranded-seat" },
|
||||
{ words: ["subscription usage"], expect: "fleet-ops/read-usage-honestly" },
|
||||
{ words: ["resets"], expect: "fleet-ops/read-usage-honestly" },
|
||||
{ words: ["context"], expect: "fleet-ops/keep-context-healthy" },
|
||||
{ words: ["compaction"], expect: "fleet-ops/keep-context-healthy" },
|
||||
];
|
||||
export function searchProblems(entries) {
|
||||
const p = [];
|
||||
for (const s of SEARCHES) {
|
||||
const hits = entries.filter((e) => s.words.some((w) => String(e.purpose ?? "").toLowerCase().includes(w)));
|
||||
if (!hits.some((h) => h.ref === s.expect)) p.push(`searching "${s.words[0]}" does not find ${s.expect}`);
|
||||
}
|
||||
return p;
|
||||
}
|
||||
|
||||
// Every `rig ...` command the know-how and the agent's text name, with the options checked against
|
||||
// `rig <command> --help` and the CLI source (see the increment's notes for the cites).
|
||||
const VERIFIED = {
|
||||
"rig ps": ["--nodes", "--json", "--all-hosts", "--hosts"],
|
||||
"rig parked": ["--rig", "--json"],
|
||||
"rig capture": [],
|
||||
"rig send": [],
|
||||
"rig provider status": ["--json", "--refresh", "--all-hosts", "--hosts", "--provider"],
|
||||
"rig auth status": ["--runtime"],
|
||||
"rig seat restart": ["--keep-conversation", "--reason", "--plan", "--json"],
|
||||
"rig seat launch": [],
|
||||
"rig seat handover": ["--dry-run", "--reason", "--source", "--json"],
|
||||
"rig up": ["--existing", "--plan", "--json"],
|
||||
"rig grow": [],
|
||||
"rig config set": [],
|
||||
"rig usage": [],
|
||||
"rig health": [],
|
||||
"rig compact-plan": ["--rig", "--refresh", "--json"],
|
||||
"rig watchdog": [],
|
||||
"rig watchdog register": ["--policy", "--spec", "--target-session", "--interval-seconds", "--registered-by", "--threshold-mb", "--threshold-bytes", "--message", "--help"],
|
||||
"rig queue": [],
|
||||
"rig host pair": [],
|
||||
"rig context": [],
|
||||
"rig context list": ["--json"],
|
||||
"rig context get": [],
|
||||
"rig context add": [],
|
||||
"rig whoami": ["--json"],
|
||||
"rig env status": [],
|
||||
"rig env logs": [],
|
||||
// Named in the agent's role as things it must not do to another rig or seat.
|
||||
"rig down": [],
|
||||
"rig seat": [],
|
||||
};
|
||||
const ANY_COMMAND_FLAGS = ["--help"];
|
||||
const COMMAND_NAMES = Object.keys(VERIFIED).sort((a, b) => b.length - a.length);
|
||||
|
||||
export function commandProblems(texts) {
|
||||
const p = [];
|
||||
for (const [file, text] of texts) {
|
||||
for (const m of text.matchAll(/`(rig [^`]+)`/g)) {
|
||||
const cmd = m[1].replace(/\s+/g, " ");
|
||||
if (/<command>/.test(cmd)) continue; // "rig <command> --help" is a pointer, not a command
|
||||
const name = COMMAND_NAMES.find((n) => cmd === n || cmd.startsWith(`${n} `));
|
||||
if (!name) { p.push(`${file}: \`${cmd}\` is not a verified command`); continue; }
|
||||
for (const flag of cmd.match(/(?<=\s|^)--[a-z][a-z-]*/g) ?? []) {
|
||||
if (!VERIFIED[name].includes(flag) && !ANY_COMMAND_FLAGS.includes(flag)) p.push(`${file}: \`${name}\` has no verified option ${flag}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
return p;
|
||||
}
|
||||
|
||||
const read = (p) => fs.readFileSync(p, "utf8");
|
||||
const knowHowTexts = () => [
|
||||
...loadPacks().map((pk) => [`${pk.name}/practice.md`, read(path.join(pk.dir, "practice.md"))]),
|
||||
["fleet-studio-startup.md", read(path.join(rigDir, "fleet-studio-startup.md"))],
|
||||
["starter-ops role.md", read(path.join(agentDir, "guidance/role.md"))],
|
||||
["starter-ops startup/context.md", read(path.join(agentDir, "startup/context.md"))],
|
||||
];
|
||||
|
||||
test("each fleet-ops pack is a valid context pack with a purpose written for discovery", () => {
|
||||
assert.deepEqual(packProblems(loadPacks()), []);
|
||||
});
|
||||
|
||||
test("a search by purpose finds the right practice", () => {
|
||||
const entries = loadPacks().map((pk) => ({ ref: pk.manifest.name, purpose: pk.manifest.purpose }));
|
||||
assert.deepEqual(searchProblems(entries), []);
|
||||
});
|
||||
|
||||
test("every command the know-how and the agent's text name exists, with those options", () => {
|
||||
assert.deepEqual(commandProblems(knowHowTexts()), []);
|
||||
});
|
||||
|
||||
test("the know-how never prices usage, never measures it with a model request, and leaves moves to their owners", () => {
|
||||
for (const [file, text] of knowHowTexts().slice(0, 3)) {
|
||||
assert.doesNotMatch(text, /\$\s?\d|\bUSD\b|\bdollars?\b|\bcost of\b/i, `${file} mentions money`);
|
||||
assert.doesNotMatch(text, /\/v1\/messages|api\.anthropic\.com|api\.openai\.com|curl\s/i, `${file} measures by a model request`);
|
||||
assert.doesNotMatch(text, /`rig provider switch|`rig auth switch/, `${file} gives account-move steps`);
|
||||
}
|
||||
const usage = read(path.join(packsDir, "read-usage-honestly/practice.md"));
|
||||
assert.match(usage, /Sending a model request to "check" usage\./);
|
||||
assert.match(usage, /Adding readings from several machines into one total\./);
|
||||
assert.match(usage, /Unknown is unknown\./);
|
||||
});
|
||||
|
||||
const FLEET_RIG = { project: "openrig-fleet-studio", port: 9450, providerRoute: "/api/fleet-view/" };
|
||||
const fleetRigProblems = (r) => [...rigProblems(r, FLEET_RIG), ...composeProblems(r, { provider: "studio-fleet-view" })];
|
||||
|
||||
test("the Fleet Studio rig runs the Fleet page in its own Compose project and port, pinned, with the provider's healthcheck", () => {
|
||||
const r = loadRig(rigDir);
|
||||
assert.deepEqual(fleetRigProblems(r), []);
|
||||
assert.equal(r.compose.name, "openrig-fleet-studio");
|
||||
assert.deepEqual(Object.keys(r.compose.volumes), ["fleet-studio-data"]);
|
||||
assert.deepEqual(r.rig.services.surfaces.urls, [{ name: "Fleet", url: "http://127.0.0.1:9450/surfaces/fleet.html" }]);
|
||||
assert.match(r.dockerfile, /^COPY apps\/fleet \/studio\/apps\/apps\/fleet$/m);
|
||||
assert.match(r.dockerfile, /install-app\.mjs \/studio\/apps\/apps\/fleet --enable/);
|
||||
assert.match(r.dockerfile, /CMD \["node", "tools\/studio\.mjs", "--port", "9450"\]/);
|
||||
assert.doesNotMatch(r.dockerfile, /observatory/i, "the Fleet image carries nothing of the Observatory");
|
||||
const ignore = read(path.join(rigDir, "Dockerfile.dockerignore")).split("\n").map((l) => l.trim()).filter((l) => l && !l.startsWith("#"));
|
||||
assert.deepEqual(ignore, ["*", "!apps/fleet/**", "!providers/studio-fleet-view/**", "!providers/shared/**"]);
|
||||
});
|
||||
|
||||
test("the Fleet rig checks catch a shared project, a wait on the provider, the Observatory's healthcheck, and a credential mount", () => {
|
||||
const plant = (edit) => { const r = loadRig(rigDir); edit(r); return fleetRigProblems(r); };
|
||||
assert.ok(plant((r) => { r.rig.services.project_name = "openrig-observatory"; }).some((m) => /project_name/.test(m)));
|
||||
assert.ok(plant((r) => { r.rig.services.wait_for = [{ url: "http://127.0.0.1:9450/api/fleet-view/ready" }]; }).some((m) => /wait on the provider|wait for the studio/.test(m)));
|
||||
assert.ok(plant((r) => { r.compose.services.studio.healthcheck.test = ["CMD", "node", "/studio/apps/providers/studio-observatory/healthcheck.mjs"]; }).some((m) => /healthcheck/.test(m)));
|
||||
assert.ok(plant((r) => { r.compose.services.studio.volumes = ["/home/u/.claude:/x"]; }).some((m) => /credential|named volumes/.test(m)));
|
||||
});
|
||||
|
||||
test("the Fleet Studio rig has one ops seat on the shared agent, the Fleet link, and the know-how step", () => {
|
||||
const rig = parseYaml(read(path.join(rigDir, "rig.yaml")));
|
||||
assert.equal(rig.version, "0.2");
|
||||
assert.equal(rig.name, "fleet-studio");
|
||||
const members = rig.pods.flatMap((p) => p.members);
|
||||
assert.equal(members.length, 1);
|
||||
assert.equal(members[0].agent_ref, "local:../agents/starter-ops");
|
||||
assert.ok(fs.existsSync(path.join(rigDir, members[0].agent_ref.slice(6), "agent.yaml")));
|
||||
const own = members[0].startup.files.map((f) => f.path);
|
||||
assert.deepEqual(own, ["fleet-studio-startup.md"]);
|
||||
const startup = read(path.join(rigDir, own[0]));
|
||||
for (const pk of loadPacks()) {
|
||||
assert.ok(startup.includes(`\`${pk.manifest.name}\``), `the startup step names ${pk.manifest.name}`);
|
||||
}
|
||||
assert.match(startup, /`rig context add context\/fleet-ops\/<name>`/);
|
||||
assert.match(startup, /Add only the ones that are missing/);
|
||||
assert.match(startup, /127\.0\.0\.1:9450\/surfaces\/fleet\.html/);
|
||||
assert.match(startup, /restart `studio` once/);
|
||||
assert.doesNotMatch(read(path.join(rigDir, "rig.yaml")), /openrig-observatory|observatory[./-](compose|startup)|rigs\/observatory/, "Fleet Studio references no Observatory files or project");
|
||||
});
|
||||
|
||||
test("the shared agent names the fleet routes and carries the know-how without owning it", () => {
|
||||
const role = read(path.join(agentDir, "guidance/role.md"));
|
||||
const routes = role.match(/## Routes you work with([\s\S]*?)\n## /)?.[1] ?? "";
|
||||
for (const r of ["rig ps", "rig queue", "rig provider status", "rig auth status", "rig compact-plan", "rig host pair", "rig up", "rig grow", "rig watchdog", "rig context list", "openrig-upgrade"]) {
|
||||
assert.ok(routes.includes(r), `the routes name ${r}`);
|
||||
}
|
||||
assert.match(routes, /`rig up` and `rig grow`: bring up or grow \*\*your own\*\* rig/);
|
||||
assert.match(role, /They are not yours alone: any agent on the\s+machine can find and use them the same way\./);
|
||||
});
|
||||
|
||||
// Each check above must catch the mistake it exists for.
|
||||
test("a missing purpose, a purpose that no search finds, and a broken manifest are caught", () => {
|
||||
const packs = loadPacks();
|
||||
const noPurpose = packs.map((pk, i) => (i === 0 ? { ...pk, manifest: { ...pk.manifest, purpose: undefined } } : pk));
|
||||
assert.ok(packProblems(noPurpose).some((m) => /needs a purpose/.test(m)));
|
||||
const vague = packs.map((pk) => ({ ref: pk.manifest.name, purpose: pk.name === "read-usage-honestly" ? "Assorted notes for operators." : pk.manifest.purpose }));
|
||||
assert.ok(searchProblems(vague).some((m) => /subscription usage/.test(m)));
|
||||
const broken = packs.map((pk, i) => (i === 1 ? { ...pk, manifest: { ...pk.manifest, taxonomy: "notes", files: [{ path: "missing.md" }] } } : pk));
|
||||
const bp = packProblems(broken);
|
||||
assert.ok(bp.some((m) => /taxonomy/.test(m)) && bp.some((m) => /path and role|missing/.test(m)));
|
||||
});
|
||||
|
||||
test("an invented command or option is caught", () => {
|
||||
assert.ok(commandProblems([["x", "run `rig fleet rebalance` now"]]).some((m) => /not a verified command/.test(m)));
|
||||
assert.ok(commandProblems([["x", "`rig provider status --everything`"]]).some((m) => /no verified option --everything/.test(m)));
|
||||
});
|
||||
@@ -2,7 +2,7 @@
|
||||
// table that says which OpenRig it was checked against. Stand-in daemons, invented data.
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFleet } from "../providers/studio-observatory/lib/fleet-reader.mjs";
|
||||
import { readFleet } from "../providers/shared/fleet-reader.mjs";
|
||||
import { createFleetCache, createObservatoryServer, FLEET_VERBS as VERBS } from "../providers/studio-observatory/observatory-server.mjs";
|
||||
import { startFakeDaemon } from "./observatory-fake-daemon.mjs";
|
||||
import { loadPage } from "./observatory-page.mjs";
|
||||
@@ -32,8 +32,11 @@ test("all seven verbs inside the keep time cost one read of the daemon", async (
|
||||
}
|
||||
clock += 11_000; // past the keep time
|
||||
const later = await (await fetch(base + VERBS[1])).json();
|
||||
assert.equal(later.served.fromCache, false);
|
||||
assert.equal(healthzCalls(a), 2, "a new read after the keep time");
|
||||
// Answered at once from the kept read, labelled stale; the refresh runs outside the request.
|
||||
assert.equal(later.served.fromCache, true);
|
||||
assert.equal(later.served.stale, true);
|
||||
await server.fleet.pending();
|
||||
assert.equal(healthzCalls(a), 2, "one new read, started by the request after the keep time");
|
||||
} finally {
|
||||
await new Promise((r) => { server.closeAllConnections?.(); server.close(r); });
|
||||
}
|
||||
@@ -79,6 +82,10 @@ test("the page shows a kept read's age, never as fresh", async () => {
|
||||
const out = text(page.renderHosts(fleet, now));
|
||||
assert.match(out, /read by the provider 10 s ago \(a kept read, 10 s old; reads are kept up to 15 s\)/);
|
||||
assert.doesNotMatch(out, /just now|\b0 s ago/);
|
||||
// Past the keep time the kept read is still shown at once, and says it is the last good read.
|
||||
const stale = text(page.renderHosts({ ...fleet, served: { ...fleet.served, cacheAgeSeconds: 40, stale: true, refreshing: true } }, now));
|
||||
assert.match(stale, /\(the last good read, 40 s old, past the 15 s keep; a fresh read is on its way\)/);
|
||||
assert.doesNotMatch(stale, /reads are kept up to/);
|
||||
});
|
||||
|
||||
test("a daemon restart between reads is flagged, by boot epoch where the daemon gives one", async () => {
|
||||
|
||||
@@ -22,6 +22,12 @@ import { fileURLToPath } from "node:url";
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
const providerDir = path.resolve(here, "../providers/studio-observatory");
|
||||
// Code both read-only providers run (the fleet reader and the server core). It ships with them and is
|
||||
// checked as part of each provider's closure.
|
||||
const sharedDir = path.resolve(here, "../providers/shared");
|
||||
// The Fleet provider and app get every check the Observatory's get.
|
||||
const fleetProviderDir = path.resolve(here, "../providers/studio-fleet-view");
|
||||
const PROVIDER_DIRS = [providerDir, fleetProviderDir];
|
||||
|
||||
const PATTERNS = [
|
||||
{ name: "a macOS home path", re: /\/Users\// },
|
||||
@@ -31,6 +37,8 @@ const PATTERNS = [
|
||||
{ name: "an ssh key or config path", re: /\.ssh\// },
|
||||
{ name: "a tailnet host name", re: /\.ts\.net\b/ },
|
||||
{ name: "a model provider endpoint", re: /api\.anthropic\.com|api\.openai\.com|chatgpt\.com/ },
|
||||
// A request that spends an account's allowance, or an OAuth or sign-in flow: never in these apps.
|
||||
{ name: "a model request or sign-in flow", re: /\/v1\/messages|\boauth\b|device[_-]?code|sign-?in code/i },
|
||||
// The provider reads open GET routes. It never acquires or sends a token, so an
|
||||
// authorization header anywhere in what it runs is a design change, not a detail.
|
||||
{ name: "an authorization header", re: /authorization|\bbearer\b/i },
|
||||
@@ -45,7 +53,7 @@ function extraPatterns() {
|
||||
|
||||
const IMPORT_RE = /(?:^|[^\w.])(?:import|export)\s[^"'`]*?from\s*["']([^"']+)["']|(?:^|[^\w.])import\s*\(\s*["']([^"']+)["']\s*\)|(?:^|[^\w.])import\s*["']([^"']+)["']/g;
|
||||
|
||||
export function runtimeClosure(dir) {
|
||||
export function runtimeClosure(dir, allowed = [dir, sharedDir]) {
|
||||
const manifest = JSON.parse(fs.readFileSync(path.join(dir, "provider.json"), "utf8"));
|
||||
// Plus the container healthcheck script, which runs inside the image too.
|
||||
const roots = [manifest.run.entry, ...(manifest.run.companions ?? []).map((c) => c.entry), ...(fs.existsSync(path.join(dir, "healthcheck.mjs")) ? ["healthcheck.mjs"] : [])];
|
||||
@@ -56,7 +64,7 @@ export function runtimeClosure(dir) {
|
||||
const file = queue.shift();
|
||||
if (seen.has(file)) continue;
|
||||
seen.add(file);
|
||||
if (!file.startsWith(dir + path.sep)) { problems.push(`${file} is outside the provider`); continue; }
|
||||
if (!allowed.some((root) => file.startsWith(root + path.sep))) { problems.push(`${file} is outside the provider`); continue; }
|
||||
if (!fs.existsSync(file)) { problems.push(`${path.relative(dir, file)} is started or imported but missing`); continue; }
|
||||
const src = fs.readFileSync(file, "utf8");
|
||||
for (const m of src.matchAll(IMPORT_RE)) {
|
||||
@@ -76,12 +84,13 @@ test("the closure is found by following imports from what provider.json starts",
|
||||
// The positive control: if the walk silently stopped at the entry, every pattern
|
||||
// check below would pass on one file.
|
||||
assert.ok(rel.includes("observatory-server.mjs"));
|
||||
assert.ok(rel.includes(path.join("lib", "fleet-reader.mjs")), `the reader must be in the closure, got ${rel.join(", ")}`);
|
||||
assert.ok(rel.includes(path.join("..", "shared", "fleet-reader.mjs")) && rel.includes(path.join("..", "shared", "reader-server.mjs")), `the shared code must be in the closure, got ${rel.join(", ")}`);
|
||||
assert.ok(rel.includes("healthcheck.mjs"), "the healthcheck the container runs is checked too");
|
||||
});
|
||||
|
||||
test("nothing in the closure names a machine, an account path or a credential", () => {
|
||||
const { files } = runtimeClosure(providerDir);
|
||||
test("nothing in either provider's closure names a machine, an account path or a credential", () => {
|
||||
const files = new Set(PROVIDER_DIRS.flatMap((d) => runtimeClosure(d).files));
|
||||
assert.ok([...files].some((f) => f.endsWith("fleet-view-server.mjs")), "the Fleet provider is scanned");
|
||||
const patterns = [...PATTERNS, ...extraPatterns()];
|
||||
const hits = [];
|
||||
for (const file of files) {
|
||||
@@ -97,6 +106,7 @@ test("nothing in the closure names a machine, an account path or a credential",
|
||||
// references by src, href or import, and any vendor directory the manifest ships. Nothing may come
|
||||
// from another origin, so a page can't quietly pull code or data from somewhere unreviewed.
|
||||
const appDir = path.resolve(here, "../apps/observatory");
|
||||
const APP_DIRS = [appDir, path.resolve(here, "../apps/fleet")];
|
||||
const REF_RE = /\b(?:src|href)\s*=\s*["']([^"'#]+)["']|\bimport\s*(?:[^"'`]*?from\s*)?["']([^"']+)["']|@import\s+(?:url\()?["']?([^"')]+)/g;
|
||||
|
||||
export function appClosure(dir) {
|
||||
@@ -135,8 +145,9 @@ test("the app's closure loads nothing from another origin and nothing missing",
|
||||
assert.deepEqual(rel, ["app.json", path.join("app", "observatory.html")]);
|
||||
});
|
||||
|
||||
test("nothing in the app's closure names a machine, an account path or a credential", () => {
|
||||
const { files } = appClosure(appDir);
|
||||
test("nothing in either app's closure names a machine, an account path or a credential", () => {
|
||||
const files = APP_DIRS.flatMap((d) => appClosure(d).files);
|
||||
assert.ok(files.some((f) => f.endsWith("fleet.html")), "the Fleet page is scanned");
|
||||
const patterns = [...PATTERNS, ...extraPatterns()];
|
||||
const hits = [];
|
||||
for (const file of files) {
|
||||
@@ -160,13 +171,13 @@ test("the app check does catch a page that loads from another origin", () => {
|
||||
}
|
||||
});
|
||||
|
||||
// The launch rig ships whole: its spec, compose file, Dockerfile, agent and notes. Every file in it
|
||||
// gets the same patterns.
|
||||
test("nothing in the launch rig names a machine, an account path or a credential", () => {
|
||||
const rigDir = path.resolve(here, "../rigs/observatory");
|
||||
// The launch rigs ship whole: their specs, compose file, Dockerfile, the shared agent, the know-how and
|
||||
// notes. Every file under rigs/ gets the same patterns.
|
||||
test("nothing in the launch rigs names a machine, an account path or a credential", () => {
|
||||
const rigDir = path.resolve(here, "../rigs");
|
||||
const patterns = [...PATTERNS, ...extraPatterns()];
|
||||
const files = fs.readdirSync(rigDir, { recursive: true }).map((f) => path.join(rigDir, f)).filter((f) => fs.statSync(f).isFile());
|
||||
assert.ok(files.length >= 8, `expected the rig's files, found ${files.length}`);
|
||||
assert.ok(files.length >= 18, `expected the rigs' files, found ${files.length}`);
|
||||
const hits = [];
|
||||
for (const file of files) {
|
||||
fs.readFileSync(file, "utf8").split("\n").forEach((line, i) => {
|
||||
@@ -180,8 +191,8 @@ test("nothing in the launch rig names a machine, an account path or a credential
|
||||
// may carry a currency field or label.
|
||||
const MONEY = /\bUSD\b|\busd\b|costUsd|CostUsd|CostEstimate|\bdollars?\b|\bbilling\b/i;
|
||||
|
||||
test("nothing the provider runs or the page loads deals in money", () => {
|
||||
const files = [...runtimeClosure(providerDir).files, ...appClosure(appDir).files];
|
||||
test("nothing the providers run or the pages load deals in money", () => {
|
||||
const files = [...new Set(PROVIDER_DIRS.flatMap((d) => runtimeClosure(d).files)), ...APP_DIRS.flatMap((d) => appClosure(d).files)];
|
||||
const hits = [];
|
||||
for (const file of files) fs.readFileSync(file, "utf8").split("\n").forEach((line, i) => { if (MONEY.test(line)) hits.push(`${path.relative(path.resolve(here, ".."), file)}:${i + 1}`); });
|
||||
assert.deepEqual(hits, []);
|
||||
@@ -192,8 +203,8 @@ test("nothing the provider runs or the page loads deals in money", () => {
|
||||
// token (generation record, previews, compaction, the terminal).
|
||||
const GUARDED_ROUTES = /\/generation-record|\/preview\b|\/api\/compaction|\/api\/transcripts|\/api\/terminal|\/api\/startup\//;
|
||||
|
||||
test("nothing the provider runs or the page loads asks for transcripts or token-guarded routes", () => {
|
||||
const files = [...runtimeClosure(providerDir).files, ...appClosure(appDir).files];
|
||||
test("nothing the providers run or the pages load asks for transcripts or token-guarded routes", () => {
|
||||
const files = [...new Set(PROVIDER_DIRS.flatMap((d) => runtimeClosure(d).files)), ...APP_DIRS.flatMap((d) => appClosure(d).files)];
|
||||
const hits = [];
|
||||
for (const file of files) fs.readFileSync(file, "utf8").split("\n").forEach((line, i) => { if (GUARDED_ROUTES.test(line)) hits.push(`${path.relative(path.resolve(here, ".."), file)}:${i + 1}`); });
|
||||
assert.deepEqual(hits, []);
|
||||
@@ -203,6 +214,6 @@ test("nothing the provider runs or the page loads asks for transcripts or token-
|
||||
test("the pattern check does catch a planted path", () => {
|
||||
// Without this, a regular expression that never matches would keep the test
|
||||
// above green whatever the provider contained.
|
||||
const planted = ["const home = '/Users/someone/.config';", "fetch(url, { headers: { Authorization: x } })", "read('~/.codex/auth.json')"];
|
||||
const planted = ["const home = '/Users/someone/.config';", "fetch(url, { headers: { Authorization: x } })", "read('~/.codex/auth.json')", "read('~/.claude/.credentials.json')", "fetch(base + '/v1/messages')", "start the OAuth device_code flow"];
|
||||
for (const line of planted) assert.ok(PATTERNS.some((p) => p.re.test(line)), `no pattern caught: ${line}`);
|
||||
});
|
||||
@@ -25,7 +25,7 @@ export async function startFakeDaemon(opts = {}) {
|
||||
const defaults = {
|
||||
"/healthz": () => [200, { status: "ok", pid: opts.pid ?? 4242, semver: "0.0.0-test", commit: "abc", ...(opts.selfHostId ? { selfHostId: opts.selfHostId, selfHostIdSource: "named" } : {}) }],
|
||||
"/api/hosts": () => [200, { ownName: "test", selected: "local", hosts: opts.hosts ?? [] }],
|
||||
"/api/ps": () => [200, Object.entries(rigs).map(([rigId, r]) => ({ rigId, name: r.name, rigName: r.name, nodeCount: r.nodes.length, status: "running" }))],
|
||||
"/api/ps": () => [200, Object.entries(rigs).map(([rigId, r]) => ({ rigId, name: r.name, rigName: r.name, nodeCount: r.nodes.length, status: r.status ?? "running", lifecycleState: r.status === "stopped" ? "stopped" : "running" }))],
|
||||
"/api/queue/list": () => [200, opts.queue ?? [qitem(1), qitem(2, { state: "blocked", blockedOn: "external:x" })]],
|
||||
"/api/health-summary/nodes": () => [200, { total: 2, bySessionStatus: { running: 2 }, byLifecycle: { running: 2 }, attentionRequired: 0 }],
|
||||
"/api/health-summary/context": () => [200, { total: 2, byUrgency: { critical: 0, warning: 0, low: 2, unknown: 0 }, byFreshness: { fresh: 2, stale: 0, none: 0 }, critical: 0, warning: 0, stale: 0 }],
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
// All daemons here are stand-ins on loopback ports with invented data.
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFleet } from "../providers/studio-observatory/lib/fleet-reader.mjs";
|
||||
import { readFleet } from "../providers/shared/fleet-reader.mjs";
|
||||
import { createObservatoryServer } from "../providers/studio-observatory/observatory-server.mjs";
|
||||
import { startFakeDaemon, closedPortUrl, seat, qitem } from "./observatory-fake-daemon.mjs";
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
// daemons (invented data, shaped from OpenRig's routes) and the page's own render functions.
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFleet } from "../providers/studio-observatory/lib/fleet-reader.mjs";
|
||||
import { readFleet } from "../providers/shared/fleet-reader.mjs";
|
||||
import { startFakeDaemon, qitem } from "./observatory-fake-daemon.mjs";
|
||||
import { loadPage } from "./observatory-page.mjs";
|
||||
|
||||
|
||||
@@ -25,7 +25,8 @@ export function loadRig(dir = rigDir) {
|
||||
return { dir, rig, compose, dockerfile: read("Dockerfile"), agentDir, agent: parseYaml(fs.readFileSync(path.join(agentDir, "agent.yaml"), "utf8")), role: fs.readFileSync(path.join(agentDir, "guidance/role.md"), "utf8") };
|
||||
}
|
||||
|
||||
export function rigProblems({ dir, rig, compose }) {
|
||||
// The Fleet Studio rig runs the same shape with its own project, port and provider.
|
||||
export function rigProblems({ dir, rig, compose }, { project = PROJECT, port = 9440, providerRoute = "/api/observatory/" } = {}) {
|
||||
const p = [];
|
||||
const s = rig.services ?? {};
|
||||
if (rig.version !== "0.2") p.push(`rig version is ${rig.version}, expected "0.2"`);
|
||||
@@ -34,7 +35,7 @@ export function rigProblems({ dir, rig, compose }) {
|
||||
const cf = String(s.compose_file ?? "");
|
||||
if (!cf || cf.startsWith("/") || cf.split(/[\\/]/).includes("..")) p.push(`compose_file must be relative and inside the rig directory, got "${cf}"`);
|
||||
else if (!fs.existsSync(path.join(dir, cf))) p.push(`compose_file ${cf} does not exist`);
|
||||
if (s.project_name !== PROJECT) p.push(`project_name must be ${PROJECT}`);
|
||||
if (s.project_name !== project) p.push(`project_name must be ${project}`);
|
||||
if (!["down", "leave_running", "down_and_volumes"].includes(s.down_policy)) p.push(`down_policy ${s.down_policy} is not one OpenRig accepts`);
|
||||
if (s.down_policy === "down_and_volumes") p.push("down_policy would delete the app's data on every rig down");
|
||||
const waits = s.wait_for ?? [];
|
||||
@@ -47,8 +48,8 @@ export function rigProblems({ dir, rig, compose }) {
|
||||
if (!(s.surfaces?.urls ?? []).length) p.push("surfaces.urls names no link for the person");
|
||||
// The rig comes up on the studio alone. Provider health is a runtime reading for the agent: waiting on
|
||||
// it (the ready verb, or the compose health, which is provider-level) would roll the rig back.
|
||||
if (!waits.some((w) => w.url === "http://127.0.0.1:9440/api/contract")) p.push("wait_for must wait for the studio (/api/contract on 9440)");
|
||||
if (waits.some((w) => String(w.url ?? "").includes("/api/observatory/"))) p.push("wait_for must not wait on the provider");
|
||||
if (!waits.some((w) => w.url === `http://127.0.0.1:${port}/api/contract`)) p.push(`wait_for must wait for the studio (/api/contract on ${port})`);
|
||||
if (waits.some((w) => String(w.url ?? "").includes(providerRoute))) p.push("wait_for must not wait on the provider");
|
||||
if (waits.some((w) => w.service !== undefined)) p.push("wait_for must not wait on compose health, which is provider-level");
|
||||
const pods = rig.pods ?? [];
|
||||
const members = pods.flatMap((pod) => pod.members ?? []);
|
||||
@@ -63,7 +64,7 @@ export function rigProblems({ dir, rig, compose }) {
|
||||
|
||||
const CREDENTIALISH = /credential|auth\.json|\.ssh|\.openrig|\.claude|\.codex|docker\.sock|token|secret|password/i;
|
||||
|
||||
export function composeProblems({ compose, dockerfile }) {
|
||||
export function composeProblems({ compose, dockerfile }, { provider = "studio-observatory" } = {}) {
|
||||
const p = [];
|
||||
const declaredVolumes = new Set(Object.keys(compose.volumes ?? {}));
|
||||
for (const [name, svc] of Object.entries(compose.services ?? {})) {
|
||||
@@ -76,7 +77,7 @@ export function composeProblems({ compose, dockerfile }) {
|
||||
// inside the container, so a crash could not be tested and children would not be reaped.
|
||||
if (svc.init !== true) p.push(`${name}: init must be true`);
|
||||
// Health is the provider's ready verb through the studio's port, not the studio alone.
|
||||
if (!(svc.healthcheck?.test ?? []).some((a) => String(a).endsWith("/providers/studio-observatory/healthcheck.mjs"))) p.push(`${name}: the healthcheck must run the provider's healthcheck (ready verb through the studio)`);
|
||||
if (!(svc.healthcheck?.test ?? []).some((a) => String(a).endsWith(`/providers/${provider}/healthcheck.mjs`))) p.push(`${name}: the healthcheck must run the provider's healthcheck (ready verb through the studio)`);
|
||||
if (svc.privileged) p.push(`${name}: privileged`);
|
||||
for (const v of svc.volumes ?? []) {
|
||||
const source = String(v).split(":")[0];
|
||||
@@ -102,7 +103,8 @@ export function composeProblems({ compose, dockerfile }) {
|
||||
const ALLOWED = [
|
||||
new RegExp(`^docker compose -p ${PROJECT}( (ps|logs|up -d studio|restart studio))?(, .*)?$`),
|
||||
/^(ps|logs|up -d studio|restart studio)$/,
|
||||
/^rig env (status|logs) observatory( studio)?$/,
|
||||
/^docker compose -p openrig-fleet-studio( (ps|logs|up -d studio|restart studio))?(, .*)?$/,
|
||||
/^rig env (status|logs) (observatory|fleet-studio)( studio)?$/,
|
||||
/^rig watchdog register --policy periodic-reminder$/,
|
||||
/^rig (whoami|ps)( --json| --all-hosts)*$/,
|
||||
];
|
||||
@@ -111,7 +113,8 @@ export function scopeProblems({ role }) {
|
||||
const p = [];
|
||||
const yours = role.match(/## What is yours([\s\S]*?)\n## /)?.[1] ?? "";
|
||||
if (!yours.includes(PROJECT)) p.push("the guidance does not name the app's own Compose project");
|
||||
const mayRun = yours.match(/You may run:([\s\S]*)$/)?.[1] ?? "";
|
||||
const mayRun = yours.match(/You may run[^:\n]*:([\s\S]*)$/)?.[1] ?? "";
|
||||
if (!mayRun.trim()) p.push("the guidance has no list of what the agent may run");
|
||||
for (const cmd of [...mayRun.matchAll(/`([^`]+)`/g)].map((m) => m[1])) {
|
||||
if (!ALLOWED.some((re) => re.test(cmd))) p.push(`the guidance lets the agent run \`${cmd}\``);
|
||||
}
|
||||
@@ -156,7 +159,7 @@ export function scopeProblems({ role }) {
|
||||
// The path for a provider that is not serving: see unhealthy, read the logs, restart once, report.
|
||||
const path = role.match(/studio: running \(unhealthy\)[\s\S]*?logs studio[\s\S]*?restart `studio` once[\s\S]*?say what happened/);
|
||||
if (!path) p.push("the guidance does not name the unhealthy -> logs -> restart once -> report path");
|
||||
for (const m of role.matchAll(/`docker compose(?! -p openrig-observatory)[^`]*`/g)) p.push(`a docker compose command without the app's project: ${m[0]}`);
|
||||
for (const m of role.matchAll(/`docker compose(?! -p openrig-(observatory|fleet-studio)\b)[^`]*`/g)) p.push(`a docker compose command without the app's project: ${m[0]}`);
|
||||
return p;
|
||||
}
|
||||
|
||||
@@ -172,17 +175,22 @@ test("the agent may act on its own Compose project only, and tells an intentiona
|
||||
assert.deepEqual(scopeProblems(loadRig()), []);
|
||||
});
|
||||
|
||||
test("the agent's startup files exist and say where the app is", () => {
|
||||
const { agentDir, agent } = loadRig();
|
||||
test("the agent's startup files exist, and the Observatory rig's own startup says where the app is", () => {
|
||||
const { dir, rig, agentDir, agent } = loadRig();
|
||||
for (const f of agent.startup.files) assert.ok(fs.existsSync(path.join(agentDir, f.path)), `${f.path} is missing`);
|
||||
const ctx = fs.readFileSync(path.join(agentDir, "startup/context.md"), "utf8");
|
||||
const general = fs.readFileSync(path.join(agentDir, "startup/context.md"), "utf8");
|
||||
// A periodic reminder needs a spec file; the general startup shows the form that works.
|
||||
assert.match(general, /rig watchdog register --policy periodic-reminder --spec <file> --target-session <you>\s+--interval-seconds \d+ --registered-by <you>/);
|
||||
const member = rig.pods[0].members[0];
|
||||
const own = member.startup.files.map((f) => f.path);
|
||||
assert.deepEqual(own, ["observatory-startup.md"]);
|
||||
const ctx = fs.readFileSync(path.join(dir, own[0]), "utf8");
|
||||
assert.match(ctx, /127\.0\.0\.1:9440\/surfaces\/observatory\.html/);
|
||||
assert.match(ctx, /periodic-reminder/);
|
||||
});
|
||||
|
||||
test("the build context sends only the observatory app and provider", () => {
|
||||
const ignore = fs.readFileSync(path.join(rigDir, "Dockerfile.dockerignore"), "utf8").split("\n").map((l) => l.trim()).filter((l) => l && !l.startsWith("#"));
|
||||
assert.deepEqual(ignore, ["*", "!apps/observatory/**", "!providers/studio-observatory/**"]);
|
||||
assert.deepEqual(ignore, ["*", "!apps/observatory/**", "!providers/studio-observatory/**", "!providers/shared/**"]);
|
||||
});
|
||||
|
||||
// Each check above must actually catch the mistake it exists for.
|
||||
@@ -239,7 +247,7 @@ test("customizing outside the app's own files is caught", () => {
|
||||
});
|
||||
|
||||
test("an Observatory-only role is caught", () => {
|
||||
assert.ok(planted((r) => { r.role = r.role.replace("The Observatory is one\napp you run and shape here; your other work does not depend on it.", "You are the Observatory-only agent:\nyou run the Observatory and nothing else."); }).some((m) => /Observatory-only/.test(m)));
|
||||
assert.ok(planted((r) => { r.role = r.role.replace("The Observatory is one\napp you may run and shape; your other work does not depend on it.", "You are the Observatory-only agent:\nyou run the Observatory and nothing else."); }).some((m) => /Observatory-only/.test(m)));
|
||||
});
|
||||
|
||||
test("growing its own rig is allowed, and removing that is caught", () => {
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
// generation history as the whole one. Stand-in daemons, invented data.
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFleet, readSeat, findSeat } from "../providers/studio-observatory/lib/fleet-reader.mjs";
|
||||
import { readFleet, readSeat, findSeat } from "../providers/shared/fleet-reader.mjs";
|
||||
import { createObservatoryServer } from "../providers/studio-observatory/observatory-server.mjs";
|
||||
import { startFakeDaemon, seat, qitem } from "./observatory-fake-daemon.mjs";
|
||||
import { loadPage } from "./observatory-page.mjs";
|
||||
@@ -168,6 +168,11 @@ test("a kept seat never outlives its machine's status in the fleet read", async
|
||||
assert.equal((await (await fetch(`${base}/api/observatory/seat?key=${key}`)).json()).status, "ok");
|
||||
await b.close(); started.splice(started.indexOf(b), 1);
|
||||
clock += 3_000; // past the fleet keep time, well inside the seat keep time
|
||||
// The first answer after the keep time comes from the kept read, and says it is stale...
|
||||
const first = await (await fetch(`${base}/api/observatory/seat?key=${key}`)).json();
|
||||
assert.equal(first.fleetServed.stale, true);
|
||||
await server.fleet.pending();
|
||||
// ...and once the refresh lands, the stopped machine's seat is not served from the older read.
|
||||
const body = await (await fetch(`${base}/api/observatory/seat?key=${key}`)).json();
|
||||
assert.notEqual(body.status, "ok", "a stopped machine's seat is not served from an older read");
|
||||
assert.equal(body.status, "seat_host_not_read");
|
||||
|
||||
@@ -11,7 +11,7 @@ import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { readFleet } from "../providers/studio-observatory/lib/fleet-reader.mjs";
|
||||
import { readFleet } from "../providers/shared/fleet-reader.mjs";
|
||||
import { startFakeDaemon, closedPortUrl, seat, qitem } from "./observatory-fake-daemon.mjs";
|
||||
import { loadPage, pagePath } from "./observatory-page.mjs";
|
||||
|
||||
|
||||
Reference in new issue
Block a user