mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-12 16:03:17 -10:00
Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
59845be8f7 | ||
|
|
55cc990d28 | ||
|
|
9bbb94a0d2 | ||
|
|
53165ddf45 | ||
|
|
63a96d2508 | ||
|
|
a84980b92c | ||
|
|
a279981029 | ||
|
|
d6d8fce70f | ||
|
|
40da7331ad | ||
|
|
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 nothing you wrote is removed.**
|
||||
- **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.
|
||||
|
||||
@@ -175,10 +177,28 @@ and nothing else is offered:
|
||||
`rig_queue_create` that leaves out `destination` goes to that receiver. The operator sets it; a caller can't. It must be
|
||||
exactly one existing local node within that request's grant, or nothing is written. An explicit `destination` works as
|
||||
before.
|
||||
- **A seat by its name:** a `destination` with no `@` names a seat as its team shows it: its session's own name, then its
|
||||
pod-member form, then that name's slug, in the connection's one rig or the configured receiver's. Only seats the grant
|
||||
can address count, and the first of these that any of them matches decides, so another seat's slug never makes an exact
|
||||
name ambiguous and a seat the grant can't address never shadows one it can. The item is stored under that
|
||||
seat's actual session. A slug two seats match returns `destination_ambiguous` with the candidates, and a name that no
|
||||
seat the grant can reach has returns `destination_not_found`. A name the grant can't address as written (its own seat,
|
||||
or another assistant's without `team`/`list`) stays `out_of_scope`, whether or not that seat exists.
|
||||
- **Two assistants together:** a grant with `team`/`list` sees its rigs' seats with `rig_team`, and may address and hear from
|
||||
another connected assistant's seat. A grant without it never reaches a seat in the service's caller form
|
||||
(`cus-<id>-<connection>@<rig>`), so an existing connection keeps the reach it had.
|
||||
- **Messages are items:** a message is an item the receiver reads and closes, and a reply is an item back to its sender.
|
||||
- **Messages are items:** a message is an item the receiver reads and closes, with any reply in its `resolution`. The
|
||||
sender reads that reply on the same item. Nothing wakes the sender or posts a new item for it. A separate new message is
|
||||
new work.
|
||||
- **A fair-use limit on new work:** the service can add `throttle: { new_work: true, retry_after_seconds: <n> }` to an
|
||||
active answer.
|
||||
- While it does, a `rig_queue_create` that would open a new item writes nothing. It returns `fair_use_retry_later` with
|
||||
`retryAfterSeconds` (1 to 600) and the `qitemId` to retry with.
|
||||
- A retry of a create that already landed is answered from the stored item, as before.
|
||||
- Everything else is served: reading, claiming and closing existing items (a reply in `resolution` included), and
|
||||
answering the team's questions. A separate new message or revision waits like any other new item.
|
||||
- It's never a credential refusal, and a `throttle` that can't be read is ignored.
|
||||
- The service is asked once per HTTP request (initialize and tool listing included), not once per tool call.
|
||||
- **What the sender can tell, from the item it reads:**
|
||||
- **Queued:** the item exists and is `pending` with no claim.
|
||||
- **Picked up:** the receiver's claim (`in-progress`).
|
||||
|
||||
@@ -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
|
||||
@@ -58,9 +60,13 @@ waits for any other change to this enrollment to finish, within the same 30 seco
|
||||
waiting says so and doesn't send the code. The account makes a new installation, with a new key, for the new code. Here,
|
||||
that key takes the old one's place and the bindings made through the old installation end, in one step:
|
||||
|
||||
- If that step fails after the account accepted the code, nothing here changes, and the error says so. The old
|
||||
enrollment keeps working only while the account keeps the old installation, and the account now has a new
|
||||
installation whose key nothing here holds: remove it on the account page, then enroll again with a new code.
|
||||
- If that step fails after the account accepted the code, the enrollment's record here doesn't change, and the error
|
||||
says so. The key is put back too, but moving key files happens outside that record's rollback. If putting it back also
|
||||
fails (on a full or read-only disk, for example), the old key is left as `key.json.prev` beside `key.json` in the
|
||||
installation's folder under `network-secrets/` in the OpenRig home: move it back to `key.json` before you go on. A
|
||||
`key.json.next` there is a staged new key, and the next replace removes it. The old enrollment keeps working only
|
||||
while the account keeps the old installation, and the account now has a new installation whose key nothing here
|
||||
holds: remove it on the account page, then enroll again with a new code.
|
||||
- Once the step is done, `enroll` names the bindings it ended. The old installation may still list their names, and the
|
||||
new key can't drop them, so remove them on the account page. `rig network status` marks each of them as possibly still
|
||||
listed under the old installation, as it does a binding that ended earlier without the account confirming the drop:
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Command } from "commander";
|
||||
import { ConfigStore, VALID_KEYS, type ResolvedSetting, type ValidKey } from "../config-store.js";
|
||||
import { ConfigStore, ENV_MAP, VALID_KEYS, type ResolvedSetting, type ValidKey } from "../config-store.js";
|
||||
import { initWorkspaceCommand } from "./config-init-workspace.js";
|
||||
|
||||
function formatRow(key: string, value: unknown): string {
|
||||
@@ -10,6 +10,40 @@ function summarizeSettings(store: ConfigStore): Record<ValidKey, ResolvedSetting
|
||||
return store.resolveAllWithSource();
|
||||
}
|
||||
|
||||
interface ContextPressureFallbackInfo {
|
||||
effectiveValue: number;
|
||||
explanation: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns fallback explanation and effective default value if setting/resetting a context-pressure
|
||||
* key caused an inverted threshold pair to be rejected and fall back to 95/99 defaults in this shell.
|
||||
* Returns null if no pair rejection occurred.
|
||||
*/
|
||||
function getContextPressureFallbackInfo(store: ConfigStore, key: string): ContextPressureFallbackInfo | null {
|
||||
if (key !== "health.context_pressure.warning_percent" && key !== "health.context_pressure.critical_percent") {
|
||||
return null;
|
||||
}
|
||||
const state = store.resolveContextPressurePolicyState();
|
||||
if (!state.rejectedPair) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const warnKey = "health.context_pressure.warning_percent" as const;
|
||||
const critKey = "health.context_pressure.critical_percent" as const;
|
||||
const envSources: string[] = [];
|
||||
if (state.warning.source === "env") {
|
||||
envSources.push(`${ENV_MAP[warnKey].primary}=${state.warning.value}`);
|
||||
}
|
||||
if (state.critical.source === "env") {
|
||||
envSources.push(`${ENV_MAP[critKey].primary}=${state.critical.value}`);
|
||||
}
|
||||
const envDetails = envSources.length > 0 ? ` from ${envSources.join(", ")}` : "";
|
||||
const explanation = `; inverted pair ${state.rejectedPair.warning}/${state.rejectedPair.critical}${envDetails} falls back to 95/99 defaults in this shell`;
|
||||
const effectiveValue = key === warnKey ? 95 : 99;
|
||||
return { effectiveValue, explanation };
|
||||
}
|
||||
|
||||
// These settings remain boot-only; capture interval/line settings reload live.
|
||||
const BOOT_ONLY_KEYS = ["daemon.port", "daemon.host", "daemon.db_conflict", "daemon.seat_poll_interval_ms", "db.path", "terminal.tmux_server"];
|
||||
const BOOT_ONLY_PREFIXES = ["transcripts.enabled", "transcripts.path"];
|
||||
@@ -76,7 +110,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
|
||||
@@ -220,11 +254,16 @@ Precedence: CLI flag > environment variable > config file > default`)
|
||||
try {
|
||||
store.set(key, value);
|
||||
const resolved = store.resolveWithSource(key);
|
||||
console.log(resolved.source === "env"
|
||||
? `${key} = ${resolved.value} (source: env; file override saved but masked)`
|
||||
: resolved.source === "default"
|
||||
? `${key} file override saved; effective value ${resolved.value} (source: default)`
|
||||
: `${key} = ${resolved.value}`);
|
||||
const fallbackInfo = getContextPressureFallbackInfo(store, key);
|
||||
if (fallbackInfo) {
|
||||
console.log(`${key} file override saved; effective value ${fallbackInfo.effectiveValue} (source: default${fallbackInfo.explanation})`);
|
||||
} else {
|
||||
console.log(resolved.source === "env"
|
||||
? `${key} = ${resolved.value} (source: env; file override saved but masked)`
|
||||
: resolved.source === "default"
|
||||
? `${key} file override saved; effective value ${resolved.value} (source: default)`
|
||||
: `${key} = ${resolved.value}`);
|
||||
}
|
||||
// SWEEP-c: boot-only key + running daemon = stale-until-restart; say so loudly.
|
||||
if (isBootOnlyKey(key) && (await probeDaemonRunning())) {
|
||||
console.error(resolved.source === "env"
|
||||
@@ -246,9 +285,14 @@ Precedence: CLI flag > environment variable > config file > default`)
|
||||
store.reset(key);
|
||||
if (key) {
|
||||
const resolved = store.resolveWithSource(key);
|
||||
console.log(resolved.source === "env"
|
||||
? `${key} file override cleared; effective value ${resolved.value} (source: env; default: ${resolved.defaultValue}).`
|
||||
: `${key} reset to default (${resolved.value}).`);
|
||||
const fallbackInfo = getContextPressureFallbackInfo(store, key);
|
||||
if (fallbackInfo) {
|
||||
console.log(`${key} file override cleared; effective value ${fallbackInfo.effectiveValue} (source: default${fallbackInfo.explanation}).`);
|
||||
} else {
|
||||
console.log(resolved.source === "env"
|
||||
? `${key} file override cleared; effective value ${resolved.value} (source: env; default: ${resolved.defaultValue}).`
|
||||
: `${key} reset to default (${resolved.value}).`);
|
||||
}
|
||||
} else {
|
||||
let envKeys: string[] = [];
|
||||
try {
|
||||
|
||||
@@ -142,7 +142,8 @@ 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. 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}.`);
|
||||
});
|
||||
|
||||
|
||||
@@ -1319,6 +1319,12 @@ export interface ResolvedSetting {
|
||||
defaultValue: string | number | boolean;
|
||||
}
|
||||
|
||||
export interface ContextPressurePolicyState {
|
||||
warning: ResolvedSetting;
|
||||
critical: ResolvedSetting;
|
||||
rejectedPair: { warning: number; critical: number } | null;
|
||||
}
|
||||
|
||||
export class ConfigStore {
|
||||
readonly configPath: string;
|
||||
|
||||
@@ -1585,6 +1591,34 @@ export class ConfigStore {
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Inspect resolved context-pressure policy and whether an inverted pair was rejected. */
|
||||
resolveContextPressurePolicyState(
|
||||
fileConfig?: Record<string, unknown>,
|
||||
workspaceRoot?: string,
|
||||
): ContextPressurePolicyState {
|
||||
const fc = fileConfig ?? this.readConfigFile();
|
||||
const wr = workspaceRoot ?? (this.resolveOne("workspace.root", fc, DEFAULT_WORKSPACE_ROOT).value as string);
|
||||
return this.resolveContextPressureStateRaw(fc, wr);
|
||||
}
|
||||
|
||||
private resolveContextPressureStateRaw(
|
||||
fileConfig: Record<string, unknown>,
|
||||
workspaceRoot: string,
|
||||
): ContextPressurePolicyState {
|
||||
const warningKey = "health.context_pressure.warning_percent" as const;
|
||||
const criticalKey = "health.context_pressure.critical_percent" as const;
|
||||
const warning = this.resolveOneUnpaired(warningKey, fileConfig, workspaceRoot);
|
||||
const critical = this.resolveOneUnpaired(criticalKey, fileConfig, workspaceRoot);
|
||||
if ((warning.value as number) < (critical.value as number)) {
|
||||
return { warning, critical, rejectedPair: null };
|
||||
}
|
||||
return {
|
||||
warning,
|
||||
critical,
|
||||
rejectedPair: { warning: warning.value as number, critical: critical.value as number },
|
||||
};
|
||||
}
|
||||
|
||||
private resolveOne(key: ValidKey, fileConfig: Record<string, unknown>, workspaceRoot: string): ResolvedSetting {
|
||||
if (key === "health.context_pressure.warning_percent" || key === "health.context_pressure.critical_percent") {
|
||||
return this.resolveContextPressurePair(fileConfig, workspaceRoot)[key];
|
||||
@@ -1598,13 +1632,12 @@ export class ConfigStore {
|
||||
): Record<"health.context_pressure.warning_percent" | "health.context_pressure.critical_percent", ResolvedSetting> {
|
||||
const warningKey = "health.context_pressure.warning_percent" as const;
|
||||
const criticalKey = "health.context_pressure.critical_percent" as const;
|
||||
const warning = this.resolveOneUnpaired(warningKey, fileConfig, workspaceRoot);
|
||||
const critical = this.resolveOneUnpaired(criticalKey, fileConfig, workspaceRoot);
|
||||
if ((warning.value as number) < (critical.value as number)) {
|
||||
return { [warningKey]: warning, [criticalKey]: critical };
|
||||
const state = this.resolveContextPressureStateRaw(fileConfig, workspaceRoot);
|
||||
if (!state.rejectedPair) {
|
||||
return { [warningKey]: state.warning, [criticalKey]: state.critical };
|
||||
}
|
||||
process.stderr.write(
|
||||
`[openrig-config] context-pressure policy rejected: warning (${warning.value}) must be less than critical (${critical.value}); falling back to 95/99 defaults\n`,
|
||||
`[openrig-config] context-pressure policy rejected: warning (${state.warning.value}) must be less than critical (${state.critical.value}); falling back to 95/99 defaults\n`,
|
||||
);
|
||||
return {
|
||||
[warningKey]: { value: 95, source: "default", defaultValue: 95 },
|
||||
|
||||
@@ -9,7 +9,8 @@
|
||||
//
|
||||
// With --grant-service, grants come from an external grant service instead and the token file is never read: each
|
||||
// request's key is checked with that service (openrig.grant/v1) for this daemon's instance, every answer but active is
|
||||
// refused, and an external caller reaches only what its grant names (externalScope in mcp-server.ts).
|
||||
// refused, and an external caller reaches only what its grant names (externalScope in mcp-server.ts). An active answer's
|
||||
// fair-use throttle holds back only that caller's new work (rig_queue_create), never the request.
|
||||
//
|
||||
// The endpoint serves both protocol revisions: 2026-07-28 per request, and 2025's initialize and exchanges statelessly.
|
||||
import http from "node:http";
|
||||
@@ -231,6 +232,18 @@ const unavailable = (log: string): Refusal => ({
|
||||
|
||||
const strings = (value: unknown): value is string[] => Array.isArray(value) && value.every((item) => typeof item === "string" && item !== "");
|
||||
|
||||
/** An active answer's optional fair-use limit on new work (`throttle`): the seconds until new work is accepted again,
|
||||
* rounded up and kept within 1..600, undefined when there is none, or "unusable" when it can't be read. */
|
||||
function fairUseThrottle(value: unknown): number | undefined | "unusable" {
|
||||
if (value === undefined || value === null) return undefined;
|
||||
if (typeof value !== "object" || Array.isArray(value)) return "unusable";
|
||||
const { new_work: newWork, retry_after_seconds: seconds } = value as Record<string, unknown>;
|
||||
if (typeof newWork !== "boolean") return "unusable";
|
||||
if (!newWork) return undefined;
|
||||
if (typeof seconds !== "number" || !Number.isFinite(seconds)) return "unusable";
|
||||
return Math.min(600, Math.max(1, Math.ceil(seconds)));
|
||||
}
|
||||
|
||||
/** One request's caller from the grant service's answer for its key. A refusal never falls back to the token file or
|
||||
* the env's seat. */
|
||||
async function grantServiceCaller(service: GrantServiceOptions, url: URL, key: string, instance: string): Promise<{ seat: string; access: McpAccess; principal: McpPrincipal } | Refusal> {
|
||||
@@ -274,11 +287,16 @@ async function grantServiceCaller(service: GrantServiceOptions, url: URL, key: s
|
||||
if (person !== undefined && person !== null && !personId) {
|
||||
console.error(`rig mcp serve --http: grant ${grant.id} names an unusable person.entity_id, so this connection isn't offered the person tools (rig_question_*)`);
|
||||
}
|
||||
// The service's fair-use limit, when it sets one, holds back only new work (rig_queue_create). One that can't be read is
|
||||
// ignored: it never refuses the request or reads as a credential problem.
|
||||
const throttle = fairUseThrottle(body.throttle);
|
||||
if (throttle === "unusable") console.error(`rig mcp serve --http: grant ${grant.id} answered an unusable throttle, so it's ignored`);
|
||||
// The principal the daemon records beside this caller's rows. A version that isn't a finite number is left out (a v1
|
||||
// answer sends a number).
|
||||
const version = Number.isFinite(grant.version) ? { version: grant.version as number } : {};
|
||||
return { seat, access: { principal: principal.id, owner: false, rigs, roots, workspace: false,
|
||||
external: { objects, grant: { id: grant.id, ...version }, ...(personId ? { person: personId } : {}) } },
|
||||
external: { objects, grant: { id: grant.id, ...version }, ...(personId ? { person: personId } : {}),
|
||||
...(typeof throttle === "number" ? { throttle: { retryAfterSeconds: throttle } } : {}) } },
|
||||
principal: { source: "grant-service", id: principal.id, grant: grant.id, ...version } };
|
||||
}
|
||||
|
||||
|
||||
+132
-43
@@ -432,6 +432,17 @@ function outOfScope(what: string, hint?: string): TextResult {
|
||||
return errorResult(JSON.stringify({ ok: false, error: "out_of_scope", message }), "out_of_scope", message);
|
||||
}
|
||||
|
||||
/** A new create the grant service's fair-use limit holds back: nothing was written, and it says when to retry with the same
|
||||
* id. `unread`: whether an earlier create with this id was saved couldn't be read. */
|
||||
function fairUseRetryLater(qitemId: string, retryAfterSeconds: number, unread: boolean): TextResult {
|
||||
const earlier = unread ? ` Whether an earlier attempt with this id was saved is unknown: check rig_queue_show with qitemId ${qitemId} before retrying.` : "";
|
||||
const message = `OpenRig: this account has reached its fair-use limit for new work just now. Nothing was created by this call.${earlier}`
|
||||
+ ` Retry after ${retryAfterSeconds} seconds with the same qitemId ${qitemId}. Work you already have continues as usual: read, claim, answer and`
|
||||
+ " close it, and to reply to an item addressed to you, put the reply in its resolution when you close it.";
|
||||
return errorResult(message, "fair_use_retry_later", message,
|
||||
{ retryable: true, retryAfterSeconds, qitemId, outcome: "not_written", ...(unread ? { earlierAttempt: "unknown" } : {}) });
|
||||
}
|
||||
|
||||
/** A file the daemon read through a shared root, as text from offset: at most CONTEXT_TEXT_LIMIT characters, with
|
||||
* nextOffset when more of what the daemon read remains, and its byte cut when the file was longer than the daemon reads. */
|
||||
function artifactView(source: string, read: FileRead, offset: number): Record<string, unknown> {
|
||||
@@ -492,6 +503,28 @@ const READ_ONLY: ToolAnnotations = { readOnlyHint: true };
|
||||
const ADDITIVE: ToolAnnotations = { readOnlyHint: false, destructiveHint: false };
|
||||
const DESTRUCTIVE: ToolAnnotations = { readOnlyHint: false, destructiveHint: true };
|
||||
|
||||
/** The title and plain first sentence a connected assistant's client shows a person for each tool. Each replaces only the
|
||||
* opening it names; every rule after it stays as the model reads it. */
|
||||
export const PLAIN_WORDING: Readonly<Record<string, { title: string; replaces: string; with: string }>> = {
|
||||
rig_queue_list: { title: "List my OpenRig requests", replaces: "List queue items.",
|
||||
with: "Lists the requests addressed to this assistant, or that it sent." },
|
||||
rig_queue_show: { title: "Show one request", replaces: "Read one queue item and its transitions.",
|
||||
with: "Shows one request, who picked it up, and its answer." },
|
||||
rig_queue_create: { title: "Send a request", replaces: "Hand a task to another seat as a durable queue item.",
|
||||
with: "Sends a request to another assistant on your team." },
|
||||
rig_queue_claim: { title: "Pick up a request", replaces: "Claim a queue item assigned to this seat before working on it.",
|
||||
with: "Marks a request addressed to this assistant as picked up, so others see it's being worked on. Do it before working on the request." },
|
||||
rig_queue_update: { title: "Answer a request", replaces: "Update a queue item: add a note, block it, close it, or reopen a closed one.",
|
||||
with: "Adds this assistant's answer to a request, or a note while it works. It can also block, close or reopen one." },
|
||||
rig_artifact_read: { title: "Read a shared file",
|
||||
replaces: "Read a finished item's artifact (its evidenceRef), or a named file, through the daemon's shared roots (OPENRIG_FILES_ALLOWLIST), as text:",
|
||||
with: "Reads a file attached to a request. It returns text:" },
|
||||
rig_team: { title: "Show my team", replaces: "Show a rig's team.",
|
||||
with: "Shows the assistants on your team, each one's address for sending it a request, and which one leads." },
|
||||
rig_whoami: { title: "Show this connection", replaces: "Show which OpenRig seat this server acts as: ",
|
||||
with: "Shows which assistant this connection is and which team it belongs to. It answers with " },
|
||||
};
|
||||
|
||||
/** Who an authenticated HTTP caller must be to be offered a tool: work tools (the work loop, orientation, and
|
||||
* communication with seats) reach a team within the caller's scope; workspace tools read the work tree and context when
|
||||
* the caller's grant includes them; owner tools administer the instance (launch, stop, snapshot and restore, discover and
|
||||
@@ -507,8 +540,10 @@ export type McpAccessClass = "work" | "workspace" | "owner" | "person";
|
||||
export type McpAccess = {
|
||||
principal: string; owner: boolean; rigs: readonly string[]; roots: readonly string[]; workspace: boolean;
|
||||
/** An external caller's grant: the object/action pairs it names (openrig.grant/v1 `objects`), the grant it answered
|
||||
* with, and `person`, the gateway entity id of the person the grant service vouches owns this connection. */
|
||||
external?: { objects: Readonly<Record<string, readonly string[]>>; grant?: { id: string; version?: number | string }; person?: string };
|
||||
* with, `person`, the gateway entity id of the person the grant service vouches owns this connection, and `throttle`,
|
||||
* the service's fair-use limit on new work for this request: the seconds before a new create is accepted again. */
|
||||
external?: { objects: Readonly<Record<string, readonly string[]>>; grant?: { id: string; version?: number | string }; person?: string;
|
||||
throttle?: { retryAfterSeconds: number } };
|
||||
};
|
||||
|
||||
/** The tool each external object/action pair offers. A pair not listed here offers nothing. */
|
||||
@@ -638,7 +673,7 @@ const EXTERNAL_PERSON_INSTRUCTIONS = [
|
||||
|
||||
/** For an external grant that names claim and update: how it works the items addressed to it, messages included. */
|
||||
const EXTERNAL_INBOX_INSTRUCTIONS = [...SHARED_INSTRUCTIONS,
|
||||
"Items addressed to your seat are yours: rig_queue_list with owned set lists them. For each, claim it (rig_queue_claim), do it, then close it done with closure reason no-follow-on and the answer in resolution (rig_queue_update); the sender reads that answer on the same item. A message is an item too: read it, close it done, and reply, when it needs one, with a new item to its sender. Nothing promises to wake you, so check your items again.",
|
||||
"Items addressed to your seat are yours: rig_queue_list with owned set lists them. For each, claim it (rig_queue_claim), do it, then close it done with closure reason no-follow-on and the answer in resolution (rig_queue_update); the sender reads that answer on the same item. A message is an item too: read it and close it done, with your reply in resolution when it needs one; its sender reads that reply on the same item. Nothing promises to wake you, so check your items again.",
|
||||
].join("\n");
|
||||
|
||||
function instructionsFor(access: McpAccess | undefined, frontDoor = false): string {
|
||||
@@ -649,25 +684,41 @@ function instructionsFor(access: McpAccess | undefined, frontDoor = false): stri
|
||||
return tools.has("rig_team") ? `Discover the team this grant connects you to by calling rig_team without rigId. A sole granted rig is selected automatically; with several, it returns the granted names to choose from.\n${instructions}` : instructions;
|
||||
}
|
||||
|
||||
/** A configured default must identify exactly one existing local node. No topology relationship, name convention
|
||||
* for a lead, caller identity or task history supplies a missing receiver. Resolve again on each omitted destination. */
|
||||
async function taskDestinationExists(client: DaemonClient, destination: string): Promise<boolean> {
|
||||
const rigName = destination.split("@")[1]!;
|
||||
/** The seats of `rigName` that `name` names, each as its actual session: its node's canonical session, or the
|
||||
* pod-member form while the node has no session yet. A name matches, in this order, a seat's session's own name before
|
||||
* `@`, its pod-member form (logical id `pod.member` written `pod-member`), or that own name's slug, so a seat attached
|
||||
* under its own name is found by the name its team shows. Only seats the caller may address (`addressable`) count, and
|
||||
* the first of these that any of them matches decides: another seat's slug never makes an exact name ambiguous, and a
|
||||
* seat the caller can't address never decides or shadows one it can. No topology relationship, name convention for a
|
||||
* lead, caller identity or task history supplies a seat. Throws when the rig isn't one active rig here or its node list
|
||||
* can't be read. */
|
||||
async function namedSeats(client: DaemonClient, rigName: string, name: string, addressable: (session: string) => boolean): Promise<string[]> {
|
||||
const rig = await resolveRigHandle(client, rigName, { strict: true });
|
||||
if (rig.kind !== "resolved") return false;
|
||||
if (rig.kind !== "resolved") throw new Error(`rig ${rigName} isn't one active rig here`);
|
||||
const nodes = await client.get(`/api/rigs/${encodeURIComponent(rig.id)}/nodes`);
|
||||
if (nodes.status !== 200 || !Array.isArray(nodes.data)) return false;
|
||||
let matches = 0;
|
||||
if (nodes.status !== 200 || !Array.isArray(nodes.data)) throw new Error(`the node list for ${rigName} answered HTTP ${nodes.status}`);
|
||||
const wanted = seatSlug(name);
|
||||
const [byOwn, byPodMember, bySlug] = [new Set<string>(), new Set<string>(), new Set<string>()];
|
||||
for (const entry of nodes.data as unknown[]) {
|
||||
const { logicalId, rigName: name } = (entry ?? {}) as Record<string, unknown>;
|
||||
if (typeof logicalId !== "string" || name !== rigName) return false;
|
||||
const { logicalId, rigName: inRig, canonicalSessionName } = (entry ?? {}) as Record<string, unknown>;
|
||||
if (typeof logicalId !== "string" || inRig !== rigName) throw new Error(`the node list for ${rigName} has a malformed entry`);
|
||||
const dot = logicalId.indexOf(".");
|
||||
if (dot <= 0 || dot === logicalId.length - 1) return false;
|
||||
if (`${logicalId.slice(0, dot)}-${logicalId.slice(dot + 1)}@${name}` === destination) matches++;
|
||||
if (dot <= 0 || dot === logicalId.length - 1) throw new Error(`the node list for ${rigName} has a malformed entry`);
|
||||
const podMember = `${logicalId.slice(0, dot)}-${logicalId.slice(dot + 1)}`;
|
||||
const session = typeof canonicalSessionName === "string" && canonicalSessionName.endsWith(`@${rigName}`)
|
||||
&& canonicalSessionName.length > rigName.length + 1 ? canonicalSessionName : `${podMember}@${rigName}`;
|
||||
const own = session.slice(0, -(rigName.length + 1));
|
||||
if (!addressable(session)) continue;
|
||||
if (name === own) byOwn.add(session);
|
||||
else if (name === podMember) byPodMember.add(session);
|
||||
else if (wanted !== "" && wanted === seatSlug(own)) bySlug.add(session);
|
||||
}
|
||||
return matches === 1;
|
||||
return [...(byOwn.size > 0 ? byOwn : byPodMember.size > 0 ? byPodMember : bySlug)];
|
||||
}
|
||||
|
||||
/** A seat name as a slug: lowercase, with each run of other characters a single hyphen. */
|
||||
const seatSlug = (name: string): string => name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
||||
|
||||
/**
|
||||
* Maps a DaemonResponse to MCP tool result.
|
||||
* Three error levels: HTTP status, in-body errors, and structural failure fields.
|
||||
@@ -791,6 +842,15 @@ export function createMcpServer(client: DaemonClient, options: { seat?: string;
|
||||
return kind === "work" || (kind === "workspace" && access.workspace);
|
||||
};
|
||||
const scope = access && !access.owner ? scopeFor(access, seatOf) : null;
|
||||
/** A tool's title and plain opening for a connected assistant (an external grant), whose client shows a person these;
|
||||
* rig_whoami's are true for every caller, so it has them everywhere. Another caller keeps the tool's own text, which
|
||||
* covers what only it can do (listing its rig's work, reaching a person, reading a named shared file). The same title
|
||||
* goes in both of MCP's title fields, and the hints are unchanged. */
|
||||
const worded = (tool: string, description: string, annotations: ToolAnnotations): { title?: string; description: string; annotations: ToolAnnotations } => {
|
||||
const plain = PLAIN_WORDING[tool];
|
||||
if (!plain || !(access?.external || tool === "rig_whoami") || !description.startsWith(plain.replaces)) return { description, annotations };
|
||||
return { title: plain.title, description: plain.with + description.slice(plain.replaces.length), annotations: { ...annotations, title: plain.title } };
|
||||
};
|
||||
const caller = access ? { authenticated: true, principal: access.principal } : { authenticated: false, identity: "declared" };
|
||||
/** For an authenticated non-owner: the error for an item it can't reach (or can't read), else null. `owner` asks
|
||||
* whether it may change the item, which needs the item's owner in scope. */
|
||||
@@ -1338,10 +1398,10 @@ export function createMcpServer(client: DaemonClient, options: { seat?: string;
|
||||
if (offered("rig_queue_create")) server.registerTool(
|
||||
"rig_queue_create",
|
||||
{
|
||||
description: "Hand a task to another seat as a durable queue item. Returns its qitemId: read the answer later with rig_queue_show. If the call times out, check rig_queue_show with the same qitemId before retrying; never retry with a new id. To ask for a revision of earlier work, name it in revises: the new item opens with 'Revision of <id>' and records it in its chainOfRecord.",
|
||||
...worded("rig_queue_create", "Hand a task to another seat as a durable queue item. Returns its qitemId: read the answer later with rig_queue_show. If the call times out, check rig_queue_show with the same qitemId before retrying; never retry with a new id. To ask for a revision of earlier work, name it in revises: the new item opens with 'Revision of <id>' and records it in its chainOfRecord.", ADDITIVE),
|
||||
inputSchema: z.object({
|
||||
destination: access?.external
|
||||
? z.string().optional().describe("Destination seat: member@rig, or its name alone (e.g. workshop-lead) within this connection's team; omit to use the connected team's configured receiver")
|
||||
? z.string().optional().describe("Destination seat: member@rig, or its name alone as the team shows it (e.g. workshop-lead) within this connection's team; omit to use the connected team's configured receiver")
|
||||
: z.string().describe("Destination seat (e.g. dev-impl@my-rig)"),
|
||||
body: z.string().describe("The task: what to do and what to return"),
|
||||
summary: z.string().optional().describe("One-line summary"),
|
||||
@@ -1352,7 +1412,6 @@ export function createMcpServer(client: DaemonClient, options: { seat?: string;
|
||||
revises: z.string().optional().describe("The qitemId of the work this asks to revise"),
|
||||
alert: z.boolean().optional().describe("For an ask to a person that's urgent: something only they can do, or an approval whose wait costs now (as rig queue create --alert). It stands out in Slack. Without it the ask still posts to Slack unless the person's Slack subscription is urgent-only, in which case it waits on Steering and the TUI"),
|
||||
}),
|
||||
annotations: ADDITIVE,
|
||||
},
|
||||
async ({ destination, body, summary, tags, priority, tier, qitemId, revises, alert }) => {
|
||||
// The server's one receiver counts for this connection only when its grant reaches it. Then a refused destination
|
||||
@@ -1368,22 +1427,46 @@ export function createMcpServer(client: DaemonClient, options: { seat?: string;
|
||||
// Check scope before looking up the configured receiver: configuration never expands a grant or reveals
|
||||
// another rig's destination. An explicit destination continues through the existing path below.
|
||||
if (!scope?.destination(destination)) return outOfScope("Configured task destination");
|
||||
// The receiver is exactly one seat this connection may address, and the item goes to that seat's actual session.
|
||||
const at = destination.lastIndexOf("@");
|
||||
let seats: string[];
|
||||
try {
|
||||
if (!await taskDestinationExists(client, destination)) return unavailable();
|
||||
seats = await namedSeats(client, destination.slice(at + 1), destination.slice(0, at), (seat) => scope?.destination(seat) === true);
|
||||
} catch { return unavailable(); }
|
||||
if (seats.length !== 1) return unavailable();
|
||||
destination = seats[0]!;
|
||||
} else if (destination !== undefined && access?.external && !destination.includes("@")) {
|
||||
// A person names their lead as their team shows it ("workshop-lead"), and the assistant passes that on. A name
|
||||
// alone is the receiver this connection reaches when it is that seat's name, else a seat of this connection's one
|
||||
// rig; never another rig's. It must be in scope and exist, or nothing is written.
|
||||
// A person names a seat as their team shows it ("workshop-lead"), and the assistant passes that on. A name alone
|
||||
// looks in the rig of the receiver this connection reaches when it is that receiver's name, else in this
|
||||
// connection's one rig; never another rig's. It reaches one seat the connection may address, stored under that
|
||||
// seat's actual session, or nothing is written.
|
||||
const named = destination;
|
||||
const resolved = receiver?.split("@")[0] === named ? receiver
|
||||
: access.rigs.length === 1 ? `${named}@${access.rigs[0]}` : undefined;
|
||||
let exists = false;
|
||||
if (resolved && scope?.destination(resolved)) {
|
||||
try { exists = await taskDestinationExists(client, resolved); } catch { /* not resolved */ }
|
||||
const receiverAt = receiver?.lastIndexOf("@") ?? -1;
|
||||
const rigName = receiver && receiver.slice(0, receiverAt) === named ? receiver.slice(receiverAt + 1)
|
||||
: access.rigs.length === 1 ? access.rigs[0] : undefined;
|
||||
if (!rigName) return outOfScope(`Destination ${named}`, omitHint);
|
||||
// A name this connection may not address as written (another caller's seat without team/list, or its own) is
|
||||
// refused before the team is read, so the answer doesn't say whether such a seat exists.
|
||||
if (named !== "" && !scope?.destination(`${named}@${rigName}`)) return outOfScope(`Destination ${named}`, omitHint);
|
||||
let reachable: string[];
|
||||
try {
|
||||
reachable = named === "" ? [] : await namedSeats(client, rigName, named, (seat) => scope?.destination(seat) === true);
|
||||
} catch {
|
||||
const message = "This connection's team couldn't be read just now, so nothing was written. Try again.";
|
||||
return errorResult(message, "destination_unchecked", message);
|
||||
}
|
||||
if (!exists) return outOfScope(`Destination ${named}`, omitHint);
|
||||
destination = resolved!;
|
||||
if (reachable.length > 1) {
|
||||
const message = `'${named}' names ${reachable.length} seats here. Nothing was written; pass one of these: ${reachable.join(", ")}.`;
|
||||
return errorResult(JSON.stringify({ ok: false, code: "destination_ambiguous", message, candidates: reachable }),
|
||||
"destination_ambiguous", message, { candidates: reachable });
|
||||
}
|
||||
// A seat it may not address reads as no seat at all, so a hidden seat's name says nothing about it.
|
||||
if (reachable.length === 0) {
|
||||
const where = offered("rig_team") ? " rig_team lists the seats you can reach and their names." : " Check the name the person gave.";
|
||||
const message = `No seat here is named '${named}', so nothing was written.${where}${omitHint ? ` ${omitHint}` : ""}`;
|
||||
return errorResult(message, "destination_not_found", message);
|
||||
}
|
||||
destination = reachable[0]!;
|
||||
}
|
||||
if (scope && !scope.destination(destination)) return outOfScope(`Destination ${destination}`, access?.external ? omitHint : undefined);
|
||||
// A revision carries its original's lineage as a handoff does: the original's chain, then the original.
|
||||
@@ -1402,6 +1485,19 @@ export function createMcpServer(client: DaemonClient, options: { seat?: string;
|
||||
}
|
||||
}
|
||||
const id = qitemId ?? `qitem-${new Date().toISOString().replace(/\D/g, "").slice(0, 14)}-${randomBytes(8).toString("hex")}`;
|
||||
// The grant service's fair-use limit holds back new work only. A retry of a create that already landed isn't new
|
||||
// work: the daemon answers it from the stored row, so it goes through as before.
|
||||
const throttle = access?.external?.throttle;
|
||||
if (throttle) {
|
||||
let landed: boolean | undefined = false;
|
||||
if (qitemId) {
|
||||
try {
|
||||
const existing = await client.get(`/api/queue/${encodeURIComponent(qitemId)}`);
|
||||
landed = existing.status < 400 ? true : existing.status === 404 ? false : undefined;
|
||||
} catch { landed = undefined; }
|
||||
}
|
||||
if (landed !== true) return fairUseRetryLater(id, throttle.retryAfterSeconds, landed === undefined);
|
||||
}
|
||||
// With no seat, the task goes to its destination as from the unknown sender, with the note at the top of its body.
|
||||
const note = unknownSenderNote("task");
|
||||
try {
|
||||
@@ -1437,11 +1533,10 @@ export function createMcpServer(client: DaemonClient, options: { seat?: string;
|
||||
if (offered("rig_queue_show")) server.registerTool(
|
||||
"rig_queue_show",
|
||||
{
|
||||
description: "Read one queue item and its transitions. For a task you handed off, this is where its answer is: the state, the closure reason and target, the notes the assignee recorded, and evidenceRef when it names an artifact (read it with rig_artifact_read). When the item was handed on, successor names the next item (on another host: its id and host); follow it with rig_queue_show. null means none was found. A revision names the work it revises in chainOfRecord.",
|
||||
...worded("rig_queue_show", "Read one queue item and its transitions. For a task you handed off, this is where its answer is: the state, the closure reason and target, the notes the assignee recorded, and evidenceRef when it names an artifact (read it with rig_artifact_read). When the item was handed on, successor names the next item (on another host: its id and host); follow it with rig_queue_show. null means none was found. A revision names the work it revises in chainOfRecord.", READ_ONLY),
|
||||
inputSchema: z.object({
|
||||
qitemId: z.string().describe("The qitemId returned by rig_queue_create (or any queue item)"),
|
||||
}),
|
||||
annotations: READ_ONLY,
|
||||
},
|
||||
async ({ qitemId }) => {
|
||||
try {
|
||||
@@ -1471,7 +1566,7 @@ export function createMcpServer(client: DaemonClient, options: { seat?: string;
|
||||
if (offered("rig_queue_list")) server.registerTool(
|
||||
"rig_queue_list",
|
||||
{
|
||||
description: "List queue items. owned: assigned to this seat. mine: this seat is the source or the destination. Without a scope, this seat's rig. Without state, open items only (all adds closed ones); a state filter returns exactly those states, closed ones included. since finds work updated after a time; handedOffFrom finds where a handed-off item went. tags are exact, case-sensitive AND filters; mission and slice add their corresponding exact tags.",
|
||||
...worded("rig_queue_list", "List queue items. owned: assigned to this seat. mine: this seat is the source or the destination. Without a scope, this seat's rig. Without state, open items only (all adds closed ones); a state filter returns exactly those states, closed ones included. since finds work updated after a time; handedOffFrom finds where a handed-off item went. tags are exact, case-sensitive AND filters; mission and slice add their corresponding exact tags.", READ_ONLY),
|
||||
inputSchema: z.object({
|
||||
owned: z.boolean().optional().describe("Only items assigned to this seat: its whole inbox (overrides destination)"),
|
||||
mine: z.boolean().optional().describe("Items this seat sent or owns"),
|
||||
@@ -1486,7 +1581,6 @@ export function createMcpServer(client: DaemonClient, options: { seat?: string;
|
||||
handedOffFrom: z.string().optional().describe("The items handed on from this qitemId, in any state and any rig"),
|
||||
limit: z.number().int().positive().optional().describe("Result limit (default 100)"),
|
||||
}),
|
||||
annotations: READ_ONLY,
|
||||
},
|
||||
async ({ owned, mine: mineAsked, destination, source, state, tags: requestedTags, mission, slice, all, since, handedOffFrom, limit }) => {
|
||||
try {
|
||||
@@ -1550,9 +1644,8 @@ export function createMcpServer(client: DaemonClient, options: { seat?: string;
|
||||
if (offered("rig_queue_claim")) server.registerTool(
|
||||
"rig_queue_claim",
|
||||
{
|
||||
description: "Claim a queue item assigned to this seat before working on it.",
|
||||
...worded("rig_queue_claim", "Claim a queue item assigned to this seat before working on it.", DESTRUCTIVE),
|
||||
inputSchema: z.object({ qitemId: z.string().describe("The queue item to claim") }),
|
||||
annotations: DESTRUCTIVE,
|
||||
},
|
||||
async ({ qitemId }) => {
|
||||
try {
|
||||
@@ -1572,7 +1665,7 @@ export function createMcpServer(client: DaemonClient, options: { seat?: string;
|
||||
if (offered("rig_queue_update")) server.registerTool(
|
||||
"rig_queue_update",
|
||||
{
|
||||
description: "Update a queue item: add a note, block it, close it, or reopen a closed one. To answer a task, close it with state done, a closure reason and the answer in resolution, which the item keeps; note records why this transition happened.",
|
||||
...worded("rig_queue_update", "Update a queue item: add a note, block it, close it, or reopen a closed one. To answer a task, close it with state done, a closure reason and the answer in resolution, which the item keeps; note records why this transition happened.", DESTRUCTIVE),
|
||||
inputSchema: z.object({
|
||||
qitemId: z.string().describe("The queue item to update"),
|
||||
state: z.string().optional().describe("New state (e.g. in-progress, blocked, done)"),
|
||||
@@ -1586,7 +1679,6 @@ export function createMcpServer(client: DaemonClient, options: { seat?: string;
|
||||
blockedOn: z.string().optional().describe("What it waits on, when blocking: another qitemId (this item returns to pending when that one closes), or the address of the person this item came from, not a shared channel (they're asked with this item's summary, and their answer returns it to you in progress)"),
|
||||
wakeAfterSeconds: z.number().int().positive().optional().describe("Wake this seat after this many seconds, when blocking. Delivered only to a seat with a terminal: an attached app can't receive it today, so the item just waits until it is checked."),
|
||||
}),
|
||||
annotations: DESTRUCTIVE,
|
||||
},
|
||||
async ({ qitemId, state, closureReason, closureTarget, note, resolution, reopen, summary, evidenceRef, blockedOn, wakeAfterSeconds }) => {
|
||||
try {
|
||||
@@ -1673,9 +1765,8 @@ export function createMcpServer(client: DaemonClient, options: { seat?: string;
|
||||
if (offered("rig_whoami")) server.registerTool(
|
||||
"rig_whoami",
|
||||
{
|
||||
description: "Show which OpenRig seat this server acts as: its rig, pod, peers and edges, and caller: authenticated (a validated HTTP credential, with its principal) or declared (OPENRIG_SESSION_NAME where the server was configured, which nothing verifies).",
|
||||
...worded("rig_whoami", "Show which OpenRig seat this server acts as: its rig, pod, peers and edges, and caller: authenticated (a validated HTTP credential, with its principal) or declared (OPENRIG_SESSION_NAME where the server was configured, which nothing verifies).", READ_ONLY),
|
||||
inputSchema: z.object({}),
|
||||
annotations: READ_ONLY,
|
||||
},
|
||||
async () => {
|
||||
try {
|
||||
@@ -1730,11 +1821,10 @@ export function createMcpServer(client: DaemonClient, options: { seat?: string;
|
||||
if (offered("rig_team")) server.registerTool(
|
||||
"rig_team",
|
||||
{
|
||||
description: "Show a rig's team. For an external grant, omit rigId to use its sole rig or see its granted names to choose from. The answer includes its lead, the rig's one delegates_to root (the seat that delegates to others and that nothing delegates to: the seat to delegate to), and each seat's pod, role, runtime, delegation (reportsTo, delegatesTo) and activity as the daemon reports it. Activity the daemon doesn't know reads unknown, never idle. hostedMembers are the rig's members that live outside a terminal (roster entries marked hosted): their authored capabilities and limits, with no pane and activity unknown.",
|
||||
...worded("rig_team", "Show a rig's team. For an external grant, omit rigId to use its sole rig or see its granted names to choose from. The answer includes its lead, the rig's one delegates_to root (the seat that delegates to others and that nothing delegates to: the seat to delegate to), and each seat's pod, role, runtime, delegation (reportsTo, delegatesTo) and activity as the daemon reports it. Activity the daemon doesn't know reads unknown, never idle. hostedMembers are the rig's members that live outside a terminal (roster entries marked hosted): their authored capabilities and limits, with no pane and activity unknown.", READ_ONLY),
|
||||
inputSchema: z.object({
|
||||
rigId: z.string().optional().describe("Rig name or identifier; optional for an external grant to discover its team"),
|
||||
}),
|
||||
annotations: READ_ONLY,
|
||||
},
|
||||
async ({ rigId }) => {
|
||||
try {
|
||||
@@ -1907,14 +1997,13 @@ export function createMcpServer(client: DaemonClient, options: { seat?: string;
|
||||
if (offered("rig_artifact_read")) server.registerTool(
|
||||
"rig_artifact_read",
|
||||
{
|
||||
description: `Read a finished item's artifact (its evidenceRef), or a named file, through the daemon's shared roots (OPENRIG_FILES_ALLOWLIST), as text: at most ${CONTEXT_TEXT_LIMIT} characters from offset; nextOffset reads on. An artifact outside every shared root, a link, or an item with no evidenceRef comes back as available: false with the reason and the shared roots' names; nothing outside those roots is read. A code-result manifest (kind openrig.code-result/v1) also comes back parsed as codeResult: its repo, the exact base and revision commits, and paths; a branch or fetch ref in it is a hint, never the revision.`,
|
||||
...worded("rig_artifact_read", `Read a finished item's artifact (its evidenceRef), or a named file, through the daemon's shared roots (OPENRIG_FILES_ALLOWLIST), as text: at most ${CONTEXT_TEXT_LIMIT} characters from offset; nextOffset reads on. An artifact outside every shared root, a link, or an item with no evidenceRef comes back as available: false with the reason and the shared roots' names; nothing outside those roots is read. A code-result manifest (kind openrig.code-result/v1) also comes back parsed as codeResult: its repo, the exact base and revision commits, and paths; a branch or fetch ref in it is a hint, never the revision.`, READ_ONLY),
|
||||
inputSchema: z.object({
|
||||
qitemId: z.string().optional().describe("Read this item's evidenceRef"),
|
||||
root: z.string().optional().describe("A shared root's name: with path, the file to read; with qitemId, the root a relative evidenceRef is under"),
|
||||
path: z.string().optional().describe("With root: the file's path inside that root"),
|
||||
offset: z.number().int().min(0).optional().describe("The character to start from (default 0)"),
|
||||
}),
|
||||
annotations: READ_ONLY,
|
||||
},
|
||||
async ({ qitemId, root, path, offset }) => {
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { configCommand } from "../src/commands/config.js";
|
||||
import { ConfigStore } from "../src/config-store.js";
|
||||
|
||||
describe("issue #2529: context-pressure inverted environment pair reporting", () => {
|
||||
let root: string;
|
||||
let configPath: string;
|
||||
let savedEnv: Record<string, string | undefined>;
|
||||
let logSpy: ReturnType<typeof vi.spyOn>;
|
||||
let errSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
root = fs.mkdtempSync(path.join(os.tmpdir(), "opr-issue-2529-"));
|
||||
configPath = path.join(root, "config.json");
|
||||
savedEnv = {
|
||||
OPENRIG_HEALTH_CONTEXT_PRESSURE_WARNING_PERCENT: process.env["OPENRIG_HEALTH_CONTEXT_PRESSURE_WARNING_PERCENT"],
|
||||
OPENRIG_HEALTH_CONTEXT_PRESSURE_CRITICAL_PERCENT: process.env["OPENRIG_HEALTH_CONTEXT_PRESSURE_CRITICAL_PERCENT"],
|
||||
};
|
||||
delete process.env["OPENRIG_HEALTH_CONTEXT_PRESSURE_WARNING_PERCENT"];
|
||||
delete process.env["OPENRIG_HEALTH_CONTEXT_PRESSURE_CRITICAL_PERCENT"];
|
||||
logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
errSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
process.exitCode = undefined;
|
||||
for (const [k, v] of Object.entries(savedEnv)) {
|
||||
if (v === undefined) delete process.env[k];
|
||||
else process.env[k] = v;
|
||||
}
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("persists valid file value and reports inverted environment pair with env sources", async () => {
|
||||
// Scenario 2 from issue #2529:
|
||||
// warning is 98 in env. Setting critical to 97 in file.
|
||||
// The write succeeds, persists critical 97 to file, but reports the inverted pair
|
||||
// and that it falls back to 95/99 defaults in this shell.
|
||||
process.env["OPENRIG_HEALTH_CONTEXT_PRESSURE_WARNING_PERCENT"] = "98";
|
||||
const stderr = vi.spyOn(process.stderr, "write").mockImplementation(() => true);
|
||||
|
||||
const cmd = configCommand(configPath);
|
||||
await cmd.parseAsync(["node", "rig", "set", "health.context_pressure.critical_percent", "97"]);
|
||||
|
||||
const fileContent = JSON.parse(fs.readFileSync(configPath, "utf8"));
|
||||
expect(fileContent.health?.contextPressure?.criticalPercent).toBe(97);
|
||||
|
||||
const output = logSpy.mock.calls.map((c) => c.join(" ")).join("\n");
|
||||
expect(output).toContain(
|
||||
"health.context_pressure.critical_percent file override saved; effective value 99 (source: default; inverted pair 98/97 from OPENRIG_HEALTH_CONTEXT_PRESSURE_WARNING_PERCENT=98 falls back to 95/99 defaults in this shell)",
|
||||
);
|
||||
stderr.mockRestore();
|
||||
});
|
||||
|
||||
it("persists and prints cleanly without warning when there is no environment conflict", async () => {
|
||||
const cmd = configCommand(configPath);
|
||||
await cmd.parseAsync(["node", "rig", "set", "health.context_pressure.critical_percent", "100"]);
|
||||
|
||||
const fileContent = JSON.parse(fs.readFileSync(configPath, "utf8"));
|
||||
expect(fileContent.health?.contextPressure?.criticalPercent).toBe(100);
|
||||
|
||||
const output = logSpy.mock.calls.map((c) => c.join(" ")).join("\n");
|
||||
expect(output).toBe("health.context_pressure.critical_percent = 100");
|
||||
});
|
||||
|
||||
it("whole-file reset still works even if config file is malformed JSON", async () => {
|
||||
fs.writeFileSync(configPath, "{ not valid json ... ");
|
||||
const cmd = configCommand(configPath);
|
||||
await cmd.parseAsync(["node", "rig", "reset"]);
|
||||
|
||||
expect(fs.existsSync(configPath)).toBe(false);
|
||||
const output = logSpy.mock.calls.map((c) => c.join(" ")).join("\n");
|
||||
expect(output).toBe("Config reset to defaults.");
|
||||
});
|
||||
|
||||
it("reports inverted pair on reset <key> when active environment override causes fallback", async () => {
|
||||
// File has warning 90, critical 98
|
||||
const store = new ConfigStore(configPath);
|
||||
store.set("health.context_pressure.warning_percent", "90");
|
||||
store.set("health.context_pressure.critical_percent", "98");
|
||||
|
||||
// Environment sets warning=99
|
||||
process.env["OPENRIG_HEALTH_CONTEXT_PRESSURE_WARNING_PERCENT"] = "99";
|
||||
const stderr = vi.spyOn(process.stderr, "write").mockImplementation(() => true);
|
||||
|
||||
const cmd = configCommand(configPath);
|
||||
// Resetting critical removes file 98, so file has warning 90 / default critical 99.
|
||||
// But with env warning=99, env warning (99) >= default critical (99), falling back to 95/99.
|
||||
await cmd.parseAsync(["node", "rig", "reset", "health.context_pressure.critical_percent"]);
|
||||
|
||||
const fileContent = JSON.parse(fs.readFileSync(configPath, "utf8"));
|
||||
expect(fileContent.health?.contextPressure?.criticalPercent).toBeUndefined();
|
||||
|
||||
const output = logSpy.mock.calls.map((c) => c.join(" ")).join("\n");
|
||||
expect(output).toContain(
|
||||
"health.context_pressure.critical_percent file override cleared; effective value 99 (source: default; inverted pair 99/99 from OPENRIG_HEALTH_CONTEXT_PRESSURE_WARNING_PERCENT=99 falls back to 95/99 defaults in this shell).",
|
||||
);
|
||||
stderr.mockRestore();
|
||||
});
|
||||
|
||||
it("resets cleanly to default without inversion warning when no environment conflict exists", async () => {
|
||||
const store = new ConfigStore(configPath);
|
||||
store.set("health.context_pressure.critical_percent", "100");
|
||||
|
||||
const cmd = configCommand(configPath);
|
||||
await cmd.parseAsync(["node", "rig", "reset", "health.context_pressure.critical_percent"]);
|
||||
|
||||
const fileContent = JSON.parse(fs.readFileSync(configPath, "utf8"));
|
||||
expect(fileContent.health?.contextPressure?.criticalPercent).toBeUndefined();
|
||||
|
||||
const output = logSpy.mock.calls.map((c) => c.join(" ")).join("\n");
|
||||
expect(output).toBe("health.context_pressure.critical_percent reset to default (99).");
|
||||
expect(output).not.toContain("inverted");
|
||||
});
|
||||
|
||||
it("handles rejected partial environment override without false inversion warning", async () => {
|
||||
// Warning env is partial/malformed '98junk', setting critical to 97 on an empty file.
|
||||
// The resolver discards '98junk' (falls back to default 95), so pair is 95/97 (valid).
|
||||
// The write saves 97, effective is 97 from file, and no inversion/fallback is reported.
|
||||
process.env["OPENRIG_HEALTH_CONTEXT_PRESSURE_WARNING_PERCENT"] = "98junk";
|
||||
const stderr = vi.spyOn(process.stderr, "write").mockImplementation(() => true);
|
||||
|
||||
const cmd = configCommand(configPath);
|
||||
await cmd.parseAsync(["node", "rig", "set", "health.context_pressure.critical_percent", "97"]);
|
||||
|
||||
const fileContent = JSON.parse(fs.readFileSync(configPath, "utf8"));
|
||||
expect(fileContent.health?.contextPressure?.criticalPercent).toBe(97);
|
||||
|
||||
const output = logSpy.mock.calls.map((c) => c.join(" ")).join("\n");
|
||||
expect(output).toBe("health.context_pressure.critical_percent = 97");
|
||||
expect(output).not.toContain("inverted");
|
||||
stderr.mockRestore();
|
||||
});
|
||||
|
||||
it("handles rejected out-of-range environment override without false inversion warning", async () => {
|
||||
// Warning env is out-of-range '101', setting critical to 97 on an empty file.
|
||||
// The resolver discards '101' (falls back to default 95), so pair is 95/97 (valid).
|
||||
// The write saves 97, effective is 97 from file, and no inversion/fallback is reported.
|
||||
process.env["OPENRIG_HEALTH_CONTEXT_PRESSURE_WARNING_PERCENT"] = "101";
|
||||
const stderr = vi.spyOn(process.stderr, "write").mockImplementation(() => true);
|
||||
|
||||
const cmd = configCommand(configPath);
|
||||
await cmd.parseAsync(["node", "rig", "set", "health.context_pressure.critical_percent", "97"]);
|
||||
|
||||
const fileContent = JSON.parse(fs.readFileSync(configPath, "utf8"));
|
||||
expect(fileContent.health?.contextPressure?.criticalPercent).toBe(97);
|
||||
|
||||
const output = logSpy.mock.calls.map((c) => c.join(" ")).join("\n");
|
||||
expect(output).toBe("health.context_pressure.critical_percent = 97");
|
||||
expect(output).not.toContain("inverted");
|
||||
stderr.mockRestore();
|
||||
});
|
||||
|
||||
it("correctly explains fallback when an invalid file value is discarded and causes inversion", async () => {
|
||||
// File has invalid warningPercent: 0, critical env is 90.
|
||||
// Writing critical 100 to file saves critical 100.
|
||||
// The resolver discards file warningPercent 0, uses default 95.
|
||||
// With critical env 90, warning 95 >= critical 90 causes inversion and fallback to 95/99.
|
||||
fs.mkdirSync(path.dirname(configPath), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
configPath,
|
||||
JSON.stringify({ health: { contextPressure: { warningPercent: 0 } } }, null, 2) + "\n",
|
||||
);
|
||||
process.env["OPENRIG_HEALTH_CONTEXT_PRESSURE_CRITICAL_PERCENT"] = "90";
|
||||
const stderr = vi.spyOn(process.stderr, "write").mockImplementation(() => true);
|
||||
|
||||
const cmd = configCommand(configPath);
|
||||
await cmd.parseAsync(["node", "rig", "set", "health.context_pressure.critical_percent", "100"]);
|
||||
|
||||
const fileContent = JSON.parse(fs.readFileSync(configPath, "utf8"));
|
||||
expect(fileContent.health?.contextPressure?.criticalPercent).toBe(100);
|
||||
|
||||
const output = logSpy.mock.calls.map((c) => c.join(" ")).join("\n");
|
||||
expect(output).toContain(
|
||||
"health.context_pressure.critical_percent file override saved; effective value 99 (source: default; inverted pair 95/90 from OPENRIG_HEALTH_CONTEXT_PRESSURE_CRITICAL_PERCENT=90 falls back to 95/99 defaults in this shell)",
|
||||
);
|
||||
stderr.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -119,7 +119,7 @@ describe("rig config get --with-source", () => {
|
||||
const stderr = vi.spyOn(process.stderr, "write").mockImplementation(() => true);
|
||||
try {
|
||||
expect(await run(["set", "health.context_pressure.critical_percent", "97"]))
|
||||
.toContain("file override saved; effective value 99 (source: default)");
|
||||
.toContain("file override saved; effective value 99 (source: default");
|
||||
expect(JSON.parse(fs.readFileSync(configPath, "utf8")).health.contextPressure.criticalPercent).toBe(97);
|
||||
expect(stderr).toHaveBeenCalled();
|
||||
} finally { stderr.mockRestore(); }
|
||||
|
||||
@@ -156,14 +156,14 @@ describe("rig mcp serve --http --grant-service on the daemon's routes", () => {
|
||||
const args = { qitemId: "qitem-first-task", body: "Create a useful file and return it" };
|
||||
const first = await ask("rig_queue_create", args);
|
||||
expect(first.isError).toBe(false);
|
||||
expect(repo.getById(args.qitemId)).toMatchObject({ sourceSession: CALLER, destinationSession: "pod-lead@team-a", body: args.body });
|
||||
expect(repo.getById(args.qitemId)).toMatchObject({ sourceSession: CALLER, destinationSession: "lead@team-a", body: args.body });
|
||||
const retry = await ask("rig_queue_create", args);
|
||||
expect(retry.isError).toBe(false);
|
||||
expect(retry.structured.qitemId).toBe(args.qitemId);
|
||||
expect(rows()).toBe(1);
|
||||
const revision = await ask("rig_queue_create", { body: "Improve the file", revises: args.qitemId });
|
||||
expect(revision.isError).toBe(false);
|
||||
expect(repo.getById(revision.structured.qitemId)).toMatchObject({ sourceSession: CALLER, destinationSession: "pod-lead@team-a",
|
||||
expect(repo.getById(revision.structured.qitemId)).toMatchObject({ sourceSession: CALLER, destinationSession: "lead@team-a",
|
||||
chainOfRecord: [args.qitemId], body: `Revision of ${args.qitemId}.\n\nImprove the file` });
|
||||
// A supplied destination still takes the original route, even with a different configured receiver.
|
||||
expect((await ask("rig_queue_create", { destination: "other@team-a", body: "Explicit task" })).isError).toBe(false);
|
||||
@@ -185,7 +185,7 @@ describe("rig mcp serve --http --grant-service on the daemon's routes", () => {
|
||||
expect(tools.find((tool) => tool.name === "rig_queue_create")?.inputSchema.required).not.toContain("destination");
|
||||
const result = await client.callTool({ name: "rig_queue_create", arguments: { qitemId: "qitem-modern-first", body: "Create the file" } });
|
||||
expect(result.isError).not.toBe(true);
|
||||
expect(repo.getById("qitem-modern-first")).toMatchObject({ sourceSession: CALLER, destinationSession: "pod-lead@team-a" });
|
||||
expect(repo.getById("qitem-modern-first")).toMatchObject({ sourceSession: CALLER, destinationSession: "lead@team-a" });
|
||||
});
|
||||
|
||||
it("does not infer a receiver from grant metadata, caller identity or an empty queue", async () => {
|
||||
@@ -219,16 +219,129 @@ describe("rig mcp serve --http --grant-service on the daemon's routes", () => {
|
||||
const ask = await connect(url);
|
||||
const sent = await ask("rig_queue_create", { qitemId: "qitem-bare-name", destination: "pod-lead", body: "Do this directly" });
|
||||
expect(sent.isError).toBe(false);
|
||||
expect(repo.getById("qitem-bare-name")).toMatchObject({ sourceSession: CALLER, destinationSession: "pod-lead@team-a" });
|
||||
// A name that is no seat there, or the caller's own, writes nothing.
|
||||
for (const name of ["my-lead", "cus-vvmaexample01-caller", ""]) {
|
||||
expect((await ask("rig_queue_create", { destination: name, body: "Nobody" })).structured.code).toBe("out_of_scope");
|
||||
expect(repo.getById("qitem-bare-name")).toMatchObject({ sourceSession: CALLER, destinationSession: "lead@team-a" });
|
||||
// A name that is no seat there says so; the caller's own seat stays a scope refusal. Neither writes anything.
|
||||
for (const name of ["my-lead", ""]) {
|
||||
expect((await ask("rig_queue_create", { destination: name, body: "Nobody" })).structured.code).toBe("destination_not_found");
|
||||
}
|
||||
expect((await ask("rig_queue_create", { destination: "cus-vvmaexample01-caller", body: "Myself" })).structured.code).toBe("out_of_scope");
|
||||
expect(rows()).toBe(1);
|
||||
const described = (await ask.catalog()).tools.find((tool) => tool.name === "rig_queue_create")?.inputSchema.properties?.destination as { description?: string };
|
||||
expect(described.description).toContain("name alone");
|
||||
});
|
||||
|
||||
describe("a name alone reaches the seat's actual session", () => {
|
||||
/** A seat whose session is its own name, as a self-attached seat's is, rather than its pod-member form. */
|
||||
function namedSeat(rigName: string, member: string, session: string) {
|
||||
const rig = setup.rigRepo.listRigs?.().find((r: { name: string }) => r.name === rigName) ?? setup.rigRepo.createRig(rigName);
|
||||
const node = setup.rigRepo.addNode(rig.id, `pod.${member}`, { role: "worker" });
|
||||
setup.sessionRegistry.registerSession(node.id, session);
|
||||
}
|
||||
|
||||
it("finds a seat by its session's own name, that name's slug, or its pod-member form, and stores its actual session", async () => {
|
||||
namedSeat("team-a", "caller-7f3a", "Workshop.Caller@team-a");
|
||||
const { url } = await listen();
|
||||
const ask = await connect(url);
|
||||
for (const [name, qitemId] of [["Workshop.Caller", "qitem-own-name"], ["workshop-caller", "qitem-slug"], ["pod-caller-7f3a", "qitem-pod-member"]]) {
|
||||
const sent = await ask("rig_queue_create", { qitemId, destination: name, body: `Reach me as ${name}` });
|
||||
expect(sent.isError).toBe(false);
|
||||
expect(repo.getById(qitemId)).toMatchObject({ sourceSession: CALLER, destinationSession: "Workshop.Caller@team-a" });
|
||||
}
|
||||
expect(rows()).toBe(3);
|
||||
});
|
||||
|
||||
it("names the candidates when a name matches no seat exactly and two by slug, and writes nothing", async () => {
|
||||
namedSeat("team-a", "reviewer", "reviewer@team-a");
|
||||
namedSeat("team-a", "reviewer-two", "Reviewer@team-a");
|
||||
const { url } = await listen();
|
||||
const ask = await connect(url);
|
||||
const refused = await ask("rig_queue_create", { destination: "REVIEWER", body: "Which one?" });
|
||||
expect(refused.structured).toMatchObject({ code: "destination_ambiguous" });
|
||||
expect([...refused.structured.candidates].sort()).toEqual(["Reviewer@team-a", "reviewer@team-a"]);
|
||||
expect(rows()).toBe(0);
|
||||
});
|
||||
|
||||
it("says no such seat, naming rig_team when the grant offers it, apart from a scope refusal and an unreadable team", async () => {
|
||||
namedSeat("team-a", "lead", "lead@team-a");
|
||||
answer = () => [200, active({ objects: { queue: ["create", "show", "list"], team: ["list"] } })];
|
||||
const { url } = await listen();
|
||||
const ask = await connect(url);
|
||||
const missing = await ask("rig_queue_create", { destination: "led", body: "Typo" });
|
||||
expect(missing.structured).toMatchObject({ code: "destination_not_found" });
|
||||
expect(missing.text).toContain("No seat here is named 'led'");
|
||||
expect(missing.text).toContain("rig_team lists the seats you can reach");
|
||||
broken = (req) => req.method === "GET" && /\/api\/rigs\/[^/]+\/nodes$/.test(new URL(req.url).pathname)
|
||||
? new Response(JSON.stringify({ error: "boom" }), { status: 500, headers: { "Content-Type": "application/json" } }) : undefined;
|
||||
expect((await ask("rig_queue_create", { destination: "lead", body: "Unread" })).structured).toMatchObject({ code: "destination_unchecked" });
|
||||
broken = undefined;
|
||||
expect(rows()).toBe(0);
|
||||
expect((await ask("rig_queue_create", { destination: "lead", body: "Reached" })).isError).toBe(false);
|
||||
expect(rows()).toBe(1);
|
||||
});
|
||||
|
||||
it("an exact name wins over another seat's slug twin, as a name alone and as the configured receiver", async () => {
|
||||
namedSeat("team-a", "lead", "pod-lead@team-a");
|
||||
namedSeat("team-a", "other", "Pod.Lead@team-a");
|
||||
const { url } = await listen(undefined, "pod-lead@team-a");
|
||||
const ask = await connect(url);
|
||||
expect((await ask("rig_queue_create", { qitemId: "qitem-exact-name", destination: "pod-lead", body: "Exact" })).isError).toBe(false);
|
||||
expect((await ask("rig_queue_create", { qitemId: "qitem-exact-receiver", body: "Receiver" })).isError).toBe(false);
|
||||
for (const qitemId of ["qitem-exact-name", "qitem-exact-receiver"]) expect(repo.getById(qitemId)).toMatchObject({ destinationSession: "pod-lead@team-a" });
|
||||
});
|
||||
|
||||
it("a pod-member name that reached one seat still does beside a self-attached slug twin", async () => {
|
||||
namedSeat("team-a", "lead", "lead@team-a");
|
||||
namedSeat("team-a", "twin", "Pod_Lead@team-a");
|
||||
const { url } = await listen();
|
||||
const ask = await connect(url);
|
||||
expect((await ask("rig_queue_create", { qitemId: "qitem-pod-member-kept", destination: "pod-lead", body: "As before" })).isError).toBe(false);
|
||||
expect(repo.getById("qitem-pod-member-kept")).toMatchObject({ destinationSession: "lead@team-a" });
|
||||
});
|
||||
|
||||
it("without team/list, another caller's name answers the same whether or not its seat exists, before the team is read", async () => {
|
||||
let nodeReads = 0;
|
||||
broken = (req) => { if (/\/api\/rigs\/[^/]+\/nodes$/.test(new URL(req.url).pathname)) nodeReads++; return undefined; };
|
||||
const { url } = await listen();
|
||||
const ask = await connect(url);
|
||||
const answers = async () => Promise.all(["cus-other-connection", "Cus_Other_Connection"].map(async (destination) =>
|
||||
(await ask("rig_queue_create", { destination, body: "Hidden?" })).structured));
|
||||
const absent = await answers();
|
||||
namedSeat("team-a", "other-connection", "cus-other-connection@team-a");
|
||||
const present = await answers();
|
||||
expect(present).toEqual(absent);
|
||||
// As written it's out of scope, without a read; a slug of it reads as no seat at all.
|
||||
expect(absent.map((answer) => answer.code)).toEqual(["out_of_scope", "destination_not_found"]);
|
||||
expect(nodeReads).toBe(2);
|
||||
expect(rows()).toBe(0);
|
||||
});
|
||||
|
||||
it("a seat the grant can't address never shadows one it can, by name alone", async () => {
|
||||
namedSeat("team-a", "visible", "Pod_Hidden@team-a");
|
||||
const { url } = await listen();
|
||||
const ask = await connect(url);
|
||||
expect((await ask("rig_queue_create", { qitemId: "qitem-slug-alone", destination: "pod-hidden", body: "Slug" })).isError).toBe(false);
|
||||
// Another caller's seat whose pod-member form is the name exactly: hidden without team/list, so it doesn't count.
|
||||
namedSeat("team-a", "hidden", "cus-other-connection@team-a");
|
||||
expect((await ask("rig_queue_create", { qitemId: "qitem-slug-beside-hidden", destination: "pod-hidden", body: "Slug" })).isError).toBe(false);
|
||||
for (const qitemId of ["qitem-slug-alone", "qitem-slug-beside-hidden"]) expect(repo.getById(qitemId)).toMatchObject({ destinationSession: "Pod_Hidden@team-a" });
|
||||
});
|
||||
|
||||
it("a configured receiver that names only a hidden seat is unavailable, as when it names none, and never names that seat", async () => {
|
||||
const { url } = await listen(undefined, "pod-hidden@team-a");
|
||||
const ask = await connect(url);
|
||||
const none = await ask("rig_queue_create", { body: "Receiver?" });
|
||||
namedSeat("team-a", "hidden", "cus-other-connection@team-a");
|
||||
const hidden = await ask("rig_queue_create", { body: "Receiver?" });
|
||||
expect(hidden.structured).toEqual(none.structured);
|
||||
expect(hidden.structured.code).toBe("task_destination_unavailable");
|
||||
expect(JSON.stringify(hidden)).not.toContain("cus-other-connection");
|
||||
expect(rows()).toBe(0);
|
||||
namedSeat("team-a", "visible", "Pod_Hidden@team-a");
|
||||
expect((await ask("rig_queue_create", { qitemId: "qitem-receiver-beside-hidden", body: "Receiver" })).isError).toBe(false);
|
||||
expect(repo.getById("qitem-receiver-beside-hidden")).toMatchObject({ destinationSession: "Pod_Hidden@team-a" });
|
||||
});
|
||||
});
|
||||
|
||||
it("with several rigs, a name alone reaches only the configured receiver of that name; a refusal says to omit destination, without naming it", async () => {
|
||||
seat("team-a", "lead");
|
||||
seat("team-a", "other"); // A seat of one of the rigs: with several, its name alone still doesn't pick that rig.
|
||||
@@ -236,7 +349,7 @@ describe("rig mcp serve --http --grant-service on the daemon's routes", () => {
|
||||
const { url } = await listen(undefined, "pod-lead@team-a");
|
||||
const ask = await connect(url);
|
||||
expect((await ask("rig_queue_create", { qitemId: "qitem-bare-receiver", destination: "pod-lead", body: "Do this directly" })).isError).toBe(false);
|
||||
expect(repo.getById("qitem-bare-receiver")).toMatchObject({ destinationSession: "pod-lead@team-a" });
|
||||
expect(repo.getById("qitem-bare-receiver")).toMatchObject({ destinationSession: "lead@team-a" });
|
||||
for (const destination of ["pod-other", "my-lead", "pod-lead@team-c"]) {
|
||||
const refused = await ask("rig_queue_create", { destination, body: "Ambiguous or unknown" });
|
||||
expect(refused.structured).toMatchObject({ code: "out_of_scope", message: expect.stringContaining("Omit destination to reach the team's configured receiver.") });
|
||||
@@ -251,9 +364,9 @@ describe("rig mcp serve --http --grant-service on the daemon's routes", () => {
|
||||
const { url } = await listen(undefined, "pod-lead@team-b");
|
||||
const ask = await connect(url);
|
||||
expect((await ask("rig_queue_create", { qitemId: "qitem-own-lead", destination: "pod-lead", body: "Do this directly" })).isError).toBe(false);
|
||||
expect(repo.getById("qitem-own-lead")).toMatchObject({ destinationSession: "pod-lead@team-a" });
|
||||
expect(repo.getById("qitem-own-lead")).toMatchObject({ destinationSession: "lead@team-a" });
|
||||
const refused = await ask("rig_queue_create", { destination: "my-lead", body: "Nobody" });
|
||||
expect(refused.structured.code).toBe("out_of_scope");
|
||||
expect(refused.structured.code).toBe("destination_not_found");
|
||||
expect(refused.structured.message).not.toContain("Omit destination");
|
||||
expect(rows()).toBe(1);
|
||||
});
|
||||
@@ -1045,6 +1158,94 @@ describe("rig mcp serve --http --grant-service on the daemon's routes", () => {
|
||||
expect((await asExternal("rig_queue_create", { destination: "lead@team-a", body: "far", qitemId: far })).text).not.toContain("other-host");
|
||||
});
|
||||
|
||||
describe("a fair-use throttle on an active answer", () => {
|
||||
const ALL = { queue: ["create", "show", "list", "claim", "update"] };
|
||||
const throttled = (throttle: unknown) => (): [number, unknown] => [200, active({ objects: ALL, throttle })];
|
||||
|
||||
it("holds back only new creates, which write nothing and say when to retry with the same id; a replay, claim, update, show and list are served", async () => {
|
||||
answer = () => [200, active({ objects: ALL })];
|
||||
const { url } = await listen();
|
||||
const ask = await connect(url);
|
||||
const landed = { destination: "lead@team-a", body: "First task", qitemId: "qitem-landed" };
|
||||
expect((await ask("rig_queue_create", landed)).isError).toBe(false);
|
||||
const theirs = (await create("lead@team-a", CALLER, "A task for the assistant")).qitemId;
|
||||
answer = throttled({ new_work: true, retry_after_seconds: 30 });
|
||||
const before = rows();
|
||||
for (const args of [
|
||||
{ destination: "lead@team-a", body: "New work" },
|
||||
{ destination: "lead@team-a", body: "New work", qitemId: "qitem-new" },
|
||||
{ destination: "lead@team-a", body: "Improve it", revises: landed.qitemId },
|
||||
] as Array<Record<string, string>>) {
|
||||
const held = await ask("rig_queue_create", args);
|
||||
expect(held.isError).toBe(true);
|
||||
expect(held.structured).toMatchObject({ ok: false, code: "fair_use_retry_later", retryable: true, retryAfterSeconds: 30, outcome: "not_written" });
|
||||
expect(held.structured.earlierAttempt).toBeUndefined();
|
||||
expect(held.text).toContain("Nothing was created by this call.");
|
||||
expect(held.text).toContain(`Retry after 30 seconds with the same qitemId ${held.structured.qitemId}.`);
|
||||
if (args.qitemId) expect(held.structured.qitemId).toBe(args.qitemId);
|
||||
}
|
||||
expect(rows()).toBe(before);
|
||||
expect(repo.getById("qitem-new")).toBeFalsy();
|
||||
const replay = await ask("rig_queue_create", landed);
|
||||
expect(replay.isError).toBe(false);
|
||||
expect(replay.structured.qitemId).toBe(landed.qitemId);
|
||||
expect((await ask("rig_queue_claim", { qitemId: theirs })).isError).toBe(false);
|
||||
expect((await ask("rig_queue_update", { qitemId: theirs, state: "done", closureReason: "no-follow-on", resolution: "Done" })).isError).toBe(false);
|
||||
expect(repo.getById(theirs)).toMatchObject({ state: "done", resolution: "Done" });
|
||||
expect((await ask("rig_queue_show", { qitemId: landed.qitemId })).isError).toBe(false);
|
||||
expect((await ask("rig_queue_list", {})).isError).toBe(false);
|
||||
expect(rows()).toBe(before);
|
||||
// Once the service lifts it, the same retry lands.
|
||||
answer = () => [200, active({ objects: ALL })];
|
||||
expect((await ask("rig_queue_create", { destination: "lead@team-a", body: "New work", qitemId: "qitem-new" })).isError).toBe(false);
|
||||
expect(rows()).toBe(before + 1);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["a string", "yes"],
|
||||
["a list", [true]],
|
||||
["new_work as text", { new_work: "true", retry_after_seconds: 30 }],
|
||||
["a delay as text", { new_work: true, retry_after_seconds: "30" }],
|
||||
["no delay", { new_work: true }],
|
||||
])("ignores a throttle it can't read (%s): nothing is refused, and the log says so", async (_label, throttle) => {
|
||||
const logged = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
answer = throttled(throttle);
|
||||
const { url } = await listen();
|
||||
const ask = await connect(url);
|
||||
expect((await ask("rig_queue_create", { destination: "lead@team-a", body: "Served" })).isError).toBe(false);
|
||||
expect(rows()).toBe(1);
|
||||
expect(logged.mock.calls.some(([line]) => String(line).includes("answered an unusable throttle, so it's ignored"))).toBe(true);
|
||||
logged.mockRestore();
|
||||
});
|
||||
|
||||
it("serves everything when new_work is false, and keeps a delay within 1 to 600 seconds, rounded up", async () => {
|
||||
answer = throttled({ new_work: false, retry_after_seconds: 30 });
|
||||
const { url } = await listen();
|
||||
const ask = await connect(url);
|
||||
expect((await ask("rig_queue_create", { destination: "lead@team-a", body: "Served" })).isError).toBe(false);
|
||||
for (const [given, kept] of [[0, 1], [-5, 1], [12.2, 13], [9000, 600]]) {
|
||||
answer = throttled({ new_work: true, retry_after_seconds: given });
|
||||
expect((await ask("rig_queue_create", { destination: "lead@team-a", body: "Held" })).structured.retryAfterSeconds).toBe(kept);
|
||||
}
|
||||
expect(rows()).toBe(1);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["answers an error", () => new Response(JSON.stringify({ error: "boom" }), { status: 500, headers: { "Content-Type": "application/json" } })],
|
||||
["answers a body it can't parse", () => new Response("{", { status: 200, headers: { "Content-Type": "application/json" } })],
|
||||
])("writes nothing, and says an earlier attempt is unknown, when the check of whether the id landed %s", async (_label, failure) => {
|
||||
answer = throttled({ new_work: true, retry_after_seconds: 5 });
|
||||
const { url } = await listen();
|
||||
const ask = await connect(url);
|
||||
broken = (req) => req.method === "GET" && new URL(req.url).pathname === "/api/queue/qitem-unread" ? failure() : undefined;
|
||||
const held = await ask("rig_queue_create", { destination: "lead@team-a", body: "Maybe", qitemId: "qitem-unread" });
|
||||
broken = undefined;
|
||||
expect(held.structured).toMatchObject({ code: "fair_use_retry_later", outcome: "not_written", earlierAttempt: "unknown", qitemId: "qitem-unread" });
|
||||
expect(held.text).toContain("Whether an earlier attempt with this id was saved is unknown");
|
||||
expect(rows()).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
it("a task parked on an internal child reads blocked, with no child id, owner or state anywhere", async () => {
|
||||
const { asExternal, asLead, mine } = await externalAndLead();
|
||||
const child = await create("lead@team-a", "qa@team-b", "SECRET child");
|
||||
|
||||
@@ -50,6 +50,8 @@ describe("agent network increment A: OAuth-connected assistants work with each o
|
||||
let serviceUrl: string;
|
||||
/** What each connection's grant names, by key; a key set to "revoked" is answered revoked. */
|
||||
let grants: Map<string, Record<string, string[]> | "revoked">;
|
||||
/** The keys whose active answer carries the grant service's fair-use throttle on new work. */
|
||||
let throttled: Set<string>;
|
||||
const closers: Array<() => Promise<void>> = [];
|
||||
const saved = { home: process.env.OPENRIG_HOME, session: process.env.OPENRIG_SESSION_NAME };
|
||||
|
||||
@@ -65,6 +67,7 @@ describe("agent network increment A: OAuth-connected assistants work with each o
|
||||
daemonUrl = `http://127.0.0.1:${(daemon.address() as AddressInfo).port}`;
|
||||
setSelfHostId(INSTANCE);
|
||||
grants = new Map([[A.key, NETWORK], [B.key, NETWORK]]);
|
||||
throttled = new Set();
|
||||
service = http.createServer((req, res) => {
|
||||
req.resume();
|
||||
req.on("end", () => {
|
||||
@@ -72,7 +75,8 @@ describe("agent network increment A: OAuth-connected assistants work with each o
|
||||
const who = key === A.key ? A : key === B.key ? B : null;
|
||||
const objects = who ? grants.get(who.key) : undefined;
|
||||
const body = !who || !objects ? { schema: SCHEMA, status: "unknown" }
|
||||
: objects === "revoked" ? { schema: SCHEMA, status: "revoked" } : active(who, objects);
|
||||
: objects === "revoked" ? { schema: SCHEMA, status: "revoked" }
|
||||
: { ...active(who, objects), ...(throttled.has(who.key) ? { throttle: { new_work: true, retry_after_seconds: 20 } } : {}) };
|
||||
res.writeHead(200, { "Content-Type": "application/json" }).end(JSON.stringify(body));
|
||||
});
|
||||
});
|
||||
@@ -113,7 +117,7 @@ describe("agent network increment A: OAuth-connected assistants work with each o
|
||||
const result = await client.callTool({ name, arguments: args });
|
||||
return { isError: result.isError === true, structured: result.structuredContent as Record<string, any>, text: (result.content as Array<{ text: string }>)[0]?.text ?? "" };
|
||||
};
|
||||
return Object.assign(call, { tools: async () => (await client.listTools()).tools.map((tool) => tool.name).sort() });
|
||||
return Object.assign(call, { tools: async () => (await client.listTools()).tools.map((tool) => tool.name).sort(), instructions: () => client.getInstructions() });
|
||||
}
|
||||
|
||||
const rows = () => (db.prepare("SELECT COUNT(*) AS n FROM queue_items").get() as { n: number }).n;
|
||||
@@ -174,6 +178,42 @@ describe("agent network increment A: OAuth-connected assistants work with each o
|
||||
expect((await a("rig_queue_show", { qitemId: reply })).text).toContain("Agreed; I'll review the fix.");
|
||||
});
|
||||
|
||||
it("under a fair-use throttle, B answers A's message on the same item and A reads that answer through its own grant; B's separate new message waits", async () => {
|
||||
const served = await listen();
|
||||
const a = await connect(served.url, A);
|
||||
const b = await connect(served.url, B);
|
||||
expect(b.instructions()).toContain("with your reply in resolution when it needs one; its sender reads that reply on the same item");
|
||||
expect(b.instructions()).not.toContain("with a new item to its sender");
|
||||
const message = createdId(await a("rig_queue_create", { destination: B.seat, summary: "Which fix first?", body: "Smaller fix or the full one?", tags: ["message"] }));
|
||||
throttled.add(B.key);
|
||||
const before = rows();
|
||||
expect((await b("rig_queue_list", { owned: true })).text).toContain(message);
|
||||
expect((await b("rig_queue_claim", { qitemId: message })).isError).toBe(false);
|
||||
expect((await b("rig_queue_update", { qitemId: message, state: "done", closureReason: "no-follow-on", resolution: "The smaller fix first." })).isError).toBe(false);
|
||||
const separate = await b("rig_queue_create", { destination: A.seat, summary: "Also", body: "One more thing.", tags: ["message"] });
|
||||
expect(separate).toMatchObject({ isError: true, structured: { code: "fair_use_retry_later", retryAfterSeconds: 20, outcome: "not_written" } });
|
||||
expect(rows()).toBe(before);
|
||||
// A, the sender, isn't throttled, and reads B's answer on its own item through its own grant's ordinary show and list.
|
||||
const answered = await a("rig_queue_show", { qitemId: message });
|
||||
expect(answered.structured.item).toMatchObject({ state: "done", resolution: "The smaller fix first.", destinationSession: B.seat });
|
||||
expect((await a("rig_queue_list", { mine: true, all: true })).text).toContain("The smaller fix first.");
|
||||
});
|
||||
|
||||
it("under a fair-use throttle, A answers the lead's message on the same item and the lead reads it on its ordinary read; A's new item to the lead waits", async () => {
|
||||
const served = await listen();
|
||||
const a = await connect(served.url, A);
|
||||
const message = (await repo.create({ sourceSession: LEAD, destinationSession: A.seat, body: "Can you take the review?", nudge: false } as never)).qitemId;
|
||||
throttled.add(A.key);
|
||||
const before = rows();
|
||||
expect((await a("rig_queue_claim", { qitemId: message })).isError).toBe(false);
|
||||
expect((await a("rig_queue_update", { qitemId: message, state: "done", closureReason: "no-follow-on", resolution: "Yes, after lunch." })).isError).toBe(false);
|
||||
expect((await a("rig_queue_create", { destination: LEAD, body: "Starting the review." })).structured).toMatchObject({ code: "fair_use_retry_later", outcome: "not_written" });
|
||||
expect(rows()).toBe(before);
|
||||
const read = await fetch(`${daemonUrl}/api/queue/${encodeURIComponent(message)}`, { headers: { "X-OpenRig-Session": LEAD } });
|
||||
expect(read.status).toBe(200);
|
||||
expect(await read.json()).toMatchObject({ sourceSession: LEAD, state: "done", resolution: "Yes, after lunch." });
|
||||
});
|
||||
|
||||
it("B hands work on to the team and reads the team's result; A's task and B's task are separate items, each with its own result", async () => {
|
||||
const served = await listen();
|
||||
const b = await connect(served.url, B);
|
||||
|
||||
@@ -96,7 +96,8 @@ describe("MCP operation table against the daemon", () => {
|
||||
external: { objects: { question: ["read", "answer"] }, person: "ada" } } });
|
||||
const personTools = (await asPerson.listTools()).tools;
|
||||
expect(personTools.map((tool) => tool.name).sort()).toEqual(MCP_OPERATIONS.filter((op) => op.access === "person").map((op) => op.tool).sort());
|
||||
const hints = Object.fromEntries([...tools, ...personTools].map((tool) => [tool.name, tool.annotations]));
|
||||
// A title is a display name, not a hint.
|
||||
const hints = Object.fromEntries([...tools, ...personTools].map((tool) => [tool.name, { ...tool.annotations, title: undefined }]));
|
||||
for (const op of MCP_OPERATIONS) {
|
||||
expect(hints[op.tool], op.tool).toEqual(HINTS[op.effect]);
|
||||
// Every tool says who an authenticated HTTP caller must be to be offered it.
|
||||
|
||||
@@ -4,7 +4,7 @@ import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
||||
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
|
||||
import { createMcpServer, dropUnfilledSeat, mintUnknownSender, FRONT_DOOR_WORKFLOW, FRONT_DOOR_WORKFLOW_VERSION, MEMBER_WORKFLOW, MEMBER_WORKFLOW_VERSION, type McpAccess, type UnfilledSeat } from "../src/mcp-server.js";
|
||||
import { createMcpServer, dropUnfilledSeat, mintUnknownSender, FRONT_DOOR_WORKFLOW, FRONT_DOOR_WORKFLOW_VERSION, MEMBER_WORKFLOW, MEMBER_WORKFLOW_VERSION, PLAIN_WORDING, type McpAccess, type UnfilledSeat } from "../src/mcp-server.js";
|
||||
import { DaemonClient, DaemonConnectionError, senderIdentityHeaders, type DaemonResponse } from "../src/client.js";
|
||||
|
||||
function mockClient(overrides?: {
|
||||
@@ -246,7 +246,8 @@ describe("MCP Server", () => {
|
||||
// A delegated decision closes the person's ask; a handoff closes the item passed on; an unclaim ends a claim.
|
||||
const destructive = ["rig_bind", "rig_discover", "rig_down", "rig_queue_claim", "rig_queue_decide", "rig_queue_handoff", "rig_queue_unclaim",
|
||||
"rig_queue_update", "rig_restore", "rig_send", "rig_up"];
|
||||
const hints = Object.fromEntries(tools.map((tool) => [tool.name, tool.annotations]));
|
||||
// A title is a display name, not a hint (the plain-wording test checks it).
|
||||
const hints = Object.fromEntries(tools.map((tool) => [tool.name, { ...tool.annotations, title: undefined }]));
|
||||
expect(Object.keys(hints).sort()).toEqual([...reads, ...additive, ...destructive].sort());
|
||||
for (const name of reads) expect(hints[name], name).toEqual({ readOnlyHint: true });
|
||||
for (const name of additive) expect(hints[name], name).toEqual({ readOnlyHint: false, destructiveHint: false });
|
||||
@@ -881,6 +882,54 @@ describe("MCP Server", () => {
|
||||
await server.close();
|
||||
});
|
||||
|
||||
it("shows a connected assistant's person plain titles and openings, keeping each tool's rules and hints", async () => {
|
||||
// What a person sees: each tool's title and the first sentence of its description.
|
||||
const plain: Record<string, [title: string, sentence: string]> = {
|
||||
rig_queue_list: ["List my OpenRig requests", "Lists the requests addressed to this assistant, or that it sent."],
|
||||
rig_queue_show: ["Show one request", "Shows one request, who picked it up, and its answer."],
|
||||
rig_queue_create: ["Send a request", "Sends a request to another assistant on your team."],
|
||||
rig_queue_claim: ["Pick up a request", "Marks a request addressed to this assistant as picked up, so others see it's being worked on."],
|
||||
rig_queue_update: ["Answer a request", "Adds this assistant's answer to a request, or a note while it works."],
|
||||
rig_artifact_read: ["Read a shared file", "Reads a file attached to a request."],
|
||||
rig_team: ["Show my team", "Shows the assistants on your team, each one's address for sending it a request, and which one leads."],
|
||||
rig_whoami: ["Show this connection", "Shows which assistant this connection is and which team it belongs to."],
|
||||
};
|
||||
expect(Object.keys(PLAIN_WORDING).sort()).toEqual(Object.keys(plain).sort());
|
||||
const catalog = async (options?: Parameters<typeof createMcpServer>[1]) => {
|
||||
const server = createMcpServer(mockClient(), options);
|
||||
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
||||
const client = new Client({ name: "catalog-client", version: "1.0.0" });
|
||||
await server.connect(serverTransport);
|
||||
await client.connect(clientTransport);
|
||||
const { tools } = await client.listTools();
|
||||
await client.close();
|
||||
await server.close();
|
||||
return new Map(tools.map((tool) => [tool.name, tool]));
|
||||
};
|
||||
const local = await catalog();
|
||||
const external = await catalog({ seat: "cus-1-caller@team", access: { principal: "cus-1", owner: false, rigs: ["team"], roots: [], workspace: false,
|
||||
external: { objects: { queue: ["create", "show", "list", "claim", "update"], team: ["list"], artifact: ["read"] } } } });
|
||||
for (const [name, [title, sentence]] of Object.entries(plain)) {
|
||||
const own = local.get(name)!;
|
||||
// rig_whoami isn't offered to an external grant, and its plain text is true for every caller, so the local one shows it.
|
||||
const shown = name === "rig_whoami" ? own : external.get(name)!;
|
||||
expect(shown.title, name).toBe(title);
|
||||
expect(shown.annotations?.title, name).toBe(title);
|
||||
expect(shown.description?.slice(0, sentence.length + 1), name).toBe(`${sentence} `);
|
||||
if (name === "rig_whoami") continue;
|
||||
// Only the opening changes: every rule after it, and each hint, is the tool's own.
|
||||
const { replaces, with: opening } = PLAIN_WORDING[name]!;
|
||||
expect(own.description?.startsWith(replaces), name).toBe(true);
|
||||
expect(shown.description, name).toBe(opening + own.description!.slice(replaces.length));
|
||||
expect({ ...shown.annotations, title: undefined }, name).toEqual(own.annotations);
|
||||
// A local caller keeps the tool's own text, which says what only it can do.
|
||||
expect(own.title, name).toBeUndefined();
|
||||
expect(own.annotations?.title, name).toBeUndefined();
|
||||
}
|
||||
expect(local.get("rig_whoami")?.description).toBe("Shows which assistant this connection is and which team it belongs to. It answers with its rig, pod, peers and edges, and caller: authenticated (a validated HTTP credential, with its principal) or declared (OPENRIG_SESSION_NAME where the server was configured, which nothing verifies).");
|
||||
expect(local.get("rig_whoami")?.annotations).toEqual({ readOnlyHint: true, title: "Show this connection" });
|
||||
});
|
||||
|
||||
it("rig_down calls POST /api/down with correct params", async () => {
|
||||
const postFn = vi.fn(async () => ({
|
||||
status: 200,
|
||||
|
||||
@@ -1278,7 +1278,8 @@ reaching them is ordinary work rather than an escalation.
|
||||
- **The `relay` gateway connector** carries a registered person's questions, updates and notices to a
|
||||
bridge process you run, over loopback HTTP, and takes their replies back with the sender resolved
|
||||
against their bindings. It's off until a person has a relay binding (`rig gateway human add <id> …
|
||||
--binding "relay:<connectorRef>:file:<absolute env file>:primary"`), and the bridge, with its reach to
|
||||
--binding "relay:<connectorRef>:file:<absolute env file>:primary:handle=<alias>"`). The handle is how
|
||||
their replies are matched to them: a binding without one is outbound-only. The bridge, with its reach to
|
||||
any messaging platform, is yours (`docs/reference/gateway-relay.md`).
|
||||
- **`rig gateway channel add <id> --display-name <name> --binding
|
||||
slack:<connectorRef>:<secretsRef>:<channelId>`** — a Slack shared channel as one participant,
|
||||
@@ -1597,8 +1598,11 @@ your circumstances is configuration, and the ones that are not, another agent ca
|
||||
without that storage), so don't copy them by hand; read the `plugin_skill_*` warnings if one
|
||||
is missing. TDD is no longer a default skill.
|
||||
- **`on_restore: follow_source`** on a spec's `resources.skills[]` entry refreshes that skill from its source when
|
||||
a Claude or Codex seat is restored, keeping its conversation; `keep_saved`, the default, keeps the saved copy, but a
|
||||
skill whose source changed after `rig down` comes back missing, named in the restore's warnings.
|
||||
a Claude or Codex seat is restored, keeping its conversation, while the seat's copy is still what OpenRig wrote: an
|
||||
edited, unrecorded or linked copy is kept and named, and a source that can't be read leaves the saved copy.
|
||||
`keep_saved`, the default, keeps the saved copy, but a missing file comes back only from a source that still has the
|
||||
bytes OpenRig wrote: a file whose source changed after `rig down` stays missing, named in the restore's warnings,
|
||||
even when the rest of the skill (its `SKILL.md`, say) is unchanged.
|
||||
- **`rig startup-proof submit`** — answer an explicitly selected authenticated startup challenge.
|
||||
Startup adds no proof exercise by default. Declare `startup_proof` with `authenticated` or
|
||||
`none` in the applicable startup layers; see `docs/reference/rig-spec.md#startup-block/startup-proof-selection`.
|
||||
|
||||
@@ -12,6 +12,7 @@ import { networkReceiptsSchema } from "./migrations/161_network_receipts.js";
|
||||
import { sessionAddressReadIndexesSchema } from "./migrations/162_session_address_read_indexes.js";
|
||||
import { networkInstallationsSchema } from "./migrations/163_network_installations.js";
|
||||
import { queueItemsReplyToIndexSchema } from "./migrations/164_queue_items_reply_to_index.js";
|
||||
import { queueListReadIndexesSchema } from "./migrations/166_queue_list_read_indexes.js";
|
||||
import { activityGrantsSchema } from "./migrations/143_activity_grants.js";
|
||||
import { environmentBindingsSchema } from "./migrations/144_environment_bindings.js";
|
||||
import { nodePermissionsToSchema } from "./migrations/145_node_permissions_to.js";
|
||||
@@ -331,4 +332,5 @@ export const ALL_MIGRATIONS: Migration[] = [
|
||||
sessionAddressReadIndexesSchema,
|
||||
networkInstallationsSchema,
|
||||
queueItemsReplyToIndexSchema,
|
||||
queueListReadIndexesSchema,
|
||||
];
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { Migration } from "../migrate.js";
|
||||
|
||||
/** Bound queue selection by its existing order, and notification reads by their eligible history. */
|
||||
export const queueListReadIndexesSchema: Migration = {
|
||||
name: "166_queue_list_read_indexes.sql",
|
||||
sql: `
|
||||
CREATE INDEX IF NOT EXISTS idx_queue_items_created
|
||||
ON queue_items(ts_created DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_queue_items_active_created
|
||||
ON queue_items(CASE WHEN state IN ('pending', 'in-progress', 'blocked') THEN 0 ELSE 1 END, ts_created DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_queue_items_active_updated
|
||||
ON queue_items(CASE WHEN state IN ('pending', 'in-progress', 'blocked') THEN 0 ELSE 1 END, ts_updated DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_queue_transitions_owner_notification_qitem
|
||||
ON queue_transitions(qitem_id, transition_id DESC) WHERE owner_notification_level IS NOT NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_queue_transitions_archive_owner_notification_qitem
|
||||
ON queue_transitions_archive(qitem_id, transition_id DESC) WHERE owner_notification_level IS NOT NULL;
|
||||
`,
|
||||
};
|
||||
@@ -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));
|
||||
|
||||
@@ -42,7 +42,9 @@ export function freshRecoveryId(db: Database.Database, qitemId: string, overdueD
|
||||
* idx_queue_items_recovery_tags' WHERE clause (migration 099) byte for byte, which is what lets the
|
||||
* planner read that small index instead of scanning and parsing every row. */
|
||||
const RECOVERY_TAG_ROWS = `(tags LIKE '%"recovery-for:%' OR tags LIKE '%\\%')`;
|
||||
const RECOVERY_ORDER = "ORDER BY CASE WHEN state IN ('pending','in-progress','blocked') THEN 0 ELSE 1 END, ts_updated DESC, qitem_id DESC LIMIT 1";
|
||||
// Unary + preserves the numeric CASE order but prevents a prefix match on the full-history listing
|
||||
// index. Both standalone lookups and the batch need the small recovery-tag index instead.
|
||||
const RECOVERY_ORDER = "ORDER BY +CASE WHEN state IN ('pending','in-progress','blocked') THEN 0 ELSE 1 END, ts_updated DESC, qitem_id DESC LIMIT 1";
|
||||
/** A listing's recovery index, built by SQLite's own json_each (the same values, and the same truthy excludeTag
|
||||
* test, as the per-row query): for each string tag value, the rows that carry it in RECOVERY_ORDER, and each row's
|
||||
* string values. Built lazily, on the first lookup inside `withRecoveryIndex`, so a listing that never asks (a
|
||||
|
||||
@@ -2,7 +2,7 @@ import { readFileSync } from "node:fs";
|
||||
import { createHash } from "node:crypto";
|
||||
import { join, isAbsolute } from "node:path";
|
||||
import { getOpenRigHome } from "../../openrig-compat.js";
|
||||
import { SETTINGS_VALID_KEYS, type SettingsStore, type ResolvedSetting } from "./settings-store.js";
|
||||
import { SETTINGS_VALID_KEYS, describeSettingKey, type SettingKeyDescription, type SettingsStore, type ResolvedSetting } from "./settings-store.js";
|
||||
import { connectionsProjection, readConnectionConfiguration } from "../gateway/connections-projection.js";
|
||||
import { DEFAULT_CONFIG, INHERIT_INBOUND_DESTINATION, LEGACY_INBOUND_DESTINATION } from "../gateway/slack/config.js";
|
||||
import { projectionPath } from "../gateway/human-registry.js";
|
||||
@@ -13,7 +13,13 @@ import { describeDefaultReceiver, type DefaultReceiver } from "../default-receiv
|
||||
type Value = string | number | boolean | null;
|
||||
type State = "available" | "missing" | "malformed" | "unavailable";
|
||||
type Kind = "scalar" | "text" | "path" | "identity" | "url" | "names" | "paths" | "withheld";
|
||||
export interface BrowserEntry {
|
||||
/** Each is optional and separate: the rules a value must meet, whether a write route exists for the key, when a change is
|
||||
* known to take effect, and (above, on the entry) whether this caller is shown the value. None implies another. */
|
||||
export interface BrowserEntryMetadata extends SettingKeyDescription {
|
||||
/** "daemon_restart" when the application guidance says so; otherwise "unknown": OpenRig does not guess. */
|
||||
appliesAt: "daemon_restart" | "unknown";
|
||||
}
|
||||
export interface BrowserEntry extends BrowserEntryMetadata {
|
||||
key: string;
|
||||
group: "general" | "slack" | "people" | "hosts" | "health";
|
||||
value: Value;
|
||||
@@ -90,6 +96,8 @@ function sourceFile(file: string): { state: State; bytes: string | null } {
|
||||
try { return { state: "available", bytes: readFileSync(file, "utf8") }; }
|
||||
catch (error) { return { state: (error as NodeJS.ErrnoException).code === "ENOENT" ? "missing" : "unavailable", bytes: null }; }
|
||||
}
|
||||
/** The keys whose guidance below says a daemon restart or startup applies them. The browser test ties this to that text. */
|
||||
const DAEMON_RESTART_KEYS = new Set(["terminal.tmux_server", "retention.enabled"]);
|
||||
function application(key: string): string {
|
||||
if (key === "terminal.tmux_server") return "Selected at daemon restart; surviving sessions keep their previous server until deliberately stopped. See terminal status for the effective choice.";
|
||||
if (key === "ui.terminal.max_live_terminals") return "Legacy web client; unset resolver value, consumer fallback 2. No TUI limit implied.";
|
||||
@@ -119,7 +127,8 @@ export function settingsBrowser(store: SettingsStore, gateway: Record<string, un
|
||||
const d = safeValue(defaultValue, kind, redact);
|
||||
entries.push({ key, group, value: v.value, defaultValue: d.value, defaultKnown: defaultValue !== undefined && !["people", "hosts"].includes(group), source,
|
||||
visibility: source === "unavailable" ? "unavailable" : v.value === null && v.reason ? "withheld" : "shown",
|
||||
reason: v.reason ?? d.reason, scope, application: applied });
|
||||
reason: v.reason ?? d.reason, scope, application: applied,
|
||||
...describeSettingKey(key), appliesAt: DAEMON_RESTART_KEYS.has(key) ? "daemon_restart" : "unknown" });
|
||||
}
|
||||
const file = sourceFile(store.configPath);
|
||||
let generalState = file.state;
|
||||
|
||||
@@ -842,6 +842,20 @@ function coerceValue(key: SettingsValidKey, raw: string, workspaceRoot: string):
|
||||
// in `set()`. Lockstep with cli/src/config-store.ts KEY_CONSTRAINTS so the
|
||||
// daemon's HTTP write surface (/api/config POST) rejects the same input
|
||||
// the CLI rejects.
|
||||
/** What a validator can say about the values it accepts, kept on the validator itself so it cannot drift from it. */
|
||||
interface ConstraintMeta { choices?: readonly string[]; format?: string }
|
||||
type KeyConstraint = ((raw: string, coerced: string | number | boolean) => void) & { meta?: ConstraintMeta };
|
||||
function described(meta: ConstraintMeta, check: (raw: string, coerced: string | number | boolean) => void): KeyConstraint {
|
||||
return Object.assign(check, { meta });
|
||||
}
|
||||
/** A closed list of accepted strings: the list is both what is enforced and what the browser view reports. */
|
||||
function choiceConstraint(choices: readonly string[], message: (raw: string) => string, trim = false): KeyConstraint {
|
||||
return described({ choices }, (raw) => {
|
||||
if (!choices.includes(trim ? (raw ?? "").trim() : raw)) throw new Error(message(raw));
|
||||
});
|
||||
}
|
||||
const PROVIDER_SELECTED_CHOICES: readonly string[] = ["", "claude", "codex", "claude,codex", "codex,claude"];
|
||||
|
||||
function positiveIntegerConstraint(key: string) {
|
||||
return (raw: string, coerced: string | number | boolean): void => {
|
||||
if (!/^\d+$/.test((raw ?? "").trim()) || typeof coerced !== "number" || !Number.isInteger(coerced) || coerced <= 0) {
|
||||
@@ -850,12 +864,6 @@ function positiveIntegerConstraint(key: string) {
|
||||
};
|
||||
}
|
||||
|
||||
function wakeHumanRungConstraint(raw: string): void {
|
||||
if (raw !== "always" && raw !== "explicit-only") {
|
||||
throw new Error(`Invalid value for queue.wake_human_rung: must be "always" or "explicit-only", got "${raw}"`);
|
||||
}
|
||||
}
|
||||
|
||||
function percentageConstraint(key: string) {
|
||||
return (raw: string, coerced: string | number | boolean): void => {
|
||||
if (!/^\d+$/.test((raw ?? "").trim())
|
||||
@@ -868,15 +876,10 @@ function percentageConstraint(key: string) {
|
||||
};
|
||||
}
|
||||
|
||||
const KEY_CONSTRAINTS: Partial<Record<SettingsValidKey, (raw: string, coerced: string | number | boolean) => void>> = {
|
||||
"terminal.tmux_server": (raw) => {
|
||||
if (raw !== "inherit" && raw !== "isolated") {
|
||||
throw new Error(`Invalid value for terminal.tmux_server: must be "inherit" or "isolated", got "${raw}"`);
|
||||
}
|
||||
},
|
||||
"daemon.db_conflict": (raw) => {
|
||||
if (raw !== "warn" && raw !== "stop") throw new Error("daemon.db_conflict must be warn or stop");
|
||||
},
|
||||
const KEY_CONSTRAINTS: Partial<Record<SettingsValidKey, KeyConstraint>> = {
|
||||
"terminal.tmux_server": choiceConstraint(["inherit", "isolated"],
|
||||
(raw) => `Invalid value for terminal.tmux_server: must be "inherit" or "isolated", got "${raw}"`),
|
||||
"daemon.db_conflict": choiceConstraint(["warn", "stop"], () => "daemon.db_conflict must be warn or stop"),
|
||||
"daemon.seat_poll_interval_ms": (raw, value) => {
|
||||
// Startup-only: the seat pollers read it once when the daemon starts. Only slower than the 1 s default, up to the
|
||||
// ceiling the structural observation's expiry allows (seat-activity-service.ts MAX_SEAT_POLL_INTERVAL_MS).
|
||||
@@ -884,9 +887,8 @@ const KEY_CONSTRAINTS: Partial<Record<SettingsValidKey, (raw: string, coerced: s
|
||||
throw new Error("Invalid daemon.seat_poll_interval_ms: must be a whole number of milliseconds in [1000, 2000]");
|
||||
}
|
||||
},
|
||||
"transport.draft_delivery": (raw) => {
|
||||
if (raw !== "refuse" && raw !== "deliver") throw new Error(`Invalid value for transport.draft_delivery: must be "refuse" or "deliver", got "${raw}"`);
|
||||
},
|
||||
"transport.draft_delivery": choiceConstraint(["refuse", "deliver"],
|
||||
(raw) => `Invalid value for transport.draft_delivery: must be "refuse" or "deliver", got "${raw}"`),
|
||||
"transcripts.lines": (raw, value) => {
|
||||
if (!/^\d+$/.test(raw.trim()) || typeof value !== "number" || !Number.isSafeInteger(value) || value < 1 || value > 1_000_000) {
|
||||
throw new Error("Invalid transcripts.lines: must be an integer in [1, 1000000]");
|
||||
@@ -928,37 +930,34 @@ const KEY_CONSTRAINTS: Partial<Record<SettingsValidKey, (raw: string, coerced: s
|
||||
throw new Error(`Invalid value for daemon.port: must be an integer in [0, 65535], got "${raw}"`);
|
||||
}
|
||||
},
|
||||
"ui.timezone": (_raw, value) => {
|
||||
"ui.timezone": described({ format: "iana-timezone" }, (_raw, value) => {
|
||||
try {
|
||||
if (typeof value !== "string" || !value || /^[+-]/.test(value)) throw new Error();
|
||||
new Intl.DateTimeFormat("en-US", { timeZone: value });
|
||||
} catch { throw new Error("Invalid ui.timezone: use an IANA timezone such as America/Los_Angeles or Europe/London"); }
|
||||
},
|
||||
}),
|
||||
"health.context_pressure.warning_percent": percentageConstraint("health.context_pressure.warning_percent"),
|
||||
"health.context_pressure.critical_percent": percentageConstraint("health.context_pressure.critical_percent"),
|
||||
"policies.idle_gate_qitem.scan_interval_seconds": positiveIntegerConstraint("policies.idle_gate_qitem.scan_interval_seconds"),
|
||||
"policies.idle_gate_qitem.active_wake_interval_seconds": positiveIntegerConstraint("policies.idle_gate_qitem.active_wake_interval_seconds"),
|
||||
"policies.idle_gate_qitem.auto_register": (raw) => {
|
||||
const v = (raw ?? "").trim();
|
||||
if (v !== "off" && v !== "all") {
|
||||
throw new Error(`Invalid value for policies.idle_gate_qitem.auto_register: must be "off" or "all", got "${raw}"`);
|
||||
}
|
||||
},
|
||||
"policies.idle_gate_qitem.auto_register": choiceConstraint(["off", "all"],
|
||||
(raw) => `Invalid value for policies.idle_gate_qitem.auto_register: must be "off" or "all", got "${raw}"`, true),
|
||||
// Policy threshold: integer in [1, 100]. Documented contract from
|
||||
// slice 27 README. parseInt's permissive coercion ("80abc" → 80;
|
||||
// "80.5" → 80) is not safe for a key the daemon's compaction trigger
|
||||
// reads at every poll tick; runtime validator rejects what the
|
||||
// contract forbids per banked feedback_static_gates_mirror_runtime_validators.
|
||||
"provider.selected": (_raw, value) => {
|
||||
if (typeof value !== "string" || !/^(|claude|codex|claude,codex|codex,claude)$/.test(value)) {
|
||||
// Checks the typed value too: a config.json array would otherwise pass as its String() form.
|
||||
"provider.selected": described({ choices: PROVIDER_SELECTED_CHOICES }, (_raw, value) => {
|
||||
if (typeof value !== "string" || !PROVIDER_SELECTED_CHOICES.includes(value)) {
|
||||
throw new Error("Invalid provider.selected: use claude, codex, claude,codex, or an empty string for no recorded choice");
|
||||
}
|
||||
},
|
||||
"policies.claude_compaction.preparation_root": (_raw, value) => {
|
||||
}),
|
||||
"policies.claude_compaction.preparation_root": described({ format: "absolute-path-or-empty" }, (_raw, value) => {
|
||||
if (typeof value !== "string" || (value !== "" && (!path.isAbsolute(value) || value.includes("\0")))) {
|
||||
throw new Error("Invalid policies.claude_compaction.preparation_root: use an absolute path or an empty string for the default");
|
||||
}
|
||||
},
|
||||
}),
|
||||
"policies.claude_compaction.threshold_percent": (raw, coerced) => {
|
||||
const trimmed = (raw ?? "").trim();
|
||||
if (!/^-?\d+$/.test(trimmed)) {
|
||||
@@ -1049,12 +1048,12 @@ const KEY_CONSTRAINTS: Partial<Record<SettingsValidKey, (raw: string, coerced: s
|
||||
}
|
||||
},
|
||||
// Empty (the default) keeps no erasure ledger; otherwise an absolute file path, kept outside the backed-up data.
|
||||
"retention.erasure_ledger": (raw) => {
|
||||
"retention.erasure_ledger": described({ format: "absolute-path-or-empty" }, (raw) => {
|
||||
const value = (raw ?? "").trim();
|
||||
if (value !== "" && !value.startsWith("/")) {
|
||||
throw new Error(`Invalid value for retention.erasure_ledger: must be empty or an absolute file path, got "${raw}"`);
|
||||
}
|
||||
},
|
||||
}),
|
||||
"retention.watchdog_keep_per_job": (raw, coerced) => {
|
||||
if (!/^-?\d+$/.test((raw ?? "").trim()) || typeof coerced !== "number" || !Number.isInteger(coerced) || coerced < 0) {
|
||||
throw new Error(`Invalid value for retention.watchdog_keep_per_job: must be an integer >= 0, got "${raw}"`);
|
||||
@@ -1070,12 +1069,12 @@ const KEY_CONSTRAINTS: Partial<Record<SettingsValidKey, (raw: string, coerced: s
|
||||
throw new Error(`Invalid value for retention.stream_max_items: must be an integer >= 1, got "${raw}"`);
|
||||
}
|
||||
},
|
||||
"stream.intake": (raw) => {
|
||||
"stream.intake": described({ format: "queue.worker, off, or member@rig" }, (raw) => {
|
||||
const val = (raw ?? "").trim();
|
||||
if (val !== "off" && val !== "queue.worker" && !/^[a-zA-Z0-9\-_.]+@[a-zA-Z0-9\-_.]+$/.test(val)) {
|
||||
throw new Error(`Invalid value for stream.intake: must be "queue.worker", a canonical session (<seat>@<rig>), or "off", got "${raw}"`);
|
||||
}
|
||||
},
|
||||
}),
|
||||
"retention.batch_size": (raw, coerced) => {
|
||||
if (!/^-?\d+$/.test((raw ?? "").trim()) || typeof coerced !== "number" || !Number.isInteger(coerced) || coerced < 1) {
|
||||
throw new Error(`Invalid value for retention.batch_size: must be an integer >= 1, got "${raw}"`);
|
||||
@@ -1089,18 +1088,19 @@ const KEY_CONSTRAINTS: Partial<Record<SettingsValidKey, (raw: string, coerced: s
|
||||
"queue.wake_retry_cap": positiveIntegerConstraint("queue.wake_retry_cap"),
|
||||
"queue.wake_unconfirmed_window_minutes": positiveIntegerConstraint("queue.wake_unconfirmed_window_minutes"),
|
||||
"queue.wake_swap_grace_seconds": positiveIntegerConstraint("queue.wake_swap_grace_seconds"),
|
||||
"queue.wake_human_rung": wakeHumanRungConstraint,
|
||||
"decisions.template_dir": (_raw, value) => {
|
||||
"queue.wake_human_rung": choiceConstraint(["always", "explicit-only"],
|
||||
(raw) => `Invalid value for queue.wake_human_rung: must be "always" or "explicit-only", got "${raw}"`),
|
||||
"decisions.template_dir": described({ format: "absolute-path-or-empty" }, (_raw, value) => {
|
||||
if (typeof value !== "string" || (value !== "" && (!path.isAbsolute(value) || value.includes("\0")))) {
|
||||
throw new Error("Invalid decisions.template_dir: use an absolute path, or an empty string for the shipped templates only");
|
||||
}
|
||||
},
|
||||
}),
|
||||
// rig acp serve's receiver when --to isn't given: a team seat, member@rig, or empty for none.
|
||||
"acp.to": (_raw, value) => {
|
||||
"acp.to": described({ format: "member@rig-or-empty" }, (_raw, value) => {
|
||||
if (typeof value !== "string" || (value !== "" && !/^[^@\s]+@[^@\s]+$/.test(value))) {
|
||||
throw new Error("Invalid acp.to: use the seat that receives your ACP tasks as member@rig, or an empty string for none");
|
||||
}
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
function validateKeyConstraints(key: SettingsValidKey, raw: string, coerced: string | number | boolean): void {
|
||||
@@ -1126,6 +1126,58 @@ function validateTypedFileValue(key: SettingsValidKey, value: string | number |
|
||||
check(raw, value);
|
||||
}
|
||||
|
||||
/** What the browser view reports about a key's values, read from the rules that govern it. */
|
||||
export interface SettingKeyDescription {
|
||||
/** Whether POST and DELETE /api/config/:key accept this key. */
|
||||
writeRoute: boolean;
|
||||
type?: "boolean" | "integer" | "string" | "enum";
|
||||
choices?: string[];
|
||||
range?: { min?: number; max?: number };
|
||||
format?: string;
|
||||
}
|
||||
|
||||
const ACCEPTED_INTEGER_LIMIT = Number.MAX_SAFE_INTEGER;
|
||||
|
||||
/** The interval of whole numbers a key's validator accepts, found by asking the validator itself; a side it does not bound is left out. */
|
||||
function acceptedIntegerRange(key: SettingsValidKey, start: number): { min?: number; max?: number } | undefined {
|
||||
const check = KEY_CONSTRAINTS[key];
|
||||
if (!check) return undefined;
|
||||
const accepts = (n: number): boolean => {
|
||||
try { check(String(n), n); return true; } catch { return false; }
|
||||
};
|
||||
if (!accepts(start)) return undefined;
|
||||
const range: { min?: number; max?: number } = {};
|
||||
if (!accepts(ACCEPTED_INTEGER_LIMIT)) {
|
||||
let lo = start, hi = ACCEPTED_INTEGER_LIMIT;
|
||||
while (hi - lo > 1) { const mid = Math.floor(lo + (hi - lo) / 2); if (accepts(mid)) lo = mid; else hi = mid; }
|
||||
range.max = lo;
|
||||
}
|
||||
if (!accepts(-ACCEPTED_INTEGER_LIMIT)) {
|
||||
let lo = -ACCEPTED_INTEGER_LIMIT, hi = start;
|
||||
while (hi - lo > 1) { const mid = Math.floor(lo + (hi - lo) / 2); if (accepts(mid)) hi = mid; else lo = mid; }
|
||||
range.min = hi;
|
||||
}
|
||||
return range.min === undefined && range.max === undefined ? undefined : range;
|
||||
}
|
||||
|
||||
/** Derived from coerceValue (the type), KEY_CONSTRAINTS (choices, format, range) and the key grammar (write route); no second list. */
|
||||
export function describeSettingKey(key: string): SettingKeyDescription {
|
||||
if (parseFeedHostSubscriptionKey(key)) return { writeRoute: true, type: "boolean" };
|
||||
if (!isSettingsValidKey(key)) return { writeRoute: false };
|
||||
const fallback = getDefaultValue(key, "");
|
||||
const meta = KEY_CONSTRAINTS[key]?.meta;
|
||||
const out: SettingKeyDescription = { writeRoute: true };
|
||||
if (typeof fallback === "boolean") out.type = "boolean";
|
||||
else if (typeof fallback === "number") {
|
||||
out.type = "integer";
|
||||
const range = acceptedIntegerRange(key, fallback);
|
||||
if (range) out.range = range;
|
||||
} else out.type = meta?.choices ? "enum" : "string";
|
||||
if (meta?.choices) out.choices = [...meta.choices];
|
||||
if (meta?.format) out.format = meta.format;
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Slice 27 — projected Claude auto-compaction policy. ContextMonitor
|
||||
* consumes this snapshot per-poll; the PreCompact hook reads the same
|
||||
|
||||
@@ -361,6 +361,22 @@ export class WorkflowValidator {
|
||||
});
|
||||
}
|
||||
|
||||
// #3087: validate never resolves seats, so a harness pin is only
|
||||
// reconciled when a run resolves the step's owner. Say so, so a
|
||||
// clean result is not read as "every pin is satisfiable".
|
||||
spec.steps.forEach((step, idx) => {
|
||||
if (!step.harness) return;
|
||||
const binding = spec.target?.rig
|
||||
? `rig "${spec.target.rig}"`
|
||||
: "no rig is bound";
|
||||
issues.push({
|
||||
code: "harness_pin_unchecked",
|
||||
message: `step "${step.id}" pins harness "${step.harness}", but validate does not check harness pins against any seats (${binding}). The pin is checked when a run resolves the step's owner, and the run fails with harness_pin_unsatisfied if no preferred_target of role "${step.actor_role}" runs ${step.harness}.`,
|
||||
field: `workflow.steps[${idx}].harness`,
|
||||
severity: "warning",
|
||||
});
|
||||
});
|
||||
|
||||
// OPR.0.4.6.WF2 FR-5: gate target semantics — a HUMAN-seat target
|
||||
// requires summary + evidence_ref (the shipped human-route write
|
||||
// path enforces them at create; failing HERE is the fail-at-author-
|
||||
|
||||
@@ -188,6 +188,7 @@ export const migrationsForFullTestDb = [coreSchema, bindingsSessionsSchema, even
|
||||
* its exclusion); the guard's redundant-exclusion check then keeps the two in sync.
|
||||
*/
|
||||
export const migrationsForFullTestDbExclusions: Record<string, string> = {
|
||||
"166_queue_list_read_indexes.sql": "read indexes depend on owner-notification columns/archive omitted here (054/076); startup and queue-list-order-indexes.test.ts exercise ALL_MIGRATIONS.",
|
||||
"156_queue_erasure_link_indexes.sql": "indexes outbox_entries, inbox_entries and the stream tables (all excluded from the core edge) for queue erasure's lookups; erasure tests migrate the canonical full schema.",
|
||||
"082_archive_identity_provenance.sql": "archive extension; 054 is excluded from this core fixture. Archive identity regressions use ALL_MIGRATIONS.",
|
||||
"026_inbox_entries.sql": "inbox subsystem table — not on the shared core edge (inbox suites migrate it inline).",
|
||||
|
||||
@@ -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,164 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import Database from "better-sqlite3";
|
||||
import { migrate } from "../src/db/migrate.js";
|
||||
import { ALL_MIGRATIONS } from "../src/db/all-migrations.js";
|
||||
import { EventBus } from "../src/domain/event-bus.js";
|
||||
import { QueueRepository, type QueueListOptions } from "../src/domain/queue-repository.js";
|
||||
import { RECOVERY_INDEX_SQL, findQueueRecovery } from "../src/domain/queue-recovery.js";
|
||||
import { QueueTransitionLog } from "../src/domain/queue-transition-log.js";
|
||||
import { runStuckSweep } from "../src/domain/queue-stuck-sweep.js";
|
||||
import { OutboxHandler } from "../src/domain/outbox-handler.js";
|
||||
import { RigRepository } from "../src/domain/rig-repository.js";
|
||||
|
||||
describe("bounded queue lists use their ordering indexes", () => {
|
||||
let db: Database.Database;
|
||||
let repo: QueueRepository;
|
||||
|
||||
beforeEach(() => {
|
||||
db = new Database(":memory:");
|
||||
migrate(db, ALL_MIGRATIONS);
|
||||
repo = new QueueRepository(db, new EventBus(db), { loadHumanRegistry: () => ({ ok: true, entities: [] }) });
|
||||
const insert = db.prepare(`INSERT INTO queue_items
|
||||
(qitem_id, ts_created, ts_updated, source_session, destination_session, state, body, tags)
|
||||
VALUES (?, ?, ?, 'sender@r', 'worker@r', ?, ?, ?)`);
|
||||
db.transaction(() => {
|
||||
for (let i = 0; i < 240; i++) {
|
||||
// Equal timestamps across states exercise the existing scan's tie order too.
|
||||
const ts = new Date(Date.UTC(2026, 10, 12, 0, 0, Math.floor(i / 3))).toISOString();
|
||||
const tags = i % 7 === 0 ? ["first", "second"] : i % 2 ? ["first"] : ["second"];
|
||||
insert.run(`q-${i}`, ts, ts, ["pending", "done", "blocked"][i % 3], "body".repeat(256), JSON.stringify(tags));
|
||||
}
|
||||
})();
|
||||
});
|
||||
afterEach(() => { vi.restoreAllMocks(); db.close(); });
|
||||
|
||||
function selection(options: QueueListOptions) {
|
||||
const prepare = db.prepare.bind(db);
|
||||
let selected: { sql: string; args: unknown[] } | undefined;
|
||||
const spy = vi.spyOn(db, "prepare").mockImplementation((sql: string) => {
|
||||
const statement = prepare(sql);
|
||||
if (/^SELECT (?:\*|qitem_id,).* FROM queue_items\s+(?:WHERE\b|ORDER BY\b)/s.test(sql)) {
|
||||
const all = statement.all.bind(statement);
|
||||
statement.all = (...args: unknown[]) => { selected = { sql, args }; return all(...args); };
|
||||
}
|
||||
return statement;
|
||||
});
|
||||
let ids: string[];
|
||||
try { ids = repo.list(options).map((item) => item.qitemId); }
|
||||
finally { spy.mockRestore(); }
|
||||
expect(selected).toBeDefined();
|
||||
const { sql, args } = selected!;
|
||||
const legacy = prepare(sql.replace("FROM queue_items", "FROM queue_items NOT INDEXED"))
|
||||
.all(...args) as Array<{ qitem_id: string }>;
|
||||
expect(ids).toEqual(legacy.map((row) => row.qitem_id));
|
||||
const plan = prepare(`EXPLAIN QUERY PLAN ${sql}`).all(...args) as Array<{ detail: string }>;
|
||||
return { ids, details: plan.map((row) => row.detail).join("\n") };
|
||||
}
|
||||
|
||||
it.each([false, true])("does not scan and sort all rows for a bounded list (compact=%s)", (compact) => {
|
||||
const { ids, details } = selection({ compact, limit: 6 });
|
||||
expect(ids).toHaveLength(6);
|
||||
expect(details).toContain("USING INDEX");
|
||||
expect(details).not.toContain("TEMP B-TREE");
|
||||
});
|
||||
|
||||
it.each([false, true])("keeps active rows first without sorting all rig history (compact=%s)", (compact) => {
|
||||
const { ids, details } = selection({ rig: "r", compact, limit: 6 });
|
||||
expect(ids).toHaveLength(6);
|
||||
expect(details).toContain("USING INDEX");
|
||||
expect(details).not.toContain("TEMP B-TREE");
|
||||
});
|
||||
|
||||
it.each([false, true])("keeps scoped update-time ordering indexed (compact=%s)", (compact) => {
|
||||
const { ids, details } = selection({ rig: "r", sortByUpdated: true, compact, limit: 6 });
|
||||
expect(ids).toHaveLength(6);
|
||||
expect(details).toContain("USING INDEX");
|
||||
expect(details).not.toContain("TEMP B-TREE");
|
||||
});
|
||||
|
||||
it("looks for owner notifications without reading ordinary live or archived transitions", () => {
|
||||
const log = new QueueTransitionLog(db);
|
||||
const prepare = db.prepare.bind(db);
|
||||
let query = "";
|
||||
const spy = vi.spyOn(db, "prepare").mockImplementation((sql: string) => {
|
||||
if (sql.includes("ROW_NUMBER()") && sql.includes("owner_notification_level IS NOT NULL")) query = sql;
|
||||
return prepare(sql);
|
||||
});
|
||||
try { expect(log.ownerNotificationsForQitems(["q-0"])).toEqual(new Map()); }
|
||||
finally { spy.mockRestore(); }
|
||||
expect(query).not.toBe("");
|
||||
const plan = prepare(`EXPLAIN QUERY PLAN ${query}`).all(JSON.stringify(["q-0"])) as Array<{ detail: string }>;
|
||||
const details = plan.map((row) => row.detail).join("\n");
|
||||
expect(details).toContain("idx_queue_transitions_owner_notification_qitem");
|
||||
expect(details).toContain("idx_queue_transitions_archive_owner_notification_qitem");
|
||||
});
|
||||
|
||||
it("still applies both exact tags before the bound, including zero and unbounded limits", () => {
|
||||
for (const limit of [0, 1, 6, -1]) {
|
||||
const { ids } = selection({ rig: "r", tag: ["first", "second"], limit });
|
||||
expect(ids.every((id) => Number(id.slice(2)) % 7 === 0)).toBe(true);
|
||||
expect(ids).toHaveLength(limit < 0 ? 35 : Math.min(limit, 35));
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps recovery lookup on its small partial index when listing indexes exist", () => {
|
||||
const plan = db.prepare(`EXPLAIN QUERY PLAN ${RECOVERY_INDEX_SQL}`).all() as Array<{ detail: string }>;
|
||||
expect(plan.some((row) => row.detail.includes("idx_queue_items_recovery_tags"))).toBe(true);
|
||||
});
|
||||
|
||||
async function pointPlan(prefix: string, run: () => unknown | Promise<unknown>) {
|
||||
const prepare = db.prepare.bind(db);
|
||||
let selected: { sql: string; args: unknown[] } | undefined;
|
||||
const spy = vi.spyOn(db, "prepare").mockImplementation((sql: string) => {
|
||||
const statement = prepare(sql);
|
||||
if (sql.startsWith(prefix)) {
|
||||
const get = statement.get.bind(statement);
|
||||
statement.get = (...args: unknown[]) => { selected = { sql, args }; return get(...args); };
|
||||
}
|
||||
return statement;
|
||||
});
|
||||
try { await run(); } finally { spy.mockRestore(); }
|
||||
expect(selected).toBeDefined();
|
||||
const plan = prepare(`EXPLAIN QUERY PLAN ${selected!.sql}`).all(...selected!.args) as Array<{ detail: string }>;
|
||||
expect(plan.some((row) => row.detail.includes("idx_queue_items_recovery_tags"))).toBe(true);
|
||||
}
|
||||
|
||||
it("keeps standalone recovery hits and misses on the recovery partial index", async () => {
|
||||
db.prepare("UPDATE queue_items SET tags = ? WHERE qitem_id = 'q-0'").run(JSON.stringify(["recovery-for:subject"]));
|
||||
for (const subject of ["subject", "no-match"]) {
|
||||
for (const exclude of [undefined, "excluded"]) {
|
||||
await pointPlan("SELECT qitem_id, state, ts_updated FROM queue_items", () => {
|
||||
expect(findQueueRecovery(db, subject, exclude)?.qitemId ?? null).toBe(subject === "subject" ? "q-0" : null);
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
async function sweepForPlan(state: "pending" | "in-progress") {
|
||||
db.exec("DELETE FROM queue_items");
|
||||
repo.attachOutbox(new OutboxHandler(db));
|
||||
if (state === "pending") {
|
||||
const rigs = new RigRepository(db);
|
||||
const rig = rigs.createRig("r");
|
||||
const node = rigs.addNode(rig.id, "worker", { runtime: "claude-code" });
|
||||
db.prepare("INSERT INTO sessions (id,node_id,session_name,status) VALUES ('plan-session',?,'worker@r','running')").run(node.id);
|
||||
}
|
||||
const past = new Date(Date.now() - 120 * 60_000).toISOString();
|
||||
const insert = db.prepare(`INSERT INTO queue_items
|
||||
(qitem_id, ts_created, ts_updated, source_session, destination_session, state, body, claimed_at, closure_required_at)
|
||||
VALUES (?, ?, ?, 'sender@r', 'worker@r', ?, 'work', ?, ?)`);
|
||||
for (let i = 0; i < (state === "pending" ? 3 : 1); i++) {
|
||||
insert.run(`candidate-${i}`, past, past, state, state === "in-progress" ? past : null, state === "in-progress" ? past : null);
|
||||
}
|
||||
return runStuckSweep({ db, queueRepo: repo, resolveOrchestrator: () => null, resolveRecoveryAgentSeat: () => null,
|
||||
resolveOperatorSeat: () => null, isRegisteredHost: () => false, unclaimedAgeMinutes: 60, log: () => {} });
|
||||
}
|
||||
|
||||
it("keeps the per-row finding lookup on the recovery partial index", async () => {
|
||||
await pointPlan("SELECT qitem_id, source_session, state, ts_updated FROM queue_items", () => sweepForPlan("in-progress"));
|
||||
});
|
||||
|
||||
it("keeps the latest backlog finding lookup on the recovery partial index", async () => {
|
||||
await pointPlan("SELECT qitem_id, state, ts_updated, summary, body, destination_session, tags FROM queue_items", () => sweepForPlan("pending"));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,166 @@
|
||||
import { beforeEach, afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { Hono } from "hono";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { SettingsStore, SETTINGS_VALID_KEYS, isSettingsValidKey, parseFeedHostSubscriptionKey } from "../src/domain/user-settings/settings-store.js";
|
||||
import { settingsBrowser } from "../src/domain/user-settings/settings-browser.js";
|
||||
import { configRoutes } from "../src/routes/config.js";
|
||||
|
||||
let home: string;
|
||||
let n = 0;
|
||||
const freshStore = () => new SettingsStore(join(home, `config-${n++}.json`));
|
||||
const browse = () => settingsBrowser(freshStore(), null, home);
|
||||
/** The write path is the oracle: does the daemon's own set() accept this raw value for this key? */
|
||||
const accepts = (key: string, raw: string): boolean => {
|
||||
try { freshStore().set(key, raw); return true; } catch { return false; }
|
||||
};
|
||||
/** Each accepts 1 to 100 on its own, but set() also requires warning < critical: a cross-key rule these per-key fields do not describe. */
|
||||
const PAIRED = new Set(["health.context_pressure.warning_percent", "health.context_pressure.critical_percent"]);
|
||||
|
||||
beforeEach(() => {
|
||||
home = mkdtempSync(join(tmpdir(), "s06-browser-meta-"));
|
||||
for (const key of Object.keys(process.env).filter((k) => /^(OPENRIG_|RIGGED_|SLACK_)/.test(k))) vi.stubEnv(key, "");
|
||||
vi.stubEnv("OPENRIG_HOME", home);
|
||||
vi.stubEnv("OPENRIG_WORKSPACE_ROOT", join(home, "workspace"));
|
||||
});
|
||||
afterEach(() => { vi.unstubAllEnvs(); rmSync(home, { recursive: true, force: true }); });
|
||||
|
||||
describe("browser entry metadata", () => {
|
||||
it("adds type, choices, range, format, write route and timing without changing the existing fields", () => {
|
||||
const entries = browse().entries;
|
||||
expect(entries.find((e) => e.key === "terminal.history_limit")).toMatchObject({
|
||||
type: "integer", range: { min: 2000, max: 1_000_000 }, writeRoute: true, appliesAt: "unknown",
|
||||
value: expect.anything(), defaultKnown: true, application: expect.stringContaining("tmux"),
|
||||
});
|
||||
expect(entries.find((e) => e.key === "terminal.tmux_server")).toMatchObject({
|
||||
type: "enum", choices: ["inherit", "isolated"], writeRoute: true, appliesAt: "daemon_restart",
|
||||
});
|
||||
expect(entries.find((e) => e.key === "daemon.port")).toMatchObject({ type: "integer", range: { min: 0, max: 65535 } });
|
||||
expect(entries.find((e) => e.key === "retention.stream_days")).toMatchObject({ type: "integer", range: { min: 1 } });
|
||||
expect(entries.find((e) => e.key === "retention.stream_days")!.range).not.toHaveProperty("max");
|
||||
expect(entries.find((e) => e.key === "ui.timezone")).toMatchObject({ type: "string", format: "iana-timezone" });
|
||||
expect(entries.find((e) => e.key === "retention.enabled")).toMatchObject({ type: "boolean", appliesAt: "daemon_restart" });
|
||||
// A key with no validator reports a type and nothing it cannot back up.
|
||||
const free = entries.find((e) => e.key === "host.name")!;
|
||||
expect(free).toMatchObject({ type: "string", writeRoute: true });
|
||||
expect(free).not.toHaveProperty("choices");
|
||||
expect(free).not.toHaveProperty("range");
|
||||
expect(free).not.toHaveProperty("format");
|
||||
});
|
||||
|
||||
it("matches the daemon's own validator for every key it can be written under", () => {
|
||||
const entries = browse().entries.filter((e) => e.group === "general" && isSettingsValidKey(e.key));
|
||||
expect(entries.map((e) => e.key)).toEqual([...SETTINGS_VALID_KEYS]);
|
||||
for (const e of entries) {
|
||||
expect(e.writeRoute, e.key).toBe(true);
|
||||
expect(e.type, e.key).toBeDefined();
|
||||
if (e.type === "boolean") {
|
||||
expect(accepts(e.key, "true"), e.key).toBe(true);
|
||||
expect(accepts(e.key, "false"), e.key).toBe(true);
|
||||
expect(accepts(e.key, "not-a-boolean"), e.key).toBe(false);
|
||||
}
|
||||
if (e.type === "integer" && !PAIRED.has(e.key)) {
|
||||
expect(accepts(e.key, "not-a-number"), e.key).toBe(false);
|
||||
if (e.range?.min !== undefined) {
|
||||
expect(accepts(e.key, String(e.range.min)), `${e.key} min`).toBe(true);
|
||||
expect(accepts(e.key, String(e.range.min - 1)), `${e.key} below min`).toBe(false);
|
||||
}
|
||||
if (e.range?.max !== undefined) {
|
||||
expect(accepts(e.key, String(e.range.max)), `${e.key} max`).toBe(true);
|
||||
expect(accepts(e.key, String(e.range.max + 1)), `${e.key} above max`).toBe(false);
|
||||
}
|
||||
// Where a side is left out, the validator must accept a value far on that side.
|
||||
if (e.range?.max === undefined) expect(accepts(e.key, "1000000000"), `${e.key} no max`).toBe(true);
|
||||
if (e.range?.min === undefined) expect(accepts(e.key, "0") || accepts(e.key, "1"), `${e.key} no min`).toBe(true);
|
||||
}
|
||||
if (e.choices) {
|
||||
expect(e.type, e.key).toBe("enum");
|
||||
for (const choice of e.choices) expect(accepts(e.key, choice), `${e.key}=${choice}`).toBe(true);
|
||||
expect(accepts(e.key, "not-one-of-the-choices"), e.key).toBe(false);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("states a range only where the validator is an interval, at sample points across the whole line", () => {
|
||||
const samples = [-1_000_000_000, -100_001, -1000, -61, -60, -2, -1, 0, 1, 2, 59, 60, 100, 101, 599, 600, 601,
|
||||
999, 1000, 1999, 2000, 2001, 3600, 3601, 65535, 65536, 999_999, 1_000_000, 1_000_001, 1_000_000_000];
|
||||
const ranged = browse().entries.filter((x) => x.type === "integer" && x.range && !PAIRED.has(x.key));
|
||||
expect(ranged.length).toBeGreaterThan(25);
|
||||
for (const e of ranged) {
|
||||
for (const sample of samples) {
|
||||
const inside = (e.range!.min === undefined || sample >= e.range!.min) && (e.range!.max === undefined || sample <= e.range!.max);
|
||||
expect(accepts(e.key, String(sample)), `${e.key} at ${sample}`).toBe(inside);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("reports the per-key range of the paired percentages and leaves their cross-key rule out", () => {
|
||||
const entries = browse().entries;
|
||||
for (const key of PAIRED) expect(entries.find((e) => e.key === key)).toMatchObject({ type: "integer", range: { min: 1, max: 100 } });
|
||||
// Per key the bounds are the validator's: critical takes 100, nothing takes 0 or 101 ...
|
||||
expect(accepts("health.context_pressure.critical_percent", "100")).toBe(true);
|
||||
for (const key of PAIRED) for (const bad of ["0", "101"]) expect(accepts(key, bad), `${key}=${bad}`).toBe(false);
|
||||
expect(accepts("health.context_pressure.warning_percent", "1")).toBe(true);
|
||||
// ... while the pair rule (warning below critical) still refuses a value inside that range, and is not a field here.
|
||||
expect(accepts("health.context_pressure.warning_percent", "100")).toBe(false);
|
||||
expect(entries.find((e) => e.key === "health.context_pressure.warning_percent")).not.toHaveProperty("pair");
|
||||
});
|
||||
|
||||
it("names a format that agrees with what the validator accepts and refuses", () => {
|
||||
const cases: Record<string, { format: string; ok: string[]; bad: string[] }> = {
|
||||
"ui.timezone": { format: "iana-timezone", ok: ["Europe/London", "UTC"], bad: ["Not/AZone", "+01:00"] },
|
||||
"policies.claude_compaction.preparation_root": { format: "absolute-path-or-empty", ok: ["/var/lib/example", ""], bad: ["relative/path"] },
|
||||
"decisions.template_dir": { format: "absolute-path-or-empty", ok: ["/var/lib/example", ""], bad: ["relative/path"] },
|
||||
"retention.erasure_ledger": { format: "absolute-path-or-empty", ok: ["/var/lib/example/ledger", ""], bad: ["relative/ledger"] },
|
||||
"stream.intake": { format: "queue.worker, off, or member@rig", ok: ["off", "queue.worker", "seat@rig"], bad: ["no at sign"] },
|
||||
"acp.to": { format: "member@rig-or-empty", ok: ["seat@rig", ""], bad: ["no-at-sign", "a@b@c"] },
|
||||
};
|
||||
const entries = browse().entries;
|
||||
for (const [key, c] of Object.entries(cases)) {
|
||||
expect(entries.find((e) => e.key === key)!.format, key).toBe(c.format);
|
||||
for (const ok of c.ok) expect(accepts(key, ok), `${key}=${ok}`).toBe(true);
|
||||
for (const bad of c.bad) expect(accepts(key, bad), `${key}=${bad}`).toBe(false);
|
||||
}
|
||||
// Every key that reports a format is in this table, so a new one cannot ship unchecked.
|
||||
expect(entries.filter((e) => e.format).map((e) => e.key).sort()).toEqual(Object.keys(cases).sort());
|
||||
});
|
||||
|
||||
it("reports the write route as the daemon's own key rules decide, for every entry", () => {
|
||||
const store = freshStore();
|
||||
store.set("feed.subscriptions.fixture-host.enabled", "true");
|
||||
const entries = settingsBrowser(store, null, home).entries;
|
||||
for (const e of entries) {
|
||||
expect(e.writeRoute, e.key).toBe(isSettingsValidKey(e.key) || parseFeedHostSubscriptionKey(e.key) !== null);
|
||||
}
|
||||
expect(entries.find((e) => e.key === "feed.subscriptions.fixture-host.enabled")).toMatchObject({ type: "boolean", writeRoute: true });
|
||||
expect(entries.find((e) => e.key === "slack.enabled")).toMatchObject({ writeRoute: false });
|
||||
expect(entries.find((e) => e.key === "slack.enabled")).not.toHaveProperty("type");
|
||||
expect(entries.filter((e) => e.group !== "general" && e.writeRoute)).toEqual([]);
|
||||
});
|
||||
|
||||
it("agrees with the write route itself: the keys it reports writable are accepted, the others are unknown keys", async () => {
|
||||
const store = freshStore();
|
||||
const app = new Hono();
|
||||
app.use("*", async (c, next) => { c.set("settingsStore" as never, store as never); await next(); });
|
||||
app.route("/api/config", configRoutes({ home }));
|
||||
const post = (key: string, value: string) => app.request(`/api/config/${key}`, {
|
||||
method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ value }) });
|
||||
expect((await post("terminal.history_limit", "5000")).status).toBe(200);
|
||||
expect((await post("terminal.history_limit", "1999")).status).toBe(400);
|
||||
const refused = await post("slack.enabled", "true");
|
||||
expect(refused.status).toBe(400);
|
||||
expect((await refused.json()).error).toContain("Unknown config key");
|
||||
const browser = await (await app.request("/api/config?view=browser")).json();
|
||||
expect(browser.entries.find((e: { key: string }) => e.key === "terminal.history_limit")).toMatchObject({ writeRoute: true, range: { min: 2000, max: 1_000_000 } });
|
||||
expect(browser.entries.find((e: { key: string }) => e.key === "slack.enabled")).toMatchObject({ writeRoute: false });
|
||||
expect(browser.readOnly).toBe(true);
|
||||
});
|
||||
|
||||
it("marks timing only where the application guidance says a restart applies it, and says unknown otherwise", () => {
|
||||
for (const e of browse().entries) {
|
||||
const guidanceSaysRestart = /\bdaemon (?:restart|startup)\b/i.test(e.application);
|
||||
expect(e.appliesAt, e.key).toBe(guidanceSaysRestart ? "daemon_restart" : "unknown");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -96,6 +96,8 @@ describe("Slack sweep selection", () => {
|
||||
it("keeps a legacy schema without owner classification quiet", async () => {
|
||||
row("legacy");
|
||||
for (const table of ["queue_transitions", "queue_transitions_archive"]) {
|
||||
// The simulated pre-classification schema also predates its partial index.
|
||||
db.exec(`DROP INDEX idx_${table}_owner_notification_qitem`);
|
||||
db.exec(`ALTER TABLE ${table} DROP COLUMN owner_notification_level; ALTER TABLE ${table} DROP COLUMN owner_notification_kind`);
|
||||
}
|
||||
repo = new QueueRepository(db, new EventBus(db), { loadHumanRegistry: () => registry });
|
||||
|
||||
@@ -625,6 +625,19 @@ describe("SettingsStore (User Settings v0)", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("a non-string provider.selected in config.json falls back to the default with a warning", () => {
|
||||
writeFileSync(configPath, JSON.stringify({ provider: { selected: ["claude"] } }));
|
||||
const stderrSpy = vi.spyOn(process.stderr, "write").mockImplementation(() => true);
|
||||
try {
|
||||
const store = new SettingsStore(configPath);
|
||||
expect(store.resolveOne("provider.selected")).toMatchObject({ value: "", source: "default" });
|
||||
const warns = stderrSpy.mock.calls.map((c) => String(c[0]));
|
||||
expect(warns.some((w) => w.includes("file value for provider.selected rejected"))).toBe(true);
|
||||
} finally {
|
||||
stderrSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("a quoted in-range port in config.json keeps its port, and the CLI target and daemon bind agree", () => {
|
||||
writeFileSync(configPath, JSON.stringify({ daemon: { port: "7434" } }));
|
||||
const store = new SettingsStore(configPath);
|
||||
|
||||
@@ -577,6 +577,23 @@ describe("OPR.0.4.6.WF2 — spec language", () => {
|
||||
).rejects.toMatchObject({ code: "harness_pin_unsatisfied" });
|
||||
});
|
||||
|
||||
it("validate warns that a harness pin is unchecked instead of reporting a clean result (#3087)", () => {
|
||||
const spec = parseWorkflowSpec(HARNESS_SPEC, "harness.yaml");
|
||||
const result = new WorkflowValidator().validate(spec);
|
||||
expect(result.ok).toBe(true);
|
||||
const unchecked = result.issues.filter((i) => i.code === "harness_pin_unchecked");
|
||||
expect(unchecked).toHaveLength(1);
|
||||
expect(unchecked[0]).toMatchObject({ severity: "warning", field: "workflow.steps[0].harness" });
|
||||
expect(unchecked[0]!.message).toContain('"codex"');
|
||||
expect(unchecked[0]!.message).toContain("no rig is bound");
|
||||
});
|
||||
|
||||
it("validate adds no pin warning to a spec without harness pins", () => {
|
||||
const spec = parseWorkflowSpec(LINEAR_SPEC, "linear.yaml");
|
||||
const result = new WorkflowValidator().validate(spec);
|
||||
expect(result.issues.some((i) => i.code === "harness_pin_unchecked")).toBe(false);
|
||||
});
|
||||
|
||||
it("steps with no pin resolve preferred_targets[0] unchanged (zero regression)", async () => {
|
||||
const specPath = writeSpec(tmp, "linear.yaml", LINEAR_SPEC);
|
||||
const result = await runtime.instantiate({
|
||||
|
||||
@@ -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