mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-12 16:23:00 -10:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
80f923a2a0 | ||
|
|
16509d0091 | ||
|
|
2e3521b345 | ||
|
|
2eaa1ea3d9 |
No files matched your search
+3
-3
@@ -24,9 +24,9 @@ committed change. Two repairs aren't in this release: see Known limits.
|
||||
|
||||
### Before you upgrade
|
||||
|
||||
- **Back up the database, install 0.7.13, then restart the daemon:** `rig daemon stop`,
|
||||
copy the database file (`rig config get db.path` prints its path), install
|
||||
OpenRig 0.7.13 using your existing installation method, then `rig daemon start`. Your agents keep running through a daemon restart; don't
|
||||
- **Back up the database, then restart the daemon:** `rig daemon stop`, copy the
|
||||
database file (`rig config get db.path` prints its path), then
|
||||
`rig daemon start`. Your agents keep running through a daemon restart; don't
|
||||
use `rig down`, which ends them. Three migrations run on the daemon's first start: 160 and 161 (for the experimental hub connector below)
|
||||
and 162 (indexes for session reads). Going back to 0.7.12 after them isn't
|
||||
established. ([#2993](https://github.com/mvschwarz/openrig/pull/2993), [#2997](https://github.com/mvschwarz/openrig/pull/2997))
|
||||
|
||||
@@ -423,10 +423,10 @@ Each startup file and action specifies when it applies:
|
||||
- a launch replaces it, as launches always did, and names it (`guidance_block_refreshed_unrecorded`);
|
||||
- an exact resume or a handover leaves it, and names it as possibly stale (`guidance_block_may_be_stale`).
|
||||
|
||||
**A file keeps one copy of each block.**
|
||||
**A file keeps one copy of each distinct block text, and tidying the copies never removes a text you wrote.**
|
||||
- **Touched markers:** a marker an editor or formatter rewrapped, with spaces or line breaks inside the comment, still reads as its block, and the next write puts it back in OpenRig's form.
|
||||
- **Collected copies:** they're reduced to the first. Beside a copy you edited, OpenRig's own unedited copies and exact repeats are removed, and every different edited copy stays (`guidance_block_copies_removed`).
|
||||
- **A marker that lost its pair:** OpenRig can't tell where that block starts or ends, so it adds no copy beside it and names it (`guidance_block_markers_damaged`). Put back the missing marker or remove the partial block, and OpenRig's next write keeps one block. `rig package install` leaves such a file as it is, names it in its result (`notes`), and installs the rest. A marker without its pair beside a complete block is named too (`guidance_block_marker_unpaired`), and the block is written.
|
||||
- **Collected copies:** a copy that repeats an earlier one exactly is removed. Beside a copy you edited, OpenRig's own unedited copies are removed too, and every different edited copy stays (`guidance_block_copies_removed`).
|
||||
- **A marker that lost its pair:** OpenRig can't tell where that block starts or ends, so it adds no copy beside it and names it (`guidance_block_markers_damaged`). Put back the missing marker or remove the partial block, and OpenRig's next write treats that block like any other. `rig package install` leaves such a file as it is, names it in its result (`notes`), and installs the rest. A marker without its pair beside a complete block is named too (`guidance_block_marker_unpaired`), and the block is written.
|
||||
|
||||
An exact resume doesn't type anything into the resumed conversation. So there, the restore result names the block (`guidanceNotes`), but the seat isn't told.
|
||||
|
||||
|
||||
@@ -141,7 +141,9 @@ reason when it's unknown); `rig roster show` and `find` show it as `Check-in:`,
|
||||
without `--nodes`, and when it has no rigs or nodes to show) lists every connected assistant after its table, one line
|
||||
each. That's on this host, and in the node view of an HTTP host; an SSH host's `rig ps` prints its own. An HTTP host's
|
||||
rig view and the multi-host fan-out leave the section out. When the list can't be read, for example from an older
|
||||
daemon, it says check-in is unavailable instead. Its `--json` output is unchanged.
|
||||
daemon, it says check-in is unavailable instead. Its `--json` output is unchanged. The TUI's
|
||||
instance and rig tables list them the same way after their seats, with times in the viewer's zone; when the list can't be read,
|
||||
they say check-in is unavailable, as `rig ps` does.
|
||||
When the member has unclaimed work, the standing notice described above states the same status in its `check-ins:`
|
||||
line; when that notice is filed doesn't change.
|
||||
|
||||
|
||||
@@ -8,6 +8,8 @@ with one grant per participant. The network connector puts one selected local se
|
||||
The connector opens no port: your daemon makes every connection outward, and the hub never connects to your machine. Work the hub
|
||||
sends that participant runs on your seat, under your accounts, and the result goes back to the hub item.
|
||||
|
||||
**The network connector is experimental and unsupported.** Use it only if you choose to, at your own risk.
|
||||
|
||||
## Enroll in an account, then connect by name
|
||||
|
||||
When your hub is an account's network, enroll this daemon once with the one-time code its page shows, then connect
|
||||
|
||||
@@ -24,7 +24,7 @@ A queue row is erased in one of two ways:
|
||||
- events whose payload names it;
|
||||
- its mission-control audit rows.
|
||||
|
||||
**Copies held with someone else go only when they're the owner's.** These are outbox and inbox entries that point at the row or wake for it, and stream reader actions and dispositions that name it (including `duplicate_of`). For account deletion, the owner is the account: both of a copy's sessions must be members. Otherwise the owner is the row's own source and destination. Any other copy is kept and listed under `keptCopies`:
|
||||
**Copies held with someone else go only when they're the owner's.** These are outbox and inbox entries that point at the row or wake for it, and stream reader actions and dispositions that name it (including `duplicate_of`). For account deletion, the owner is the account: both of a copy's sessions must be members. Otherwise the owner is the row's own source and destination. OpenRig's own sessions (`daemon@kernel`, and any session starting `system:`), which send its wakes and notices, aren't counted when a copy has another session, so OpenRig's wake to the owner is the owner's. Any other copy is kept and listed under `keptCopies`:
|
||||
- `mixed`: one of its sessions is the owner's;
|
||||
- `foreign`: none of them are.
|
||||
|
||||
@@ -36,7 +36,7 @@ Name exactly one.
|
||||
|
||||
| Scope | Erases | Action |
|
||||
|---|---|---|
|
||||
| `--members <file>` | Account deletion. Every row whose source and destination are both in the file, in any state, open work included. Add `--rig <id>` for the account rig's own records | delete |
|
||||
| `--members <file>` | Account deletion. Every row whose source and destination are both in the file, or one in the file and the other OpenRig's own session, in any state, open work included. Add `--rig <id>` for the account rig's own records | delete |
|
||||
| `--ids <file>` | Exactly the listed rows. A report's `qitems` entries, or a ledger's lines, keep each entry's own action. A ledger's entries are replayed only for the batches that committed (below) | `--action delete` (default for plain ids) or `--action scrub` |
|
||||
| `--routine` | Closed rows past this daemon's retention windows (below) | scrub, then delete |
|
||||
|
||||
@@ -44,8 +44,10 @@ A file is one entry per line (blank lines and `#` comments are skipped; a line s
|
||||
|
||||
**Account deletion is exact.**
|
||||
- The members file is the account's recorded membership: the exact session names of its seats, including departed ones. List every address form the account used, such as a host-suffixed form. Names are compared as given, never by pattern, so a row addressed through an unlisted form is neither erased nor reported.
|
||||
- **Mixed rows:** a row with exactly one member endpoint is reported under `mixed` (with which endpoint is the member) and left untouched. Deciding who owns it is a separate decision. A row rerouted out of the account while the run is under way (by a fallback, say) is kept too, and reported as `changed-during-run`.
|
||||
- **After the rows:** account deletion also removes outbox and inbox entries between two members that no row owns, and the members' stream items. An entry with one member endpoint, or a stream item something still references, is counted and left.
|
||||
- **OpenRig's own sessions:** `daemon@kernel` and any `system:` session send OpenRig's own wakes and notices, such as the wake for a sweep finding, a wake escalation or a health finding. They belong to no account, so a row or entry between one of them and a member is the member's and is erased. One with no member endpoint, such as an OpenRig wake to another account's seat or an entry between two of OpenRig's own sessions, is never touched.
|
||||
- **Mixed rows:** a row with one member endpoint and the other outside the account is reported under `mixed` (with which endpoint is the member) and left untouched. Deciding who owns it is a separate decision. A row rerouted out of the account while the run is under way (by a fallback, say) is kept too, and reported as `changed-during-run`.
|
||||
- **After the rows:** account deletion also removes outbox and inbox entries that no row owns between two members, or between a member and OpenRig's own session, and the members' stream items. An entry with one member endpoint and the other outside the account, or a stream item something still references, is counted and left.
|
||||
- **Runs on earlier versions** kept OpenRig's wake for a row they erased (as a mixed copy, then a mixed entry) and reported OpenRig's rows to a member as mixed. Rerunning the same command removes them.
|
||||
|
||||
**The account's rig.** A rig's own records outlive the rig. Events are kept past rig and node deletion by design, and snapshots, node and seat records and watchdog jobs have no key that cascades. `--rig <id>`, given with `--members`, erases them:
|
||||
- the rig's events, including those of its nodes;
|
||||
@@ -94,7 +96,7 @@ The command prints one JSON report (`--json` makes it a single line):
|
||||
| `clean` | `complete`, and nothing was left: no `mixed` rows, mixed `keptCopies`, `gaps`, `unreadableEvents`, left-over entries, or rig records left or unknown. Only a clean report means the scope is fully erased. It's about this run's scope: a routine or members run's `clean` isn't affected by the ledger's other batches, and only a replay (`--ids` with a ledger) reports a batch it can't show committed, as the gap `ledger-unresolved` |
|
||||
| `error` | why the run stopped (`stage` and `message`). Everything listed in `qitems` was committed before it; the command exits 2 |
|
||||
| `qitems` | each row erased (or that would be), with its `action` and `state` |
|
||||
| `mixed` | members scope: rows with one member endpoint, left untouched |
|
||||
| `mixed` | members scope: rows with one member endpoint and the other outside the account, left untouched |
|
||||
| `keptCopies` | copies of erased rows held with someone else, kept: `table`, `id`, `qitemId`, `ownership` (`mixed` or `foreign`) |
|
||||
| `gaps` | rows not erased and why: `workflow-reference`; `not-found` for an id to scrub that doesn't exist; `changed-during-run` for a row that no longer qualified when its batch ran (reopened, rerouted out of the account, or written to so it left its window) or an absent id created since selection; `unreadable-event` for a row an unreadable event names; `ledger-unresolved` for a replayed entry whose batch can't be shown to have committed |
|
||||
| `skipped` | ids scope, a ledger replay: entries whose batch rolled back, with the batch id; not replayed |
|
||||
|
||||
@@ -76,7 +76,7 @@ Keys:
|
||||
health.context_pressure.* context-pressure warning/critical percentages (defaults 95/99)
|
||||
files.allowlist name:/abs/path,name:/abs/path
|
||||
progress.scan_roots name:/abs/path,name:/abs/path
|
||||
ui.timezone TUI IANA timezone (default America/Los_Angeles; reopen TUI after changing)
|
||||
ui.timezone IANA timezone for the TUI's times, the Feed's included (default America/Los_Angeles; reopen TUI after changing)
|
||||
ui.enabled serve the web UI and its terminal WebSocket (default off; restart the daemon)
|
||||
ui.preview.* refresh_interval_seconds, max_pins, default_lines
|
||||
ui.terminal.max_live_terminals
|
||||
|
||||
@@ -142,7 +142,9 @@ const LONG_RUNNING_TIMEOUT_MS = 45_000;
|
||||
export function downCommand(depsOverride?: DownDeps): Command {
|
||||
const cmd = new Command("down").description("Tear down a rig")
|
||||
.addHelpText("after", `
|
||||
rig down stops every agent in the rig: their sessions end, and any work in progress stops with them.
|
||||
rig down stops every agent OpenRig started in the rig: their sessions end, and any work in progress stops
|
||||
with them. For a community harness only its pane stops: its own processes may keep running, and rig down
|
||||
names it. A session OpenRig didn't start (adopted, bound or discovered) is left running, and rig down names it.
|
||||
Before running it, check who is working (rig ps --nodes --rig <rig>) and that the person asked for it;
|
||||
don't stop a team you didn't start unless asked. OpenRig snapshots the rig first (best-effort), and
|
||||
rig up <rig> --existing brings it back, resuming each seat's conversation where its harness allows. With
|
||||
|
||||
@@ -54,7 +54,7 @@ function line(b: Binding): string {
|
||||
* another OpenRig hub), see where each connection stands, and disconnect one. */
|
||||
export function networkCommand(depsOverride?: NetworkDeps): Command {
|
||||
const cmd = new Command("network")
|
||||
.description("Connect selected local seats to a remote hub, see their status, and disconnect them");
|
||||
.description("Connect selected local seats to a remote hub, see their status, and disconnect them. Experimental and unsupported: use it only if you choose to, at your own risk");
|
||||
const getDeps = (): NetworkDeps => depsOverride ?? { lifecycleDeps: realDeps(), clientFactory: (url: string) => new DaemonClient(url) };
|
||||
|
||||
const client = async () => {
|
||||
@@ -157,7 +157,7 @@ export function networkCommand(depsOverride?: NetworkDeps): Command {
|
||||
const b = raw as unknown as Binding;
|
||||
if (b.accountRemoval === "confirmed") return `Disconnected ${line(b)}\nThe account stopped listing ${b.participantName}, which revokes it there.`;
|
||||
if (b.accountRemoval) return `Disconnected ${line(b)}`;
|
||||
return `Disconnected ${line(b)}\nRemove the participant on the hub too, so the account stops listing it.`;
|
||||
return `Disconnected ${line(b)}\nRemove the participant on the hub too: until then, the hub still lists it.`;
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -2189,8 +2189,10 @@ Examples:
|
||||
"Name exactly one scope. What each one erases:",
|
||||
" --members every row whose source and destination are both members, in any state, deleted with its transitions,",
|
||||
" outbox and inbox copies, stream actions, events and audit rows; then member-to-member outbox and inbox",
|
||||
" entries no row owns, and the members' stream items. A row with one member endpoint is reported under",
|
||||
" mixed and left untouched. With --rig, also the rig's own events, snapshots and node and seat records.",
|
||||
" entries no row owns, and the members' stream items. OpenRig's own sessions (daemon@kernel, system:*)",
|
||||
" belong to no account, so its wakes and notices to a member are erased too. A row with one member",
|
||||
" endpoint and the other outside the account is reported under mixed and left untouched. With --rig,",
|
||||
" also the rig's own events, snapshots and node and seat records.",
|
||||
" --ids the listed rows, deleted or scrubbed (a report's qitems entries keep their action); a deleted id",
|
||||
" that's already gone keeps (or regains) its id marker.",
|
||||
" --routine closed rows (done, canceled, denied, handed-off) past the windows, aged from when they closed.",
|
||||
|
||||
@@ -1022,8 +1022,8 @@ Example:
|
||||
}
|
||||
if (opts.json) return;
|
||||
const state = res.data.state;
|
||||
console.log(state === "answered" ? `Decided: ${res.data.optionId}. The runner gives it to its agent at its next read.`
|
||||
: state === "refused" ? "Decided: refused. The runner gives its agent the refusal at its next read."
|
||||
console.log(state === "answered" ? `Decided: ${res.data.optionId}. A runner waiting on the request gets it now; one that isn't gets it at its next read.`
|
||||
: state === "refused" ? "Decided: refused. A runner waiting on the request gets the refusal now; one that isn't gets it at its next read."
|
||||
: `Nothing was decided: the request is ${state}.`);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import { appendFileSync, closeSync, openSync, readSync, statSync } from "node:fs";
|
||||
import type Database from "better-sqlite3";
|
||||
import { isOpenRigSender, OPENRIG_SENDER } from "../lib/pane-envelope.js";
|
||||
|
||||
/**
|
||||
* Queue erasure: the maintained way to remove queue content, for closed-work retention and account deletion on a
|
||||
@@ -13,11 +14,13 @@ import type Database from "better-sqlite3";
|
||||
* The row's own copies follow it: its transitions, live and archived; events whose payload names it; its
|
||||
* mission-control audit rows. Copies another party holds (outbox and inbox entries, stream reader actions and
|
||||
* dispositions) go only when they're the owner's: both endpoints the account's (members scope) or the row's own
|
||||
* (other scopes). Others are kept and reported.
|
||||
* (other scopes). Others are kept and reported. OpenRig's own sessions (`daemon@kernel`, `system:*`), which send its
|
||||
* wakes and notices, are no one's: a row, copy or entry is judged by its other sessions, and needs one of them.
|
||||
*
|
||||
* Scope is exact. Account deletion takes the account's recorded membership (exact session names): a row whose two
|
||||
* endpoints are both members is deleted, whatever its state; a row with one member endpoint is reported as mixed and
|
||||
* left untouched. Named with the account's rig, it also removes the rig's own records that outlive the rig. Routine
|
||||
* Scope is exact. Account deletion takes the account's recorded membership (exact session names): a row between two
|
||||
* members, or between a member and OpenRig, is deleted, whatever its state; a row with one member endpoint and the
|
||||
* other outside the account is reported as mixed and left untouched. Named with the account's rig, it also removes
|
||||
* the rig's own records that outlive the rig. Routine
|
||||
* retention touches only closed rows (done, canceled, denied, handed-off; never failed or open work), aged from the
|
||||
* start of their final closed state. A row a workflow still references is reported, never erased.
|
||||
*
|
||||
@@ -95,7 +98,8 @@ export interface EraseReport {
|
||||
error?: { stage: Stage; message: string };
|
||||
/** rows erased (applied, committed) or that would be (dry run) */
|
||||
qitems: Array<{ qitemId: string; action: EraseAction; state: string }>;
|
||||
/** members scope: rows with exactly one member endpoint, left untouched */
|
||||
/** members scope: rows with one member endpoint and the other outside the account (OpenRig's own isn't), left
|
||||
* untouched */
|
||||
mixed: Array<{ qitemId: string; memberEndpoint: "source" | "destination" }>;
|
||||
/** rows not erased, and why: a workflow references them; (ids scope) no such row to scrub; the row changed after
|
||||
* selection so it no longer qualifies (reopened, rerouted out of the account, written to out of its window, or an
|
||||
@@ -116,7 +120,8 @@ export interface EraseReport {
|
||||
unreadableEvents: Array<{ seq: number; qitemIds: string[] }>;
|
||||
/** copies erased with the rows, or that would be */
|
||||
linked: { transitions: number; archivedTransitions: number; outbox: number; inbox: number; events: number; streamActions: number; missionControl: number };
|
||||
/** members scope: entries between members that no queue row owns; mixed ones are counted and left */
|
||||
/** members scope: entries no queue row owns between members, or a member and OpenRig; mixed ones are counted and
|
||||
* left */
|
||||
unlinked?: { outbox: number; inbox: number; streamItems: number; mixedEntries: number; referencedStreamItems: number };
|
||||
/** members scope with rig: whether the rig was found and verified, its records erased and left per table, the
|
||||
* tables whose records can't be counted (an unverified removed rig), and when its erasure completed */
|
||||
@@ -425,6 +430,9 @@ export async function eraseQueue(db: Database.Database, opts: EraseOptions): Pro
|
||||
const ledgerReason = (action: EraseAction) => scope.kind === "routine" && action === "scrub" ? "retention-content" : reason;
|
||||
const memberSet = new Set(scope.kind === "members" ? scope.members : []);
|
||||
const members = JSON.stringify([...memberSet]);
|
||||
// Members scope: a row is the account's when one endpoint is a member and the other is a member or OpenRig's own.
|
||||
const accountRow = (source: string, destination: string) => (memberSet.has(source) || memberSet.has(destination))
|
||||
&& (memberSet.has(source) || isOpenRigSender(source)) && (memberSet.has(destination) || isOpenRigSender(destination));
|
||||
// A ledger replay applies only the batches that committed, by their own commit line or this database's record of
|
||||
// them. A rolled-back batch is skipped; one that is neither is reported and never replayed.
|
||||
let listed = scope.kind === "ids" ? scope.entries : [];
|
||||
@@ -488,10 +496,8 @@ export async function eraseQueue(db: Database.Database, opts: EraseOptions): Pro
|
||||
ORDER BY qitem_id LIMIT @limit`).all({ members, after, limit: batchSize }) as Array<Omit<Candidate, "action">>;
|
||||
const rows: Candidate[] = [];
|
||||
for (const row of found) {
|
||||
const fromMember = memberSet.has(row.source);
|
||||
const toMember = memberSet.has(row.destination);
|
||||
if (fromMember && toMember) rows.push({ ...row, action: "delete" });
|
||||
else report.mixed.push({ qitemId: row.qitemId, memberEndpoint: fromMember ? "source" : "destination" });
|
||||
if (accountRow(row.source, row.destination)) rows.push({ ...row, action: "delete" });
|
||||
else report.mixed.push({ qitemId: row.qitemId, memberEndpoint: memberSet.has(row.source) ? "source" : "destination" });
|
||||
}
|
||||
return { rows, last: found.at(-1)?.qitemId ?? null, seen: found.length };
|
||||
}
|
||||
@@ -511,10 +517,14 @@ export async function eraseQueue(db: Database.Database, opts: EraseOptions): Pro
|
||||
|
||||
// Whose copies these are: the account's (members scope), or the row's own participants (other scopes).
|
||||
const ownerOf = (candidate: Candidate) => scope.kind === "members" ? memberSet : new Set([candidate.source, candidate.destination]);
|
||||
// OpenRig's own sessions (its wake for the row, say) aren't counted when the copy has any other: an OpenRig wake to
|
||||
// the owner is the owner's, and one to no owner is foreign.
|
||||
const ownership = (copy: Copy, owners: Set<string>): "owned" | "mixed" | "foreign" => {
|
||||
const sessions = copy.sessions.filter((session): session is string => session !== null);
|
||||
const owned = sessions.filter((session) => owners.has(session)).length;
|
||||
return owned === sessions.length ? "owned" : owned > 0 ? "mixed" : "foreign";
|
||||
const others = sessions.filter((session) => !isOpenRigSender(session));
|
||||
const counted = others.length > 0 ? others : sessions;
|
||||
const owned = counted.filter((session) => owners.has(session)).length;
|
||||
return owned === counted.length ? "owned" : owned > 0 ? "mixed" : "foreign";
|
||||
};
|
||||
// Stream actions and dispositions this run deletes, so the unlinked pass plans on the same references either way.
|
||||
const plannedActions: string[] = [];
|
||||
@@ -635,7 +645,7 @@ export async function eraseQueue(db: Database.Database, opts: EraseOptions): Pro
|
||||
else plan.dropped.push({ code: "changed-during-run", qitemId: candidate.qitemId });
|
||||
} else if (referenced.has(candidate.qitemId)) {
|
||||
plan.dropped.push({ code: "workflow-reference", qitemId: candidate.qitemId });
|
||||
} else if (scope.kind === "members" && !(memberSet.has(now.source) && memberSet.has(now.destination))) {
|
||||
} else if (scope.kind === "members" && !accountRow(now.source, now.destination)) {
|
||||
// Rerouted since selection (a fallback, say): no longer the account's own row.
|
||||
plan.dropped.push({ code: "changed-during-run", qitemId: candidate.qitemId });
|
||||
} else if (scope.kind === "routine" && routineAction(now) !== candidate.action) {
|
||||
@@ -787,10 +797,11 @@ export async function eraseQueue(db: Database.Database, opts: EraseOptions): Pro
|
||||
return report;
|
||||
}
|
||||
|
||||
/** Account deletion's second pass: outbox and inbox entries between two members that no queue row owns, and the
|
||||
* members' stream items. An entry with one member endpoint, or a stream item something still references, is counted
|
||||
* and left. Rows and references this run erases are counted as they were before it, so a dry run and an apply report
|
||||
* the same set. */
|
||||
/** Account deletion's second pass: outbox and inbox entries no queue row owns between two members, or between a
|
||||
* member and OpenRig (its wake for a row erased earlier, say), and the members' stream items. An entry with one
|
||||
* member endpoint and the other outside the account, or a stream item something still references, is counted and
|
||||
* left. Rows and references this run erases are counted as they were before it, so a dry run and an apply report the
|
||||
* same set. */
|
||||
function eraseUnlinked(db: Database.Database, st: Statements,
|
||||
plan: { members: string; plannedActions: string[]; plannedDispositions: string[]; erasedRows: string[] }, apply: boolean): NonNullable<EraseReport["unlinked"]> {
|
||||
// A row this run erased still owns its copies here: they were reported with it (keptCopies), not as unowned.
|
||||
@@ -799,9 +810,14 @@ function eraseUnlinked(db: Database.Database, st: Statements,
|
||||
const ownedOutbox = `(COALESCE(audit_pointer, '') IN ${rows}
|
||||
OR (outbox_id LIKE 'wake-intent-%' AND substr(outbox_id, 13) IN ${rows}))`;
|
||||
const ownedInbox = `(COALESCE(absorbed_qitem_id, '') IN ${rows} OR COALESCE(audit_pointer, '') IN ${rows})`;
|
||||
const both = `sender_session IN ${MEMBERS} AND destination_session IN ${MEMBERS}`;
|
||||
const fromMember = `sender_session IN ${MEMBERS} AND destination_session NOT IN ${MEMBERS}`;
|
||||
const toMember = `destination_session IN ${MEMBERS} AND sender_session NOT IN ${MEMBERS}`;
|
||||
// A member, or OpenRig's own session: isOpenRigSender in SQL. The prefix is compared exactly, since LIKE ignores
|
||||
// ASCII case and reads _ as a wildcard.
|
||||
const accountSide = (column: string) =>
|
||||
`(${column} IN ${MEMBERS} OR ${column} = '${OPENRIG_SENDER}' OR substr(${column}, 1, 7) = 'system:')`;
|
||||
const both = `${accountSide("sender_session")} AND ${accountSide("destination_session")}
|
||||
AND (sender_session IN ${MEMBERS} OR destination_session IN ${MEMBERS})`;
|
||||
const fromMember = `sender_session IN ${MEMBERS} AND NOT ${accountSide("destination_session")}`;
|
||||
const toMember = `destination_session IN ${MEMBERS} AND NOT ${accountSide("sender_session")}`;
|
||||
// The same references the stream pass keeps an item for (queue-retention pruneStreamItems), less this run's own.
|
||||
const classificationColumns = new Set((db.prepare("PRAGMA table_info(project_classifications)").all() as Array<{ name: string }>)
|
||||
.map((column) => column.name));
|
||||
|
||||
@@ -39,11 +39,13 @@ beforeEach(() => {
|
||||
});
|
||||
afterEach(() => db.close());
|
||||
|
||||
/** A row whose every content field and linked copy carries `secret-<id>`, with its history at chosen ages. */
|
||||
async function row(id: string, source: string, destination: string, history: Array<{ state: string; daysAgo: number }> = []) {
|
||||
/** A row whose every content field and linked copy carries `secret-<id>`, with its history at chosen ages. `openrig`:
|
||||
* OpenRig created it (a sweep finding, an escalation), so the create stages its wake from OpenRig's own session. */
|
||||
async function row(id: string, source: string, destination: string, history: Array<{ state: string; daysAgo: number }> = [],
|
||||
wakeTrigger?: "openrig") {
|
||||
const secret = `secret-${id}`;
|
||||
await repo.create({ qitemId: id, sourceSession: source, destinationSession: destination, body: `${secret} body`, summary: `${secret} summary`,
|
||||
tags: [`${secret}-tag`] });
|
||||
tags: [`${secret}-tag`], wakeTrigger });
|
||||
db.prepare("UPDATE queue_transitions SET ts = ?, transition_note = ? WHERE qitem_id = ?").run(ago(400), `${secret} created`, id);
|
||||
for (const step of history) {
|
||||
const { transitionId } = log.append({ qitemId: id, state: step.state, actorSession: destination, transitionNote: `${secret} note` });
|
||||
@@ -682,3 +684,110 @@ describe("round 3 controls", () => {
|
||||
expect(everything()).toContain("secret-l2-new body");
|
||||
});
|
||||
});
|
||||
|
||||
describe("OpenRig's own wakes and notices", () => {
|
||||
const DAEMON = "daemon@kernel";
|
||||
const HEALTH = "system:health";
|
||||
const members = { kind: "members" as const, members: [A, B] };
|
||||
const wakeOf = (id: string) => db.prepare("SELECT sender_session AS from_, destination_session AS to_ FROM outbox_entries WHERE outbox_id = ?")
|
||||
.get(`wake-intent-${id}`);
|
||||
const loose = (table: "outbox_entries" | "inbox_entries", id: string, from: string, to: string, body: string) => {
|
||||
if (table === "outbox_entries") {
|
||||
db.prepare(`INSERT INTO outbox_entries (outbox_id, sender_session, destination_session, body, ts_dispatched) VALUES (?, ?, ?, ?, ?)`)
|
||||
.run(id, from, to, body, NOW);
|
||||
} else {
|
||||
db.prepare(`INSERT INTO inbox_entries (inbox_id, sender_session, destination_session, body, ts_dropped) VALUES (?, ?, ?, ?, ?)`)
|
||||
.run(id, from, to, body, NOW);
|
||||
}
|
||||
};
|
||||
|
||||
it("an OpenRig-triggered finding to a member erases clean, with OpenRig's wake for it and its notices", async () => {
|
||||
await row("f-finding", A, A, [], "openrig");
|
||||
// The premise: the finding's wake is staged from OpenRig, not from the member named as its source.
|
||||
expect(wakeOf("f-finding")).toEqual({ from_: DAEMON, to_: A });
|
||||
await row("f-escalation", DAEMON, B, [], "openrig");
|
||||
await row("f-health", HEALTH, A, [{ state: "done", daysAgo: 2 }], "openrig");
|
||||
loose("outbox_entries", "o-notice", DAEMON, A, "secret-loose-notice");
|
||||
loose("inbox_entries", "i-notice", HEALTH, B, "secret-loose-inbox");
|
||||
const dry = await eraseQueue(db, { scope: members, apply: false, nowIso: NOW });
|
||||
const report = await eraseQueue(db, { scope: members, apply: true, nowIso: NOW });
|
||||
expect({ ...report, applied: false }).toEqual(dry);
|
||||
expect(report.qitems.map((q) => q.qitemId)).toEqual(["f-escalation", "f-finding", "f-health"]);
|
||||
expect(report.mixed).toEqual([]);
|
||||
expect(report.keptCopies).toEqual([]);
|
||||
expect(report.unlinked).toMatchObject({ outbox: 1, inbox: 1, mixedEntries: 0 });
|
||||
expect(report.clean).toBe(true);
|
||||
const all = everything();
|
||||
expect(all).not.toContain("secret-f-");
|
||||
expect(all).not.toContain("secret-loose-");
|
||||
});
|
||||
|
||||
it("an OpenRig wake to another account's seat is kept, and a row OpenRig woke with one member endpoint stays mixed", async () => {
|
||||
await row("f-own", A, B, [], "openrig");
|
||||
await row("x-finding", OUT, OUT, [], "openrig");
|
||||
await row("x-escalation", DAEMON, OUT, [], "openrig");
|
||||
await row("x-mixed", OUT, A, [], "openrig");
|
||||
loose("outbox_entries", "o-elsewhere", DAEMON, OUT, "secret-loose-elsewhere");
|
||||
const report = await eraseQueue(db, { scope: members, apply: true, nowIso: NOW });
|
||||
expect(report.qitems.map((q) => q.qitemId)).toEqual(["f-own"]);
|
||||
expect(report.mixed).toEqual([{ qitemId: "x-mixed", memberEndpoint: "destination" }]);
|
||||
expect(report.unlinked).toMatchObject({ outbox: 0, mixedEntries: 0 });
|
||||
expect(report.clean).toBe(false);
|
||||
const all = everything();
|
||||
for (const id of ["x-finding", "x-escalation", "x-mixed"]) {
|
||||
expect(exists(id)).toBe(true);
|
||||
expect(all).toContain(`secret-${id} body`);
|
||||
}
|
||||
expect(wakeOf("x-finding")).toEqual({ from_: DAEMON, to_: OUT });
|
||||
expect(wakeOf("x-mixed")).toEqual({ from_: DAEMON, to_: A });
|
||||
expect(all).toContain("secret-loose-elsewhere");
|
||||
expect(all).not.toContain("secret-f-own");
|
||||
});
|
||||
|
||||
it("OpenRig's entries with itself are untouched, and a session that only looks like OpenRig's is outside the account", async () => {
|
||||
await row("f-own", A, B, [], "openrig");
|
||||
loose("outbox_entries", "o-self", DAEMON, DAEMON, "secret-loose-self");
|
||||
loose("outbox_entries", "o-health", HEALTH, DAEMON, "secret-loose-health");
|
||||
loose("inbox_entries", "i-self", HEALTH, DAEMON, "secret-loose-inbox-self");
|
||||
// isOpenRigSender is exact: "System:" isn't OpenRig's prefix.
|
||||
loose("outbox_entries", "o-lookalike", "System:health", A, "secret-loose-lookalike");
|
||||
const report = await eraseQueue(db, { scope: members, apply: true, nowIso: NOW });
|
||||
expect(report.qitems.map((q) => q.qitemId)).toEqual(["f-own"]);
|
||||
expect(report.unlinked).toMatchObject({ outbox: 0, inbox: 0, mixedEntries: 1 });
|
||||
expect(report.clean).toBe(false);
|
||||
const all = everything();
|
||||
for (const body of ["secret-loose-self", "secret-loose-health", "secret-loose-inbox-self", "secret-loose-lookalike"]) expect(all).toContain(body);
|
||||
expect(all).not.toContain("secret-f-own");
|
||||
});
|
||||
|
||||
it("rerunning the same plan over what an earlier daemon left removes it: the erased row's OpenRig wake, and its OpenRig rows", async () => {
|
||||
await row("r-finding", A, A, [], "openrig");
|
||||
await row("r-escalation", DAEMON, B, [], "openrig");
|
||||
// What an earlier daemon's run leaves: it deleted r-finding but kept OpenRig's wake for it as mixed, and left
|
||||
// r-escalation untouched as a mixed row. Built here by deleting r-finding and restoring its wake as it was.
|
||||
const wake = db.prepare("SELECT * FROM outbox_entries WHERE outbox_id = 'wake-intent-r-finding'").get() as Record<string, unknown>;
|
||||
await eraseQueue(db, { scope: { kind: "ids", entries: [{ qitemId: "r-finding", action: "delete" }] }, apply: true, nowIso: NOW });
|
||||
const columns = Object.keys(wake);
|
||||
db.prepare(`INSERT OR REPLACE INTO outbox_entries (${columns.join(", ")}) VALUES (${columns.map(() => "?").join(", ")})`)
|
||||
.run(...columns.map((c) => wake[c]));
|
||||
expect([exists("r-finding"), marker("r-finding") !== undefined, exists("r-escalation")]).toEqual([false, true, true]);
|
||||
expect(everything()).toContain("secret-r-finding summary");
|
||||
|
||||
const rerun = await eraseQueue(db, { scope: members, apply: true, nowIso: NOW });
|
||||
expect(rerun.qitems.map((q) => q.qitemId)).toEqual(["r-escalation"]);
|
||||
expect(rerun.unlinked).toMatchObject({ outbox: 1, mixedEntries: 0 });
|
||||
expect(rerun.clean).toBe(true);
|
||||
expect(everything()).not.toContain("secret-r-");
|
||||
});
|
||||
|
||||
it("routine retention deletes OpenRig's wake for a row with the row, and OpenRig's own rows keep theirs together", async () => {
|
||||
await row("t-old", A, B, [{ state: "done", daysAgo: 40 }], "openrig");
|
||||
await row("t-self", DAEMON, DAEMON, [{ state: "done", daysAgo: 40 }], "openrig");
|
||||
expect(wakeOf("t-old")).toEqual({ from_: DAEMON, to_: B });
|
||||
const report = await eraseQueue(db, { scope: { kind: "routine", contentDays: 0, metadataDays: 30 }, apply: true, nowIso: NOW });
|
||||
expect(report.qitems.map((q) => [q.qitemId, q.action])).toEqual([["t-old", "delete"], ["t-self", "delete"]]);
|
||||
expect(report.keptCopies).toEqual([]);
|
||||
expect(report.clean).toBe(true);
|
||||
expect(everything()).not.toContain("secret-t-");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import { CHECK_IN_DISPLAY, describeCheckIn, type CheckInStatus } from "@openrig/daemon/connection-contract";
|
||||
import { wrapDetailLines, type ContentLine } from "./detail.js";
|
||||
import { displayTime } from "./time.js";
|
||||
import type { ConnectedAssistant } from "./types.js";
|
||||
|
||||
/** The hosted-members answer as connected assistants; null when it isn't one. A status this TUI doesn't know reads
|
||||
* unknown, as in `rig ps`. */
|
||||
export function toConnectedAssistants(body: unknown): ConnectedAssistant[] | null {
|
||||
const members = (body as { members?: unknown } | null)?.members;
|
||||
if (!Array.isArray(members)) return null;
|
||||
return members.flatMap((member: Record<string, unknown>) => {
|
||||
if (typeof member?.seat !== "string" || !member.seat) return [];
|
||||
const known = typeof member.status === "string" && member.status in CHECK_IN_DISPLAY;
|
||||
return [{ seat: member.seat, checkIn: {
|
||||
status: known ? member.status as CheckInStatus : "unknown",
|
||||
lastCheckInAt: typeof member.lastCheckInAt === "string" ? member.lastCheckInAt : null,
|
||||
expectedMinutes: typeof member.expectedMinutes === "number" ? member.expectedMinutes : null,
|
||||
onRequest: member.onRequest === true,
|
||||
...(known && typeof member.reason === "string" ? { reason: member.reason } : {}),
|
||||
} }];
|
||||
});
|
||||
}
|
||||
|
||||
/** The hosted-members read as the list, or why it couldn't be read, in `rig ps`'s words: an older daemon's 404, a failed
|
||||
* read, or an answer that isn't one. A failed read isn't an empty list. */
|
||||
export function connectedAssistantsOf(answer: { status: number; body: unknown }): { assistants: ConnectedAssistant[] } | { unavailable: string } {
|
||||
if (answer.status === 404) return { unavailable: "this daemon doesn't serve it" };
|
||||
if (answer.status >= 400) return { unavailable: `the read failed: HTTP ${answer.status}` };
|
||||
const assistants = toConnectedAssistants(answer.body);
|
||||
return assistants ? { assistants } : { unavailable: "the daemon's answer couldn't be read" };
|
||||
}
|
||||
|
||||
/** The connected assistants under a seat table, each with its check-in line in the daemon's words and the time in the
|
||||
* viewer's zone; only one rig's when `rig` is given. Nothing when there are none; one line saying check-in is
|
||||
* unavailable, with the reason, when the list couldn't be read. */
|
||||
export function connectedAssistantLines(assistants: ConnectedAssistant[] | undefined, unavailable: string | undefined, rig: string | null,
|
||||
filter: string | null, width: number, timeZone?: string): ContentLine[] {
|
||||
if (unavailable) return [{ text: "" }, ...wrapDetailLines([{ text: `Connected assistants: check-in unavailable (${unavailable})` }], width)];
|
||||
const shown = (assistants ?? [])
|
||||
.filter((a) => !rig || a.seat.endsWith(`@${rig}`))
|
||||
.filter((a) => !filter || a.seat.includes(filter));
|
||||
if (shown.length === 0) return [];
|
||||
return [
|
||||
{ text: "" },
|
||||
{ text: "Connected assistants (outside the terminal; each checks its own queue):" },
|
||||
...wrapDetailLines(shown.map((a) => ({ text: ` ${a.seat} ${describeCheckIn({ ...a.checkIn,
|
||||
lastCheckInAt: a.checkIn.lastCheckInAt === null ? null : displayTime(a.checkIn.lastCheckInAt, timeZone) })}` })), width),
|
||||
];
|
||||
}
|
||||
@@ -419,6 +419,14 @@ export class DaemonClient {
|
||||
const query = [item ? `item=${encodeURIComponent(item)}` : null, view ? `view=${encodeURIComponent(view)}` : null].filter(Boolean).join("&");
|
||||
return this.get(`/api/attention${query ? `?${query}` : ""}`);
|
||||
}
|
||||
/** Connected assistants (roster members outside any terminal) and their check-in. Optional: an older daemon has no
|
||||
* such route. */
|
||||
/** This daemon's connected assistants, as the status and body it answered; an older daemon answers 404. A failed read
|
||||
* isn't an empty list, so the caller is given the status to tell them apart. */
|
||||
async hostedMembers(): Promise<{ status: number; body: unknown }> {
|
||||
const res = await this.optionalFetchImpl(`${this.baseUrl}/api/queue/hosted-members`, { headers: this.headers, signal: AbortSignal.timeout(5_000) });
|
||||
return { status: res.status, body: res.ok ? await res.json().catch(() => null) : null };
|
||||
}
|
||||
queueItem(qitemId: string, options: { optional?: boolean } = {}) {
|
||||
return this.get(`/api/queue/${encodeURIComponent(qitemId)}`, options.optional ? this.optionalFetchImpl : this.fetchImpl);
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ import { parse as parseYaml } from "yaml";
|
||||
import type { AgentRow, FleetSnapshot, HealthCoverage, HealthRecord, HostNode, NeedsItem, PodNode, QueueRead, RecentTransitionSnap, SeatActivitySummary, SeatEffortRead, SliceDetailSnap, SpecEntry, ViewState } from "./types.js";
|
||||
import { isHumanSeatSession } from "./pulse/pulse-model.js";
|
||||
import { sanitizeHostDisplayName } from "./topology/host-label.js";
|
||||
import { connectedAssistantsOf } from "./connected-assistants.js";
|
||||
|
||||
// Narrow read-shapes: just the served fields this module consumes (names match
|
||||
// the daemon's serialized output — see the Phase-2 endpoint-shape survey).
|
||||
@@ -572,6 +573,15 @@ export async function hydrateSnapshot(
|
||||
// A drilled-into host that isn't this one: its pages read none of this host's seats or queue changes.
|
||||
const drilledHost = viewContext?.section === "topology" ? viewContext.drill.find((segment) => segment.kind === "host")?.name : undefined;
|
||||
const remoteDrill = !!drilledHost && drilledHost !== (instanceHealth?.selfHostId?.trim() || "local");
|
||||
// Optional, like the Slack manifest: a failed read isn't a page failure. But it isn't an empty list either, so the seat
|
||||
// tables say check-in is unavailable, as `rig ps` does. Read only for the instance and rig tables that list them, and
|
||||
// only this daemon's, never a remote host's.
|
||||
const listsAssistants = !viewContext || (viewContext.section === "topology" && viewContext.viewTab === "table"
|
||||
&& (topologyLeaf?.kind === "host" || topologyLeaf?.kind === "rig"));
|
||||
const connectedAssistants = listsAssistants && !remoteDrill
|
||||
? await client.hostedMembers().then(connectedAssistantsOf,
|
||||
(error: unknown) => ({ unavailable: `the read failed: ${error instanceof Error ? error.message : String(error)}` }))
|
||||
: null;
|
||||
const recentTransitionsRig = currentRigName ?? (!viewContext ? summaries?.[0]?.name : null) ?? null;
|
||||
const recentTransitionsScope = topologyLeaf?.kind === "host"
|
||||
? { kind: "instance" } as const
|
||||
@@ -685,6 +695,8 @@ export async function hydrateSnapshot(
|
||||
displayName: ownName && ownName !== "localhost" ? ownName : undefined,
|
||||
reachable: true,
|
||||
rigs: rigs.sort(byRigName),
|
||||
...(connectedAssistants ? "unavailable" in connectedAssistants
|
||||
? { connectedAssistantsUnavailable: connectedAssistants.unavailable } : { connectedAssistants: connectedAssistants.assistants } : {}),
|
||||
};
|
||||
// A remote host is any registered host, or one the attention aggregate answered for. Its reachability is the aggregate's
|
||||
// answer where there is one, else the registry's probe. Only a drilled-into remote host's rigs are read, through this
|
||||
|
||||
@@ -31,6 +31,7 @@ import type { CrashCartModel } from "./crash-cart/crash-cart-model.js";
|
||||
import type { DaemonState, DaemonUnverifiedEvidence } from "./crash-cart/contract.js";
|
||||
import { runtimeMarkSegs } from "./topology/runtime-marks.js";
|
||||
import { hostLabel, hostReachabilityLabel } from "./topology/host-label.js";
|
||||
import { connectedAssistantLines } from "./connected-assistants.js";
|
||||
import { barCells, flashActive, reducedMotion, spinnerFrame } from "./motion.js";
|
||||
import { explorerWidth, MOTION_FRAME_MS } from "./visual-layout.js";
|
||||
import type { ColorMode, Token } from "./theme.js";
|
||||
@@ -867,6 +868,7 @@ function instanceContentLines(
|
||||
});
|
||||
}
|
||||
}
|
||||
lines.push(...connectedAssistantLines(host.connectedAssistants, host.connectedAssistantsUnavailable, null, state.filter, contentWidth, state.timeZone));
|
||||
lines.push({ text: "" }, { text: host.rigs.some(r => r.inventoryUnavailable) ? `${host.rigs.length} rigs · inventory incomplete · ${seatCount} seats read` : `${host.rigs.length} rigs · ${seatCount} seats · ${workingCount} working · ${attentionCount} need attention${attentionUnknown ? ` · ${attentionUnknown} attention unknown` : ""} · ${openCount}${queueIncomplete(snap) ? "+ sampled" : ""} open rows` });
|
||||
lines.push(...recentLines(snap, scope, contentWidth, false, state.timeZone));
|
||||
return lines;
|
||||
@@ -1107,6 +1109,7 @@ function contentLines(state: ViewState, snap: FleetSnapshot, contentWidth: numbe
|
||||
zones,
|
||||
});
|
||||
}
|
||||
if (!podFilter) lines.push(...connectedAssistantLines(host.connectedAssistants, host.connectedAssistantsUnavailable, rig.name, state.filter, contentWidth, state.timeZone));
|
||||
lines.push({ text: "" });
|
||||
const working = rows.filter((agent) => ["active", "working", "running"].includes(agent.status)).length;
|
||||
const attention = rows.filter((agent) => /attention|needs|blocked|unknown|failed/.test(agent.status)).length;
|
||||
|
||||
@@ -97,6 +97,18 @@ export interface HostNode {
|
||||
/** true/false only when a probe answered; null keeps missing or unknown distinct. */
|
||||
reachable: boolean | null;
|
||||
rigs: RigNode[];
|
||||
/** Roster members outside any terminal (assistants reaching the team over MCP) and their check-in, read from this
|
||||
* daemon only. Absent when not read, or when the read failed. */
|
||||
connectedAssistants?: ConnectedAssistant[];
|
||||
/** Why the connected assistants couldn't be read (an older daemon, a failed read, an unusable answer); absent when
|
||||
* they were read or not asked for. */
|
||||
connectedAssistantsUnavailable?: string;
|
||||
}
|
||||
|
||||
/** A connected assistant's check-in as this daemon reports it. */
|
||||
export interface ConnectedAssistant {
|
||||
seat: string;
|
||||
checkIn: Pick<import("@openrig/daemon/connection-contract").MemberCheckIn, "status" | "lastCheckInAt" | "expectedMinutes" | "onRequest" | "reason">;
|
||||
}
|
||||
|
||||
export type SpecKind = "rig" | "agent" | "workflow";
|
||||
|
||||
@@ -37,6 +37,7 @@ const SPEC_4A_ROUTES = [
|
||||
"/api/provider/status",
|
||||
"/api/provider/usage/hosts",
|
||||
"/api/telemetry/usage/cost",
|
||||
"/api/queue/hosted-members",
|
||||
];
|
||||
|
||||
describe("daemon client = the §4.A table, one module, nothing else (FR-8/FR-9)", () => {
|
||||
@@ -75,6 +76,7 @@ describe("daemon client = the §4.A table, one module, nothing else (FR-8/FR-9)"
|
||||
await c.providerStatus();
|
||||
await c.usageAcrossHosts();
|
||||
await c.usageCost();
|
||||
await c.hostedMembers();
|
||||
|
||||
expect(seen.sort()).toEqual([...SPEC_4A_ROUTES].sort());
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@ import { hydrateSnapshot } from "../src/hydrate.js";
|
||||
import { renderScreen } from "../src/render.js";
|
||||
import { createViewState } from "../src/state.js";
|
||||
import { rowStatusGlyph, statusGlyph } from "../src/topology/glyphs.js";
|
||||
import { displayTime } from "../src/time.js";
|
||||
|
||||
// Fixtures mirror the SERVED shapes traced firsthand at 5f3b5bd4 (Phase-2
|
||||
// endpoint-shape survey): real field names, real enum values, real evidence
|
||||
@@ -162,6 +163,7 @@ const FIXTURES: Record<string, unknown> = {
|
||||
"/api/queue/list?state=pending&limit=50": [],
|
||||
"/api/queue/list?state=done,handed-off&sort=updated&limit=20": [],
|
||||
"/api/queue/recent-transitions?scope=rig&rig=myrig&limit=20": [],
|
||||
"/api/queue/hosted-members": { hostId: "mm2-openrig1", members: [] },
|
||||
};
|
||||
|
||||
function fixtureClient(
|
||||
@@ -173,6 +175,8 @@ function fixtureClient(
|
||||
const route = String(url).replace("http://x", "");
|
||||
calls?.push(route);
|
||||
const failure = overrides[route];
|
||||
// Status 0 stands for a read that never got an answer, such as a dropped connection.
|
||||
if (failure?.status === 0) throw new Error("fetch failed");
|
||||
if (failure) return { ok: false, status: failure.status, json: async () => failure.body ?? null } as Response;
|
||||
if (route in responses) return { ok: true, json: async () => responses[route] } as Response;
|
||||
if (!(route in FIXTURES)) throw new Error(`unexpected route in test: ${route}`);
|
||||
@@ -752,6 +756,65 @@ describe("snapshot hydration over the §4.A reads (Phase 2)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("lists connected assistants and their check-in under the seat tables, one rig's in the rig view", async () => {
|
||||
const hosted = { hostId: "mm2-openrig1", members: [
|
||||
{ seat: "chat@myrig", rung: false, status: "check-in-overdue", lastCheckInAt: "2026-11-12T13:05:00.000Z", expectedMinutes: 60, onRequest: false, reporter: "openrig-observed" },
|
||||
{ seat: "desk@myrig", rung: false, status: "on-request", lastCheckInAt: "2026-11-12T12:00:00.000Z", expectedMinutes: null, onRequest: true, reporter: "openrig-observed" },
|
||||
{ seat: "bot@downrig", rung: false, status: "a-newer-status", lastCheckInAt: null, expectedMinutes: null, onRequest: false, reporter: "openrig-observed", reason: "not shown" },
|
||||
] };
|
||||
const snap = await hydrateSnapshot(fixtureClient({}, { "/api/queue/hosted-members": hosted }));
|
||||
expect(snap.readErrors.some((error) => error.includes("hosted"))).toBe(false);
|
||||
|
||||
const rigView = createViewState({ instanceId: "t", getSnapshot: () => snap });
|
||||
rigView.dispatch({ type: "drill", resource: "rig", name: "myrig" });
|
||||
const rigLines = renderScreen(rigView.get(), snap, { cols: 220, rows: 60 }).lines.join("\n");
|
||||
expect(rigLines).toContain("Connected assistants (outside the terminal; each checks its own queue):");
|
||||
expect(rigLines).toContain(`chat@myrig overdue: last check-in ${displayTime("2026-11-12T13:05:00.000Z")}, expected every 60 min; its schedule may be off or waiting for an approval`);
|
||||
expect(rigLines).toContain(`desk@myrig on request: it checks its queue when a person asks it; last check-in ${displayTime("2026-11-12T12:00:00.000Z")}`);
|
||||
expect(rigLines).not.toContain("bot@downrig");
|
||||
|
||||
const instanceView = createViewState({ instanceId: "t", getSnapshot: () => snap });
|
||||
instanceView.dispatch({ type: "drill", resource: "host", name: snap.hosts[0]!.name });
|
||||
const instanceLines = renderScreen(instanceView.get(), snap, { cols: 160, rows: 60 }).lines.join("\n");
|
||||
expect(instanceLines).toContain("chat@myrig overdue:");
|
||||
// A status this TUI doesn't know reads unknown, without the newer daemon's reason.
|
||||
expect(instanceLines).toContain("bot@downrig unknown");
|
||||
expect(instanceLines).not.toContain("not shown");
|
||||
});
|
||||
|
||||
it("a known-empty list of connected assistants stays quiet", async () => {
|
||||
const snap = await hydrateSnapshot(fixtureClient());
|
||||
expect(snap.hosts[0]!.connectedAssistants).toEqual([]);
|
||||
expect(snap.hosts[0]!.connectedAssistantsUnavailable).toBeUndefined();
|
||||
const view = createViewState({ instanceId: "t", getSnapshot: () => snap });
|
||||
view.dispatch({ type: "drill", resource: "rig", name: "myrig" });
|
||||
expect(renderScreen(view.get(), snap, { cols: 160, rows: 60 }).lines.join("\n")).not.toContain("Connected assistants");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["an older daemon without the route", { overrides: { "/api/queue/hosted-members": { status: 404 } } }, "this daemon doesn't serve it"],
|
||||
["a failed read", { overrides: { "/api/queue/hosted-members": { status: 500 } } }, "the read failed: HTTP 500"],
|
||||
["a dropped connection", { overrides: { "/api/queue/hosted-members": { status: 0 } } }, "the read failed: fetch failed"],
|
||||
["an answer without a members list", { responses: { "/api/queue/hosted-members": { hostId: "mm2-openrig1" } } }, "the daemon's answer couldn't be read"],
|
||||
])("%s says check-in is unavailable, not that there are none", async (_case, setup, reason) => {
|
||||
const snap = await hydrateSnapshot(fixtureClient("overrides" in setup ? setup.overrides : {}, "responses" in setup ? setup.responses : {}));
|
||||
expect(snap.hosts[0]!.connectedAssistants).toBeUndefined();
|
||||
expect(snap.hosts[0]!.connectedAssistantsUnavailable).toBe(reason);
|
||||
// Optional, like the other enrichment: the page itself still reads as whole.
|
||||
expect(snap.readErrors).toEqual([]);
|
||||
const line = `Connected assistants: check-in unavailable (${reason})`;
|
||||
|
||||
const rigView = createViewState({ instanceId: "t", getSnapshot: () => snap });
|
||||
rigView.dispatch({ type: "drill", resource: "rig", name: "myrig" });
|
||||
const rigLines = renderScreen(rigView.get(), snap, { cols: 160, rows: 60 }).lines.join("\n");
|
||||
expect(rigLines).toContain(line);
|
||||
expect(rigLines).not.toContain("each checks its own queue");
|
||||
|
||||
const instanceView = createViewState({ instanceId: "t", getSnapshot: () => snap });
|
||||
instanceView.dispatch({ type: "drill", resource: "host", name: snap.hosts[0]!.name });
|
||||
expect(renderScreen(instanceView.get(), snap, { cols: 160, rows: 60 }).lines.join("\n")).toContain(line);
|
||||
});
|
||||
|
||||
it("shows confirmed missing sessions as detached unless lifecycle still requires attention", async () => {
|
||||
const base = (FIXTURES["/api/rigs/01JRIG/nodes"] as Array<Record<string, unknown>>)[0]!;
|
||||
const nodes = [
|
||||
|
||||
@@ -66,7 +66,9 @@ it("a 24-rig landing fetches names only; a selected rig never reads unrelated in
|
||||
]);
|
||||
// The rig Table reads health once without fetching other rigs' node inventories.
|
||||
expect(calls.filter(c => c.startsWith("/api/health"))).toEqual(["/api/health?limit=200"]);
|
||||
expect(calls).toHaveLength(10);
|
||||
// One read of this daemon's connected assistants, which the rig Table lists after its seats.
|
||||
expect(calls.filter(c => c === "/api/queue/hosted-members")).toHaveLength(1);
|
||||
expect(calls).toHaveLength(11);
|
||||
expect(snap.health).toMatchObject({ availability: "loaded", total: 0, records: [] });
|
||||
expect(renderScreen(view.get(), snap, { cols: 220, rows: 60 }).lines.join("\n")).toContain("no findings served; not a healthy verdict");
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user