mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-12 01:41:39 -10:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1d6f5a2d24 | ||
|
|
a8397dd711 | ||
|
|
e35eec247b | ||
|
|
96fc0f3e88 | ||
|
|
f95fa4cd77 |
No files matched your search
@@ -405,11 +405,6 @@ 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.**
|
||||
- **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.
|
||||
|
||||
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.
|
||||
|
||||
Use this to avoid re-sending context that a fresh conversation doesn't need twice. For example, a one-time project briefing might only apply on `fresh_start`, while identity grounding should apply on both, so a seat that comes back fresh gets it again.
|
||||
|
||||
@@ -13,9 +13,7 @@ provider usage stop updating, and Claude resume-token capture at adoption or
|
||||
handover loses its current session evidence. An earlier sidecar can remain and
|
||||
contain an older session ID; without one, context and capture report
|
||||
`missing_sidecar`, and provider usage reports `no_statusline_cache_yet` unless an
|
||||
earlier cache remains. A cache that exists but cannot be read reports
|
||||
`claude_statusline_cache_unreadable`; a readable cache that carried no rate
|
||||
limits reports `claude_statusline_cache_had_no_windows`.
|
||||
earlier cache remains.
|
||||
|
||||
## Feed both commands the same input
|
||||
|
||||
|
||||
@@ -342,16 +342,9 @@ startup reconcile leaves when every kernel terminal is gone, for example after a
|
||||
reboot. It does not detect a reboot as such. It uses the same restore as
|
||||
`rig up kernel --existing`, and `rig status` reports it as `Kernel: booting`
|
||||
until the seats are ready. A kernel stopped with `rig down kernel`, including
|
||||
one whose seats were already detached, stays down on later starts; start it again
|
||||
with `rig up kernel --existing`. A seat you stopped or unclaimed also stays down.
|
||||
While the kernel's other seats are running, `rig up kernel --existing` refuses
|
||||
(it restores only a kernel with no live session), so start one stopped seat with
|
||||
`rig launch kernel <logical id>` (`--plan` first). It restores that seat from the
|
||||
kernel's latest restore-usable snapshot and resumes its saved conversation; a seat
|
||||
it can't resume is reported as waiting for a decision, not as started.
|
||||
`rig seat launch <seat> --fresh --reason "…"` instead starts a new conversation
|
||||
with no continuity source: use it only when you want a fresh occupant, since the
|
||||
seat's earlier conversation isn't carried.
|
||||
one whose seats were already detached, stays down on later starts. So do a kernel
|
||||
with only some seats stopped and seats you unclaimed. Start one again with
|
||||
`rig up kernel --existing`.
|
||||
When the tmux server is gone, an explicit `rig down` of any rig can start an
|
||||
empty server and probe again. It records the stop and clears eligible bindings
|
||||
only after confirming the sessions are absent. This can leave an empty tmux
|
||||
|
||||
@@ -112,33 +112,6 @@ OpenRig doesn't observe a hosted member between its calls, so it never reads idl
|
||||
- `lastWake`: its latest ring and the result, among its 50 most recent rows.
|
||||
- `rig roster show` and `find` show the last call; the last ring is in `rig_team`.
|
||||
|
||||
### Check-in status
|
||||
|
||||
A connected assistant (a roster member marked `hosted: true`) checks in each time it reads its own pending inbox (through the queue list, as the MCP list tool does
|
||||
when it lists the member's own work). Other calls it makes don't count yet. Its status compares its last check-in with
|
||||
what its roster entry states: a cadence (`check_in_minutes`), or that it checks in when a person asks it
|
||||
(`check_in_on_request: true`):
|
||||
|
||||
| Status | Meaning |
|
||||
|---|---|
|
||||
| `checking-in` | its last check-in is no older than twice its stated cadence |
|
||||
| `check-in-overdue` | its last check-in is older than that: its schedule may be off or waiting for an approval |
|
||||
| `no-check-in-yet` | no check-in recorded for it on this daemon, whatever its entry states |
|
||||
| `on-request` | its entry says it checks in when a person asks it |
|
||||
| `disconnected` | reserved for a recorded revocation or removal of its connection. Nothing records one yet, so a revoked assistant reads overdue (or unknown, with no cadence) |
|
||||
| `unknown` | its entry states neither, its statement can't be used, or its check-ins couldn't be read; the reason says which, and its last check-in is shown. OpenRig assumes no cadence, so this never reads idle or overdue |
|
||||
|
||||
Each place it's shown gives the evidence with it, for example `overdue: last check-in 2026-11-12T13:05:00Z, expected
|
||||
every 60 min; its schedule may be off or waiting for an approval`.
|
||||
|
||||
A stated cadence is its person's statement: neither reading proves the assistant's own schedule ran, or that it picked
|
||||
anything up. It's a status only. Work addressed to a member that isn't checking in still queues and is rung as before, a check-in
|
||||
isn't pickup (pickup is its claim), and no check-in doesn't mean it's idle, failed or safe. `GET /api/queue/hosted-members`
|
||||
gives this daemon's host id and each member on this host with its status, last check-in and stated cadence (with the
|
||||
reason when it's unknown); `rig roster show` and `find` show it as `Check-in:`, for a team with or without terminal seats.
|
||||
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.
|
||||
|
||||
## Revoke
|
||||
|
||||
`rig mcp token revoke grok` refuses its next request. The rows it worked, and their answers, stay.
|
||||
|
||||
@@ -8,7 +8,67 @@ 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.
|
||||
|
||||
## Connect
|
||||
## 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
|
||||
seats by name:
|
||||
|
||||
```bash
|
||||
rig network enroll <code> --site <site URL>
|
||||
rig network connect <seat> --as <name>
|
||||
```
|
||||
|
||||
- `enroll` trades the code, with this daemon's host id, for an installation key. The key is stored like a participant's
|
||||
token, under `<OpenRig home>/network-secrets/<installation>/key.json`, and is never returned or shown. A code is
|
||||
single-use and short-lived; a refused one says why, so get a new one from the page. If the account doesn't answer,
|
||||
whether it took the code is unknown: try again with the same code, and get a new one if that's refused.
|
||||
- Enrolling trusts the site with where your connected seats' work goes: for each name, the account answers the front
|
||||
and rig the binding reaches.
|
||||
- The installation key goes only to the site you enrolled at: a redirect from it isn't followed. Each `enroll`,
|
||||
`connect` or `disconnect` spends at most 30 seconds with the account in all, and the command waits 45 seconds for the
|
||||
daemon, so you see the account's own answer or error.
|
||||
- The account keeps one list of the installation's names, and every change sends the whole list. Changes to it run one
|
||||
at a time on this daemon, each reading the names to keep in its turn, so two connects started together both stay
|
||||
connected.
|
||||
- `connect --as <name>` lists the name with the account, which answers that participant's bearer, its MCP front and its
|
||||
rig. The binding then reaches that front as `<name>@<rig>`, the same as a direct connect. A name is lowercase letters,
|
||||
digits and inner dashes, up to 32. One that another connection to the account uses is refused.
|
||||
- If the answer to a connect is lost, the next connect drops the name and lists it again, which gets a fresh bearer and
|
||||
ends the old one.
|
||||
- If the account listed the name but the binding can't be made here, OpenRig asks the account to drop the name again.
|
||||
That's best effort: if it fails too, the error says the account may still list the name.
|
||||
- `disconnect` ends the binding here, then has the account stop listing the name, which revokes it there. Once the
|
||||
account's answer no longer lists the name, `disconnect` says the account stopped listing it. If the account can't be
|
||||
reached or its answer is lost, the binding is still ended, the error says OpenRig couldn't confirm the account dropped
|
||||
the name and names the `rig network disconnect <binding id>` to run again, and `rig network status` keeps showing it
|
||||
as unconfirmed. A later answer from the same account installation that no longer lists the name marks it dropped.
|
||||
- `rig network status` shows the enrollment above the bindings.
|
||||
|
||||
### Enroll again after a lost key or a removed installation
|
||||
|
||||
When the installation key is missing, or the account no longer recognizes the installation, the error says to enroll
|
||||
again with a new code from the account page:
|
||||
|
||||
```bash
|
||||
rig network enroll <code> --site <site URL> --replace
|
||||
```
|
||||
|
||||
Without `--replace`, a second enroll at the same site is refused and the code isn't spent. With it, the replace first
|
||||
waits for any other change to this enrollment to finish, within the same 30 seconds; one that runs out of time while
|
||||
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.
|
||||
- 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:
|
||||
only the installation a name was listed under can confirm it.
|
||||
- If cleanup after the step doesn't finish (removing the old key's backup, or an ended binding's credential), `enroll`
|
||||
reports it, and the replace still stands.
|
||||
|
||||
## Connect with a hub URL and a token
|
||||
|
||||
```bash
|
||||
rig network connect <seat> --hub <url> --as <participant> --token-file <path>
|
||||
@@ -69,8 +129,9 @@ rig queue update <local item> --state done --closure-reason no-follow-on --resol
|
||||
rig network status [--json]
|
||||
```
|
||||
|
||||
Each binding shows its seat, participant, hub, state, and its last contact or last error. `--json` adds the
|
||||
connector's view: each binding's next pass, backoff and last pass.
|
||||
Each binding shows its seat, participant, hub, state, and its last contact or last error. An ended binding made through
|
||||
the account also says when the account hasn't confirmed it dropped the name, or when the name may still be listed under
|
||||
a replaced installation. `--json` adds the connector's view: each binding's next pass, backoff and last pass.
|
||||
|
||||
## When things go wrong
|
||||
|
||||
@@ -88,7 +149,8 @@ rig network disconnect <binding-or-seat> [--reason "<why>"]
|
||||
```
|
||||
|
||||
The binding ends here, so no new work comes in through it, and its credential is removed. Local items and results
|
||||
stay. The hub still lists the participant until it's removed there too.
|
||||
stay. A binding made through the account is then dropped there too (see above). For a binding made with a hub URL and
|
||||
a token, the hub still lists the participant until it's removed there too.
|
||||
|
||||
If the credential can't be removed, the binding still ends, and the error names the
|
||||
`rig network disconnect <binding id>` to run again. The daemon also removes credentials whose binding isn't active
|
||||
@@ -104,11 +166,14 @@ each time it starts.
|
||||
An atomic close-if-open needs the hub to offer a conditional close.
|
||||
- After a disconnect or a revocation, work already taken keeps running here, but its pickup and result no longer reach
|
||||
the hub item, which stays at its last reported stage.
|
||||
- If the daemon stops between storing a new installation key and recording the enrollment, that key's folder under
|
||||
`network-secrets/` stays with no enrollment, and nothing removes it. It's private to you and unused, so remove it by
|
||||
hand.
|
||||
- **Plain http needs your choice.** `rig network connect` refuses an `http:` hub URL until you add `--allow-http`, because
|
||||
the participant's token and its work then cross the network unencrypted, where they can be read. Use the hub's
|
||||
`https:` URL where it has one. The choice is recorded on that binding, so the connector doesn't ask again, and
|
||||
`rig network status` (and its `--json`) marks the binding unencrypted. OpenRig doesn't judge the address: a private or
|
||||
tailnet address doesn't prove the connection is protected.
|
||||
tailnet address doesn't prove the connection is protected. `rig network enroll` asks the same for a plain-http site.
|
||||
- **Redirects:** the connector follows a hub's redirect only within its own origin, or to its `https:` form on the same
|
||||
host. Any other redirect stops the connection with its target named, and the token isn't sent there. Connect the
|
||||
binding to the hub's own URL.
|
||||
@@ -92,8 +92,3 @@ A member marked `"hosted": true` is a hosted member: a tool outside the terminal
|
||||
`show` and `find` say it's hosted rather than unmatched. See [Hosted members](hosted-members.md).
|
||||
Its entry can declare `wake.webhook`, the secret names for a trigger OpenRig rings when work lands for it
|
||||
(see [Ring it when work lands](hosted-members.md#ring-it-when-work-lands)).
|
||||
It can also declare `check_in_minutes`, how often it's expected to check its queue (a positive whole number of minutes;
|
||||
an hourly schedule is `60`), or `check_in_on_request: true` when it checks in only when a person asks it. That's what
|
||||
its person set up, not something OpenRig observes. With neither, its check-in status reads unknown: OpenRig doesn't
|
||||
assume a cadence. A value that can't be used doesn't stop the roster loading: that member's check-in
|
||||
status reads unknown, with the reason. See [Check-in status](hosted-members.md#check-in-status).
|
||||
@@ -226,18 +226,6 @@ retry is scheduled. The reasons are:
|
||||
one with a reused `deliveryId`; the result warns older callers that this field no
|
||||
longer deduplicates input. The CLI does not generate a deduplication key.
|
||||
|
||||
The last recorded managed-compaction input refusal appears as `inputReadFailure` in the attempt
|
||||
record and `rig compact --state`: the preparation or compact stage, `composer`
|
||||
(the explicit composer hold) or `draft-aware` check, reader status, confidence,
|
||||
a fixed read-failure reason and cursor flags. An owed restore step reports it in
|
||||
`owedRestore.lastDelivery`. These fields contain no input text, suggestions,
|
||||
terminal frames or exception messages. Confidence and timeout fields are `null`
|
||||
when that reader does not supply them; a known composer read timeout reports
|
||||
`timedOut: true`. A generic `capture_unavailable` does not identify the failed
|
||||
capture operation. Older records lack this diagnostic. It neither changes the
|
||||
refusal nor retries an attempt; missing metadata cannot establish which check
|
||||
caused a historical refusal.
|
||||
|
||||
Clearing a draft, changing modes or restarting does not flush saved messages or
|
||||
callbacks. To try again, send a current request: it rechecks the recipient and input.
|
||||
A caller abort before paste cancels input; after a write may have started, the
|
||||
|
||||
@@ -17,7 +17,6 @@ import {
|
||||
OPENRIG_DIR,
|
||||
STATE_FILE,
|
||||
resolveBindIntent,
|
||||
statusGuardMessage,
|
||||
} from "../daemon-lifecycle.js";
|
||||
|
||||
interface ProcessAliveDeps {
|
||||
@@ -38,16 +37,14 @@ export function createIsProcessAlive(deps: ProcessAliveDeps): (pid: number) => b
|
||||
|
||||
export type SignalOutcome = "sent" | "missing" | "not-permitted";
|
||||
|
||||
/** kill(pid, 0) as an outcome. POSIX: kill(pid, 0) fails only with ESRCH (no such process) or EPERM
|
||||
* (the process EXISTS but this shell may not signal it: another user's process, or a sandbox such as
|
||||
* Codex's). Only ESRCH is "missing"; any other error is not evidence of death, so it reads as
|
||||
* "not-permitted" (uninspectable), never as a dead process. */
|
||||
/** kill(pid, 0) as an outcome. POSIX: only ESRCH means no such process; EPERM means the process
|
||||
* EXISTS but this shell may not signal it (another user's process, or a sandbox such as Codex's). */
|
||||
export function signalProbe(pid: number, kill: (pid: number, signal: 0) => unknown = process.kill): SignalOutcome {
|
||||
try {
|
||||
kill(pid, 0);
|
||||
return "sent";
|
||||
} catch (error) {
|
||||
return (error as NodeJS.ErrnoException).code === "ESRCH" ? "missing" : "not-permitted";
|
||||
return (error as NodeJS.ErrnoException).code === "EPERM" ? "not-permitted" : "missing";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -162,11 +159,6 @@ export function realDeps(): LifecycleDeps {
|
||||
isProcessAlive,
|
||||
// RULING 1ae863d2 — sibling-home scan (home-resolution honesty).
|
||||
listDir: (p) => { try { return fs.readdirSync(p); } catch { return []; } },
|
||||
// #2860: where the OS has pid namespaces (Linux), a missing pid is only proof from the daemon's own.
|
||||
pidNamespace: () => {
|
||||
if (process.platform !== "linux") return "host";
|
||||
try { return fs.readlinkSync("/proc/self/ns/pid"); } catch { return undefined; }
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -377,12 +369,6 @@ export function daemonCommand(depsOverride?: LifecycleDeps): Command {
|
||||
console.log(` resolved: ${status.siblingHint.resolvedHome}`);
|
||||
console.log(` sibling: ${status.siblingHint.siblingHome}`);
|
||||
console.log(" Fix: point OPENRIG_HOME at the right home (or check your shell env) — this CLI is likely resolving the wrong home.");
|
||||
} else if (status.pidUnseen) {
|
||||
// #2860: the same words as the command guard.
|
||||
const unseen = statusGuardMessage(status);
|
||||
console.log(unseen.fact);
|
||||
console.log(` ${unseen.consequence}`);
|
||||
console.log(` ${unseen.action}`);
|
||||
} else if (!status.startPending) {
|
||||
// A pending start's probe was refused, not inconclusive; its line above says what is known.
|
||||
console.log("Daemon state UNVERIFIED — the probe timed out or was inconclusive (this is NOT evidence the daemon is down).");
|
||||
|
||||
@@ -15,8 +15,25 @@ interface Binding {
|
||||
lastContactAt: string | null; lastError: string | null; unencrypted?: boolean;
|
||||
/** Hub items someone else closed before this binding's result reached them. */
|
||||
closedElsewhere?: Array<{ hubItem: string; localItem: string }>;
|
||||
participantName?: string | null;
|
||||
/** For an ended binding made through the account: whether the account confirmed it stopped listing the name. */
|
||||
accountRemoval?: "confirmed" | "unconfirmed" | "old-installation" | null;
|
||||
}
|
||||
|
||||
/** The network writes can wait on the account, which the daemon bounds at 30 s in all; the CLI waits past that so the
|
||||
* account's own answer is shown rather than a daemon timeout. */
|
||||
const ACCOUNT_WRITE_TIMEOUT_MS = 45_000;
|
||||
|
||||
const accountNote = (b: Binding): string => {
|
||||
if (b.accountRemoval === "unconfirmed") return `\n couldn't confirm the account dropped ${b.participantName}: run rig network disconnect ${b.id} again`;
|
||||
if (b.accountRemoval === "old-installation") return `\n ${b.participantName} may still be listed under the old installation: remove it on the account page`;
|
||||
return "";
|
||||
};
|
||||
|
||||
interface Installation { id: string; site: string; installationId: string; name: string | null }
|
||||
|
||||
const enrolled = (i: Installation) => `Enrolled at ${i.site} as installation ${i.name ?? i.installationId} (${i.id})`;
|
||||
|
||||
/** The participant's token, read from the file the account gave the person. A file others can read is used, with a note. */
|
||||
function defaultReadTokenFile(path: string): string {
|
||||
const stat = lstatSync(path);
|
||||
@@ -30,7 +47,7 @@ function line(b: Binding): string {
|
||||
const state = b.state === "active" ? contact : `${b.state}${b.stateReason ? ` (${b.stateReason})` : ""}`;
|
||||
const plain = b.unencrypted ? " UNENCRYPTED (http, allowed for this binding)" : "";
|
||||
const conflicts = (b.closedElsewhere ?? []).map((c) => `\n hub item ${c.hubItem} was closed elsewhere before its result arrived; the result stays on ${c.localItem}`).join("");
|
||||
return `${b.id} ${b.localSession} -> ${b.hubSeat} at ${b.hubUrl}${plain} ${state}${b.lastError && b.state === "active" ? ` last error: ${b.lastError}` : ""}${conflicts}`;
|
||||
return `${b.id} ${b.localSession} -> ${b.hubSeat} at ${b.hubUrl}${plain} ${state}${b.lastError && b.state === "active" ? ` last error: ${b.lastError}` : ""}${conflicts}${accountNote(b)}`;
|
||||
}
|
||||
|
||||
/** `rig network`: connect a selected local seat to a participant of a remote hub (an account's network rig, or
|
||||
@@ -56,25 +73,60 @@ export function networkCommand(depsOverride?: NetworkDeps): Command {
|
||||
console.log(json ? JSON.stringify(data, null, 2) : ok(data as Record<string, unknown>));
|
||||
};
|
||||
|
||||
cmd.command("enroll")
|
||||
.description("Enroll this daemon in an account with the one-time code its page shows, so seats connect by name")
|
||||
.argument("<code>", "The one-time code from the account page")
|
||||
.requiredOption("--site <url>", "The account's site")
|
||||
.option("--allow-http", "Accept a plain-http site: the installation key and tokens cross the network unencrypted")
|
||||
.option("--replace", "Replace this daemon's enrollment at the site, once the account accepts the new code (after a lost key or a removed installation)")
|
||||
.option("--json", "Print JSON")
|
||||
.action(async (code: string, opts: { site: string; allowHttp?: boolean; replace?: boolean; json?: boolean }) => {
|
||||
const daemon = await client();
|
||||
if (!daemon) return;
|
||||
const res = await daemon.post<Record<string, unknown>>("/api/network/installations", { code, site: opts.site, allowHttp: opts.allowHttp === true, replace: opts.replace === true },
|
||||
{ headers: operatorAuthHeaders(), timeoutMs: ACCOUNT_WRITE_TIMEOUT_MS });
|
||||
finish(res.status, res.data, opts.json, (i) => {
|
||||
const replaced = (i as { replaced?: { endedBindings?: Array<{ id: string; localSession: string; participantName: string | null }>; problems?: string[] } }).replaced;
|
||||
const ended = replaced?.endedBindings ?? [];
|
||||
const lines = [enrolled(i as unknown as Installation)];
|
||||
if (ended.length) {
|
||||
lines.push(`Ended ${ended.length} binding${ended.length === 1 ? "" : "s"} made through the old installation; their names may still be listed under it, so remove them on the account page:`);
|
||||
for (const b of ended) lines.push(` ${b.id} ${b.localSession} as ${b.participantName}`);
|
||||
}
|
||||
for (const problem of replaced?.problems ?? []) lines.push(`Replaced, with cleanup left over: ${problem}`);
|
||||
lines.push("Connect a seat with rig network connect <seat> --as <name>.");
|
||||
return lines.join("\n");
|
||||
});
|
||||
});
|
||||
|
||||
cmd.command("connect")
|
||||
.description("Connect a local seat to a hub participant, using that participant's token")
|
||||
.description("Connect a local seat to a hub participant: by name through the enrolled account, or with a hub URL and token file")
|
||||
.argument("<seat>", "The local seat to connect (its address, member@rig)")
|
||||
.requiredOption("--hub <url>", "The hub's MCP URL")
|
||||
.requiredOption("--as <participant>", "The participant's address in the hub (member@rig)")
|
||||
.requiredOption("--token-file <path>", "A file holding the participant's token")
|
||||
.requiredOption("--as <participant>", "Through the account: the name to connect as. With --hub: the participant's address in the hub (member@rig)")
|
||||
.option("--hub <url>", "The hub's MCP URL, to connect without the account (with --token-file)")
|
||||
.option("--token-file <path>", "A file holding the participant's token (with --hub)")
|
||||
.option("--allow-http", "Accept a plain-http hub for this binding: its token and work cross the network unencrypted")
|
||||
.option("--json", "Print JSON")
|
||||
.action(async (seat: string, opts: { hub: string; as: string; tokenFile: string; allowHttp?: boolean; json?: boolean }) => {
|
||||
let token: string;
|
||||
try { token = (getDeps().readTokenFile ?? defaultReadTokenFile)(opts.tokenFile); } catch (error) {
|
||||
console.error(`Can't read the token file: ${(error as Error).message}`);
|
||||
.action(async (seat: string, opts: { hub?: string; as: string; tokenFile?: string; allowHttp?: boolean; json?: boolean }) => {
|
||||
if (!opts.hub !== !opts.tokenFile) {
|
||||
console.error("Give both --hub and --token-file, or neither to connect through the enrolled account.");
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
let body: Record<string, unknown> = { session: seat, participantName: opts.as };
|
||||
if (opts.hub && opts.tokenFile) {
|
||||
let token: string;
|
||||
try { token = (getDeps().readTokenFile ?? defaultReadTokenFile)(opts.tokenFile); } catch (error) {
|
||||
console.error(`Can't read the token file: ${(error as Error).message}`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
body = { session: seat, hubUrl: opts.hub, hubSeat: opts.as, token, allowHttp: opts.allowHttp === true };
|
||||
}
|
||||
const daemon = await client();
|
||||
if (!daemon) return;
|
||||
const res = await daemon.post<Record<string, unknown>>("/api/network/bindings", { session: seat, hubUrl: opts.hub, hubSeat: opts.as, token, allowHttp: opts.allowHttp === true }, { headers: operatorAuthHeaders() });
|
||||
finish(res.status, res.data, opts.json, (b) => `Connected ${line(b as unknown as Binding)}\nWork for ${opts.as} reaches ${seat} once the connector runs; rig network status shows its last contact.`);
|
||||
const res = await daemon.post<Record<string, unknown>>("/api/network/bindings", body, { headers: operatorAuthHeaders(), timeoutMs: ACCOUNT_WRITE_TIMEOUT_MS });
|
||||
finish(res.status, res.data, opts.json, (b) => `Connected ${line(b as unknown as Binding)}\nWork for ${(b as unknown as Binding).hubSeat} reaches ${seat} once the connector runs; rig network status shows its last contact.`);
|
||||
});
|
||||
|
||||
cmd.command("status")
|
||||
@@ -83,10 +135,11 @@ export function networkCommand(depsOverride?: NetworkDeps): Command {
|
||||
.action(async (opts: { json?: boolean }) => {
|
||||
const daemon = await client();
|
||||
if (!daemon) return;
|
||||
const res = await daemon.get<{ bindings: Binding[] }>("/api/network/bindings");
|
||||
const res = await daemon.get<{ bindings: Binding[]; installations?: Installation[] }>("/api/network/bindings");
|
||||
finish(res.status, res.data, opts.json, (d) => {
|
||||
const bindings = (d.bindings ?? []) as Binding[];
|
||||
return bindings.length ? bindings.map(line).join("\n") : "No seats are connected to a hub.";
|
||||
const installations = (d.installations ?? []) as Installation[];
|
||||
return [...installations.map(enrolled), ...(bindings.length ? bindings.map(line) : ["No seats are connected to a hub."])].join("\n");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -98,8 +151,14 @@ export function networkCommand(depsOverride?: NetworkDeps): Command {
|
||||
.action(async (ref: string, opts: { reason?: string; json?: boolean }) => {
|
||||
const daemon = await client();
|
||||
if (!daemon) return;
|
||||
const res = await daemon.post<Record<string, unknown>>(`/api/network/bindings/${encodeURIComponent(ref)}/disconnect`, { reason: opts.reason }, { headers: operatorAuthHeaders() });
|
||||
finish(res.status, res.data, opts.json, (b) => `Disconnected ${line(b as unknown as Binding)}\nRemove the participant on the hub too, so the account stops listing it.`);
|
||||
const res = await daemon.post<Record<string, unknown>>(`/api/network/bindings/${encodeURIComponent(ref)}/disconnect`, { reason: opts.reason },
|
||||
{ headers: operatorAuthHeaders(), timeoutMs: ACCOUNT_WRITE_TIMEOUT_MS });
|
||||
finish(res.status, res.data, opts.json, (raw) => {
|
||||
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 cmd;
|
||||
|
||||
@@ -137,7 +137,6 @@ export function packageCommand(depsOverride?: StatusDeps): Command {
|
||||
conflicts?: Array<{ existingPath: string; reason: string }>;
|
||||
verification?: { passed: boolean };
|
||||
policyRejected?: Array<{ entry: { exportType: string; exportName: string }; reason: string }>;
|
||||
notes?: string[];
|
||||
error?: string;
|
||||
errors?: string[];
|
||||
code?: string;
|
||||
@@ -205,11 +204,6 @@ export function packageCommand(depsOverride?: StatusDeps): Command {
|
||||
console.log(` ${r.entry.exportType.padEnd(12)} ${r.entry.exportName.padEnd(20)} ${r.reason}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (res.data.notes && res.data.notes.length > 0) {
|
||||
console.log("Left as it is:");
|
||||
for (const note of res.data.notes) console.log(` ${note}`);
|
||||
}
|
||||
});
|
||||
|
||||
// rig package rollback <installId>
|
||||
|
||||
@@ -1395,13 +1395,6 @@ export function queueCommand(depsOverride?: QueueDeps): Command {
|
||||
.option("--summary <text>", "Summary persisted onto the item (human-seat parks, or standalone summary update without --state)")
|
||||
.option("--evidence-ref <path>", "Pointer to the durable artifact, persisted onto the item on a human-seat park or a done close (--state done). For a code result, a JSON file of kind openrig.code-result/v1 (repo, base and revision as full commit ids, paths) lets a reader diff it exactly")
|
||||
.option("--note <text>", "Transition note for the audit log")
|
||||
.addHelpText("after", `
|
||||
After reading an existing remote successor, record its exact closure target, including the host:
|
||||
rig queue update <source-id> --note "custody-verified: receiver@team@remote-host. Registered-host read confirmed the successor"
|
||||
The note can accompany the closing update or be appended later; a separate note is not required.
|
||||
A trailing period, comma, semicolon, colon or closing parenthesis is accepted; a longer or different target is not.
|
||||
For a new cross-host transfer, prefer rig queue handoff --host <registered-host>.
|
||||
`)
|
||||
.option("--source <name@external>", "Outside a managed seat: the external routine that appends the --note, recorded as an external actor (claimed:v1), never a seat; note only, on this host's daemon (a --host that resolves to another host is refused). A managed seat stays the actor. Like any note, it resets the item's stall and notification timing (and its recovery and operator episodes) and advances a claimed item's last activity, but never resets how long an unclaimed item has waited")
|
||||
.option("--host <id>", QUEUE_HOST_ROW_OPTION_HELP)
|
||||
.option("--full", "Print the whole response row(s) as today, even when output.compact is on (the receipt's waiting keeps only blocker and nextBackstop)")
|
||||
|
||||
@@ -5,10 +5,6 @@ import { fileURLToPath } from "node:url";
|
||||
import { execFile } from "node:child_process";
|
||||
import { ConfigStore } from "../config-store.js";
|
||||
import { mcpPresenceFilePath, readPresence } from "../mcp-presence.js";
|
||||
import { CHECK_IN_DISPLAY, describeCheckIn, type CheckInStatus } from "@openrig/daemon/connection-contract";
|
||||
import { getDaemonStatus, getDaemonUrl } from "../daemon-lifecycle.js";
|
||||
import { DaemonClient } from "../client.js";
|
||||
import { realDeps } from "./daemon.js";
|
||||
import { parseRoster, readRosters, type Roster, type RosterMember, type RosterWake } from "@openrig/daemon/rosters";
|
||||
|
||||
// One roster parser, shared with the daemon (a hosted member's wake route reads the same files).
|
||||
@@ -21,23 +17,6 @@ export interface RosterInventory {
|
||||
sources: Row[];
|
||||
/** This host's id, from the local daemon's own nodes; null when it has none to read it from. */
|
||||
localHostId?: string | null;
|
||||
/** The local daemon's hosted members with their check-in status, and that daemon's own host id (the host whose
|
||||
* check-ins these are); null when it couldn't be read. */
|
||||
checkIns?: { hostId: string | null; members: Row[] } | null;
|
||||
}
|
||||
|
||||
/** Each hosted member's check-in status, from the local daemon. Null when the daemon isn't running or doesn't answer
|
||||
* (an older daemon has no such read): every hosted member's status then reads unknown. */
|
||||
export async function readHostedCheckIns(): Promise<RosterInventory["checkIns"]> {
|
||||
try {
|
||||
const status = await getDaemonStatus(realDeps());
|
||||
if (status.state !== "running") return null;
|
||||
const res = await new DaemonClient(getDaemonUrl(status)).get<unknown>("/api/queue/hosted-members");
|
||||
return res.status < 400 && object(res.data) && Array.isArray(res.data.members)
|
||||
? { hostId: text(res.data.hostId), members: res.data.members.filter(object) } : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
const object = (v: unknown): v is Row => !!v && typeof v === "object" && !Array.isArray(v);
|
||||
const text = (v: unknown): string | null => typeof v === "string" && v.trim() ? v : null;
|
||||
@@ -70,30 +49,7 @@ export async function readRosterInventory(): Promise<RosterInventory> {
|
||||
sources.push({ scope, status: "unavailable", error: result.stderr.trim() || (error as Error).message });
|
||||
}
|
||||
}
|
||||
return { readAt: new Date().toISOString(), nodes, sources, localHostId, checkIns: await readHostedCheckIns() };
|
||||
}
|
||||
|
||||
function hostedCheckIn(member: RosterMember, inventory: RosterInventory): Row {
|
||||
const unknown = (reason: string) => ({ status: "unknown", lastCheckInAt: null, expectedMinutes: member.check_in_minutes ?? null,
|
||||
onRequest: member.check_in_on_request === true, reason });
|
||||
// The daemon names its own host with its check-ins, so a team with no terminal seat still matches its members.
|
||||
if (!inventory.checkIns) return unknown("this host's daemon gave no check-in status");
|
||||
if (!inventory.checkIns.hostId || member.host !== inventory.checkIns.hostId) return unknown("it's listed on another host, whose check-ins this host doesn't hold");
|
||||
const row = inventory.checkIns.members.find((m) => m.seat === member.seat);
|
||||
return row && typeof row.status === "string" && row.status in CHECK_IN_DISPLAY ? row
|
||||
: unknown("this host's daemon gave no status for it");
|
||||
}
|
||||
|
||||
/** A connected assistant's check-in in one human line with its evidence, in the daemon's own words
|
||||
* (`describeCheckIn`): "overdue: last check-in 13:05, expected every 60 min; its schedule may be off or waiting for an
|
||||
* approval". A status this CLI doesn't know reads unknown. */
|
||||
export function formatCheckIn(checkIn: Row): string {
|
||||
const known = typeof checkIn.status === "string" && checkIn.status in CHECK_IN_DISPLAY;
|
||||
return describeCheckIn({ status: known ? checkIn.status as CheckInStatus : "unknown",
|
||||
lastCheckInAt: typeof checkIn.lastCheckInAt === "string" ? checkIn.lastCheckInAt : null,
|
||||
expectedMinutes: typeof checkIn.expectedMinutes === "number" ? checkIn.expectedMinutes : null,
|
||||
onRequest: checkIn.onRequest === true,
|
||||
...(known && typeof checkIn.reason === "string" ? { reason: checkIn.reason } : {}) });
|
||||
return { readAt: new Date().toISOString(), nodes, sources, localHostId };
|
||||
}
|
||||
|
||||
export function enrichRosterMember(member: RosterMember, inventory: RosterInventory) {
|
||||
@@ -137,9 +93,6 @@ export function enrichRosterMember(member: RosterMember, inventory: RosterInvent
|
||||
// is in rig_team.
|
||||
...(member.hosted ? { lastCallAt: inventory.localHostId && member.host === inventory.localHostId
|
||||
? readPresence(mcpPresenceFilePath())[member.seat]?.lastCallAt ?? null : null } : {}),
|
||||
// Whether it's checking its queue, for a member on this host; another host's member, or a status this daemon
|
||||
// couldn't give, reads unknown.
|
||||
...(member.hosted ? { checkIn: hostedCheckIn(member, inventory) } : {}),
|
||||
} };
|
||||
}
|
||||
|
||||
@@ -181,7 +134,7 @@ export function rosterCommand(deps: Deps = {}): Command {
|
||||
else {
|
||||
const members = (verb === "show" ? (result.roster as Row).members : result.matches) as Array<ReturnType<typeof enrichRosterMember> & { roster?: { id: string } }>;
|
||||
if (verb === "show") { const r = result.roster as Roster; console.log(`${r.name} — ${r.purpose}\nCurator: ${r.curator.seat} (${r.curator.host})`); }
|
||||
for (const m of members) console.log(`${m.seat} (${m.host})${m.roster ? ` [${m.roster.id}]` : ""}\n ${m.engagement.join(", ")}: ${m.capabilities.join(", ")}\n When: ${m.use_when}\n Why: ${m.why}${m.caveat ? `\n Caveat: ${m.caveat}` : ""}\n ${m.live.status}: runtime ${m.live.runtime ?? "unknown"}; configured model ${m.live.configuredModel ?? "unknown"}; last observed model ${formatModelObservation(m.live.modelObservation)}\n Activity: ${m.live.agentActivity.state}; sampled ${m.live.agentActivity.sampledAt ?? "unknown"}${m.hosted ? `\n Last call: ${m.live.lastCallAt ?? "unknown"}\n Check-in: ${formatCheckIn(m.live.checkIn ?? {})}` : ""}\n ${m.live.reason}`);
|
||||
for (const m of members) console.log(`${m.seat} (${m.host})${m.roster ? ` [${m.roster.id}]` : ""}\n ${m.engagement.join(", ")}: ${m.capabilities.join(", ")}\n When: ${m.use_when}\n Why: ${m.why}${m.caveat ? `\n Caveat: ${m.caveat}` : ""}\n ${m.live.status}: runtime ${m.live.runtime ?? "unknown"}; configured model ${m.live.configuredModel ?? "unknown"}; last observed model ${formatModelObservation(m.live.modelObservation)}\n Activity: ${m.live.agentActivity.state}; sampled ${m.live.agentActivity.sampledAt ?? "unknown"}${m.hosted ? `\n Last call: ${m.live.lastCallAt ?? "unknown"}` : ""}\n ${m.live.reason}`);
|
||||
if (!members.length) console.log("No matching members.");
|
||||
const obs = result.observations as { sources: Row[] };
|
||||
for (const source of obs.sources) if (source.status !== "ok") console.error(`Observation gap (${source.scope}): ${source.warning ?? source.error ?? source.status}`);
|
||||
|
||||
@@ -22,15 +22,8 @@ export interface DaemonState {
|
||||
host?: string;
|
||||
db: string;
|
||||
startedAt: string;
|
||||
/** The pid namespace (Linux `/proc/self/ns/pid`) of the shell that started the daemon. A shell
|
||||
* in another namespace cannot tell a missing pid from an invisible one, nor map the number.
|
||||
* Absent on older records. */
|
||||
pidNamespace?: string;
|
||||
}
|
||||
|
||||
/** Linux' initial pid namespace: where an older record, which names no namespace, is read. */
|
||||
const INITIAL_PID_NAMESPACE = "pid:[4026531836]";
|
||||
|
||||
/**
|
||||
* OPR.0.4.3.21 — event-loop wedge evidence read from the enriched `/healthz`
|
||||
* body. Present only when the daemon actually answered healthz with a monitor
|
||||
@@ -71,10 +64,6 @@ export interface DaemonStatus {
|
||||
eventLoop?: DaemonEventLoopEvidence;
|
||||
/** #275 — the OS code behind a failed health probe (e.g. EPERM when a sandbox blocked it). */
|
||||
probeErrorCode?: string;
|
||||
/** The recorded pid looks missing and the connection was refused, but this shell is not in the
|
||||
* daemon's pid namespace and no clean-shutdown receipt matches: nothing here shows the daemon is
|
||||
* down (#2860). */
|
||||
pidUnseen?: true;
|
||||
/** T2 — a refused listener under a live start reservation (daemon-start.lock): a launch is in
|
||||
* progress and not listening yet. `pid` is the live launcher (else child) PID; `since` its start. */
|
||||
startPending?: { pid: number; since?: string };
|
||||
@@ -163,15 +152,6 @@ export function statusGuardMessage(status: DaemonStatus): DaemonNotRunningError
|
||||
...causeCode,
|
||||
};
|
||||
}
|
||||
// #2860: a refused probe and a missing pid, seen from outside the daemon's pid namespace.
|
||||
if (status.pidUnseen) {
|
||||
return {
|
||||
fact: "Couldn't connect to the daemon from this shell; it may still be running.",
|
||||
consequence: "This shell can't see the daemon's process (a sandbox or another namespace) and no clean shutdown was recorded, so a refused connection here doesn't show it is down.",
|
||||
action: "Don't start another daemon from here. Check from a normal terminal with 'rig daemon status'.",
|
||||
...causeCode,
|
||||
};
|
||||
}
|
||||
// unverified, or running-but-unhealthy: we do NOT know it is down.
|
||||
return {
|
||||
fact: "Daemon did not respond — it may be busy or stopped (state not confirmed).",
|
||||
@@ -268,9 +248,6 @@ export interface LifecycleDeps {
|
||||
/** #275 — three-state liveness for status reads (production: realDeps). Optional so existing
|
||||
* mocks keep the boolean rule; absent = derived from isProcessAlive. */
|
||||
processLiveness?: (pid: number) => ProcessLiveness;
|
||||
/** This shell's pid namespace ("host" where the OS has none, undefined when unreadable).
|
||||
* Production: realDeps. Absent = no namespace evidence, a missing pid counts as dead. */
|
||||
pidNamespace?: () => string | undefined;
|
||||
// OPR.0.4.2.1 — optional injectable delay for the status-probe bounded settle/retry.
|
||||
// Defaults to a real setTimeout in production; tests pass a no-op to stay fast. Optional so
|
||||
// existing deps / mocks / callers are untouched.
|
||||
@@ -912,8 +889,7 @@ async function startOwnedDaemon(opts: StartOptions, deps: LifecycleDeps, lock: D
|
||||
}
|
||||
assertChild();
|
||||
if (!deps.isProcessAlive(pid!)) throw new Error(`Daemon child ${pid} liveness could not be confirmed; state not published`);
|
||||
const namespace = deps.pidNamespace?.();
|
||||
const state: DaemonState = { pid: pid!, port, host: probeHost, db, startedAt: new Date().toISOString(), ...(namespace && namespace !== "host" ? { pidNamespace: namespace } : {}) };
|
||||
const state: DaemonState = { pid: pid!, port, host: probeHost, db, startedAt: new Date().toISOString() };
|
||||
try {
|
||||
deps.writeFile(STATE_FILE, JSON.stringify(state, null, 2));
|
||||
assertChild();
|
||||
@@ -974,17 +950,6 @@ function isCleanShutdown(receipt: DaemonShutdownReceipt | undefined): boolean {
|
||||
return receipt?.outcome === "clean" && receipt.phase === "complete" && receipt.failures.length === 0;
|
||||
}
|
||||
|
||||
/** Whether the recorded pid number means the same process in this shell. A recorded namespace must
|
||||
* be this shell's own: another namespace (even the host's) numbers processes differently, so its
|
||||
* missing number says nothing. Only an older record, which names none, is read as the host's. */
|
||||
function sharesPidNamespace(state: DaemonState, deps: LifecycleDeps): boolean {
|
||||
if (!deps.pidNamespace) return true;
|
||||
const own = deps.pidNamespace();
|
||||
if (own === undefined) return false;
|
||||
if (state.pidNamespace) return own === state.pidNamespace;
|
||||
return own === "host" || own === INITIAL_PID_NAMESPACE;
|
||||
}
|
||||
|
||||
function removeMatchingState(deps: LifecycleDeps, stateFile: string, state: DaemonState): void {
|
||||
// A concurrent start/rebind must not have its state removed by an old stop/status.
|
||||
const current = readState(deps);
|
||||
@@ -1188,23 +1153,7 @@ export async function getDaemonStatus(
|
||||
return { state: "running", port: Number(url.port) || DEFAULT_PORT, host: url.hostname || DEFAULT_HOST, healthy: ev.healthy, reason: ev.reason, eventLoop: ev.eventLoop, ...(ev.databaseWarnings ? { databaseWarnings: ev.databaseWarnings } : {}) };
|
||||
} catch (err) {
|
||||
// 1ae863d2: refusal = positive down; anything else (timeout/wedged/blocked) = unverified.
|
||||
if (!isRefusedError(err)) return { state: "unverified", ...endpoint, ...probeErrorCodeOf(err) };
|
||||
// #2860: a loopback URL that is this home's recorded daemon is judged like the record: a refusal
|
||||
// alone proves nothing from a shell that can't see the pid. No record for it keeps the plain rule.
|
||||
const recorded = "explicitRemote" in endpoint ? null : readState(deps);
|
||||
if (!recorded || recorded.port !== ("port" in endpoint ? endpoint.port : undefined)) return { state: "stopped" };
|
||||
const recordedHost = recorded.host ?? DEFAULT_HOST;
|
||||
const recordedLiveness: ProcessLiveness = deps.processLiveness
|
||||
? deps.processLiveness(recorded.pid)
|
||||
: deps.isProcessAlive(recorded.pid) ? "alive" : "dead";
|
||||
const { receipt } = readShutdownReceipt(deps, resolveLifecycleFile(deps, "daemon.json"));
|
||||
if ((receiptMatchesState(receipt, recorded) && isCleanShutdown(receipt))
|
||||
|| (recordedLiveness === "dead" && sharesPidNamespace(recorded, deps))) return { state: "stopped" };
|
||||
const startPending = liveStartReservation(deps);
|
||||
return {
|
||||
state: "unverified", pid: recorded.pid, port: recorded.port, host: recordedHost, ...(startPending ? { startPending } : {}),
|
||||
...(recordedLiveness !== "alive" && !startPending ? { pidUnseen: true as const } : {}), ...probeErrorCodeOf(err),
|
||||
};
|
||||
return isRefusedError(err) ? { state: "stopped" } : { state: "unverified", ...endpoint, ...probeErrorCodeOf(err) };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1238,21 +1187,17 @@ export async function getDaemonStatus(
|
||||
}
|
||||
}
|
||||
|
||||
let liveness: ProcessLiveness = deps.processLiveness
|
||||
const liveness: ProcessLiveness = deps.processLiveness
|
||||
? deps.processLiveness(state.pid)
|
||||
: deps.isProcessAlive(state.pid) ? "alive" : "dead";
|
||||
// #2860: down needs positive evidence: the daemon's clean-shutdown receipt, or a missing pid seen
|
||||
// from the daemon's own pid namespace. Elsewhere a missing pid may just be invisible.
|
||||
const stateFile = resolveLifecycleFile(deps, "daemon.json");
|
||||
const { receipt } = readShutdownReceipt(deps, stateFile);
|
||||
const cleanShutdown = receiptMatchesState(receipt, state) && isCleanShutdown(receipt);
|
||||
if (liveness === "dead") {
|
||||
if (cleanShutdown && options.cleanupStaleState !== false) {
|
||||
// A status read must not erase the identity needed to judge a failed stop.
|
||||
// A status read must not erase the identity needed to judge a failed stop.
|
||||
const stateFile = resolveLifecycleFile(deps, "daemon.json");
|
||||
const { receipt } = readShutdownReceipt(deps, stateFile);
|
||||
if (options.cleanupStaleState !== false && receiptMatchesState(receipt, state) && isCleanShutdown(receipt)) {
|
||||
removeMatchingState(deps, stateFile, state);
|
||||
}
|
||||
if (cleanShutdown || sharesPidNamespace(state, deps)) return { state: "stale" };
|
||||
liveness = "unknown";
|
||||
return { state: "stale" };
|
||||
}
|
||||
|
||||
const host = state.host ?? DEFAULT_HOST;
|
||||
@@ -1268,9 +1213,7 @@ export async function getDaemonStatus(
|
||||
// T2: unless a live start reservation says a launch is in progress.
|
||||
if (isRefusedError(err)) {
|
||||
const startPending = liveStartReservation(deps);
|
||||
if (startPending) return { state: "unverified", pid: state.pid, port: state.port, host, startPending, ...probeErrorCodeOf(err) };
|
||||
// #2860: a refusal from a shell that can't see the pid proves nothing without a clean shutdown.
|
||||
return cleanShutdown ? { state: "stopped" } : { state: "unverified", pid: state.pid, port: state.port, host, pidUnseen: true, ...probeErrorCodeOf(err) };
|
||||
return startPending ? { state: "unverified", pid: state.pid, port: state.port, host, startPending, ...probeErrorCodeOf(err) } : { state: "stopped" };
|
||||
}
|
||||
return { state: "unverified", pid: state.pid, port: state.port, host, ...probeErrorCodeOf(err) };
|
||||
}
|
||||
|
||||
@@ -65,7 +65,7 @@ export function getOfflineHelpPath(): string | undefined {
|
||||
* reporting a starved loop) gets the request, under its normal timeout. */
|
||||
export function daemonMayAnswer(status: DaemonStatus): boolean {
|
||||
if (status.state === "stopped" || status.state === "stale") return false;
|
||||
if (status.probeErrorCode === "ECONNREFUSED" || status.startPending || status.pidUnseen) return false;
|
||||
if (status.probeErrorCode === "ECONNREFUSED" || status.startPending) return false;
|
||||
if (status.siblingHint || isBlockedConnectionCode(status.probeErrorCode)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -289,12 +289,6 @@ describe("sandboxed liveness: EPERM on kill(pid, 0) is not a dead daemon", () =>
|
||||
expect(signalProbe(1, throwing("ESRCH"))).toBe("missing");
|
||||
});
|
||||
|
||||
it("an error other than ESRCH is never evidence that the process is gone", () => {
|
||||
const throwing = (code: string | undefined) => () => { throw Object.assign(new Error(String(code)), { code }); };
|
||||
expect(signalProbe(1, throwing("EINVAL"))).toBe("not-permitted");
|
||||
expect(signalProbe(1, throwing(undefined))).toBe("not-permitted");
|
||||
});
|
||||
|
||||
it("unknown liveness plus a blocked probe is unverified with EPERM, never stale, and the guard says blocked", async () => {
|
||||
const status = await getDaemonStatus(withState({
|
||||
isProcessAlive: () => false,
|
||||
@@ -310,16 +304,14 @@ describe("sandboxed liveness: EPERM on kill(pid, 0) is not a dead daemon", () =>
|
||||
expect(guard.fact).not.toMatch(/not running/i);
|
||||
});
|
||||
|
||||
// #2860: a refusal from a shell that can't inspect the pid is "stopped" only with the daemon's clean-shutdown receipt.
|
||||
it("unknown liveness plus a refused probe and no clean shutdown is unverified, not stopped", async () => {
|
||||
it("unknown liveness plus a refused probe is still a stopped daemon with down advice", async () => {
|
||||
const status = await getDaemonStatus(withState({
|
||||
isProcessAlive: () => false,
|
||||
processLiveness: sandboxLiveness,
|
||||
fetch: probeFailing("ECONNREFUSED"),
|
||||
}));
|
||||
expect(status.state).toBe("unverified");
|
||||
expect(status.pidUnseen).toBe(true);
|
||||
expect(statusGuardMessage(status).fact).toMatch(/couldn't connect.*from this shell/i);
|
||||
expect(status.state).toBe("stopped");
|
||||
expect(statusGuardMessage(status).fact).toMatch(/not running/i);
|
||||
});
|
||||
|
||||
it("unknown liveness plus a healthy probe is running", async () => {
|
||||
|
||||
@@ -1,207 +0,0 @@
|
||||
// #2860 — a sandbox that cuts loopback often also hides the daemon's pid. A refused probe plus a
|
||||
// missing pid then looked exactly like a stopped daemon, and the guard told the agent to run `rig up`.
|
||||
//
|
||||
// Contract pinned here: "stopped" needs positive evidence, either the daemon's own clean-shutdown
|
||||
// receipt or a missing pid seen from the daemon's pid namespace. Otherwise status says the CLI
|
||||
// couldn't connect from this shell and the daemon may still be running, and never advises starting
|
||||
// another daemon.
|
||||
import { afterEach, describe, it, expect, vi } from "vitest";
|
||||
import {
|
||||
daemonStatusGuard,
|
||||
getDaemonStatus,
|
||||
statusGuardMessage,
|
||||
STATE_FILE,
|
||||
type DaemonState,
|
||||
type LifecycleDeps,
|
||||
} from "../src/daemon-lifecycle.js";
|
||||
import { daemonCommand } from "../src/commands/daemon.js";
|
||||
import { daemonMayAnswer, shouldAutoStartDaemon } from "../src/daemon-reachability.js";
|
||||
|
||||
const INITIAL_NAMESPACE = "pid:[4026531836]";
|
||||
const DAEMON_NAMESPACE = "pid:[4026532001]";
|
||||
const SANDBOX_NAMESPACE = "pid:[4026532777]";
|
||||
const START = "2026-01-01T00:00:00Z";
|
||||
|
||||
const REFUSED: LifecycleDeps["fetch"] = async () => {
|
||||
const err = new Error("fetch failed") as Error & { cause?: { code: string } };
|
||||
err.cause = { code: "ECONNREFUSED" };
|
||||
throw err;
|
||||
};
|
||||
|
||||
function cleanReceipt(pid: number): string {
|
||||
const now = new Date().toISOString();
|
||||
return JSON.stringify({ schema: "openrig.daemon-shutdown/v1", pid, startedAt: START, completedAt: now, outcome: "clean", phase: "complete", failures: [] });
|
||||
}
|
||||
|
||||
function deps(state: DaemonState, over: Partial<LifecycleDeps> = {}, receipt?: string): LifecycleDeps {
|
||||
return {
|
||||
spawn: vi.fn() as unknown as LifecycleDeps["spawn"],
|
||||
fetch: vi.fn(REFUSED),
|
||||
kill: vi.fn(() => true),
|
||||
readFile: vi.fn((p: string) => (p === STATE_FILE ? JSON.stringify(state) : p.endsWith("daemon-shutdown.json") ? receipt ?? null : null)),
|
||||
writeFile: vi.fn(),
|
||||
removeFile: vi.fn(),
|
||||
exists: vi.fn((p: string) => p === STATE_FILE || (receipt !== undefined && p.endsWith("daemon-shutdown.json"))),
|
||||
mkdirp: vi.fn(),
|
||||
openForAppend: vi.fn(() => 0),
|
||||
isProcessAlive: vi.fn(() => false),
|
||||
processLiveness: vi.fn(() => "dead" as const),
|
||||
pidNamespace: () => SANDBOX_NAMESPACE,
|
||||
sleep: async () => {},
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
const recorded = (extra: Partial<DaemonState> = {}): DaemonState =>
|
||||
({ pid: 4242, port: 7433, db: "x.db", startedAt: START, pidNamespace: DAEMON_NAMESPACE, ...extra });
|
||||
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
describe("#2860: a refused probe with an invisible pid is not a stopped daemon", () => {
|
||||
it("reads 'couldn't connect from this shell' when the pid is missing, the probe is refused and there is no clean shutdown", async () => {
|
||||
const status = await getDaemonStatus(deps(recorded()));
|
||||
expect(status.state).toBe("unverified");
|
||||
expect(status.pidUnseen).toBe(true);
|
||||
const message = statusGuardMessage(status);
|
||||
expect(message.fact).toMatch(/couldn't connect to the daemon from this shell.*may still be running/i);
|
||||
expect(`${message.fact} ${message.consequence} ${message.action}`).not.toMatch(/rig up|rig daemon start/i);
|
||||
expect(message.action).toMatch(/don't start another daemon/i);
|
||||
expect(shouldAutoStartDaemon(status)).toBe(false);
|
||||
});
|
||||
|
||||
it("blocks commands through the guard with the same message", async () => {
|
||||
const status = await getDaemonStatus(deps(recorded()));
|
||||
const lines: string[] = [];
|
||||
vi.spyOn(console, "error").mockImplementation((line: string) => { lines.push(line); });
|
||||
const savedExit = process.exitCode;
|
||||
try {
|
||||
expect(daemonStatusGuard(status)).toBe(false);
|
||||
} finally { process.exitCode = savedExit; }
|
||||
const message = statusGuardMessage(status);
|
||||
expect(lines).toEqual([`Error: ${message.fact}`, ` ${message.consequence}`, ` ${message.action}`]);
|
||||
});
|
||||
|
||||
it("is the same on an older record that names no namespace", async () => {
|
||||
const status = await getDaemonStatus(deps({ pid: 4242, port: 7433, db: "x.db", startedAt: START }));
|
||||
expect(status.pidUnseen).toBe(true);
|
||||
});
|
||||
|
||||
it("is the same when this shell cannot read its own namespace", async () => {
|
||||
const status = await getDaemonStatus(deps(recorded(), { pidNamespace: () => undefined }));
|
||||
expect(status.pidUnseen).toBe(true);
|
||||
});
|
||||
|
||||
it("is the same when the pid exists but cannot be inspected (unknown liveness) and the probe is refused", async () => {
|
||||
const status = await getDaemonStatus(deps(recorded(), { processLiveness: vi.fn(() => "unknown" as const) }));
|
||||
expect(status.state).toBe("unverified");
|
||||
expect(status.pidUnseen).toBe(true);
|
||||
});
|
||||
|
||||
it("still answers 'running' when the probe gets through, whatever the pid looks like", async () => {
|
||||
const status = await getDaemonStatus(deps(recorded(), { fetch: vi.fn(async () => ({ ok: true })) }));
|
||||
expect(status.state).toBe("running");
|
||||
});
|
||||
|
||||
it("does not remove state it cannot prove stale", async () => {
|
||||
const d = deps(recorded());
|
||||
await getDaemonStatus(d);
|
||||
expect(d.removeFile).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("#2860: the real stopped cases keep reading as down", () => {
|
||||
it("a clean shutdown for the recorded pid reads stopped from any shell", async () => {
|
||||
const d = deps(recorded(), {}, cleanReceipt(4242));
|
||||
const status = await getDaemonStatus(d);
|
||||
expect(status.state).toBe("stale");
|
||||
expect(statusGuardMessage(status).fact).toBe("Daemon not running.");
|
||||
expect(shouldAutoStartDaemon(status)).toBe(true);
|
||||
expect(d.removeFile).toHaveBeenCalledWith(STATE_FILE);
|
||||
});
|
||||
|
||||
it("a clean shutdown plus a refused probe reads stopped even when the pid state is unknown", async () => {
|
||||
const status = await getDaemonStatus(deps(recorded(), { processLiveness: vi.fn(() => "unknown" as const) }, cleanReceipt(4242)));
|
||||
expect(status.state).toBe("stopped");
|
||||
});
|
||||
|
||||
it("a receipt for another pid is no evidence", async () => {
|
||||
const status = await getDaemonStatus(deps(recorded(), {}, cleanReceipt(1111)));
|
||||
expect(status.pidUnseen).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["the daemon's own namespace", recorded(), () => DAEMON_NAMESPACE],
|
||||
["the initial namespace it was started in", recorded({ pidNamespace: INITIAL_NAMESPACE }), () => INITIAL_NAMESPACE],
|
||||
["an older record, from the initial namespace", recorded({ pidNamespace: undefined }), () => INITIAL_NAMESPACE],
|
||||
["an older record, on a platform without pid namespaces", recorded({ pidNamespace: undefined }), () => "host"],
|
||||
])("a dead pid in a normal shell (%s) still reads down so 'rig up' can start it", async (_label, state, namespace) => {
|
||||
const status = await getDaemonStatus(deps(state, { pidNamespace: namespace }));
|
||||
expect(status.state).toBe("stale");
|
||||
expect(statusGuardMessage(status).fact).toBe("Daemon not running.");
|
||||
expect(shouldAutoStartDaemon(status)).toBe(true);
|
||||
});
|
||||
|
||||
it("a number recorded in a nested namespace says nothing from the initial one, whatever it finds there", async () => {
|
||||
const fetch = vi.fn(REFUSED);
|
||||
const status = await getDaemonStatus(deps(recorded(), { pidNamespace: () => INITIAL_NAMESPACE, fetch }));
|
||||
expect(status.state).toBe("unverified");
|
||||
expect(status.pidUnseen).toBe(true);
|
||||
expect(fetch).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("...and a healthy endpoint reads running, not stale", async () => {
|
||||
const status = await getDaemonStatus(deps(recorded(), { pidNamespace: () => INITIAL_NAMESPACE, fetch: vi.fn(async () => ({ ok: true })) }));
|
||||
expect(status.state).toBe("running");
|
||||
});
|
||||
|
||||
it("deps without namespace evidence keep the old rule", async () => {
|
||||
const d = deps(recorded());
|
||||
delete d.pidNamespace;
|
||||
expect((await getDaemonStatus(d)).state).toBe("stale");
|
||||
});
|
||||
});
|
||||
|
||||
describe("#2860: a selected loopback OPENRIG_URL is judged like the record", () => {
|
||||
const withUrl = async (url: string, d: LifecycleDeps) => {
|
||||
process.env.OPENRIG_URL = url;
|
||||
try { return await getDaemonStatus(d); } finally { delete process.env.OPENRIG_URL; }
|
||||
};
|
||||
const URL_OF_DAEMON = "http://127.0.0.1:7433";
|
||||
|
||||
it("a refusal with an invisible pid and no clean shutdown is not stopped, and 'rig up' is not advised", async () => {
|
||||
const status = await withUrl(URL_OF_DAEMON, deps(recorded()));
|
||||
expect(status.state).toBe("unverified");
|
||||
expect(status.pidUnseen).toBe(true);
|
||||
expect(shouldAutoStartDaemon(status)).toBe(false);
|
||||
expect(daemonMayAnswer(status)).toBe(false);
|
||||
});
|
||||
|
||||
it("a missing pid in the daemon's own namespace reads stopped", async () => {
|
||||
expect((await withUrl(URL_OF_DAEMON, deps(recorded(), { pidNamespace: () => DAEMON_NAMESPACE }))).state).toBe("stopped");
|
||||
});
|
||||
|
||||
it("a matching clean shutdown reads stopped from any shell", async () => {
|
||||
expect((await withUrl(URL_OF_DAEMON, deps(recorded(), {}, cleanReceipt(4242)))).state).toBe("stopped");
|
||||
});
|
||||
|
||||
it("with no record for that endpoint a refusal keeps reading stopped", async () => {
|
||||
expect((await withUrl("http://127.0.0.1:9999", deps(recorded()))).state).toBe("stopped");
|
||||
expect((await withUrl(URL_OF_DAEMON, deps(recorded(), { exists: vi.fn(() => false), readFile: vi.fn(() => null) }))).state).toBe("stopped");
|
||||
});
|
||||
|
||||
it("a remote URL keeps reading stopped on a refusal", async () => {
|
||||
expect((await withUrl("http://daemon.example:7433", deps(recorded()))).state).toBe("stopped");
|
||||
});
|
||||
});
|
||||
|
||||
describe("#2860: `rig daemon status` says what the guard says", () => {
|
||||
it("prints the guard's three lines", async () => {
|
||||
const d = deps(recorded());
|
||||
const status = await getDaemonStatus(d);
|
||||
const message = statusGuardMessage(status);
|
||||
const lines: string[] = [];
|
||||
vi.spyOn(console, "log").mockImplementation((line: string) => { lines.push(line); });
|
||||
await daemonCommand(d).parseAsync(["status"], { from: "user" });
|
||||
expect(lines).toEqual([message.fact, ` ${message.consequence}`, ` ${message.action}`]);
|
||||
});
|
||||
});
|
||||
@@ -78,8 +78,7 @@ describe("rig edge", () => {
|
||||
expect(result.out).toHaveLength(1);
|
||||
const error = JSON.parse(result.out[0]!).error;
|
||||
expect(error).toMatchObject({ fact: expect.any(String), consequence: expect.any(String), action: expect.any(String) });
|
||||
// #2860: a refused connection alone isn't proof the daemon is stopped, so rig edge says so only on positive evidence.
|
||||
if (causeCode === "ECONNREFUSED") expect(error.fact).toBe("Daemon did not respond — it may be busy or stopped (state not confirmed).");
|
||||
if (causeCode === "ECONNREFUSED") expect(error.fact).toBe("Daemon not running.");
|
||||
else expect(error).toMatchObject({ causeCode: "EPERM", fact: expect.stringContaining("blocked") });
|
||||
expect(client.get).not.toHaveBeenCalled();
|
||||
expect(client.post).not.toHaveBeenCalled();
|
||||
|
||||
@@ -17,10 +17,22 @@ let db: Database.Database;
|
||||
let home: string;
|
||||
let app: Hono;
|
||||
let rigs: RigRepository;
|
||||
/** Each write the CLI sent, with how long it would wait for the daemon. */
|
||||
const posts: Array<{ url: string; timeoutMs?: number }> = [];
|
||||
/** An account that answers the connector routes: a key for the one-time code, a bearer for each newly listed name. */
|
||||
const listed = new Set<string>();
|
||||
const accountFetch = async (url: string, init: { method: string; body: string }) => {
|
||||
const path = new URL(url).pathname;
|
||||
const body = JSON.parse(init.body) as { code?: string; participants?: string[] };
|
||||
const reply = (status: number, json: unknown) => ({ status, json: async () => json });
|
||||
if (path === "/api/connector/enroll") return body.code === "ec1.page" ? reply(200, { installation: { id: "inst-1", name: "lab" }, key: "oik1_c_inst-1_key" }) : reply(400, { error: "code_not_current" });
|
||||
const participants = (body.participants ?? []).map((name) => (listed.has(name) ? { name, state: "connected" } : (listed.add(name), { name, state: "connected", bearer: `bear-${name}` })));
|
||||
return reply(200, { participants, mcp: "https://account.example/mcp", rig: "acct" });
|
||||
};
|
||||
const routesWith = (bearerToken: string | null) => {
|
||||
const next = new Hono();
|
||||
next.use("*", async (c, n) => { c.set("rigRepo" as never, rigs as never); await n(); });
|
||||
next.route("/api/network", networkRoutes({ home, bearerToken }));
|
||||
next.route("/api/network", networkRoutes({ home, bearerToken, accountFetch, selfHostId: () => "host-a" }));
|
||||
return next;
|
||||
};
|
||||
beforeEach(() => {
|
||||
@@ -31,6 +43,7 @@ beforeEach(() => {
|
||||
db.prepare("INSERT INTO sessions (id, node_id, session_name, status) VALUES ('s1', ?, 'dev-impl@lab', 'running')").run(node.id);
|
||||
home = mkdtempSync(join(tmpdir(), "openrig-network-cli-"));
|
||||
app = routesWith(null);
|
||||
posts.length = 0;
|
||||
});
|
||||
afterEach(() => { db.close(); rmSync(home, { recursive: true, force: true }); vi.restoreAllMocks(); vi.unstubAllEnvs(); process.exitCode = undefined; });
|
||||
|
||||
@@ -46,7 +59,10 @@ function cli(tokenFile: Record<string, string> = {}) {
|
||||
};
|
||||
const deps: NetworkDeps = {
|
||||
lifecycleDeps,
|
||||
clientFactory: () => ({ get: (url: string) => call("GET", url), post: (url: string, body: unknown, options?: { headers?: Record<string, string> }) => call("POST", url, body, options?.headers) }) as never,
|
||||
clientFactory: () => ({ get: (url: string) => call("GET", url), post: (url: string, body: unknown, options?: { headers?: Record<string, string>; timeoutMs?: number }) => {
|
||||
posts.push({ url, timeoutMs: options?.timeoutMs });
|
||||
return call("POST", url, body, options?.headers);
|
||||
} }) as never,
|
||||
readTokenFile: (path) => { if (!(path in tokenFile)) throw new Error("no such file"); return tokenFile[path]!; },
|
||||
};
|
||||
return new Command().exitOverride().addCommand(networkCommand(deps));
|
||||
@@ -63,7 +79,7 @@ it("connects a seat with its token file, shows it in status without the token, a
|
||||
expect(status).not.toContain("tok-abc");
|
||||
|
||||
await cli().parseAsync(["node", "rig", "network", "disconnect", "dev-impl@lab", "--reason", "done"]);
|
||||
expect(String(out.mock.calls.at(-1)?.[0])).toMatch(/^Disconnected netb-[0-9a-f]{20} dev-impl@lab -> alex@team .*disconnected \(done\)/);
|
||||
expect(String(out.mock.calls.at(-1)?.[0])).toMatch(/^Disconnected netb-[0-9a-f]{20} dev-impl@lab -> alex@team .*disconnected \(done\)\nRemove the participant on the hub too/);
|
||||
expect(process.exitCode).toBeUndefined();
|
||||
});
|
||||
|
||||
@@ -111,6 +127,39 @@ it("status shows a hub item closed elsewhere under its binding, in text and JSON
|
||||
expect(JSON.parse(String(out.mock.calls.at(-1)?.[0])).bindings[0].closedElsewhere).toEqual([{ hubItem: "hub-9", localItem: "qitem-net-0001", note: "closed elsewhere (done, canceled)" }]);
|
||||
});
|
||||
|
||||
it("enrolls with the account page's code, connects a seat by name through it, and shows both in status", async () => {
|
||||
listed.clear();
|
||||
const out = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
const err = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
await cli().parseAsync(["node", "rig", "network", "enroll", "ec1.page", "--site", "https://account.example"]);
|
||||
expect(process.exitCode).toBeUndefined();
|
||||
expect(String(out.mock.calls.at(-1)?.[0])).toMatch(/^Enrolled at https:\/\/account\.example\/ as installation lab \(neti-[0-9a-f]{20}\)\nConnect a seat with rig network connect <seat> --as <name>\.$/);
|
||||
|
||||
await cli().parseAsync(["node", "rig", "network", "connect", "dev-impl@lab", "--hub", "https://hub.example/mcp", "--as", "alex@team"]);
|
||||
expect(process.exitCode).toBe(1);
|
||||
expect(String(err.mock.calls.at(-1)?.[0])).toBe("Give both --hub and --token-file, or neither to connect through the enrolled account.");
|
||||
process.exitCode = undefined;
|
||||
|
||||
await cli().parseAsync(["node", "rig", "network", "connect", "dev-impl@lab", "--as", "alpha"]);
|
||||
expect(process.exitCode).toBeUndefined();
|
||||
expect(String(out.mock.calls.at(-1)?.[0])).toMatch(/^Connected netb-[0-9a-f]{20} dev-impl@lab -> alpha@acct at https:\/\/account\.example\/mcp no contact yet\nWork for alpha@acct reaches dev-impl@lab/);
|
||||
await cli().parseAsync(["node", "rig", "network", "status"]);
|
||||
const status = String(out.mock.calls.at(-1)?.[0]).split("\n");
|
||||
expect(status[0]).toMatch(/^Enrolled at https:\/\/account\.example\/ as installation lab \(neti-/);
|
||||
expect(status[1]).toMatch(/^netb-[0-9a-f]{20} dev-impl@lab -> alpha@acct at https:\/\/account\.example\/mcp/);
|
||||
await cli().parseAsync(["node", "rig", "network", "status", "--json"]);
|
||||
expect(String(out.mock.calls.at(-1)?.[0])).not.toMatch(/oik1_|bear-alpha/);
|
||||
|
||||
await cli().parseAsync(["node", "rig", "network", "disconnect", "dev-impl@lab"]);
|
||||
expect(process.exitCode).toBeUndefined();
|
||||
const disconnected = String(out.mock.calls.at(-1)?.[0]);
|
||||
expect(disconnected).toMatch(/\nThe account stopped listing alpha, which revokes it there\.$/);
|
||||
expect(disconnected).not.toContain("Remove the participant on the hub");
|
||||
// Every network write waits past the daemon's 30 s account deadline, so the account's own answer is what's shown.
|
||||
expect(posts.map((p) => p.url)).toEqual(["/api/network/installations", "/api/network/bindings", expect.stringMatching(/^\/api\/network\/bindings\/.+\/disconnect$/)]);
|
||||
for (const p of posts) expect(p.timeoutMs).toBeGreaterThanOrEqual(45_000);
|
||||
});
|
||||
|
||||
it("sends the operator bearer on its writes, so a daemon with one configured takes them", async () => {
|
||||
app = routesWith("op-secret");
|
||||
const out = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
|
||||
@@ -157,3 +157,46 @@ it("takes the participant's item over the wire, carries pickup and the result ba
|
||||
expect(await pass()).toMatchObject({ error: "revoked" });
|
||||
expect(bindings.get(binding.id)!.state).toBe("revoked");
|
||||
});
|
||||
|
||||
it("a lead's aggregate over its own local child work comes back once on the original hub request, and an open parent publishes nothing early", async () => {
|
||||
const token = await addGrant(tokenFile, { name: "alex", seat: "alex@team", rigs: ["team"], roots: [], workspace: false, owner: false });
|
||||
const task = await hubRepo.create({ sourceSession: "lead@team", destinationSession: "alex@team", summary: "Ship the fix", body: "Fix the bug and have it reviewed.", nudge: false } as never);
|
||||
const localRepo = queue(localDb);
|
||||
const bindings = new NetworkBindingStore(localDb, localHome);
|
||||
const binding = bindings.create({ session: "dev-impl@lab", hubUrl, hubSeat: "alex@team", token, allowHttp: true });
|
||||
const pass = () => runNetworkBindingPass({ db: localDb, queueRepo: localRepo, bindings, home: localHome }, bindings.get(binding.id)!);
|
||||
const hubItem = () => hubRepo.getById(task.qitemId)!;
|
||||
|
||||
expect(await pass()).toMatchObject({ taken: 1 });
|
||||
const [parent] = localRepo.list({ limit: 50 });
|
||||
localRepo.claim({ qitemId: parent!.qitemId, destinationSession: "dev-impl@lab" });
|
||||
|
||||
// The lead delegates through ordinary local rows and waits on each in turn.
|
||||
const build = await localRepo.create({ sourceSession: "dev-impl@lab", destinationSession: "builder@lab", summary: "Build the fix", body: "Fix the bug.", nudge: false } as never);
|
||||
const review = await localRepo.create({ sourceSession: "dev-impl@lab", destinationSession: "reviewer@lab", summary: "Review the fix", body: "Review the build.", nudge: false } as never);
|
||||
await localRepo.update({ qitemId: parent!.qitemId, actorSession: "dev-impl@lab", state: "blocked", blockedOn: build.qitemId, transitionNote: "Waiting on the build." });
|
||||
expect(await pass()).toMatchObject({ pickupsSent: 1, resultsSent: 0 });
|
||||
expect(hubItem()).toMatchObject({ state: "in-progress", resolution: null });
|
||||
|
||||
localRepo.claim({ qitemId: build.qitemId, destinationSession: "builder@lab" });
|
||||
await localRepo.update({ qitemId: build.qitemId, actorSession: "builder@lab", state: "done", closureReason: "no-follow-on", resolution: "Built: the fix is in commit abc123." });
|
||||
await localRepo.update({ qitemId: parent!.qitemId, actorSession: "dev-impl@lab", state: "blocked", blockedOn: review.qitemId, transitionNote: "Waiting on the review." });
|
||||
// A finished child isn't a result: the parent is still open, so nothing final goes to the hub.
|
||||
expect(await pass()).toMatchObject({ resultsSent: 0 });
|
||||
expect(hubItem()).toMatchObject({ state: "in-progress", resolution: null });
|
||||
|
||||
localRepo.claim({ qitemId: review.qitemId, destinationSession: "reviewer@lab" });
|
||||
await localRepo.update({ qitemId: review.qitemId, actorSession: "reviewer@lab", state: "done", closureReason: "no-follow-on", resolution: "Reviewed: approve." });
|
||||
const children = [localRepo.getById(build.qitemId)!, localRepo.getById(review.qitemId)!];
|
||||
const aggregate = children.map((child) => child.resolution).join(" ");
|
||||
await localRepo.update({ qitemId: parent!.qitemId, actorSession: "dev-impl@lab", state: "done", closureReason: "no-follow-on", resolution: aggregate });
|
||||
expect(await pass()).toMatchObject({ resultsSent: 1 });
|
||||
expect(hubItem()).toMatchObject({ state: "done", closureReason: "no-follow-on", resolution: "Built: the fix is in commit abc123. Reviewed: approve." });
|
||||
|
||||
// Once: a further pass writes nothing, and the children's own words never reached the hub as notes.
|
||||
const transitions = hubRepo.listTransitions(task.qitemId);
|
||||
await pass();
|
||||
expect(hubRepo.listTransitions(task.qitemId)).toHaveLength(transitions.length);
|
||||
expect(transitions.filter((t) => t.state === "done")).toHaveLength(1);
|
||||
expect(transitions.map((t) => t.transitionNote ?? "").join("\n")).not.toMatch(/Waiting on|Built:.*\n.*Reviewed:/);
|
||||
});
|
||||
@@ -207,20 +207,6 @@ function createMockDaemon() {
|
||||
}));
|
||||
return;
|
||||
}
|
||||
if (sourceRef.includes("skipped")) {
|
||||
res.writeHead(201, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({
|
||||
installId: "inst-skip-1",
|
||||
packageId: "pkg-1",
|
||||
packageName: "guidance-pkg",
|
||||
applied: [],
|
||||
deferred: [],
|
||||
conflicts: [],
|
||||
verification: { passed: true },
|
||||
notes: ["guidance_block_markers_damaged: /repo/CLAUDE.md: guidance-pkg has a BEGIN or END marker without its pair"],
|
||||
}));
|
||||
return;
|
||||
}
|
||||
if (sourceRef.includes("merge")) {
|
||||
res.writeHead(201, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({
|
||||
@@ -397,14 +383,6 @@ describe("rig package", () => {
|
||||
expect(output).toContain("inst-merge-1");
|
||||
});
|
||||
|
||||
it("install with a guidance file left as it is: names the file and the reason, exit 0", async () => {
|
||||
const { logs, exitCode } = await captureLogs(() => makeProgram().parseAsync(["node", "rig", "package", "install", "/skipped/path", "--target", "/repo", "--allow-merge"]));
|
||||
const output = logs.join("\n");
|
||||
expect(output).toContain("Left as it is:");
|
||||
expect(output).toContain(" guidance_block_markers_damaged: /repo/CLAUDE.md: guidance-pkg has a BEGIN or END marker without its pair");
|
||||
expect(exitCode).toBeUndefined();
|
||||
});
|
||||
|
||||
// Test 8: install conflicts (409) → exitCode 1
|
||||
it("install conflicts: prints conflicts, exitCode 1", async () => {
|
||||
const { logs, exitCode } = await captureLogs(() => makeProgram().parseAsync(["node", "rig", "package", "install", "/conflict/path", "--target", "/repo"]));
|
||||
|
||||
@@ -5,7 +5,7 @@ import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { execFile, execFileSync } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import { enrichRosterMember, formatCheckIn, parseRoster, readRosters, rosterCommand, type RosterInventory } from "../src/commands/roster.js";
|
||||
import { enrichRosterMember, parseRoster, readRosters, rosterCommand, type RosterInventory } from "../src/commands/roster.js";
|
||||
|
||||
const member = { seat: "editor@studio", host: "host-a", capabilities: ["colour grading"], engagement: ["consult"], use_when: "Choose a look", why: "Knows the footage", caveat: "Advice only" };
|
||||
const authored = { version: 1, id: "production", name: "Production", purpose: "Make a film", curator: { seat: "lead@studio", host: "host-a" }, updated_at: "2026-09-01", members: [member] };
|
||||
@@ -133,55 +133,3 @@ it("does not choose the first duplicate route when activity observations conflic
|
||||
expect(enrichRosterMember(member, { readAt: "2026-10-01T00:00:02Z", nodes: [idle, unclocked], sources: [] }).live.agentActivity)
|
||||
.toEqual({ state: "idle", reason: null, sampledAt: null });
|
||||
});
|
||||
|
||||
it("keeps any positive whole check-in cadence; one that can't be used still loads, with its reason and no cadence", () => {
|
||||
const hourly = { ...member, seat: "bot@team", hosted: true, check_in_minutes: 60 };
|
||||
expect(parseRoster({ ...authored, members: [hourly, { ...hourly, seat: "fast@team", check_in_minutes: 1 }] }).members)
|
||||
.toEqual([hourly, { ...hourly, seat: "fast@team", check_in_minutes: 1 }]);
|
||||
for (const bad of [{ ...hourly, check_in_minutes: 0 }, { ...hourly, check_in_minutes: 1.5 }, { ...hourly, check_in_minutes: "60" },
|
||||
{ ...member, check_in_minutes: 60 }]) {
|
||||
const [loaded] = parseRoster({ ...authored, members: [bad] }).members;
|
||||
expect(loaded).not.toHaveProperty("check_in_minutes");
|
||||
expect(loaded!.check_in_problem).toMatch(/check_in_minutes/);
|
||||
}
|
||||
});
|
||||
it("shows a hosted member's check-in status from this host's daemon; another host's member, or no answer, reads unknown", () => {
|
||||
const bot = { ...member, seat: "bot@team", host: "host-a", hosted: true, check_in_minutes: 60 };
|
||||
const checkIns = [{ seat: "bot@team", rung: false, status: "check-in-overdue", lastCheckInAt: "2026-11-12T09:00:00.000Z", expectedMinutes: 60, reporter: "openrig-observed" }];
|
||||
const here = { ...inventory([]), localHostId: "host-a", checkIns: { hostId: "host-a", members: checkIns } };
|
||||
expect(enrichRosterMember(bot, here).live.checkIn).toMatchObject({ status: "check-in-overdue", lastCheckInAt: "2026-11-12T09:00:00.000Z" });
|
||||
expect(formatCheckIn(enrichRosterMember(bot, here).live.checkIn!))
|
||||
.toBe("overdue: last check-in 2026-11-12T09:00:00.000Z, expected every 60 min; its schedule may be off or waiting for an approval");
|
||||
for (const [missing, reason] of [[{ ...here, checkIns: null }, "this host's daemon gave no check-in status"],
|
||||
[{ ...here, checkIns: { hostId: "host-b", members: checkIns } }, "it's listed on another host, whose check-ins this host doesn't hold"],
|
||||
[{ ...here, checkIns: { hostId: null, members: checkIns } }, "it's listed on another host, whose check-ins this host doesn't hold"],
|
||||
[{ ...here, checkIns: { hostId: "host-a", members: [{ ...checkIns[0], status: "invented" }] } }, "this host's daemon gave no status for it"]] as const) {
|
||||
expect(enrichRosterMember(bot, missing).live.checkIn).toEqual({ status: "unknown", lastCheckInAt: null, expectedMinutes: 60, onRequest: false, reason });
|
||||
}
|
||||
// The roster line is the daemon's own sentence for each status.
|
||||
expect(formatCheckIn({ status: "unknown", lastCheckInAt: null, expectedMinutes: null, reason: "its check-ins couldn't be read" }))
|
||||
.toBe("unknown (its check-ins couldn't be read)");
|
||||
expect(formatCheckIn({ status: "no-check-in-yet", lastCheckInAt: null, expectedMinutes: null })).toBe("no check-in yet; no check-in cadence declared");
|
||||
expect(formatCheckIn({ status: "no-check-in-yet", lastCheckInAt: null, expectedMinutes: 60 })).toBe("no check-in yet; expected every 60 min");
|
||||
expect(formatCheckIn({ status: "on-request", lastCheckInAt: "2026-11-12T09:00:00.000Z", expectedMinutes: null, onRequest: true }))
|
||||
.toBe("on request: it checks its queue when a person asks it; last check-in 2026-11-12T09:00:00.000Z");
|
||||
expect(formatCheckIn({ status: "invented", lastCheckInAt: null, expectedMinutes: null })).toBe("unknown (its check-in status couldn't be read)");
|
||||
expect(enrichRosterMember(member, here).live).not.toHaveProperty("checkIn");
|
||||
});
|
||||
it("a team with no terminal seat still shows its members' check-ins: the daemon names its own host with them", () => {
|
||||
const bot = { ...member, seat: "bot@team", host: "host-a", hosted: true, check_in_minutes: 60 };
|
||||
const fresh = { seat: "bot@team", rung: false, status: "checking-in", lastCheckInAt: "2026-11-12T09:55:00.000Z", expectedMinutes: 60, reporter: "openrig-observed" };
|
||||
const noSeats: RosterInventory = { ...inventory([]), localHostId: null, checkIns: { hostId: "host-a", members: [fresh] } };
|
||||
expect(enrichRosterMember(bot, noSeats).live.checkIn).toMatchObject({ status: "checking-in", lastCheckInAt: fresh.lastCheckInAt });
|
||||
});
|
||||
it("keeps check_in_on_request: true; any other value, both statements at once, or a member not marked hosted loads with its reason", () => {
|
||||
const asked = { ...member, seat: "bot@team", hosted: true, check_in_on_request: true };
|
||||
expect(parseRoster({ ...authored, members: [asked] }).members).toEqual([asked]);
|
||||
for (const [bad, reason] of [[{ ...asked, check_in_on_request: false }, /isn't true/], [{ ...asked, check_in_minutes: 60 }, /both/],
|
||||
[{ ...member, check_in_on_request: true }, /connected assistant/]] as const) {
|
||||
const [loaded] = parseRoster({ ...authored, members: [bad] }).members;
|
||||
expect(loaded).not.toHaveProperty("check_in_on_request");
|
||||
expect(loaded).not.toHaveProperty("check_in_minutes");
|
||||
expect(loaded!.check_in_problem).toMatch(reason);
|
||||
}
|
||||
});
|
||||
@@ -7,7 +7,7 @@
|
||||
"url": "https://github.com/mvschwarz"
|
||||
},
|
||||
"homepage": "https://github.com/mvschwarz/openrig-plugins",
|
||||
"repository": "https://github.com/mvschwarz/openrig-plugins",
|
||||
"repository": "github:mvschwarz/openrig-plugins",
|
||||
"license": "Apache-2.0",
|
||||
"keywords": ["openrig", "agent", "topology", "rig", "continuity", "coordination", "compaction", "handoff"],
|
||||
"skills": "./skills",
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"url": "https://github.com/mvschwarz"
|
||||
},
|
||||
"homepage": "https://github.com/mvschwarz/openrig-plugins",
|
||||
"repository": "https://github.com/mvschwarz/openrig-plugins",
|
||||
"repository": "github:mvschwarz/openrig-plugins",
|
||||
"license": "Apache-2.0",
|
||||
"keywords": ["openrig", "agent", "topology", "rig", "continuity", "coordination", "compaction", "handoff"],
|
||||
"skills": "./skills",
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 53 KiB |
@@ -7,7 +7,7 @@
|
||||
"url": "https://github.com/mvschwarz"
|
||||
},
|
||||
"homepage": "https://github.com/mvschwarz/openrig",
|
||||
"repository": "https://github.com/mvschwarz/openrig",
|
||||
"repository": "github:mvschwarz/openrig",
|
||||
"license": "Apache-2.0",
|
||||
"keywords": ["openrig", "agents", "team", "delegation", "mcp"],
|
||||
"skills": "./skills",
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"url": "https://github.com/mvschwarz"
|
||||
},
|
||||
"homepage": "https://github.com/mvschwarz/openrig",
|
||||
"repository": "https://github.com/mvschwarz/openrig",
|
||||
"repository": "github:mvschwarz/openrig",
|
||||
"license": "Apache-2.0",
|
||||
"keywords": [
|
||||
"openrig",
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"url": "https://github.com/mvschwarz"
|
||||
},
|
||||
"homepage": "https://github.com/mvschwarz/openrig",
|
||||
"repository": "https://github.com/mvschwarz/openrig",
|
||||
"repository": "github:mvschwarz/openrig",
|
||||
"license": "Apache-2.0",
|
||||
"keywords": ["openrig", "agents", "team", "delegation", "mcp"]
|
||||
}
|
||||
@@ -31,10 +31,6 @@
|
||||
"types": "./dist/rosters-surface.d.ts",
|
||||
"import": "./dist/rosters-surface.js"
|
||||
},
|
||||
"./connection-contract": {
|
||||
"types": "./dist/connection-contract-surface.d.ts",
|
||||
"import": "./dist/connection-contract-surface.js"
|
||||
},
|
||||
"./gateway-protocol": {
|
||||
"types": "./dist/gateway-protocol-surface.d.ts",
|
||||
"import": "./dist/gateway-protocol-surface.js"
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { InputReadDiagnostic } from "../domain/input-read-diagnostic.js";
|
||||
import { DeliveryGuardError, type SeatDeliveryGuard } from "../domain/seat-delivery-guard.js";
|
||||
import { writeFile as fsWriteFile, unlink as fsUnlink } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
@@ -78,7 +77,7 @@ export function defaultTmuxFileOps(): TmuxFileOps {
|
||||
|
||||
export type TmuxResult =
|
||||
| { ok: true; warnings?: string[]; stopPhase?: StopPhase }
|
||||
| { ok: false; code: string; message: string; stopPhase?: StopPhase; inputReadFailure?: InputReadDiagnostic };
|
||||
| { ok: false; code: string; message: string; stopPhase?: StopPhase };
|
||||
|
||||
/** Furthest external stop boundary reached; an attempted command can have an uncertain effect. */
|
||||
export type StopPhase = "none" | "detach_attempted" | "detached" | "kill_attempted";
|
||||
@@ -247,7 +246,7 @@ function isTmuxTransportAbsentError(err: unknown): boolean {
|
||||
}
|
||||
|
||||
function classifyWriteError(err: unknown): TmuxResult {
|
||||
if (err instanceof DeliveryGuardError) return { ok: false, code: err.code, message: err.message, ...(err.inputReadFailure ? { inputReadFailure: err.inputReadFailure } : {}) };
|
||||
if (err instanceof DeliveryGuardError) return { ok: false, code: err.code, message: err.message };
|
||||
if (!(err instanceof Error)) {
|
||||
return { ok: false, code: "unknown", message: String(err) };
|
||||
}
|
||||
@@ -601,8 +600,7 @@ export class TmuxAdapter {
|
||||
return guard.input(identity, () => write(pane, () => guard.checkInput(identity), bound.nodeId));
|
||||
});
|
||||
} catch (error) {
|
||||
return { ok: false, code: (error as { code?: string }).code ?? "guard_target_unknown", message: String((error as Error).message),
|
||||
...(error instanceof DeliveryGuardError && error.inputReadFailure ? { inputReadFailure: error.inputReadFailure } : {}) };
|
||||
return { ok: false, code: (error as { code?: string }).code ?? "guard_target_unknown", message: String((error as Error).message) };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
// The `@openrig/daemon/connection-contract` surface: the shared outcome and status words, so the CLI and the daemon
|
||||
// read them from one place.
|
||||
export * from "./domain/connection-contract.js";
|
||||
@@ -10,6 +10,7 @@ import { queueItemsHandedOffFromIndexSchema } from "./migrations/159_queue_items
|
||||
import { networkBindingsSchema } from "./migrations/160_network_bindings.js";
|
||||
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 { activityGrantsSchema } from "./migrations/143_activity_grants.js";
|
||||
import { environmentBindingsSchema } from "./migrations/144_environment_bindings.js";
|
||||
import { nodePermissionsToSchema } from "./migrations/145_node_permissions_to.js";
|
||||
@@ -327,4 +328,5 @@ export const ALL_MIGRATIONS: Migration[] = [
|
||||
networkBindingsSchema,
|
||||
networkReceiptsSchema,
|
||||
sessionAddressReadIndexesSchema,
|
||||
networkInstallationsSchema,
|
||||
];
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { Migration } from "../migrate.js";
|
||||
|
||||
/** This daemon enrolled in an account: the account's site, its installation id, and whether the person chose plain http
|
||||
* for it. The installation key lives in a private file under the OpenRig home, never in this table. A binding made
|
||||
* through the account names its installation, its participant and the account's installation id the name was listed under,
|
||||
* and records when the account stopped listing it. */
|
||||
export const networkInstallationsSchema: Migration = {
|
||||
name: "163_network_installations.sql",
|
||||
sql: `
|
||||
CREATE TABLE IF NOT EXISTS network_installations (
|
||||
id TEXT PRIMARY KEY,
|
||||
site TEXT NOT NULL,
|
||||
installation_id TEXT NOT NULL,
|
||||
name TEXT,
|
||||
allow_http INTEGER NOT NULL DEFAULT 0 CHECK (allow_http IN (0, 1)),
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_network_installations_site ON network_installations(site);
|
||||
ALTER TABLE network_bindings ADD COLUMN installation_id TEXT;
|
||||
ALTER TABLE network_bindings ADD COLUMN participant_name TEXT;
|
||||
ALTER TABLE network_bindings ADD COLUMN account_removed_at TEXT;
|
||||
ALTER TABLE network_bindings ADD COLUMN account_installation_id TEXT;
|
||||
`,
|
||||
};
|
||||
@@ -643,9 +643,6 @@ export class BootstrapOrchestrator {
|
||||
if (!outcome.ok) {
|
||||
errors.push(`Package install failed for '${ref}' (${runtime}): ${outcome.message}`);
|
||||
packageInstallFailed = true;
|
||||
} else {
|
||||
// A file the install left as it is, and why: the rest installed, and the rig still imports.
|
||||
for (const note of outcome.notes ?? []) warnings.push(`Package '${ref}' (${runtime}): ${note}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { InputReadDiagnostic } from "./input-read-diagnostic.js";
|
||||
import type { TerminalActor, TerminalPolicyStore } from "./terminal-policy.js";
|
||||
import { TerminalPolicyRefusal } from "./terminal-policy.js";
|
||||
import { DeliveryGuardError } from "./seat-delivery-guard.js";
|
||||
@@ -426,7 +425,6 @@ type PendingPostCompactStage = "turn_boundary" | "restore_prompt" | "compliance_
|
||||
|
||||
/** Safe delivery diagnostics: never retain pane contents or an exception message. */
|
||||
export interface RestoreDeliveryAttempt {
|
||||
inputReadFailure?: InputReadDiagnostic;
|
||||
stage: PendingPostCompactStage;
|
||||
at: string;
|
||||
ok: boolean;
|
||||
@@ -463,7 +461,6 @@ export interface ObservedCompaction {
|
||||
detail: string;
|
||||
}
|
||||
export interface PreparationState {
|
||||
inputReadFailure?: InputReadDiagnostic & { stage: "preparation" | "compact" };
|
||||
/** The reading that held this preparation send; never includes draft text. */
|
||||
inputBox?: SendResult["heldInputBoxReading"];
|
||||
policyActor?: TerminalActor;
|
||||
@@ -568,7 +565,6 @@ interface PreparationFailureRun {
|
||||
}
|
||||
|
||||
interface CompactionRecord {
|
||||
inputReadFailure?: PreparationState["inputReadFailure"];
|
||||
preparationFailures?: PreparationFailureRun;
|
||||
inputBox?: PreparationState["inputBox"];
|
||||
/** The daemon process that last wrote it. */
|
||||
@@ -1004,7 +1000,6 @@ export class ClaudeCompactionEnforcer {
|
||||
} catch (error) {
|
||||
this.recordRestoreFailure(input.sessionName, stage, record, {
|
||||
ok: false, reason: error instanceof DeliveryGuardError ? error.code : "send_failed",
|
||||
...(error instanceof DeliveryGuardError && error.inputReadFailure ? { inputReadFailure: error.inputReadFailure } : {}),
|
||||
}, error instanceof Error ? error.name : typeof error);
|
||||
if (error instanceof DeliveryGuardError) return { triggered: false, reason: error.code === "typing_guard_enabled" ? "typing_guard_enabled" : "guard_target_unknown" };
|
||||
throw error;
|
||||
@@ -1032,7 +1027,7 @@ export class ClaudeCompactionEnforcer {
|
||||
private recordRestoreFailure(
|
||||
session: string, stage: PendingPostCompactStage | undefined,
|
||||
record: { file: string; record: CompactionRecord } | undefined,
|
||||
result: Pick<SendResult, "ok" | "reason" | "outcome" | "activity" | "heldInputBox" | "inputReadFailure">, errorClass?: string,
|
||||
result: Pick<SendResult, "ok" | "reason" | "outcome" | "activity" | "heldInputBox">, errorClass?: string,
|
||||
): void {
|
||||
if (!stage || !record || this.records.get(session) !== record || this.pendingPostCompactRestore.get(session) !== stage) return;
|
||||
const prior = record.record.lastDelivery;
|
||||
@@ -1047,7 +1042,6 @@ export class ClaudeCompactionEnforcer {
|
||||
const lastDelivery: RestoreDeliveryAttempt = {
|
||||
stage, at: new Date(this.now()).toISOString(), ok: result.ok, reason: result.reason ?? "send_failed",
|
||||
...(result.outcome ? { outcome: result.outcome } : {}),
|
||||
...(result.inputReadFailure ? { inputReadFailure: result.inputReadFailure } : {}),
|
||||
...(result.activity ? { activity: { state: result.activity.state, reason: result.activity.reason, evidenceSource: result.activity.evidenceSource } } : {}),
|
||||
...(errorClass ? { errorClass } : {}), failures: waiting ? 0 : (prior?.failures ?? 0) + 1,
|
||||
...(held ? { inputBox: held } : {}), ...(unreadable ? { unreadable } : {}),
|
||||
@@ -1604,7 +1598,7 @@ export class ClaudeCompactionEnforcer {
|
||||
if (error instanceof DeliveryGuardError) {
|
||||
const written = (error as { inputWritten?: boolean }).inputWritten === true;
|
||||
const attempted = (error as { inputAttempted?: boolean }).inputAttempted === true;
|
||||
prep = { ok: false, sent: written, reason: error.code, inputReadFailure: error.inputReadFailure,
|
||||
prep = { ok: false, sent: written, reason: error.code,
|
||||
inputPhase: written ? "written" as const : attempted ? "attempted" as const : "not_reached" as const };
|
||||
} else prep = null;
|
||||
}
|
||||
@@ -1617,8 +1611,7 @@ export class ClaudeCompactionEnforcer {
|
||||
// Neither a failed response nor its loss proves no paste/Enter. Never replay uncertainty.
|
||||
attempt.delivery = prep?.ok && prep.outcome !== "retained" ? "delivered" : notSent ? "not_sent" : "uncertain";
|
||||
attempt.inputBox = prep?.heldInputBoxReading;
|
||||
if (prep?.inputReadFailure) attempt.inputReadFailure = { stage: "preparation", ...prep.inputReadFailure };
|
||||
this.persist(input.sessionName, attempt.attemptId, { delivery: attempt.delivery, sends: attempt.sends, inputBox: attempt.inputBox, inputReadFailure: attempt.inputReadFailure });
|
||||
this.persist(input.sessionName, attempt.attemptId, { delivery: attempt.delivery, sends: attempt.sends, inputBox: attempt.inputBox });
|
||||
// The replacement request went out (or may have): the ended attempt's gate is used up.
|
||||
if (!notSent && attempt.afterEnded !== undefined && this.pendingPreCompactPrep.get(input.sessionName) === attempt) this.lastEnded.delete(input.sessionName);
|
||||
if (this.pendingPreCompactPrep.get(input.sessionName) !== attempt || attempt.controller.signal.aborted) return;
|
||||
@@ -1668,7 +1661,7 @@ export class ClaudeCompactionEnforcer {
|
||||
return this.sessionTransport.send(input.sessionName, composed.command,
|
||||
{ openrigNotice: true, allowDraftWhileIdle: true, beforeWrite, ...(attempt.deadlineAt !== null ? { waitForIdleMs: attempt.prepared ? idleWait : Math.max(1, Math.min(idleWait, this.compactDeadline(attempt)! - this.now())), readinessFromPaneOnly: true } : {}) });
|
||||
};
|
||||
let result: Pick<SendResult, "ok" | "reason" | "outcome" | "sent" | "inputPhase" | "activity" | "heldInputBox" | "inputReadFailure">;
|
||||
let result: Pick<SendResult, "ok" | "reason" | "outcome" | "sent" | "inputPhase" | "activity" | "heldInputBox">;
|
||||
let guardRefused = false;
|
||||
try {
|
||||
const guard = this.sessionTransport.deliveryGuard;
|
||||
@@ -1679,16 +1672,11 @@ export class ClaudeCompactionEnforcer {
|
||||
const attempted = (error as { inputAttempted?: boolean } | null)?.inputAttempted === true;
|
||||
guardRefused = error instanceof DeliveryGuardError && !written && !attempted;
|
||||
result = { ok: false, reason: error instanceof DeliveryGuardError ? error.code : "delivery_uncertain",
|
||||
...(error instanceof DeliveryGuardError && error.inputReadFailure ? { inputReadFailure: error.inputReadFailure } : {}),
|
||||
...(written ? { sent: true, inputPhase: "written" as const } : attempted ? { inputPhase: "attempted" as const } : {}) };
|
||||
} finally {
|
||||
delete attempt.compactInFlight;
|
||||
}
|
||||
if (!result.ok || result.outcome === "retained") {
|
||||
if (result.inputReadFailure) {
|
||||
attempt.inputReadFailure = { stage: "compact", ...result.inputReadFailure };
|
||||
this.persist(input.sessionName, attempt.attemptId, { inputReadFailure: attempt.inputReadFailure });
|
||||
}
|
||||
const phase = result.inputPhase;
|
||||
// A write that was issued, or text already in the input, is never "nothing sent".
|
||||
const writeAttempted = phase === "attempted" || phase === "written" || result.sent === true;
|
||||
@@ -1939,7 +1927,7 @@ export class ClaudeCompactionEnforcer {
|
||||
compact: "not_sent", sends: record.sends,
|
||||
deadlineAt: record.deadlineAt ?? (record.delivery === "not_sent" ? null : record.updatedAt + AUTO_PREP_WAIT_MS_DEFAULT),
|
||||
policyWasEnabled: true, preparationRoot: record.preparationRoot, controller: new AbortController(),
|
||||
compactInstruction: record.compactInstruction, inputBox: record.inputBox, inputReadFailure: record.inputReadFailure,
|
||||
compactInstruction: record.compactInstruction, inputBox: record.inputBox,
|
||||
// A held attempt comes back held, with its recorded hold (never stamped again) and its map's age and usage.
|
||||
...(held ? { prepared: true as const, preparedAt: record.preparedAt ?? record.updatedAt, preparedReason: record.preparedReason ?? "map_complete_seat_never_idle" } : {}),
|
||||
...(held ? { mapReadyAt: record.mapReadyAt ?? record.preparedAt ?? record.updatedAt } : record.mapReadyAt !== undefined ? { mapReadyAt: record.mapReadyAt } : {}),
|
||||
|
||||
@@ -42,102 +42,6 @@ export interface ReportedFact<F extends string> {
|
||||
reporter: Reporter;
|
||||
}
|
||||
|
||||
/** Whether a member outside any terminal is checking its queue. It's a status, never a refusal: work addressed to the
|
||||
* member queues and is rung exactly as before, a check-in isn't pickup (pickup is a claim), and no check-in isn't
|
||||
* idle, failure or safe. */
|
||||
export type CheckInStatus =
|
||||
/** Its last check-in is no older than twice its stated cadence (`CHECK_IN_OVERDUE_CADENCES`). */
|
||||
| "checking-in"
|
||||
/** Its last check-in is older than twice its stated cadence: its schedule may be off or waiting for an approval. */
|
||||
| "check-in-overdue"
|
||||
/** No check-in recorded for it. */
|
||||
| "no-check-in-yet"
|
||||
/** Its setup says it checks its queue when a person asks it (`check_in_on_request`), not on a schedule. */
|
||||
| "on-request"
|
||||
/** Its connection was revoked or removed, as recorded. Never inferred from silence. Nothing records a revocation yet,
|
||||
* so nothing emits it: a revoked member stops checking in and reads overdue. */
|
||||
| "disconnected"
|
||||
/** Its setup states no cadence, its stated one can't be used, or its check-ins couldn't be read (the reason says
|
||||
* which). Never idle and never overdue: with no stated cadence, OpenRig assumes none. */
|
||||
| "unknown";
|
||||
|
||||
/** How each check-in status reads to a person. */
|
||||
export const CHECK_IN_DISPLAY: Readonly<Record<CheckInStatus, string>> = {
|
||||
"checking-in": "checking in",
|
||||
"check-in-overdue": "overdue",
|
||||
"no-check-in-yet": "no check-in yet",
|
||||
"on-request": "on request",
|
||||
"disconnected": "disconnected",
|
||||
"unknown": "unknown",
|
||||
};
|
||||
|
||||
/** The one rule for a stated cadence: a last check-in no older than this many cadences reads checking in, and an older
|
||||
* one overdue. One missed run, plus its schedule's own jitter, isn't overdue. A cadence is its person's statement, so
|
||||
* neither reading proves a vendor schedule or a pickup. */
|
||||
export const CHECK_IN_OVERDUE_CADENCES = 2;
|
||||
|
||||
export interface MemberCheckIn {
|
||||
status: CheckInStatus;
|
||||
/** Its own latest read of its pending inbox, as OpenRig observed it. */
|
||||
lastCheckInAt: string | null;
|
||||
/** The cadence its person stated at setup; null when none was. */
|
||||
expectedMinutes: number | null;
|
||||
/** Its setup says it checks in when a person asks it. */
|
||||
onRequest: boolean;
|
||||
reporter: Reporter;
|
||||
/** Why the status is unknown, when it is. */
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
/** A connected assistant's check-in status from what its setup states (a cadence, or on request) and its last observed
|
||||
* check-in (`seen`, `never`, or `unknown` when the record can't be read). First match wins: disconnected, unknown (an
|
||||
* unusable statement or an unreadable record), no check-in yet, on request, then checking in or overdue by its cadence,
|
||||
* else unknown because it states none. Nothing passes `revokedAt` yet, so nothing reads disconnected. */
|
||||
export function memberCheckInStatus(input: {
|
||||
checkInMinutes: number | null | undefined;
|
||||
/** Its setup says it checks in on request rather than on a schedule. */
|
||||
onRequest?: boolean;
|
||||
/** Why the stated cadence can't be used, from the roster. */
|
||||
cadenceProblem?: string | null;
|
||||
checkIn: { state: "seen"; at: string } | { state: "never" } | { state: "unknown" };
|
||||
revokedAt?: string | null;
|
||||
now: Date;
|
||||
}): MemberCheckIn {
|
||||
const expectedMinutes = input.checkInMinutes ?? null;
|
||||
const lastCheckInAt = input.checkIn.state === "seen" ? input.checkIn.at : null;
|
||||
const onRequest = input.onRequest === true;
|
||||
const unknown = (reason: string): MemberCheckIn => ({ status: "unknown", lastCheckInAt, expectedMinutes, onRequest, reporter: "openrig-observed", reason });
|
||||
const known = (status: CheckInStatus): MemberCheckIn => ({ status, lastCheckInAt, expectedMinutes, onRequest, reporter: "openrig-observed" });
|
||||
if (input.revokedAt) return known("disconnected");
|
||||
if (input.cadenceProblem) return unknown(input.cadenceProblem);
|
||||
if (input.checkIn.state === "unknown") return unknown("its check-ins couldn't be read");
|
||||
if (input.checkIn.state === "never") return known("no-check-in-yet");
|
||||
if (onRequest) return known("on-request");
|
||||
if (expectedMinutes === null) return unknown(NO_CADENCE_STATED);
|
||||
const age = input.now.getTime() - Date.parse(input.checkIn.at);
|
||||
if (!Number.isFinite(age)) return unknown("its last check-in's time couldn't be read");
|
||||
return known(age <= expectedMinutes * CHECK_IN_OVERDUE_CADENCES * 60_000 ? "checking-in" : "check-in-overdue");
|
||||
}
|
||||
|
||||
/** Why a connected assistant whose setup states neither a cadence nor on request reads unknown. */
|
||||
export const NO_CADENCE_STATED = "no check-in cadence declared";
|
||||
|
||||
/** A connected assistant's check-in in one line for a person, with its evidence: "overdue: last check-in 13:05,
|
||||
* expected every 60 min; its schedule may be off or waiting for an approval". */
|
||||
export function describeCheckIn(c: Pick<MemberCheckIn, "status" | "lastCheckInAt" | "expectedMinutes" | "onRequest" | "reason">): string {
|
||||
const last = c.lastCheckInAt ? `last check-in ${c.lastCheckInAt}` : null;
|
||||
const expected = c.expectedMinutes !== null ? `expected every ${c.expectedMinutes} min`
|
||||
: c.onRequest ? "checks in when a person asks it" : NO_CADENCE_STATED;
|
||||
switch (c.status) {
|
||||
case "checking-in": return `checking in: ${last}, ${expected}`;
|
||||
case "check-in-overdue": return `overdue: ${last}, ${expected}; its schedule may be off or waiting for an approval`;
|
||||
case "no-check-in-yet": return `no check-in yet; ${expected}`;
|
||||
case "on-request": return `on request: it checks its queue when a person asks it${last ? `; ${last}` : ""}`;
|
||||
case "disconnected": return "disconnected: its connection was revoked or removed";
|
||||
case "unknown": return `unknown (${c.reason ?? "its check-in status couldn't be read"})${last ? `; ${last}` : ""}`;
|
||||
}
|
||||
}
|
||||
|
||||
/** The route a wake took. `none` means no route can carry it (a pull-only member, an address nothing holds). */
|
||||
export type WakeRoute = "terminal" | "gateway" | "hosted-trigger" | "none";
|
||||
|
||||
|
||||
@@ -95,8 +95,7 @@ export class DraftAwareDelivery {
|
||||
: guardError ? error.message : "The delivery attempt ended without a reliable write result. Repeating the request may duplicate input.";
|
||||
result = { ok: false, sessionName: session, outcome: "failed", inputPhase,
|
||||
...(inputWritten || inputPhase === "not_reached" ? { sent: inputWritten } : {}),
|
||||
reason: guardError ? error.code : "delivery_indeterminate", error: detail,
|
||||
...(guardError && error.inputReadFailure ? { inputReadFailure: error.inputReadFailure } : {}) };
|
||||
reason: guardError ? error.code : "delivery_indeterminate", error: detail };
|
||||
}
|
||||
if (inputWritten) result = { ...result, sent: true };
|
||||
const preInputRefusal = ["draft_input_busy", "draft_input_unknown", "draft_input_changed", "draft_wake_superseded",
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
/** Reporting only: never retain composer text, suggestions, frames, hints or exception messages. */
|
||||
export interface InputReadDiagnostic {
|
||||
check: "composer" | "draft-aware";
|
||||
status: "empty" | "text" | "typed" | "likely-suggestion" | "typed-with-suggestion" | "unknown" | "no-input-box";
|
||||
confidence: "high" | "medium" | "low" | "unknown" | null;
|
||||
reason: string;
|
||||
cursor: { present: boolean; valid: boolean | null; atEnd: boolean | null };
|
||||
/** null means this reader does not report whether a timeout occurred. */
|
||||
timedOut: boolean | null;
|
||||
}
|
||||
|
||||
const reasons = new Set(["capture_unavailable", "pane_in_mode", "invalid_cursor", "incomplete_frame",
|
||||
"ambiguous_composer", "unrecognized_composer", "read_timeout", "read_budget_spent", "cursor_moved",
|
||||
"cursor_unavailable", "cursor_outside", "unrecognized_input_box", "input_not_clear",
|
||||
"paste_controls", "paste_long", "paste_viewport"]);
|
||||
|
||||
/** Copy only fixed vocabulary and cursor flags. Unknown reader reasons remain content-free. */
|
||||
export function inputReadDiagnostic(check: InputReadDiagnostic["check"], reading: {
|
||||
state?: string; status?: string; confidence?: string; unknownReason?: string;
|
||||
cursor?: { x: number; y: number; width: number; height: number } | null;
|
||||
cursorAtEnd?: boolean; evidence?: string[];
|
||||
} | null, reason?: string): InputReadDiagnostic {
|
||||
const evidence = reading?.evidence ?? [];
|
||||
const timeout = evidence.includes("input box reads timed out");
|
||||
const derived = reason ?? reading?.unknownReason ?? (timeout ? "read_timeout"
|
||||
: evidence.includes("input box budget spent") ? "read_budget_spent"
|
||||
: evidence.includes("cursor moved during the capture") ? "cursor_moved"
|
||||
: evidence.includes("cursor unavailable") ? "cursor_unavailable"
|
||||
: evidence.some(e => e === "cursor outside the input box" || e === "cursor seen once outside the input box") ? "cursor_outside"
|
||||
: reading?.status === "no-input-box" ? "unrecognized_input_box"
|
||||
: reading?.status === "unknown" || !reading ? "capture_unavailable" : "input_not_clear");
|
||||
const status = reading?.status ?? reading?.state;
|
||||
const confidence = reading?.confidence;
|
||||
const cursor = reading?.cursor;
|
||||
return {
|
||||
check,
|
||||
status: ["empty", "text", "typed", "likely-suggestion", "typed-with-suggestion", "unknown", "no-input-box"].includes(status ?? "")
|
||||
? status as InputReadDiagnostic["status"] : "unknown",
|
||||
confidence: ["high", "medium", "low", "unknown"].includes(confidence ?? "")
|
||||
? confidence as InputReadDiagnostic["confidence"] : null,
|
||||
reason: reasons.has(derived) ? derived : "capture_unavailable",
|
||||
cursor: { present: !!cursor, valid: cursor ? [cursor.x, cursor.y, cursor.width, cursor.height].every(Number.isInteger)
|
||||
&& cursor.x >= 0 && cursor.y >= 0 && cursor.width > cursor.x && cursor.height > cursor.y : null,
|
||||
atEnd: typeof reading?.cursorAtEnd === "boolean" ? reading.cursorAtEnd : null },
|
||||
timedOut: timeout ? true : null,
|
||||
};
|
||||
}
|
||||
@@ -4,7 +4,6 @@ import type { InstallPlanEntry } from "./install-planner.js";
|
||||
import type { PolicyResult } from "./install-policy.js";
|
||||
import type { RefinedInstallPlan } from "./conflict-detector.js";
|
||||
import { InstallRepository, type JournalEntry } from "./install-repository.js";
|
||||
import { damagedManagedBlockNote, findManagedBlocks, managedBlockBodyMatches, mergeManagedBlockText } from "./managed-blocks.js";
|
||||
|
||||
export interface EngineFsOps {
|
||||
readFile: (filePath: string) => string;
|
||||
@@ -20,8 +19,6 @@ export interface InstallResult {
|
||||
applied: JournalEntry[];
|
||||
deferred: InstallPlanEntry[];
|
||||
conflicts: InstallPlanEntry[];
|
||||
/** Entries left as they were, each with the reason: a guidance file whose block has a marker without its pair. */
|
||||
notes: string[];
|
||||
}
|
||||
|
||||
export interface RollbackResult {
|
||||
@@ -55,19 +52,10 @@ export class InstallEngine {
|
||||
): InstallResult {
|
||||
const install = this.installRepo.createInstall(packageId, targetRoot, "project_shared", bootstrapId);
|
||||
const applied: JournalEntry[] = [];
|
||||
const notes: string[] = [];
|
||||
const backupRoot = path.join(targetRoot, ".rigged-backups", install.id);
|
||||
|
||||
try {
|
||||
for (const entry of policyResult.approved) {
|
||||
// A guidance block with a marker that lost its pair can't be replaced, and another copy beside it is the
|
||||
// duplicate to avoid: that file is left as it is and named, and the rest of the install goes on.
|
||||
const unreadable = entry.exportType === "guidance" ? this.unreadableGuidanceBlock(entry, plan.packageName) : null;
|
||||
if (unreadable) {
|
||||
notes.push(unreadable);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Ensure target directory
|
||||
this.fs.mkdirp(path.dirname(entry.targetPath));
|
||||
|
||||
@@ -133,7 +121,6 @@ export class InstallEngine {
|
||||
applied,
|
||||
deferred: plan.deferred,
|
||||
conflicts: plan.conflicts,
|
||||
notes,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -183,14 +170,6 @@ export class InstallEngine {
|
||||
return { restored, deleted };
|
||||
}
|
||||
|
||||
/** The note for a guidance entry whose target holds a marker of the package's block without its pair, and no complete
|
||||
* copy, or null. */
|
||||
private unreadableGuidanceBlock(entry: InstallPlanEntry, packageName: string): string | null {
|
||||
if (!entry.sourcePath || !this.fs.exists(entry.targetPath)) return null;
|
||||
const found = findManagedBlocks(this.fs.readFile(entry.targetPath), packageName);
|
||||
return found.blocks.length === 0 && found.unpaired > 0 ? damagedManagedBlockNote(entry.targetPath, packageName) : null;
|
||||
}
|
||||
|
||||
private applyGuidanceMerge(entry: InstallPlanEntry, packageName: string): void {
|
||||
if (!entry.sourcePath) return;
|
||||
|
||||
@@ -199,17 +178,34 @@ export class InstallEngine {
|
||||
|
||||
if (this.fs.exists(entry.targetPath)) {
|
||||
const existing = this.fs.readFile(entry.targetPath);
|
||||
const found = findManagedBlocks(existing, packageName);
|
||||
if (found.blocks.length === 0) {
|
||||
const startMarker = BLOCK_START(packageName);
|
||||
const endMarker = BLOCK_END(packageName);
|
||||
const legacyStart = `<!-- BEGIN RIGGED MANAGED BLOCK: ${packageName} -->`;
|
||||
const legacyEnd = `<!-- END RIGGED MANAGED BLOCK: ${packageName} -->`;
|
||||
// Prefer the OpenRig form; fall back to legacy markers from
|
||||
// prior installs so re-applying a guidance package over a
|
||||
// pre-rename file replaces the old block instead of appending a
|
||||
// duplicate.
|
||||
let startIdx = existing.indexOf(startMarker);
|
||||
let endIdx = existing.indexOf(endMarker, startIdx + startMarker.length);
|
||||
let matchedEndLength = endMarker.length;
|
||||
if (startIdx === -1 || endIdx === -1) {
|
||||
const legacyStartIdx = existing.indexOf(legacyStart);
|
||||
const legacyEndIdx = existing.indexOf(legacyEnd, legacyStartIdx + legacyStart.length);
|
||||
if (legacyStartIdx !== -1 && legacyEndIdx !== -1) {
|
||||
startIdx = legacyStartIdx;
|
||||
endIdx = legacyEndIdx;
|
||||
matchedEndLength = legacyEnd.length;
|
||||
}
|
||||
}
|
||||
|
||||
if (startIdx !== -1 && endIdx !== -1) {
|
||||
// Update existing block (rewrites legacy markers to OpenRig form).
|
||||
const updated = existing.slice(0, startIdx) + block + existing.slice(endIdx + matchedEndLength);
|
||||
this.fs.writeFile(entry.targetPath, updated);
|
||||
} else {
|
||||
// Insert new block at end
|
||||
this.fs.writeFile(entry.targetPath, existing + "\n\n" + block + "\n");
|
||||
} else {
|
||||
// Update the first copy in place, in the OpenRig form (legacy markers too). A later copy goes when it repeats the
|
||||
// first copy's text or the package's; a copy with other text, which someone wrote, stays.
|
||||
const first = found.blocks[0]!.body;
|
||||
const updated = mergeManagedBlockText(existing, packageName, block, [],
|
||||
(body) => !managedBlockBodyMatches(body, first) && !managedBlockBodyMatches(body, content));
|
||||
if (updated !== null) this.fs.writeFile(entry.targetPath, updated);
|
||||
}
|
||||
} else {
|
||||
// New file
|
||||
|
||||
@@ -45,9 +45,7 @@ export class InstallVerifier {
|
||||
this.fs = fs;
|
||||
}
|
||||
|
||||
/** `skipped`: entries the install deliberately left as they were (its result's notes), so an install whose every entry was
|
||||
* skipped verifies as that, not as an empty journal. */
|
||||
verify(installId: string, opts: { skipped?: number } = {}): VerificationResult {
|
||||
verify(installId: string): VerificationResult {
|
||||
const install = this.installRepo.getInstall(installId);
|
||||
if (!install) {
|
||||
return {
|
||||
@@ -78,16 +76,6 @@ export class InstallVerifier {
|
||||
const journal = this.installRepo.getJournalEntries(installId);
|
||||
const applyEntries = journal.filter((e) => e.action !== "rollback");
|
||||
|
||||
// Every entry left as it was on purpose, each named in the install's notes: nothing to check, and nothing missing.
|
||||
if (applyEntries.length === 0 && (opts.skipped ?? 0) > 0) {
|
||||
return {
|
||||
passed: true,
|
||||
installId,
|
||||
entries: [],
|
||||
statusCheck: { ...statusCheck, name: "entries_skipped", passed: true, expected: "applied entries, or skips named in notes", actual: `0 applied, ${opts.skipped} skipped` },
|
||||
};
|
||||
}
|
||||
|
||||
// Empty journal for applied install is a verification failure
|
||||
if (applyEntries.length === 0) {
|
||||
return {
|
||||
|
||||
@@ -2,8 +2,7 @@ import { createHash } from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import nodePath from "node:path";
|
||||
import type Database from "better-sqlite3";
|
||||
import { damagedManagedBlockNote, frameManagedBlockBody, managedBlockBodyMatches, managedBlockStanding, mergeManagedBlock, pruneManagedBlockCopies,
|
||||
withCanonicalMarkers, type ManagedBlockMergeFsOps, type ManagedBlockStanding } from "./managed-blocks.js";
|
||||
import { frameManagedBlockBody, managedBlockStanding, mergeManagedBlock, type ManagedBlockMergeFsOps, type ManagedBlockStanding } from "./managed-blocks.js";
|
||||
|
||||
/** #2456: OpenRig's record of what it last wrote in each managed guidance block, so a later start, exact resume, handover
|
||||
* or runtime switch can tell a block it wrote (refreshed when its source changes) from a block a person edited (kept). */
|
||||
@@ -93,8 +92,6 @@ export function decideManagedBlock(content: string, blockId: string, source: str
|
||||
const standing = managedBlockStanding(content, blockId, source, recorded, managedBlockBodyHash);
|
||||
const named = opts.sourcePath ?? "its source";
|
||||
if (standing === "absent") return { standing, write: !opts.onlyIfPresent };
|
||||
// A marker without its pair: writing would add a second copy beside the block that can't be read, at every start.
|
||||
if (standing === "damaged") return { standing, write: false, note: damagedManagedBlockNote(targetPath, blockId) };
|
||||
if (unreadable && standing === "unrecorded") {
|
||||
return { standing: "unknown", write: false,
|
||||
note: `guidance_block_record_unreadable: ${targetPath}: ${blockId} wasn't refreshed from ${named} because OpenRig couldn't read its write record` };
|
||||
@@ -106,36 +103,10 @@ export function decideManagedBlock(content: string, blockId: string, source: str
|
||||
return { standing, write: true };
|
||||
}
|
||||
|
||||
/** A block that isn't rewritten (current, edited or unrecorded) can still carry extra copies, or markers an editor
|
||||
* rewrapped: take out the copies that repeat a kept one, and OpenRig's own (its source's text, or what it last wrote)
|
||||
* beside an edited one, so the file keeps one copy of each distinct text, and put the markers in OpenRig's form. Nothing
|
||||
* a person wrote is removed. Best effort: a file that can't be read or written is named in `notes` and left as it is,
|
||||
* and the block's own decision stands. Returns whether the file changed. */
|
||||
export function pruneKeptManagedBlock(files: ManagedBlockMergeFsOps, targetPath: string, blockId: string, source: string,
|
||||
records: ManagedBlockRecords | undefined, notes?: string[]): boolean {
|
||||
let recorded: string | undefined;
|
||||
try { recorded = records?.get(targetPath, blockId); } catch { /* unreadable: only the source's text counts as OpenRig's */ }
|
||||
try {
|
||||
const existing = files.exists(targetPath) ? files.readFile(targetPath) : "";
|
||||
const pruned = pruneManagedBlockCopies(existing, blockId,
|
||||
(body) => managedBlockBodyMatches(body, source) || (recorded !== undefined && managedBlockBodyHash(body) === recorded));
|
||||
const tidy = withCanonicalMarkers(pruned ?? existing, blockId);
|
||||
if (tidy === existing) return false;
|
||||
files.writeFile(targetPath, tidy);
|
||||
if (pruned !== null) {
|
||||
notes?.push(`guidance_block_copies_removed: ${targetPath}: ${blockId} had repeated copies; the repeats were removed and every edited copy was kept`);
|
||||
}
|
||||
return true;
|
||||
} catch (err) {
|
||||
notes?.push(`guidance_block_copies_not_removed: ${targetPath}: ${blockId}'s repeated copies or markers couldn't be tidied (${(err as Error).message}); the file was left as it is`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** #2456: the one write of a managed guidance block that every adapter's guidance merge goes through. Without `records` it's
|
||||
* the plain merge, as before. With them, `decideManagedBlock` decides; a kept block is named in `warnings`, and only its
|
||||
* repeated copies are taken out (`pruneKeptManagedBlock`). A written block is recorded at once, so a later failure in the
|
||||
* same start can't lose its record. Returns whether the block was written. */
|
||||
* the plain merge, as before. With them, `decideManagedBlock` decides; a kept block is named in `warnings` and nothing is
|
||||
* written. A written block is recorded at once, so a later failure in the same start can't lose its record. Returns
|
||||
* whether the block was written. */
|
||||
export function writeManagedGuidanceBlock(files: ManagedBlockMergeFsOps, targetPath: string, blockId: string, content: string, opts: {
|
||||
records?: ManagedBlockRecords; unrecorded?: "keep" | "refresh"; onlyIfPresent?: boolean; sourcePath?: string;
|
||||
warnings?: string[]; replaceBlockIds?: string[];
|
||||
@@ -144,19 +115,15 @@ export function writeManagedGuidanceBlock(files: ManagedBlockMergeFsOps, targetP
|
||||
// Without records, the plain merge as before; a refresh-only call (`onlyIfPresent`) then writes nothing.
|
||||
if (!opts.records) {
|
||||
if (opts.onlyIfPresent) return false;
|
||||
return merge();
|
||||
merge();
|
||||
return true;
|
||||
}
|
||||
const existing = files.exists(targetPath) ? files.readFile(targetPath) : "";
|
||||
const decision = decideManagedBlock(existing, blockId, content, opts.records, targetPath,
|
||||
{ unrecorded: opts.unrecorded ?? "refresh", onlyIfPresent: opts.onlyIfPresent, sourcePath: opts.sourcePath });
|
||||
if (decision.note) opts.warnings?.push(decision.note);
|
||||
if (!decision.write) {
|
||||
if (decision.standing === "edited" || decision.standing === "unrecorded") {
|
||||
pruneKeptManagedBlock(files, targetPath, blockId, content, opts.records, opts.warnings);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (!merge()) return false;
|
||||
if (!decision.write) return false;
|
||||
merge();
|
||||
recordManagedBlockWrite(opts.records, targetPath, blockId, content, opts.warnings);
|
||||
return true;
|
||||
}
|
||||
@@ -41,95 +41,15 @@ export function managedBlockText(blockId: string, content: string): string {
|
||||
/** What `mergeManagedBlock` puts between a file's existing text and a block it adds at the end. */
|
||||
export const MANAGED_BLOCK_APPEND_SEPARATOR = "\n\n";
|
||||
|
||||
/** A BEGIN or END marker for `blockId`, in the current or the legacy form, as an editor or formatter may leave it: any
|
||||
* run of whitespace, line breaks included, between its words. */
|
||||
function markerSource(kind: "BEGIN" | "END", blockId: string): string {
|
||||
return `<!--\\s*${kind}\\s+(?:OpenRig|RIGGED)\\s+MANAGED\\s+BLOCK:\\s*${escapeRegex(blockId)}\\s*-->`;
|
||||
}
|
||||
|
||||
/** One `blockId` block in a file: where it and its body start and end, and the text between its markers. */
|
||||
export interface FoundManagedBlock { start: number; end: number; bodyStart: number; bodyEnd: number; body: string }
|
||||
|
||||
/** Every `blockId` block in `content`, in order: each BEGIN paired with the next marker when that marker is an END. A
|
||||
* marker left without a pair (an END with no BEGIN before it, or a BEGIN followed by another BEGIN or by nothing) is
|
||||
* counted in `unpaired`: where its block ends, or starts, can't be read. */
|
||||
export function findManagedBlocks(content: string, blockId: string): { blocks: FoundManagedBlock[]; unpaired: number } {
|
||||
const markers = [...content.matchAll(new RegExp(`(${markerSource("BEGIN", blockId)})|${markerSource("END", blockId)}`, "g"))]
|
||||
.map((m) => ({ begin: m[1] !== undefined, index: m.index!, length: m[0].length }));
|
||||
const blocks: FoundManagedBlock[] = [];
|
||||
let unpaired = 0;
|
||||
for (let i = 0; i < markers.length; i++) {
|
||||
const marker = markers[i]!;
|
||||
const next = markers[i + 1];
|
||||
if (marker.begin && next && !next.begin) {
|
||||
const bodyStart = marker.index + marker.length;
|
||||
blocks.push({ start: marker.index, end: next.index + next.length, bodyStart, bodyEnd: next.index, body: content.slice(bodyStart, next.index) });
|
||||
i++;
|
||||
} else {
|
||||
unpaired++;
|
||||
}
|
||||
}
|
||||
return { blocks, unpaired };
|
||||
}
|
||||
|
||||
/** The note for a block OpenRig didn't write because a marker of it has lost its pair. */
|
||||
export function damagedManagedBlockNote(targetPath: string, blockId: string): string {
|
||||
return `guidance_block_markers_damaged: ${targetPath}: ${blockId} has a BEGIN or END marker without its pair, so OpenRig `
|
||||
+ "didn't add another copy; restore the missing marker or remove the partial block, and OpenRig's next write keeps one block";
|
||||
}
|
||||
|
||||
/**
|
||||
* `existing` with `blockId`'s block written as `block`, or null when it can't be written without a second copy: the file
|
||||
* has a marker of that block without its pair, and no complete copy. The first copy is replaced in place and a later copy
|
||||
* is removed with the separator before it, so a file that collected copies keeps one; with `keepLater`, a later copy it
|
||||
* names stays, unless it repeats one already kept. Blocks of `replaceBlockIds` (older ids) are removed. A file with none
|
||||
* of these gets the block at its end.
|
||||
*/
|
||||
export function mergeManagedBlockText(existing: string, blockId: string, block: string, replaceBlockIds: readonly string[] = [],
|
||||
keepLater?: (body: string) => boolean): string | null {
|
||||
const own = findManagedBlocks(existing, blockId);
|
||||
if (own.blocks.length === 0 && own.unpaired > 0) return null;
|
||||
const keptLater: string[] = [];
|
||||
const edits: Array<{ start: number; end: number; text: string }> = [];
|
||||
own.blocks.forEach((found, i) => {
|
||||
if (i === 0) edits.push({ start: found.start, end: found.end, text: block });
|
||||
else if (keepLater?.(found.body) && !keptLater.some((body) => managedBlockBodyMatches(body, found.body))) keptLater.push(found.body);
|
||||
else edits.push({ start: withSeparator(existing, found.start), end: found.end, text: "" });
|
||||
});
|
||||
for (const id of new Set(replaceBlockIds)) {
|
||||
if (id === blockId) continue;
|
||||
for (const found of findManagedBlocks(existing, id).blocks) edits.push({ start: found.start, end: found.end, text: "" });
|
||||
}
|
||||
if (edits.length === 0) return `${existing}${MANAGED_BLOCK_APPEND_SEPARATOR}${block}`;
|
||||
// A block of one id inside another's is left to the outer edit.
|
||||
const ordered: typeof edits = [];
|
||||
for (const edit of edits.sort((a, b) => a.start - b.start)) if (edit.start >= (ordered.at(-1)?.end ?? 0)) ordered.push(edit);
|
||||
let updated = existing;
|
||||
// The block goes in by slicing, never a replacement string, so `$&` and the like in authored content stay as written.
|
||||
for (const edit of ordered.reverse()) updated = `${updated.slice(0, edit.start)}${edit.text}${updated.slice(edit.end)}`;
|
||||
if (own.blocks.length === 0) updated = `${updated.trim()}\n\n${block}`.trim();
|
||||
// End with one newline only when the file doesn't already: re-merging on every launch must not add a line each time
|
||||
// (#2372), and blank lines a person left after the last block stay.
|
||||
return updated.endsWith("\n") ? updated : `${updated}\n`;
|
||||
}
|
||||
|
||||
/** Where a later copy's removal starts: before the newlines that separate it from the text above, so removing the copy
|
||||
* takes the separator the writer put there. */
|
||||
function withSeparator(content: string, start: number): number {
|
||||
let from = start;
|
||||
while (from > 0 && content[from - 1] === "\n") from--;
|
||||
return from;
|
||||
}
|
||||
|
||||
/** Write `blockId`'s block into a guidance file (see `mergeManagedBlockText`). Returns false, writing nothing and naming
|
||||
* the block in `warnings`, when a marker of it has lost its pair. */
|
||||
export function mergeManagedBlock(
|
||||
fs: ManagedBlockMergeFsOps,
|
||||
targetPath: string,
|
||||
blockId: string,
|
||||
content: string,
|
||||
options?: MergeManagedBlockOptions,
|
||||
): boolean {
|
||||
): void {
|
||||
const begin = MANAGED_BLOCK_START(blockId);
|
||||
const end = MANAGED_BLOCK_END(blockId);
|
||||
const block = managedBlockText(blockId, content);
|
||||
|
||||
if (!fs.exists(targetPath)) {
|
||||
@@ -137,39 +57,49 @@ export function mergeManagedBlock(
|
||||
fs.writeFile(targetPath, block);
|
||||
const warnings = excludeNewGeneratedFiles(nodePath.dirname(targetPath), [targetPath]);
|
||||
options?.warnings?.push(...warnings);
|
||||
return true;
|
||||
return;
|
||||
}
|
||||
|
||||
const existing = fs.readFile(targetPath);
|
||||
const merged = mergeManagedBlockText(existing, blockId, block, options?.replaceBlockIds ?? []);
|
||||
if (merged === null) {
|
||||
options?.warnings?.push(damagedManagedBlockNote(targetPath, blockId));
|
||||
return false;
|
||||
}
|
||||
fs.writeFile(targetPath, merged);
|
||||
if (findManagedBlocks(existing, blockId).unpaired > 0) options?.warnings?.push(unpairedMarkerNote(targetPath, blockId));
|
||||
return true;
|
||||
}
|
||||
const allReplaceIds = Array.from(new Set([blockId, ...(options?.replaceBlockIds ?? [])]));
|
||||
const replaceableIds = allReplaceIds.filter((id) => {
|
||||
const candidateBegin = MANAGED_BLOCK_START(id);
|
||||
const candidateEnd = MANAGED_BLOCK_END(id);
|
||||
const legacyBegin = LEGACY_BLOCK_START(id);
|
||||
const legacyEnd = LEGACY_BLOCK_END(id);
|
||||
return (
|
||||
(existing.includes(candidateBegin) && existing.includes(candidateEnd)) ||
|
||||
(existing.includes(legacyBegin) && existing.includes(legacyEnd))
|
||||
);
|
||||
});
|
||||
|
||||
/** The note for a marker without its pair left beside a block that was written. */
|
||||
export function unpairedMarkerNote(targetPath: string, blockId: string): string {
|
||||
return `guidance_block_marker_unpaired: ${targetPath}: ${blockId} was written, and a BEGIN or END marker of it without its `
|
||||
+ "pair was left as it is; remove it or restore its pair";
|
||||
}
|
||||
|
||||
/** `content` with each `blockId` block whose markers an editor rewrapped written back in OpenRig's own form, its body
|
||||
* exactly as it is. Markers already in the current or legacy form stay as they are. */
|
||||
export function withCanonicalMarkers(content: string, blockId: string): string {
|
||||
let updated = content;
|
||||
for (const found of findManagedBlocks(content, blockId).blocks.reverse()) {
|
||||
const begin = content.slice(found.start, found.bodyStart);
|
||||
const end = content.slice(found.bodyEnd, found.end);
|
||||
const exact = (begin === MANAGED_BLOCK_START(blockId) && end === MANAGED_BLOCK_END(blockId))
|
||||
|| (begin === LEGACY_BLOCK_START(blockId) && end === LEGACY_BLOCK_END(blockId));
|
||||
if (exact) continue;
|
||||
updated = `${updated.slice(0, found.start)}${MANAGED_BLOCK_START(blockId)}${found.body}${MANAGED_BLOCK_END(blockId)}${updated.slice(found.end)}`;
|
||||
if (replaceableIds.length > 0) {
|
||||
let updated = existing;
|
||||
for (const id of replaceableIds) {
|
||||
const candidateBegin = MANAGED_BLOCK_START(id);
|
||||
const candidateEnd = MANAGED_BLOCK_END(id);
|
||||
const regex = new RegExp(`${escapeRegex(candidateBegin)}[\\s\\S]*?${escapeRegex(candidateEnd)}`, "g");
|
||||
// A replacer function inserts the block literally; a replacement string
|
||||
// would expand `$$`, `$&`, `` $` `` and `$'` in the authored content.
|
||||
const replacement = id === blockId ? block : "";
|
||||
updated = updated.replace(regex, () => replacement);
|
||||
// Legacy marker variant from prior installs — replace with the
|
||||
// OpenRig form (or strip when not the active block id).
|
||||
const legacyBegin = LEGACY_BLOCK_START(id);
|
||||
const legacyEnd = LEGACY_BLOCK_END(id);
|
||||
const legacyRegex = new RegExp(`${escapeRegex(legacyBegin)}[\\s\\S]*?${escapeRegex(legacyEnd)}`, "g");
|
||||
updated = updated.replace(legacyRegex, () => replacement);
|
||||
}
|
||||
if (!updated.includes(begin) || !updated.includes(end)) {
|
||||
updated = `${updated.trim()}\n\n${block}`.trim();
|
||||
}
|
||||
// End with one newline only when the file doesn't already: re-merging on every launch must not add a line each time
|
||||
// (#2372), and blank lines a person left after the last block stay.
|
||||
fs.writeFile(targetPath, updated.endsWith("\n") ? updated : `${updated}\n`);
|
||||
return;
|
||||
}
|
||||
return updated;
|
||||
|
||||
fs.writeFile(targetPath, `${existing}${MANAGED_BLOCK_APPEND_SEPARATOR}${block}`);
|
||||
}
|
||||
|
||||
/** Each `blockId` block in `content` (current markers): the whole block and the text between its markers. */
|
||||
@@ -206,47 +136,19 @@ export function managedBlockOpenings(content: string, blockId: string): number {
|
||||
* - `current`: it holds the source's text;
|
||||
* - `stale`: it holds what OpenRig last wrote, and the source has changed since, so it's OpenRig's to refresh;
|
||||
* - `edited`: it holds something else, which someone wrote by hand;
|
||||
* - `unrecorded`: it differs from the source and OpenRig has no record of writing it, so which of the two is unknown;
|
||||
* - `damaged`: it holds a marker of the block without its pair, and no complete copy, so the block can't be read.
|
||||
* - `unrecorded`: it differs from the source and OpenRig has no record of writing it, so which of the two is unknown.
|
||||
* A block written more than once counts as edited when any copy is. */
|
||||
export type ManagedBlockStanding = "absent" | "current" | "stale" | "edited" | "unrecorded" | "damaged";
|
||||
export type ManagedBlockStanding = "absent" | "current" | "stale" | "edited" | "unrecorded";
|
||||
|
||||
export function managedBlockStanding(content: string, blockId: string, source: string, recordedHash: string | undefined,
|
||||
hash: (body: string) => string): ManagedBlockStanding {
|
||||
const found = findManagedBlocks(content, blockId);
|
||||
const bodies = found.blocks.map((block) => block.body);
|
||||
if (bodies.length === 0) return found.unpaired > 0 ? "damaged" : "absent";
|
||||
const bodies = managedBlockBodies(content, blockId);
|
||||
if (bodies.length === 0) return "absent";
|
||||
if (bodies.every((body) => managedBlockBodyMatches(body, source))) return "current";
|
||||
if (recordedHash === undefined) return "unrecorded";
|
||||
return bodies.every((body) => hash(body) === recordedHash) ? "stale" : "edited";
|
||||
}
|
||||
|
||||
/** `content` with the extra copies of a `blockId` block taken out where no one's text is lost: a later copy whose body
|
||||
* repeats an earlier copy's, and, beside a copy that's kept, a copy that `isOpenRigs` says holds only what OpenRig
|
||||
* wrote. Every other copy stays where it is. Null when there's nothing to take out. */
|
||||
export function pruneManagedBlockCopies(content: string, blockId: string, isOpenRigs: (body: string) => boolean): string | null {
|
||||
const { blocks } = findManagedBlocks(content, blockId);
|
||||
if (blocks.length < 2) return null;
|
||||
const kept: FoundManagedBlock[] = [];
|
||||
const removed: FoundManagedBlock[] = [];
|
||||
for (const block of blocks) {
|
||||
if (kept.some((other) => managedBlockBodyMatches(other.body, block.body))) removed.push(block);
|
||||
else kept.push(block);
|
||||
}
|
||||
const people = kept.filter((block) => !isOpenRigs(block.body));
|
||||
if (people.length > 0) for (const block of kept.filter((b) => isOpenRigs(b.body))) removed.push(block);
|
||||
if (removed.length === 0) return null;
|
||||
let updated = content;
|
||||
for (const block of removed.sort((a, b) => b.start - a.start)) {
|
||||
// A copy goes with the newlines before it; one that starts the file goes with the newlines after it instead.
|
||||
const from = withSeparator(content, block.start);
|
||||
let to = block.end;
|
||||
if (from === 0) while (updated[to] === "\n") to++;
|
||||
updated = `${updated.slice(0, from)}${updated.slice(to)}`;
|
||||
}
|
||||
return updated;
|
||||
}
|
||||
|
||||
/** Whether `content` holds a `blockId` block whose body is anything but `written`: someone edited it. */
|
||||
export function hasEditedManagedBlock(content: string, blockId: string, written: string): boolean {
|
||||
return [...content.matchAll(managedBlockPattern(blockId))].some((m) => !managedBlockBodyMatches(m[1]!, written));
|
||||
|
||||
@@ -28,10 +28,18 @@ export interface NetworkBinding {
|
||||
updatedAt: string;
|
||||
lastContactAt: string | null;
|
||||
lastError: string | null;
|
||||
/** Set when the binding was made through an enrolled account: its installation (`neti-…`) and participant name. */
|
||||
installationId: string | null;
|
||||
participantName: string | null;
|
||||
/** The account's own id for the installation the name was listed under. A replaced enrollment keeps the local id and
|
||||
* moves to a new account installation, so only a list from this one can confirm the name was dropped. */
|
||||
accountInstallationId: string | null;
|
||||
/** When the account stopped listing this participant, after the binding ended here. */
|
||||
accountRemovedAt: string | null;
|
||||
}
|
||||
|
||||
export class NetworkBindingError extends Error {
|
||||
constructor(readonly code: string, message: string, readonly status: 400 | 404 | 409 | 500 = 400) {
|
||||
constructor(readonly code: string, message: string, readonly status: 400 | 401 | 404 | 409 | 500 | 502 | 503 = 400) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
@@ -39,12 +47,15 @@ export class NetworkBindingError extends Error {
|
||||
interface Row {
|
||||
id: string; node_id: string; local_session: string; hub_url: string; hub_seat: string; allow_http: number; state: NetworkBindingState;
|
||||
state_reason: string | null; created_at: string; updated_at: string; last_contact_at: string | null; last_error: string | null;
|
||||
installation_id: string | null; participant_name: string | null; account_removed_at: string | null; account_installation_id: string | null;
|
||||
}
|
||||
|
||||
const project = (row: Row): NetworkBinding => ({
|
||||
id: row.id, nodeId: row.node_id, localSession: row.local_session, hubUrl: row.hub_url, hubSeat: row.hub_seat,
|
||||
allowHttp: row.allow_http === 1, unencrypted: row.hub_url.startsWith("http:"), state: row.state, stateReason: row.state_reason, createdAt: row.created_at, updatedAt: row.updated_at,
|
||||
lastContactAt: row.last_contact_at, lastError: row.last_error,
|
||||
installationId: row.installation_id, participantName: row.participant_name, accountRemovedAt: row.account_removed_at,
|
||||
accountInstallationId: row.account_installation_id,
|
||||
});
|
||||
|
||||
const MAX_CREDENTIAL_BYTES = 16384;
|
||||
@@ -58,10 +69,15 @@ export function networkCredentialFile(home: string, bindingId: string): string {
|
||||
/** The participant's bearer, read from its private file. A file anyone else can read, a link or an oversized file is
|
||||
* refused, with the path named and the value never shown. */
|
||||
export function readNetworkCredential(home: string, bindingId: string): string {
|
||||
const file = networkCredentialFile(home, bindingId);
|
||||
return readPrivateToken(networkCredentialFile(home, bindingId),
|
||||
(file) => `The credential for ${bindingId} isn't at ${file}. Connect the seat again with its token file.`);
|
||||
}
|
||||
|
||||
/** A token from one of the network's private files (`{"version":1,"token":…}`), checked the same way for every kind. */
|
||||
export function readPrivateToken(file: string, missing: (file: string) => string): string {
|
||||
let stat;
|
||||
try { stat = lstatSync(file); } catch {
|
||||
throw new NetworkBindingError("credential_missing", `The credential for ${bindingId} isn't at ${file}. Connect the seat again with its token file.`, 404);
|
||||
throw new NetworkBindingError("credential_missing", missing(file), 404);
|
||||
}
|
||||
if (!stat.isFile() || stat.isSymbolicLink() || stat.size > MAX_CREDENTIAL_BYTES || (stat.mode & 0o077) !== 0) {
|
||||
throw new NetworkBindingError("credential_unsafe", `${file} must be a regular file readable only by this user.`);
|
||||
@@ -77,7 +93,29 @@ export function readNetworkCredential(home: string, bindingId: string): string {
|
||||
return value.token;
|
||||
}
|
||||
|
||||
function validHubUrl(raw: unknown, allowHttp: boolean): string {
|
||||
/** Write a token to its own private file: a 0700 folder and a 0600 file, opened exclusively. On failure, only what
|
||||
* this call made is removed: the file it opened, and the folder if it made it (`mkdirSync` returns the first folder it
|
||||
* made, so any value means this one is new). Returns the cleanup for a later step that fails. */
|
||||
export function writePrivateToken(file: string, token: string): () => void {
|
||||
let created = false;
|
||||
let madeFolder: string | undefined;
|
||||
const cleanup = () => {
|
||||
if (created) rmSync(file, { force: true });
|
||||
if (madeFolder !== undefined) rmSync(dirname(file), { recursive: true, force: true });
|
||||
};
|
||||
try {
|
||||
madeFolder = mkdirSync(dirname(file), { recursive: true, mode: 0o700 });
|
||||
const fd = openSync(file, "wx", 0o600);
|
||||
created = true;
|
||||
try { writeFileSync(fd, `${JSON.stringify({ version: 1, token })}\n`); } finally { closeSync(fd); }
|
||||
} catch (error) {
|
||||
cleanup();
|
||||
throw error;
|
||||
}
|
||||
return cleanup;
|
||||
}
|
||||
|
||||
export function validHubUrl(raw: unknown, allowHttp: boolean): string {
|
||||
if (typeof raw !== "string" || !raw.trim()) throw new NetworkBindingError("invalid_hub_url", "A hub URL is required.");
|
||||
let url: URL;
|
||||
try { url = new URL(raw.trim()); } catch { throw new NetworkBindingError("invalid_hub_url", `'${raw}' isn't a URL.`); }
|
||||
@@ -140,37 +178,39 @@ export class NetworkBindingStore {
|
||||
/** Bind a local seat to a hub participant with that participant's token. The credential is written first, exclusively
|
||||
* and private, then the row. If either step fails, the credential this call created is removed, so a refused bind
|
||||
* leaves none behind; a file that was already at that path is never touched. */
|
||||
create(input: { session: unknown; hubUrl: unknown; hubSeat: unknown; token: unknown; allowHttp?: unknown }): NetworkBinding {
|
||||
const session = typeof input.session === "string" ? input.session.trim() : "";
|
||||
/** The seat to bind, by its node: it must exist and have no active binding. Checked before anything is sent anywhere. */
|
||||
freeSeat(rawSession: unknown): { session: string; nodeId: string } {
|
||||
const session = typeof rawSession === "string" ? rawSession.trim() : "";
|
||||
if (!session) throw new NetworkBindingError("invalid_seat", "Name the local seat to connect.");
|
||||
const nodeId = resolveSessionNodeId(this.db, session);
|
||||
if (!nodeId) throw new NetworkBindingError("seat_not_found", `No local seat is named '${session}'.`, 404);
|
||||
const bound = this.db.prepare("SELECT id FROM network_bindings WHERE node_id = ? AND state = 'active'").get(nodeId) as { id: string } | undefined;
|
||||
if (bound) throw new NetworkBindingError("seat_already_connected", `${session} is already connected (${bound.id}). Disconnect it first.`, 409);
|
||||
return { session, nodeId };
|
||||
}
|
||||
|
||||
create(input: { session: unknown; hubUrl: unknown; hubSeat: unknown; token: unknown; allowHttp?: unknown;
|
||||
installation?: { id: string; name: string; account: string } }): NetworkBinding {
|
||||
const { session, nodeId } = this.freeSeat(input.session);
|
||||
const allowHttp = input.allowHttp === true;
|
||||
const hubUrl = validHubUrl(input.hubUrl, allowHttp);
|
||||
const hubSeat = validHubSeat(input.hubSeat);
|
||||
const token = validToken(input.token);
|
||||
const bound = this.db.prepare("SELECT id FROM network_bindings WHERE node_id = ? AND state = 'active'").get(nodeId) as { id: string } | undefined;
|
||||
if (bound) throw new NetworkBindingError("seat_already_connected", `${session} is already connected (${bound.id}). Disconnect it first.`, 409);
|
||||
const taken = this.db.prepare("SELECT id FROM network_bindings WHERE hub_url = ? AND hub_seat = ? AND state = 'active'").get(hubUrl, hubSeat) as { id: string } | undefined;
|
||||
if (taken) throw new NetworkBindingError("participant_already_connected", `${hubSeat} at ${hubUrl} is already connected (${taken.id}).`, 409);
|
||||
|
||||
const id = this.newId();
|
||||
const file = networkCredentialFile(this.home, id);
|
||||
let created = false;
|
||||
let madeFolder: string | undefined;
|
||||
// The credential first, then the row; a failed row takes the credential with it. A file that was already at the path
|
||||
// is never touched, and a folder that was already there keeps its other files.
|
||||
const cleanup = writePrivateToken(networkCredentialFile(this.home, id), token);
|
||||
try {
|
||||
madeFolder = mkdirSync(dirname(file), { recursive: true, mode: 0o700 });
|
||||
const fd = openSync(file, "wx", 0o600);
|
||||
created = true;
|
||||
try { writeFileSync(fd, `${JSON.stringify({ version: 1, token })}\n`); } finally { closeSync(fd); }
|
||||
const at = this.now().toISOString();
|
||||
this.db.prepare(`INSERT INTO network_bindings (id, node_id, local_session, hub_url, hub_seat, allow_http, state, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, 'active', ?, ?)`).run(id, nodeId, session, hubUrl, hubSeat, allowHttp ? 1 : 0, at, at);
|
||||
this.db.prepare(`INSERT INTO network_bindings (id, node_id, local_session, hub_url, hub_seat, allow_http, state, created_at, updated_at,
|
||||
installation_id, participant_name, account_installation_id) VALUES (?, ?, ?, ?, ?, ?, 'active', ?, ?, ?, ?, ?)`)
|
||||
.run(id, nodeId, session, hubUrl, hubSeat, allowHttp ? 1 : 0, at, at, input.installation?.id ?? null, input.installation?.name ?? null,
|
||||
input.installation?.account ?? null);
|
||||
} catch (error) {
|
||||
// Only what this call made: the file it opened, and this binding's folder if it made it (`mkdirSync` returns the
|
||||
// first folder it made, so any value means this one is new). A folder that was already there keeps its other files.
|
||||
if (created) rmSync(file, { force: true });
|
||||
if (madeFolder !== undefined) rmSync(dirname(file), { recursive: true, force: true });
|
||||
cleanup();
|
||||
throw error;
|
||||
}
|
||||
return this.get(id)!;
|
||||
@@ -187,6 +227,18 @@ export class NetworkBindingStore {
|
||||
return this.end(id, "revoked", reason);
|
||||
}
|
||||
|
||||
/** Ends these active bindings' state only, so a caller can do it in one transaction with its own change. Their
|
||||
* credentials stay until the caller ends each one again with `disconnect`, which removes them. */
|
||||
markDisconnected(ids: string[], reason: string): void {
|
||||
const end = this.db.prepare("UPDATE network_bindings SET state = 'disconnected', state_reason = ?, updated_at = ? WHERE id = ? AND state = 'active'");
|
||||
for (const id of ids) end.run(reason, this.now().toISOString(), id);
|
||||
}
|
||||
|
||||
/** The account stopped listing this ended binding's participant. */
|
||||
markAccountRemoved(id: string): void {
|
||||
this.db.prepare("UPDATE network_bindings SET account_removed_at = ?, updated_at = ? WHERE id = ?").run(this.now().toISOString(), this.now().toISOString(), id);
|
||||
}
|
||||
|
||||
/** The connector reached the hub as this binding. */
|
||||
recordContact(id: string, at: Date = this.now()): void {
|
||||
this.db.prepare("UPDATE network_bindings SET last_contact_at = ?, last_error = NULL, updated_at = ? WHERE id = ?")
|
||||
|
||||
@@ -0,0 +1,378 @@
|
||||
// This daemon enrolled in an account, so the person connects a seat by a name instead of a hub URL and a token file.
|
||||
// Enrolling trades a one-time code from the account page for this installation's key. Connecting a seat lists the
|
||||
// installation's participants with the account, which answers a bearer for a newly listed name, and the account's MCP
|
||||
// front and rig, where the binding then reaches. A name the installation stops listing is revoked there.
|
||||
//
|
||||
// The account replaces the whole list on every change, so changes to one installation's list, and a replaced enrollment,
|
||||
// run one at a time, each reading what it needs inside its turn. One enroll, connect or disconnect spends at most one
|
||||
// deadline with the account, waiting for its turn included.
|
||||
import type Database from "better-sqlite3";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { existsSync, renameSync, rmSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { NetworkBindingError, readPrivateToken, writePrivateToken, type NetworkBinding, type NetworkBindingStore } from "./network-bindings.js";
|
||||
|
||||
export interface NetworkInstallation {
|
||||
id: string;
|
||||
site: string;
|
||||
/** The account's id for this installation. */
|
||||
installationId: string;
|
||||
name: string | null;
|
||||
allowHttp: boolean;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
/** An enroll's answer. A replaced enrollment names the bindings it ended, whose names the old installation may still list,
|
||||
* and any cleanup after the switch that didn't finish. */
|
||||
export interface EnrollResult extends NetworkInstallation {
|
||||
replaced?: { endedBindings: Array<{ id: string; localSession: string; participantName: string | null }>; problems?: string[] };
|
||||
}
|
||||
|
||||
interface Row { id: string; site: string; installation_id: string; name: string | null; allow_http: number; created_at: string }
|
||||
|
||||
const project = (row: Row): NetworkInstallation => ({
|
||||
id: row.id, site: row.site, installationId: row.installation_id, name: row.name, allowHttp: row.allow_http === 1, createdAt: row.created_at,
|
||||
});
|
||||
|
||||
/** Where the installation key lives: `<home>/network-secrets/<installation>/key.json`, private to this user. */
|
||||
export function networkInstallationKeyFile(home: string, id: string): string {
|
||||
if (!/^neti-[0-9a-f]{20}$/.test(id)) throw new NetworkBindingError("invalid_installation_id", `'${id}' isn't a network installation id.`);
|
||||
return join(home, "network-secrets", id, "key.json");
|
||||
}
|
||||
|
||||
const NAME = /^[a-z0-9](?:[a-z0-9-]{0,30}[a-z0-9])?$/;
|
||||
|
||||
/** The state reason of a binding a replaced enrollment ended: its name belongs to the old installation. */
|
||||
export const REPLACED_REASON = "replaced by a new enrollment";
|
||||
|
||||
/** What's known about the account's side of an ended binding made through it: confirmed dropped, unconfirmed (a removal
|
||||
* that failed or whose answer was lost), or left with an earlier account installation after the enrollment was replaced,
|
||||
* which this daemon's key can neither drop nor confirm. `accountInstallationNow` is the account's id for the binding's
|
||||
* enrollment as it is now. */
|
||||
export function accountRemoval(binding: NetworkBinding, accountInstallationNow: string | null | undefined): "confirmed" | "unconfirmed" | "old-installation" | null {
|
||||
if (!binding.installationId || binding.state === "active") return null;
|
||||
if (binding.accountRemovedAt) return "confirmed";
|
||||
// The hub refused its credential, so nothing live is left there.
|
||||
if (binding.state === "revoked") return null;
|
||||
if (binding.accountInstallationId !== accountInstallationNow) return "old-installation";
|
||||
return "unconfirmed";
|
||||
}
|
||||
|
||||
/** The command's time with the account ran out before a call was sent. */
|
||||
class NotSent extends NetworkBindingError {}
|
||||
|
||||
/** What the account answers for the installation's participant list. */
|
||||
interface Identities { participants: Array<{ name: string; state?: string; bearer?: string }>; mcp: string; rig: string }
|
||||
|
||||
export type AccountFetch = (url: string, init: { method: string; headers: Record<string, string>; body: string; redirect: "error"; signal: AbortSignal })
|
||||
=> Promise<{ status: number; json(): Promise<unknown> }>;
|
||||
|
||||
/** How long one enroll, connect or disconnect may spend with the account in all, waiting for its turn included. */
|
||||
export const ACCOUNT_DEADLINE_MS = 30_000;
|
||||
|
||||
/** One change at a time to an installation's participant list, across the stores each request makes. */
|
||||
const listTurns = new Map<string, Promise<void>>();
|
||||
async function inListTurn<T>(installationId: string, run: () => Promise<T>): Promise<T> {
|
||||
const before = listTurns.get(installationId) ?? Promise.resolve();
|
||||
let release!: () => void;
|
||||
const mine = new Promise<void>((resolve) => { release = resolve; });
|
||||
const chain = before.then(() => mine);
|
||||
listTurns.set(installationId, chain);
|
||||
try {
|
||||
await before;
|
||||
return await run();
|
||||
} finally {
|
||||
release();
|
||||
if (listTurns.get(installationId) === chain) listTurns.delete(installationId);
|
||||
}
|
||||
}
|
||||
|
||||
export class NetworkInstallationStore {
|
||||
private readonly fetch: AccountFetch;
|
||||
constructor(
|
||||
private readonly db: Database.Database,
|
||||
private readonly home: string,
|
||||
private readonly opts: { fetch?: AccountFetch; selfHostId: () => string | null; now?: () => Date; newId?: () => string; timeoutMs?: number },
|
||||
) {
|
||||
this.fetch = opts.fetch ?? ((url, init) => fetch(url, init));
|
||||
}
|
||||
|
||||
list(): NetworkInstallation[] {
|
||||
return (this.db.prepare("SELECT * FROM network_installations ORDER BY created_at, id").all() as Row[]).map(project);
|
||||
}
|
||||
|
||||
/** Trade the account page's one-time code for this installation's key. The code is checked by the account only, so a
|
||||
* refused enroll here (a URL, a second enroll at the same site without `replace`) never spends it. With `replace`, the
|
||||
* installation's turn comes first, so its wait counts against the same deadline: a wait that runs out sends no code,
|
||||
* and the new key takes the old one's place only after the account accepted the new code. */
|
||||
async enroll(bindings: NetworkBindingStore, input: { code: unknown; site: unknown; allowHttp?: unknown; replace?: unknown }): Promise<EnrollResult> {
|
||||
const code = typeof input.code === "string" ? input.code.trim() : "";
|
||||
if (!code || /\s/.test(code)) throw new NetworkBindingError("invalid_code", "Give the one-time code the account page shows.");
|
||||
const site = validSite(input.site, input.allowHttp === true);
|
||||
const existing = this.db.prepare("SELECT * FROM network_installations WHERE site = ?").get(site) as Row | undefined;
|
||||
if (existing && input.replace !== true) {
|
||||
throw new NetworkBindingError("already_enrolled", `This daemon is already enrolled at ${site} (${existing.id}). To enroll again with a new code, add --replace.`, 409);
|
||||
}
|
||||
const instance = this.instance();
|
||||
const until = this.deadline();
|
||||
const allowHttp = input.allowHttp === true ? 1 : 0;
|
||||
if (existing) {
|
||||
return inListTurn(existing.id, async () => {
|
||||
const next = await this.exchange(site, code, instance, until);
|
||||
return this.replace(bindings, this.get(existing.id), next.installation, next.key, allowHttp);
|
||||
});
|
||||
}
|
||||
|
||||
const { installation, key } = await this.exchange(site, code, instance, until);
|
||||
const id = this.opts.newId?.() ?? `neti-${randomBytes(10).toString("hex")}`;
|
||||
const cleanup = writePrivateToken(networkInstallationKeyFile(this.home, id), key);
|
||||
try {
|
||||
this.db.prepare("INSERT INTO network_installations (id, site, installation_id, name, allow_http, created_at) VALUES (?, ?, ?, ?, ?, ?)")
|
||||
.run(id, site, installation.id, installation.name, allowHttp, (this.opts.now?.() ?? new Date()).toISOString());
|
||||
} catch (error) {
|
||||
cleanup();
|
||||
throw error;
|
||||
}
|
||||
return this.get(id);
|
||||
}
|
||||
|
||||
/** The code, with this daemon's host id, for an account installation and its key. */
|
||||
private async exchange(site: string, code: string, instance: string, until: number): Promise<{ installation: { id: string; name: string | null }; key: string }> {
|
||||
let answer;
|
||||
try {
|
||||
answer = await this.call(site, "POST", "/api/connector/enroll", null, { code, instance }, until);
|
||||
} catch (error) {
|
||||
if (error instanceof NotSent) throw new NetworkBindingError(error.code, `${error.message} The code wasn't sent, so it's still good while it's current: try again.`, 503);
|
||||
if (error instanceof NetworkBindingError && error.code === "account_unavailable") {
|
||||
throw new NetworkBindingError("account_unavailable", `${error.message} Whether the account took the code is unknown: try again with the same code, and if it's refused, get a new one.`, 503);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
if (answer.status !== 200) {
|
||||
const error = errorCode(answer.body);
|
||||
if (answer.status === 400 || answer.status === 409) {
|
||||
throw new NetworkBindingError(error ?? "enroll_refused", `The account refused the code (${error ?? answer.status}). Get a new one from the account page; each is single-use and short-lived.`, answer.status);
|
||||
}
|
||||
throw new NetworkBindingError("account_unavailable", `The account couldn't answer (${answer.status}). If the code is still current, try again with it; otherwise get a new one.`, 503);
|
||||
}
|
||||
const body = (answer.body ?? {}) as { installation?: { id?: unknown; name?: unknown }; key?: unknown };
|
||||
if (typeof body.installation?.id !== "string" || typeof body.key !== "string" || !body.key || /\s/.test(body.key)) {
|
||||
throw new NetworkBindingError("account_unexpected", "The account's answer to the enroll had no installation or key.", 502);
|
||||
}
|
||||
return { installation: { id: body.installation.id, name: typeof body.installation.name === "string" ? body.installation.name : null }, key: body.key };
|
||||
}
|
||||
|
||||
/** The new key goes in beside the old one and takes its place, then the row and the old installation's active bindings
|
||||
* change in one transaction. A failure before that commits puts the old key back (with no old key, it removes the new
|
||||
* one), so nothing here changes, though the account has already made the new installation and nothing here holds its
|
||||
* key. After the commit the replace has happened: the ended bindings' names are returned, since the old installation
|
||||
* may still list them and this key can't drop them, with any cleanup that didn't finish. */
|
||||
private replace(bindings: NetworkBindingStore, old: NetworkInstallation, next: { id: string; name: string | null }, key: string, allowHttp: number): EnrollResult {
|
||||
const file = networkInstallationKeyFile(this.home, old.id);
|
||||
const staged = `${file}.next`;
|
||||
const kept = `${file}.prev`;
|
||||
const ending = bindings.active().filter((b) => b.installationId === old.id);
|
||||
rmSync(staged, { force: true });
|
||||
const cleanup = writePrivateToken(staged, key);
|
||||
const hadKey = existsSync(file);
|
||||
try {
|
||||
if (hadKey) renameSync(file, kept);
|
||||
renameSync(staged, file);
|
||||
this.db.transaction(() => {
|
||||
this.db.prepare("UPDATE network_installations SET installation_id = ?, name = ?, allow_http = ? WHERE id = ?").run(next.id, next.name, allowHttp, old.id);
|
||||
bindings.markDisconnected(ending.map((b) => b.id), REPLACED_REASON);
|
||||
})();
|
||||
} catch (error) {
|
||||
if (!hadKey) rmSync(file, { force: true });
|
||||
else if (existsSync(kept)) renameSync(kept, file);
|
||||
cleanup();
|
||||
throw new NetworkBindingError("replace_failed",
|
||||
`The account accepted the new code, but this daemon couldn't switch to it (${(error as Error).message.slice(0, 200)}), so nothing here changed. 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.`, 500);
|
||||
}
|
||||
const problems: string[] = [];
|
||||
try {
|
||||
rmSync(kept, { force: true });
|
||||
} catch (error) {
|
||||
problems.push(`The earlier key's backup at ${kept} couldn't be removed (${(error as Error).message.slice(0, 200)}). It's no longer used; remove it by hand.`);
|
||||
}
|
||||
for (const binding of ending) {
|
||||
// Already ended in the transaction; this removes its credential, and a failure says how to retry.
|
||||
try { bindings.disconnect(binding.id, REPLACED_REASON); } catch (error) { problems.push((error as Error).message); }
|
||||
}
|
||||
return { ...this.get(old.id), replaced: { endedBindings: ending.map((b) => ({ id: b.id, localSession: b.localSession, participantName: b.participantName })),
|
||||
...(problems.length ? { problems } : {}) } };
|
||||
}
|
||||
|
||||
/** The one installation a name-only connect goes through. */
|
||||
only(): NetworkInstallation {
|
||||
const all = this.list();
|
||||
if (all.length === 0) throw new NetworkBindingError("not_enrolled", "This daemon isn't enrolled in an account. Run rig network enroll <code> --site <site> first, or connect with --hub and --token-file.", 404);
|
||||
if (all.length > 1) throw new NetworkBindingError("installation_ambiguous", "This daemon is enrolled at more than one site; connect with --hub and --token-file.", 409);
|
||||
return all[0]!;
|
||||
}
|
||||
|
||||
/** Connect a seat as a participant name: list it with the account, then bind to the account's MCP front with the new
|
||||
* bearer. The seat is checked first, so a refusal here sends nothing. A name the account keeps without a bearer had
|
||||
* its answer lost earlier: it's dropped and listed again for a fresh one. */
|
||||
async connect(bindings: NetworkBindingStore, input: { session: unknown; name: unknown }): Promise<NetworkBinding> {
|
||||
const installation = this.only();
|
||||
bindings.freeSeat(input.session);
|
||||
const name = typeof input.name === "string" ? input.name.trim() : "";
|
||||
if (!NAME.test(name)) throw new NetworkBindingError("invalid_name", `'${name}' isn't a participant name: lowercase letters, digits and inner dashes, up to 32.`);
|
||||
const until = this.deadline();
|
||||
return inListTurn(installation.id, async () => {
|
||||
// In this installation's turn: checked again, with the enrollment as it is now (a replace may have run while this
|
||||
// waited), and every list sent reads the names to keep afresh.
|
||||
const current = this.get(installation.id);
|
||||
bindings.freeSeat(input.session);
|
||||
if (this.listedNames(bindings, current).includes(name)) throw new NetworkBindingError("participant_already_connected", `${name} is already connected here.`, 409);
|
||||
const others = () => this.listedNames(bindings, current).filter((n) => n !== name);
|
||||
let answer = await this.setParticipants(bindings, current, () => [...others(), name], until);
|
||||
let bearer = answer.participants.find((p) => p.name === name)?.bearer;
|
||||
if (!bearer) {
|
||||
await this.setParticipants(bindings, current, others, until);
|
||||
answer = await this.setParticipants(bindings, current, () => [...others(), name], until);
|
||||
bearer = answer.participants.find((p) => p.name === name)?.bearer;
|
||||
if (!bearer) throw new NetworkBindingError("account_unexpected", `The account listed ${name} without a bearer, twice.`, 502);
|
||||
}
|
||||
try {
|
||||
return bindings.create({ session: input.session, hubUrl: answer.mcp, hubSeat: `${name}@${answer.rig}`, token: bearer,
|
||||
allowHttp: current.allowHttp, installation: { id: current.id, name, account: current.installationId } });
|
||||
} catch (error) {
|
||||
// Best effort: nothing here holds that bearer, so the account is asked to stop listing the name. If that fails,
|
||||
// the error says so, whatever failed here; the account may still list it.
|
||||
let unconfirmed = "";
|
||||
try {
|
||||
await this.setParticipants(bindings, current, others, until);
|
||||
} catch (drop) {
|
||||
unconfirmed = ` OpenRig couldn't confirm the account dropped ${name} again (${(drop as Error).message}); it may still list it.`;
|
||||
}
|
||||
if (!unconfirmed) throw error;
|
||||
if (error instanceof NetworkBindingError) throw new NetworkBindingError(error.code, `${error.message}${unconfirmed}`, error.status);
|
||||
throw new NetworkBindingError("binding_write_failed", `The binding couldn't be saved here (${(error as Error).message.slice(0, 200)}).${unconfirmed}`, 500);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** After a binding made through the account ended here, the account stops listing its name, which revokes it there.
|
||||
* Only an answered list that no longer has the name marks it removed; a failed or lost one leaves it unconfirmed. */
|
||||
async removeAtAccount(bindings: NetworkBindingStore, binding: NetworkBinding): Promise<NetworkBinding> {
|
||||
const installation = this.list().find((i) => i.id === binding.installationId);
|
||||
if (!installation || accountRemoval(binding, installation.installationId) !== "unconfirmed") return binding;
|
||||
const until = this.deadline();
|
||||
return inListTurn(installation.id, async () => {
|
||||
// A replace while this waited moved the enrollment to another account installation, which can't drop this name.
|
||||
const current = this.get(installation.id);
|
||||
if (accountRemoval(binding, current.installationId) !== "unconfirmed") return bindings.get(binding.id)!;
|
||||
try {
|
||||
await this.setParticipants(bindings, current, () => this.listedNames(bindings, current), until);
|
||||
} catch (error) {
|
||||
throw new NetworkBindingError("account_update_unconfirmed",
|
||||
`${binding.id} is ${binding.state} here, but OpenRig couldn't confirm that the account dropped ${binding.participantName} (${(error as Error).message}). Run \`rig network disconnect ${binding.id}\` again.`, 502);
|
||||
}
|
||||
return bindings.get(binding.id)!;
|
||||
});
|
||||
}
|
||||
|
||||
/** `accountRemoval` against this binding's enrollment as it is now. */
|
||||
removalOf(binding: NetworkBinding): ReturnType<typeof accountRemoval> {
|
||||
const now = binding.installationId ? this.list().find((i) => i.id === binding.installationId) : undefined;
|
||||
return accountRemoval(binding, now?.installationId);
|
||||
}
|
||||
|
||||
private get(id: string): NetworkInstallation {
|
||||
return project(this.db.prepare("SELECT * FROM network_installations WHERE id = ?").get(id) as Row);
|
||||
}
|
||||
|
||||
private listedNames(bindings: NetworkBindingStore, installation: NetworkInstallation): string[] {
|
||||
return bindings.active().filter((b) => b.installationId === installation.id && b.participantName).map((b) => b.participantName!);
|
||||
}
|
||||
|
||||
private instance(): string {
|
||||
const instance = this.opts.selfHostId();
|
||||
if (!instance) throw new NetworkBindingError("instance_unknown", "This daemon hasn't resolved its host id yet. Try again once it's fully up.", 503);
|
||||
return instance;
|
||||
}
|
||||
|
||||
private deadline(): number {
|
||||
return Date.now() + (this.opts.timeoutMs ?? ACCOUNT_DEADLINE_MS);
|
||||
}
|
||||
|
||||
private async setParticipants(bindings: NetworkBindingStore, installation: NetworkInstallation, names: () => string[], until: number): Promise<Identities> {
|
||||
const instance = this.instance();
|
||||
const repair = `Get a new code from the account page and run rig network enroll <code> --site ${installation.site} --replace.`;
|
||||
const key = readPrivateToken(networkInstallationKeyFile(this.home, installation.id),
|
||||
(file) => `The installation key for ${installation.id} isn't at ${file}. ${repair}`);
|
||||
const put = () => this.call(installation.site, "PUT", "/api/connector/identities", key, { instance, participants: names() }, until);
|
||||
let answer = await put();
|
||||
// The account couldn't apply a drop while something else changed this installation: the list is read and sent once more.
|
||||
if (answer.status === 409 && errorCode(answer.body) === "drop_failed") answer = await put();
|
||||
if (answer.status !== 200) {
|
||||
const error = errorCode(answer.body);
|
||||
if (answer.status === 409 && error === "drop_failed") {
|
||||
throw new NetworkBindingError("account_busy", "The account was changing this installation's participants at the same time. Try again.", 503);
|
||||
}
|
||||
if (answer.status === 409) throw new NetworkBindingError(error ?? "name_taken", "Another connection to the account already uses that name. Choose another.", 409);
|
||||
if (answer.status === 401) throw new NetworkBindingError(error ?? "not_enrolled", `The account doesn't recognize this installation any more. ${repair}`, 401);
|
||||
if (answer.status === 400) throw new NetworkBindingError(error ?? "bad_names", "The account refused the participant names.", 400);
|
||||
throw new NetworkBindingError("account_unavailable", `The account couldn't answer (${answer.status}). Try again.`, 503);
|
||||
}
|
||||
const body = (answer.body ?? {}) as Partial<Identities>;
|
||||
if (!Array.isArray(body.participants) || typeof body.mcp !== "string" || typeof body.rig !== "string") {
|
||||
throw new NetworkBindingError("account_unexpected", "The account's answer had no participants, MCP front or rig.", 502);
|
||||
}
|
||||
// The answer is what this account installation now lists: a binding that ended while listed under it, whose name
|
||||
// isn't in the answer, is confirmed dropped. A name listed under an earlier installation is never confirmed here.
|
||||
const listed = new Set(body.participants.map((p) => p.name));
|
||||
for (const ended of bindings.list()) {
|
||||
if (ended.installationId === installation.id && ended.participantName && !listed.has(ended.participantName)
|
||||
&& accountRemoval(ended, installation.installationId) === "unconfirmed") {
|
||||
bindings.markAccountRemoved(ended.id);
|
||||
}
|
||||
}
|
||||
return body as Identities;
|
||||
}
|
||||
|
||||
private async call(site: string, method: string, path: string, key: string | null, payload: unknown, until: number): Promise<{ status: number; body: unknown }> {
|
||||
const left = until - Date.now();
|
||||
if (left <= 0) throw new NotSent("account_unavailable", `The account at ${site} wasn't reached in time: this command's time with the account ran out.`, 503);
|
||||
let res;
|
||||
try {
|
||||
// The key goes only to the site the person enrolled at: a redirect isn't followed with it, and a stalled call ends.
|
||||
res = await this.fetch(new URL(path, site).toString(), {
|
||||
method,
|
||||
headers: { "Content-Type": "application/json", ...(key ? { Authorization: `Bearer ${key}` } : {}) },
|
||||
body: JSON.stringify(payload),
|
||||
redirect: "error",
|
||||
signal: AbortSignal.timeout(left),
|
||||
});
|
||||
} catch (error) {
|
||||
const cause = (error as { cause?: { message?: unknown } }).cause?.message;
|
||||
if (typeof cause === "string" && /redirect/i.test(cause)) {
|
||||
throw new NetworkBindingError("account_redirected", `The account at ${site} answered with a redirect, which isn't followed with the installation key. Use the site's own URL.`, 502);
|
||||
}
|
||||
throw new NetworkBindingError("account_unavailable", `The account at ${site} couldn't be reached: ${(error as Error).message.slice(0, 200)}.`, 503);
|
||||
}
|
||||
let body: unknown = null;
|
||||
try { body = await res.json(); } catch { /* no JSON body */ }
|
||||
return { status: res.status, body };
|
||||
}
|
||||
}
|
||||
|
||||
function errorCode(body: unknown): string | null {
|
||||
const error = (body as { error?: unknown } | null)?.error;
|
||||
return typeof error === "string" && /^[a-z][a-z0-9_]*$/.test(error) ? error : null;
|
||||
}
|
||||
|
||||
/** The account's site: https, or plain http on the person's choice, with no credential in it. */
|
||||
function validSite(raw: unknown, allowHttp: boolean): string {
|
||||
if (typeof raw !== "string" || !raw.trim()) throw new NetworkBindingError("invalid_site", "Give the account's site URL with --site.");
|
||||
let url: URL;
|
||||
try { url = new URL(raw.trim()); } catch { throw new NetworkBindingError("invalid_site", `'${raw}' isn't a URL.`); }
|
||||
if (url.protocol !== "https:" && url.protocol !== "http:") throw new NetworkBindingError("invalid_site", "The site must use https (or http).");
|
||||
if (url.username || url.password) throw new NetworkBindingError("invalid_site", "The site URL can't hold a credential.");
|
||||
if (url.protocol === "http:" && !allowHttp) {
|
||||
throw new NetworkBindingError("http_not_allowed", `${url.origin} is plain http, so the installation key and every participant's token would cross the network unencrypted, where they can be read. Use the site's https URL, or enroll again with --allow-http to accept that.`);
|
||||
}
|
||||
return url.origin + "/";
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import { detectConflicts } from "./conflict-detector.js";
|
||||
import { applyPolicy } from "./install-policy.js";
|
||||
|
||||
export type PackageInstallOutcome =
|
||||
| { ok: true; installId: string; packageId: string; applied: number; deferred: number; notes?: string[] }
|
||||
| { ok: true; installId: string; packageId: string; applied: number; deferred: number }
|
||||
| { ok: false; code: "conflict_blocked"; message: string }
|
||||
| { ok: false; code: "policy_rejected"; message: string }
|
||||
| { ok: false; code: "manifest_hash_mismatch"; message: string }
|
||||
@@ -101,7 +101,7 @@ export class PackageInstallService {
|
||||
}
|
||||
|
||||
// Verify
|
||||
const verification = this.installVerifier.verify(result.installId, { skipped: result.notes?.length ?? 0 });
|
||||
const verification = this.installVerifier.verify(result.installId);
|
||||
if (!verification.passed) {
|
||||
return { ok: false, code: "verification_failed", message: "Post-apply verification failed" };
|
||||
}
|
||||
@@ -112,7 +112,6 @@ export class PackageInstallService {
|
||||
packageId: pkg.id,
|
||||
applied: result.applied.length,
|
||||
deferred: result.deferred.length,
|
||||
...(result.notes?.length ? { notes: result.notes } : {}),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -21,17 +21,12 @@
|
||||
// per founder authorization 2026-05-10 (LICENSE only); 404 is the
|
||||
// expected normal-state response until a separate publish authorization.
|
||||
|
||||
import fs from "node:fs";
|
||||
import nodePath from "node:path";
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
|
||||
export interface PluginVendorFs {
|
||||
readFile(path: string): string;
|
||||
writeFile(path: string, content: string): void;
|
||||
/** A file's raw bytes. With `writeBytes`, a plugin's files are copied and compared byte for byte, so a binary file
|
||||
* (an icon) arrives intact. Optional: without them files are read and written as text. */
|
||||
readBytes?(path: string): Uint8Array;
|
||||
writeBytes?(path: string, content: Uint8Array): void;
|
||||
/** Copy raw bytes, failing if the recovery path already exists. */
|
||||
copyFileExclusive(source: string, target: string): void;
|
||||
exists(path: string): boolean;
|
||||
@@ -165,7 +160,7 @@ export class PluginVendorService {
|
||||
const destPath = nodePath.join(targetDir, relPath);
|
||||
if (
|
||||
this.fs.exists(destPath) &&
|
||||
hashContent(this.read(destPath)) === hashContent(this.read(srcPath))
|
||||
hashContent(this.fs.readFile(destPath)) === hashContent(this.fs.readFile(srcPath))
|
||||
) {
|
||||
this.preserveMode(srcPath, destPath);
|
||||
}
|
||||
@@ -175,7 +170,7 @@ export class PluginVendorService {
|
||||
}
|
||||
}
|
||||
|
||||
const updates: Array<[string, FileContent]> = this.fs.listFiles(sourceDir).map(rel => [rel, this.read(nodePath.join(sourceDir, rel))]);
|
||||
const updates: Array<[string, string]> = this.fs.listFiles(sourceDir).map(rel => [rel, this.fs.readFile(nodePath.join(sourceDir, rel))]);
|
||||
if (!this.preserveBeforeRefresh(targetDir, updates)) return;
|
||||
this.fs.mkdirp(targetDir);
|
||||
|
||||
@@ -184,34 +179,23 @@ export class PluginVendorService {
|
||||
const destPath = nodePath.join(targetDir, relPath);
|
||||
// Hash-skip: only write if content differs (idempotent re-runs). Reconcile mode even on
|
||||
// skip — a byte-identical copy staged earlier may still carry the wrong (default) mode.
|
||||
if (this.fs.exists(destPath) && hashContent(this.read(destPath)) === hashContent(content)) {
|
||||
if (this.fs.exists(destPath) && hashContent(this.fs.readFile(destPath)) === hashContent(content)) {
|
||||
this.preserveMode(srcPath, destPath);
|
||||
continue;
|
||||
}
|
||||
this.fs.mkdirp(nodePath.dirname(destPath));
|
||||
this.write(destPath, content);
|
||||
this.fs.writeFile(destPath, content);
|
||||
this.preserveMode(srcPath, destPath);
|
||||
}
|
||||
}
|
||||
|
||||
/** A plugin file as the fs reads it exactly: bytes when it can, else text. */
|
||||
private read(path: string): FileContent {
|
||||
return this.fs.readBytes ? this.fs.readBytes(path) : this.fs.readFile(path);
|
||||
}
|
||||
|
||||
private write(path: string, content: FileContent): void {
|
||||
if (typeof content === "string") this.fs.writeFile(path, content);
|
||||
else if (this.fs.writeBytes) this.fs.writeBytes(path, content);
|
||||
else this.fs.writeFile(path, Buffer.from(content).toString("utf-8"));
|
||||
}
|
||||
|
||||
/** Preserve every replaced file before touching a target, including its version authority. */
|
||||
private preserveBeforeRefresh(targetDir: string, updates: Array<[string, FileContent]>): boolean {
|
||||
private preserveBeforeRefresh(targetDir: string, updates: Array<[string, string]>): boolean {
|
||||
const backupDir = nodePath.join(nodePath.dirname(this.userPluginsDir), "backups", "managed-refresh", randomUUID());
|
||||
try {
|
||||
for (const [rel, content] of new Map(updates)) {
|
||||
const dest = nodePath.join(targetDir, rel);
|
||||
if (!this.fs.exists(dest) || hashContent(this.read(dest)) === hashContent(content)) continue;
|
||||
if (!this.fs.exists(dest) || hashContent(this.fs.readFile(dest)) === hashContent(content)) continue;
|
||||
const backup = nodePath.join(backupDir, rel);
|
||||
this.fs.mkdirp(nodePath.dirname(backup));
|
||||
this.fs.copyFileExclusive(dest, backup);
|
||||
@@ -292,7 +276,7 @@ export class PluginVendorService {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
const updates: Array<[string, FileContent]> = files.map(rel => [rel, this.read(nodePath.join(sourceDir, rel))]);
|
||||
const updates: Array<[string, string]> = files.map(rel => [rel, this.fs.readFile(nodePath.join(sourceDir, rel))]);
|
||||
if (!this.preserveBeforeRefresh(targetDir, [...updates, [GLOBAL_VENDOR_VERSION, `${sourceVersion}\n`]])) continue;
|
||||
this.fs.mkdirp(targetDir);
|
||||
for (const [relPath, content] of updates) {
|
||||
@@ -300,13 +284,13 @@ export class PluginVendorService {
|
||||
const destPath = nodePath.join(targetDir, relPath);
|
||||
if (
|
||||
this.fs.exists(destPath) &&
|
||||
hashContent(this.read(destPath)) === hashContent(content)
|
||||
hashContent(this.fs.readFile(destPath)) === hashContent(content)
|
||||
) {
|
||||
this.preserveMode(srcPath, destPath);
|
||||
continue;
|
||||
}
|
||||
this.fs.mkdirp(nodePath.dirname(destPath));
|
||||
this.write(destPath, content);
|
||||
this.fs.writeFile(destPath, content);
|
||||
this.preserveMode(srcPath, destPath);
|
||||
}
|
||||
this.fs.writeFile(versionMarker, `${sourceVersion}\n`);
|
||||
@@ -355,37 +339,6 @@ export class PluginVendorService {
|
||||
}
|
||||
}
|
||||
|
||||
/** A plugin file's content: its bytes, or its text from an fs that only reads text. */
|
||||
type FileContent = string | Uint8Array;
|
||||
|
||||
/** The same digest for a text and for its UTF-8 bytes, so a text compare and a byte compare agree. */
|
||||
function hashContent(content: FileContent): string {
|
||||
function hashContent(content: string): string {
|
||||
return createHash("sha256").update(content).digest("hex");
|
||||
}
|
||||
|
||||
/** The daemon's own filesystem for vendoring: files are copied and compared as bytes. */
|
||||
export function nodePluginVendorFs(): PluginVendorFs {
|
||||
return {
|
||||
readFile: (p) => fs.readFileSync(p, "utf-8"),
|
||||
writeFile: (p, c) => fs.writeFileSync(p, c, "utf-8"),
|
||||
readBytes: (p) => fs.readFileSync(p),
|
||||
writeBytes: (p, c) => fs.writeFileSync(p, c),
|
||||
copyFileExclusive: (src, dest) => fs.copyFileSync(src, dest, fs.constants.COPYFILE_EXCL),
|
||||
exists: (p) => fs.existsSync(p),
|
||||
mkdirp: (p) => { fs.mkdirSync(p, { recursive: true }); },
|
||||
listFiles: (dir) => {
|
||||
const r: string[] = [];
|
||||
/** Collect relative file paths recursively. */
|
||||
const w = (d: string, pre: string) => {
|
||||
for (const e of fs.readdirSync(d, { withFileTypes: true })) {
|
||||
if (e.isDirectory()) w(nodePath.join(d, e.name), nodePath.join(pre, e.name));
|
||||
else r.push(pre ? nodePath.join(pre, e.name) : e.name);
|
||||
}
|
||||
};
|
||||
w(dir, "");
|
||||
return r;
|
||||
},
|
||||
statMode: (p) => fs.statSync(p).mode,
|
||||
chmod: (p, m) => fs.chmodSync(p, m),
|
||||
};
|
||||
}
|
||||
@@ -61,16 +61,15 @@ export interface ClaudeUsageReaderDeps {
|
||||
|
||||
/**
|
||||
* Read each Claude seat's provider_usage cache → provider_statusline rows via
|
||||
* claudeStatuslineSignals. Explicit unknown rows for absent cache (no_statusline_cache_yet),
|
||||
* an unreadable cache (unreadable_cache), and a valid cache with no windows (empty_reading).
|
||||
* Never throws — a malformed cache becomes an unknown row, not a crash.
|
||||
* claudeStatuslineSignals. Explicit unknown rows for absent cache (no_statusline_cache_yet) and
|
||||
* cache-present-but-no/-malformed windows (empty_reading).
|
||||
* Never throws — a malformed cache is treated as present-with-no-windows, not a crash.
|
||||
*/
|
||||
export function collectClaudeStatuslineSignals(deps: ClaudeUsageReaderDeps): ProviderSignal[] {
|
||||
const now = deps.now();
|
||||
const out: ProviderSignal[] = [];
|
||||
for (const seat of deps.listClaudeSeats()) {
|
||||
let cachePresent = false;
|
||||
let cacheUnreadable = false;
|
||||
let reading: ClaudeStatuslineReading | undefined;
|
||||
let staleAfter: string | undefined;
|
||||
let capturedAsOf: string | undefined;
|
||||
@@ -85,8 +84,7 @@ export function collectClaudeStatuslineSignals(deps: ClaudeUsageReaderDeps): Pro
|
||||
staleAfter = parsed.staleAfter;
|
||||
capturedAsOf = typeof parsed.asOf === "string" ? parsed.asOf : undefined;
|
||||
} catch {
|
||||
// Malformed cache → present-but-unreadable (its own unknown), never a throw.
|
||||
cacheUnreadable = true;
|
||||
// Malformed cache → present-with-no-windows (empty_reading unknown), never a throw.
|
||||
reading = undefined;
|
||||
}
|
||||
}
|
||||
@@ -94,7 +92,6 @@ export function collectClaudeStatuslineSignals(deps: ClaudeUsageReaderDeps): Pro
|
||||
...claudeStatuslineSignals({
|
||||
seatSession: seat.seatSession,
|
||||
cachePresent,
|
||||
cacheUnreadable,
|
||||
reading,
|
||||
// Signal asOf = when the reading was captured (the cache's asOf) when present+valid; else now.
|
||||
asOf: cachePresent && capturedAsOf ? capturedAsOf : now,
|
||||
@@ -112,7 +109,6 @@ export function collectClaudeSignalsFromProviderUsageDirectory(
|
||||
legacyDirectory?: string,
|
||||
): ProviderSignal[] {
|
||||
const seats = new Map<string, ClaudeSeatRef>();
|
||||
const unreadableFiles = new Set<string>();
|
||||
for (const candidateDirectory of [directory, legacyDirectory]) {
|
||||
if (!candidateDirectory) continue;
|
||||
try {
|
||||
@@ -123,16 +119,10 @@ export function collectClaudeSignalsFromProviderUsageDirectory(
|
||||
if (typeof parsed.seatSession === "string") {
|
||||
seats.set(parsed.seatSession, { seatSession: parsed.seatSession });
|
||||
}
|
||||
} catch {
|
||||
// A cache file that exists but cannot be read or parsed still names its seat in the
|
||||
// filename (telemetrySidecarFilename sanitizes the same seat name). Keep it in the
|
||||
// seat list so the reader reports an unreadable cache instead of the file vanishing.
|
||||
unreadableFiles.add(file.slice(0, -".json".length));
|
||||
}
|
||||
} catch { /* malformed cache cannot create a usable cache signal */ }
|
||||
}
|
||||
} catch { /* absent cache directory */ }
|
||||
}
|
||||
for (const seatSession of unreadableFiles) seats.set(seatSession, { seatSession });
|
||||
|
||||
const readFrom = (candidateDirectory: string, seatSession: string): string | null => {
|
||||
try {
|
||||
|
||||
@@ -19,8 +19,7 @@ export interface CodexLimitBanner {
|
||||
evidence: string;
|
||||
}
|
||||
|
||||
// Codex prints a typographic apostrophe (U+2019); older builds use the straight one.
|
||||
const BANNER_LINE_RE = /^\s*■ You['’]ve hit your usage limit\b/u;
|
||||
const BANNER_LINE_RE = /^\s*■ You've hit your usage limit\b/;
|
||||
const RESET_RE = /try again at ([^.!\n]+)/i;
|
||||
|
||||
// Fenced code blocks: banner-shaped text an agent printed is quoted content,
|
||||
|
||||
@@ -131,8 +131,6 @@ export interface ClaudeSignalInput {
|
||||
seatSession: string;
|
||||
/** false = the statusline cache has not been written yet (before the first API response). */
|
||||
cachePresent: boolean;
|
||||
/** True when a cache file exists but cannot be read or parsed. Distinct from an empty reading. */
|
||||
cacheUnreadable?: boolean;
|
||||
/** Present only when the cache exists and carried a rate_limits object. */
|
||||
reading?: ClaudeStatuslineReading;
|
||||
asOf: string;
|
||||
@@ -142,7 +140,6 @@ export interface ClaudeSignalInput {
|
||||
export const CLAUDE_UNKNOWN_REASON = {
|
||||
no_statusline_cache_yet: "claude_no_statusline_cache_yet",
|
||||
empty_reading: "claude_statusline_cache_had_no_windows",
|
||||
unreadable_cache: "claude_statusline_cache_unreadable",
|
||||
} as const;
|
||||
|
||||
function claudeUnknownSignal(
|
||||
@@ -168,22 +165,17 @@ function claudeUnknownSignal(
|
||||
/**
|
||||
* Normalize a Claude statusline `rate_limits` reading into `signals[]` rows.
|
||||
* - seat before first response (no cache) → explicit unknown row.
|
||||
* - a cache that exists but cannot be read/parsed → its own unknown reason (broken, not empty).
|
||||
* - seat with subscription windows → one `provider_statusline` row per window (five_hour, and
|
||||
* seven_day normalized to "weekly"); a genuine 0 survives as 0.
|
||||
* - subscription with cache but no windows → explicit unknown row (never fabricated zeros).
|
||||
*/
|
||||
export function claudeStatuslineSignals(input: ClaudeSignalInput): ProviderSignal[] {
|
||||
const { seatSession, cachePresent, cacheUnreadable, reading, asOf, staleAfter } = input;
|
||||
const { seatSession, cachePresent, reading, asOf, staleAfter } = input;
|
||||
|
||||
if (!cachePresent) {
|
||||
return [claudeUnknownSignal(seatSession, asOf, CLAUDE_UNKNOWN_REASON.no_statusline_cache_yet, staleAfter)];
|
||||
}
|
||||
|
||||
if (cacheUnreadable) {
|
||||
return [claudeUnknownSignal(seatSession, asOf, CLAUDE_UNKNOWN_REASON.unreadable_cache, staleAfter)];
|
||||
}
|
||||
|
||||
const windows: Array<["five_hour" | "weekly", ClaudeStatuslineWindow | undefined]> = [
|
||||
["five_hour", reading?.five_hour],
|
||||
["weekly", reading?.seven_day],
|
||||
|
||||
@@ -85,8 +85,7 @@ import { parseReplyToChoice, parseReplyToThreadMissing, formatReplyToChoice, des
|
||||
import { classifyDestination, type DestinationClass } from "./gateway/destination-resolver.js";
|
||||
import { join } from "node:path";
|
||||
import { SettingsStore } from "./user-settings/settings-store.js";
|
||||
import { hostedMembersAt, hostedWakeFor, isHostedMemberAt, type RosterMember, type RosterWake } from "./rosters.js";
|
||||
import { memberCheckInStatus, type MemberCheckIn } from "./connection-contract.js";
|
||||
import { hostedWakeFor, isHostedMemberAt, type RosterWake } from "./rosters.js";
|
||||
import { hostedRingMayHaveArrived, ringHostedWake } from "./hosted-wake.js";
|
||||
import { effectiveGateRoles } from "./gate-predicate.js";
|
||||
import {
|
||||
@@ -652,16 +651,6 @@ function defaultIsHostedMember(destination: string): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
/** The hosted members the authored rosters list on this host. Empty when the rosters or this host's id can't be read. */
|
||||
function defaultHostedMembers(): RosterMember[] {
|
||||
try {
|
||||
const root = new SettingsStore().resolveOne("workspace.root").value;
|
||||
return typeof root === "string" && root ? hostedMembersAt(join(root, "rosters"), getSelfHostId()) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** A session's latest read of its own pending inbox: when it was seen, never seen, or unknown (the record can't be read). */
|
||||
export type InboxCheckIn = { state: "seen"; at: string } | { state: "never" } | { state: "unknown" };
|
||||
|
||||
@@ -1109,7 +1098,6 @@ export class QueueRepository {
|
||||
private readonly hostedWakeFn: (destination: string) => RosterWake | null;
|
||||
/** Whether the rosters list an address as a hosted member, trigger or not. */
|
||||
private readonly hostedMemberFn: (destination: string) => boolean;
|
||||
private readonly hostedMembersFn: () => RosterMember[];
|
||||
private readonly permissionDecider?: (ask: QueueItem, requester: string) => { session: string; describe?: string } | null;
|
||||
private readonly hostedWakeFetch: typeof fetch | undefined;
|
||||
/** When this process last wrote each session's inbox check-in, so a seat that polls often writes at most once a minute. */
|
||||
@@ -1163,7 +1151,6 @@ export class QueueRepository {
|
||||
sharedChannels?: () => ReadonlyArray<{ address: string }>;
|
||||
hostedWake?: (destination: string) => RosterWake | null;
|
||||
isHostedMember?: (destination: string) => boolean;
|
||||
hostedMembers?: () => RosterMember[];
|
||||
hostedWakeFetch?: typeof fetch;
|
||||
/** Who decides a runner's permission ask after its person answers, while the runner waits (startup wires the
|
||||
* receiving-agent order, `permissionDeciderFor`). Absent, or null for an ask: the ask closes with the answer, as
|
||||
@@ -1187,7 +1174,6 @@ export class QueueRepository {
|
||||
this.sharedChannelsFn = opts?.sharedChannels ?? (() => sharedChannelAddresses());
|
||||
this.hostedWakeFn = opts?.hostedWake ?? defaultHostedWake;
|
||||
this.hostedMemberFn = opts?.isHostedMember ?? defaultIsHostedMember;
|
||||
this.hostedMembersFn = opts?.hostedMembers ?? defaultHostedMembers;
|
||||
this.permissionDecider = opts?.permissionDecider;
|
||||
this.hostedWakeFetch = opts?.hostedWakeFetch;
|
||||
this.hasTargetRepoColumn = detectQueueColumn(db, "target_repo");
|
||||
@@ -5285,24 +5271,6 @@ export class QueueRepository {
|
||||
}
|
||||
}
|
||||
|
||||
/** Each hosted member on this host with its check-in status: its stated cadence against its own inbox reads. A status
|
||||
* only; nothing here changes how work reaches it. */
|
||||
hostedMemberCheckIns(now: Date = new Date()): Array<{ seat: string; rung: boolean } & MemberCheckIn> {
|
||||
return this.hostedMembersFn().map((m) => ({ seat: m.seat, rung: m.wake !== undefined,
|
||||
...memberCheckInStatus({ checkInMinutes: m.check_in_minutes, onRequest: m.check_in_on_request, cadenceProblem: m.check_in_problem,
|
||||
checkIn: this.lastInboxCheckIn(m.seat), now }) }));
|
||||
}
|
||||
|
||||
/** One member's check-in status, as `hostedMemberCheckIns` gives it. A seat the rosters don't list here (or can't be
|
||||
* read for) states no cadence, so it reads by its check-ins alone. */
|
||||
hostedMemberCheckIn(seat: string, now: Date = new Date()): MemberCheckIn {
|
||||
let member: RosterMember | undefined;
|
||||
try { member = this.hostedMembersFn().find((m) => this.isSameSession(m.seat, seat)); } catch { member = undefined; }
|
||||
// The roster's spelling of the seat, as the list read uses, so both read the same check-ins.
|
||||
return memberCheckInStatus({ checkInMinutes: member?.check_in_minutes, onRequest: member?.check_in_on_request,
|
||||
cadenceProblem: member?.check_in_problem, checkIn: this.lastInboxCheckIn(member?.seat ?? seat), now });
|
||||
}
|
||||
|
||||
/** This session's latest recorded check-in under any of its names. */
|
||||
lastInboxCheckIn(session: string): InboxCheckIn {
|
||||
try {
|
||||
|
||||
@@ -30,11 +30,11 @@ import { resolveOperatorSeat, resolveRecoveryAgentSeat, resolveSessionNodeId } f
|
||||
import { nodeExecutorStoppedReason } from "./node-inventory.js";
|
||||
import { isBoundTerminalSeat } from "./terminal-seat.js";
|
||||
import type Database from "better-sqlite3";
|
||||
import { createLinkedSuccessorLookup, deriveCrossHostSuccessorId, hasLinkedSuccessor, matchesRecordedHandoffTarget, QueueRepositoryError, type QueueItem, type QueueRepository } from "./queue-repository.js";
|
||||
import { createLinkedSuccessorLookup, deriveCrossHostSuccessorId, hasLinkedSuccessor, matchesRecordedHandoffTarget, QueueRepositoryError, type InboxCheckIn, type QueueItem, type QueueRepository } from "./queue-repository.js";
|
||||
import { stalledPickupFinding } from "./queue-pickup.js";
|
||||
import { SettingsStore } from "./user-settings/settings-store.js";
|
||||
import { loadHostRegistry } from "./hosts/hosts-registry-reader.js";
|
||||
import { describeCheckIn, readWakeOutcome, type MemberCheckIn } from "./connection-contract.js";
|
||||
import { readWakeOutcome } from "./connection-contract.js";
|
||||
|
||||
export const STUCK_SWEEP_INTERVAL_KEY = "queue.stuck_sweep_interval_seconds";
|
||||
export const DEFAULT_STUCK_SWEEP_INTERVAL_SECONDS = 300;
|
||||
@@ -291,9 +291,7 @@ function custodyVerifiedTargets(db: Database.Database, qitemId: string, targets:
|
||||
for (const { transition_note: note } of notes) {
|
||||
if (!note) continue;
|
||||
for (const token of note.slice(CUSTODY_VERIFIED_PREFIX.length).trim().split(/\s+/)) {
|
||||
const named = [token, ...token.split(",").map((t) => t.trim())]
|
||||
.filter(Boolean)
|
||||
.map((t) => targets.includes(t) ? t : t.replace(/[.,;:)]$/, ""));
|
||||
const named = [token, ...token.split(",").map((t) => t.trim())].filter(Boolean);
|
||||
if (!named.some((t) => targets.includes(t))) continue;
|
||||
for (const t of named) verified.add(t);
|
||||
break;
|
||||
@@ -621,36 +619,20 @@ function unclaimedBacklogText(seat: string, rows: BacklogRow[], thresholdMinutes
|
||||
* and what to do. Its work's age runs from each row's own time, which a check-in never resets. Built from the rows' fixed
|
||||
* facts and the member's check-in state, never a current check-in's exact time, so a pass rewrites it only when the rows
|
||||
* or that state change. */
|
||||
function polledBacklogText(member: string, rows: BacklogRow[], thresholdMinutes: number, checkIn: MemberCheckIn,
|
||||
function polledBacklogText(member: string, rows: BacklogRow[], thresholdMinutes: number, checkIn: InboxCheckIn,
|
||||
now: Date): { summary: string; body: string } {
|
||||
const { sorted, listed, count } = backlogListing(rows);
|
||||
// The member's shared check-in status, in the same words `rig roster` uses (`describeCheckIn`). Only the words follow
|
||||
// it; when this notice is filed doesn't. Where that line would carry the time of a check-in that keeps moving (checking
|
||||
// in, on request, unknown), the notice says instead whether the member read its inbox within the threshold, so a
|
||||
// check-in rewrites the notice only when it changes that, never its time alone. An overdue or never-seen member's line
|
||||
// is fixed until it checks in, so it keeps its evidence.
|
||||
const at = checkIn.lastCheckInAt;
|
||||
const lately = at !== null && now.getTime() - Date.parse(at) <= thresholdMinutes * 60_000;
|
||||
const recency = at === null ? "" : lately ? `; it read its own inbox within the last ${thresholdMinutes} min` : `; no check-in since ${at}`;
|
||||
const recent = checkIn.status === "checking-in" || ((checkIn.status === "on-request" || checkIn.status === "unknown") && lately);
|
||||
const checkIns = ((): string => {
|
||||
switch (checkIn.status) {
|
||||
case "checking-in": return `checking in: its last check-in is within twice its expected ${checkIn.expectedMinutes} min`;
|
||||
case "on-request": return `on request: it checks its queue when a person asks it${recency}`;
|
||||
case "unknown": return `unknown (${checkIn.reason ?? "its check-in status couldn't be read"})${recency}`;
|
||||
case "check-in-overdue":
|
||||
case "no-check-in-yet":
|
||||
case "disconnected":
|
||||
return describeCheckIn(checkIn);
|
||||
}
|
||||
})();
|
||||
const recent = checkIn.state === "seen" && now.getTime() - Date.parse(checkIn.at) <= thresholdMinutes * 60_000;
|
||||
const checkIns = recent
|
||||
? `checking in: it read its own inbox within the last ${thresholdMinutes} min`
|
||||
: checkIn.state === "seen"
|
||||
? `no check-in observed since ${checkIn.at}`
|
||||
: checkIn.state === "never" ? "no check-in observed yet" : "unknown: its check-ins couldn't be read";
|
||||
const why = recent
|
||||
? "it's reading its own inbox, but this work is still unclaimed. Check whether it can do this work, or route it to another seat"
|
||||
: checkIn.status === "check-in-overdue"
|
||||
? "nobody has claimed these rows, and it hasn't checked in as often as its setup expects. Ask its owner to check its schedule or a pending approval, or route the work to another seat"
|
||||
: checkIn.status === "unknown" && at === null
|
||||
? "nobody has claimed these rows, and whether it's reading its queue isn't known. Ask its owner, or route the work to another seat"
|
||||
: "nobody has claimed these rows, and no read of its own inbox has been observed in that time. Ask its owner to check it's running, or route the work to another seat";
|
||||
? "it's checking in, but this work is still unclaimed. Check whether it can do this work, or route it to another seat"
|
||||
: checkIn.state === "unknown"
|
||||
? "nobody has claimed these rows, and whether it's reading its queue isn't known. Ask its owner, or route the work to another seat"
|
||||
: "nobody has claimed these rows, and no read of its own inbox has been observed in that time. Ask its owner to check it's running, or route the work to another seat";
|
||||
return {
|
||||
summary: `Stuck sweep: unclaimed-backlog on ${member} (${count})`,
|
||||
body:
|
||||
@@ -1511,7 +1493,7 @@ export async function runStuckSweep(deps: StuckSweepDeps): Promise<StuckSweepRes
|
||||
// A polling member's notice says whether it's checking in; an agent seat's names the route to add instead.
|
||||
const polled = rows.length > 0 ? rows.some(polledUnclaimed) : pollsForWork(seat);
|
||||
const backlogText = (standsFor: BacklogRow[]) => polled
|
||||
? polledBacklogText(seat, standsFor, ageMinutes, deps.queueRepo.hostedMemberCheckIn(seat, now), now)
|
||||
? polledBacklogText(seat, standsFor, ageMinutes, deps.queueRepo.lastInboxCheckIn(seat), now)
|
||||
: unclaimedBacklogText(seat, standsFor, ageMinutes, node);
|
||||
// A polling member's notice goes where its rows' findings went: the person, else their creator when they share one,
|
||||
// else the operator agent. With none, it stays with the member, which reads it when it next checks.
|
||||
|
||||
@@ -92,7 +92,7 @@ export class Reconciler {
|
||||
// A session this server doesn't show isn't gone while its seat's recorded process still runs: the daemon is
|
||||
// looking at a different tmux server. Detaching it would have the kernel relaunch duplicates and supersede the
|
||||
// live seats. Without that evidence (after a reboot nothing recorded is running), it detaches as before.
|
||||
const elsewhere = await recordedProcessStillRunning(this.db, session, processes);
|
||||
const elsewhere = await this.recordedProcessStillRunning(session, processes);
|
||||
if (elsewhere) {
|
||||
unseen.push({ sessionId: session.id, sessionName: session.sessionName, ...elsewhere });
|
||||
continue;
|
||||
@@ -123,32 +123,31 @@ export class Reconciler {
|
||||
|
||||
return { checked, detached, detachedSessionIds, unseen, errors };
|
||||
}
|
||||
}
|
||||
|
||||
/** The seat's last observed process, as a conservative hint that it still runs, not proof of identity: the last pid
|
||||
* evidence recorded for this very session (kept with its own time, so a later poll that couldn't see the seat doesn't
|
||||
* erase it), and a process with that pid whose start isn't observably later than that observation. Start times are
|
||||
* whole seconds, so a pid reused within the observation's second still passes. An unreadable table or record is no
|
||||
* evidence. */
|
||||
export async function recordedProcessStillRunning(
|
||||
db: Database.Database,
|
||||
session: { nodeId: string; sessionName: string },
|
||||
processes: () => Promise<ProcessTableRow[] | null>,
|
||||
): Promise<{ pid: number; parent: { pid: number; command: string } | null } | null> {
|
||||
const evidence = new SeatIdentityStore(db).getProcessEvidence(session.nodeId);
|
||||
if (!evidence || evidence.sessionName !== session.sessionName) return null;
|
||||
const observedAt = Date.parse(evidence.observedAt) / 1000;
|
||||
const pids = [evidence.panePid, evidence.pid]
|
||||
.filter((pid): pid is number => typeof pid === "number" && pid > 0);
|
||||
if (!pids.length || Number.isNaN(observedAt)) return null;
|
||||
const rows = await processes();
|
||||
if (!rows) return null;
|
||||
for (const pid of pids) {
|
||||
const row = rows.find((r) => r.pid === pid);
|
||||
const started = row ? lstartToMinTs(row.startedAt) : undefined;
|
||||
if (!row || started === undefined || started > observedAt) continue;
|
||||
const parent = rows.find((r) => r.pid === row.ppid);
|
||||
return { pid, parent: parent ? { pid: parent.pid, command: parent.command } : null };
|
||||
/** The seat's last observed process, as a conservative hint that it still runs, not proof of identity: the last pid
|
||||
* evidence recorded for this very session (kept with its own time, so a later poll that couldn't see the seat doesn't
|
||||
* erase it), and a process with that pid whose start isn't observably later than that observation. Start times are
|
||||
* whole seconds, so a pid reused within the observation's second still passes. An unreadable table or record is no
|
||||
* evidence. */
|
||||
private async recordedProcessStillRunning(
|
||||
session: { nodeId: string; sessionName: string },
|
||||
processes: () => Promise<ProcessTableRow[] | null>,
|
||||
): Promise<{ pid: number; parent: { pid: number; command: string } | null } | null> {
|
||||
const evidence = new SeatIdentityStore(this.db).getProcessEvidence(session.nodeId);
|
||||
if (!evidence || evidence.sessionName !== session.sessionName) return null;
|
||||
const observedAt = Date.parse(evidence.observedAt) / 1000;
|
||||
const pids = [evidence.panePid, evidence.pid]
|
||||
.filter((pid): pid is number => typeof pid === "number" && pid > 0);
|
||||
if (!pids.length || Number.isNaN(observedAt)) return null;
|
||||
const rows = await processes();
|
||||
if (!rows) return null;
|
||||
for (const pid of pids) {
|
||||
const row = rows.find((r) => r.pid === pid);
|
||||
const started = row ? lstartToMinTs(row.startedAt) : undefined;
|
||||
if (!row || started === undefined || started > observedAt) continue;
|
||||
const parent = rows.find((r) => r.pid === row.ppid);
|
||||
return { pid, parent: parent ? { pid: parent.pid, command: parent.command } : null };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -17,21 +17,25 @@ import nodePath from "node:path";
|
||||
import { lstatSync } from "node:fs";
|
||||
import type Database from "better-sqlite3";
|
||||
import {
|
||||
MANAGED_BLOCK_START,
|
||||
MANAGED_BLOCK_END,
|
||||
DEFAULT_CLAUDE_MANAGED_BLOCK_FILE,
|
||||
findManagedBlocks,
|
||||
mergeManagedBlock,
|
||||
stripManagedBlocks,
|
||||
type ManagedBlockMergeFsOps,
|
||||
isPerSeatRoleGuidance,
|
||||
isPerSeatRoleStartupFile,
|
||||
} from "./managed-blocks.js";
|
||||
import { decideManagedBlock, pruneKeptManagedBlock, recordManagedBlockWrite, type ManagedBlockRecords } from "./managed-block-records.js";
|
||||
import { decideManagedBlock, recordManagedBlockWrite, type ManagedBlockRecords } from "./managed-block-records.js";
|
||||
import { resolveConcreteHint } from "./runtime-adapter.js";
|
||||
import { reanchorBuiltinStartupFile, reanchorShippedProjectionEntry, withContextEntryFloor } from "./builtin-startup-files.js";
|
||||
import type { NodeStartupSnapshot } from "./types.js";
|
||||
import { SEAT_SKILLS_BOOTSTRAP_BLOCK_ID } from "./seat-skill-storage.js";
|
||||
import { guidanceFileIsLink } from "./footprint-choice.js";
|
||||
|
||||
const LEGACY_BLOCK_START = (id: string) => `<!-- BEGIN RIGGED MANAGED BLOCK: ${id} -->`;
|
||||
const LEGACY_BLOCK_END = (id: string) => `<!-- END RIGGED MANAGED BLOCK: ${id} -->`;
|
||||
|
||||
|
||||
export interface GuidanceItem {
|
||||
blockId: string;
|
||||
@@ -93,10 +97,15 @@ export function recordedGuidanceFile(db: Pick<Database.Database, "prepare">, nod
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
/** Whether guidance text holds this id's block as it is written: a BEGIN marker followed by its END, in the current or
|
||||
* legacy form, read as the writer reads it (`findManagedBlocks`). For reporting an installed file. */
|
||||
/** Whether guidance text holds this id's block as it is written: a BEGIN marker followed later by its END, in the
|
||||
* current or legacy form. For reporting an installed file; restore's own `blockState` is unchanged. */
|
||||
export function hasCompleteGuidanceBlock(content: string, blockId: string): boolean {
|
||||
return findManagedBlocks(content, blockId).blocks.length > 0;
|
||||
const ordered = (start: string, end: string): boolean => {
|
||||
const begin = content.indexOf(start);
|
||||
return begin >= 0 && content.indexOf(end, begin + start.length) >= 0;
|
||||
};
|
||||
return ordered(MANAGED_BLOCK_START(blockId), MANAGED_BLOCK_END(blockId))
|
||||
|| ordered(LEGACY_BLOCK_START(blockId), LEGACY_BLOCK_END(blockId));
|
||||
}
|
||||
|
||||
/** The guidance file a seat's managed blocks live in, and the blocks it should hold on restore. */
|
||||
@@ -190,13 +199,14 @@ export function applySeatSkillsLineRule(guidance: SeatGuidance, answeredYes: boo
|
||||
|
||||
type BlockState = "complete" | "absent" | "incomplete";
|
||||
|
||||
/** A block counts only with both its markers (current or legacy form), read as the writer reads them. A lone marker is
|
||||
* incomplete: writing next to it is unsafe, because cleanup strips from a BEGIN to the next END, and it would add a
|
||||
* second copy beside one that can't be read. */
|
||||
/** A block counts only with both its markers (current or legacy form). A lone marker is incomplete:
|
||||
* writing next to it is unsafe, because cleanup strips from a BEGIN to the next END. */
|
||||
function blockState(content: string, blockId: string): BlockState {
|
||||
const found = findManagedBlocks(content, blockId);
|
||||
if (found.blocks.length > 0) return "complete";
|
||||
return found.unpaired > 0 ? "incomplete" : "absent";
|
||||
const current = [content.includes(MANAGED_BLOCK_START(blockId)), content.includes(MANAGED_BLOCK_END(blockId))];
|
||||
const legacy = [content.includes(LEGACY_BLOCK_START(blockId)), content.includes(LEGACY_BLOCK_END(blockId))];
|
||||
if ((current[0] && current[1]) || (legacy[0] && legacy[1])) return "complete";
|
||||
if (current[0] || current[1] || legacy[0] || legacy[1]) return "incomplete";
|
||||
return "absent";
|
||||
}
|
||||
|
||||
function blockStates(guidance: SeatGuidance, fs: Pick<ManagedBlockMergeFsOps, "exists" | "readFile">): Map<string, BlockState> {
|
||||
@@ -218,13 +228,11 @@ export interface GuidanceRepair {
|
||||
refreshed: string[];
|
||||
/** #2456: blocks left as they are although they differ from their source, each named with the reason
|
||||
* (`guidance_block_edited`, `guidance_block_may_be_stale` or `guidance_block_record_unreadable`). The seat runs them as
|
||||
* they are. A block written whose record wasn't saved is named here too (`guidance_block_record_not_saved`), and so are
|
||||
* a kept block's removed repeats (`guidance_block_copies_removed`). */
|
||||
* they are. A block written whose record wasn't saved is named here too (`guidance_block_record_not_saved`). */
|
||||
kept: string[];
|
||||
}
|
||||
|
||||
const KEPT_GUIDANCE_PREFIXES = ["guidance_block_edited: ", "guidance_block_may_be_stale: ", "guidance_block_record_unreadable: ",
|
||||
"guidance_block_markers_damaged: "];
|
||||
const KEPT_GUIDANCE_PREFIXES = ["guidance_block_edited: ", "guidance_block_may_be_stale: ", "guidance_block_record_unreadable: "];
|
||||
|
||||
/** #2456: the one line a seat is told when a managed guidance block was left as it is (edited by hand, or possibly stale),
|
||||
* naming each one, or undefined when none was. */
|
||||
@@ -253,8 +261,7 @@ export function appendIsCleanupSafe(content: string, blocks: Array<{ blockId: st
|
||||
|
||||
/** Put back the missing managed guidance blocks. With `records` (#2456), a block already there that OpenRig wrote is
|
||||
* refreshed from its changed source, and one edited since, or with no record, is kept and named (`decideManagedBlock`).
|
||||
* Without them a block already there keeps its text. Either way a block left in place loses its exact repeats, and
|
||||
* OpenRig's own copies beside an edited one, and rewrapped markers go back to OpenRig's form (`pruneKeptManagedBlock`). Any other text in the file is left exactly as it is,
|
||||
* Without them a block already there is left as it is, as before. Any other text in the file is left exactly as it is,
|
||||
* and nothing is written unless cleanup would later remove exactly what was written. Never throws: a failure becomes a
|
||||
* reported gap. */
|
||||
export function restoreMissingGuidance(guidance: SeatGuidance, fs: ManagedBlockMergeFsOps, records?: ManagedBlockRecords): GuidanceRepair {
|
||||
@@ -272,27 +279,17 @@ export function restoreMissingGuidance(guidance: SeatGuidance, fs: ManagedBlockM
|
||||
for (const item of guidance.items) {
|
||||
const state = states.get(item.blockId);
|
||||
if (state === "complete") {
|
||||
if (item.unresolved) continue;
|
||||
if (!records || item.unresolved) continue;
|
||||
let source: string;
|
||||
try {
|
||||
if (!fs.exists(item.sourcePath)) continue;
|
||||
source = fs.readFile(item.sourcePath);
|
||||
} catch { continue; }
|
||||
// Without records the block's text stays as it is; only its exact repeats and rewrapped markers are tidied.
|
||||
if (!records) {
|
||||
pruneKeptManagedBlock(fs, guidance.targetPath, item.blockId, source, undefined, repair.kept);
|
||||
continue;
|
||||
}
|
||||
// #2456: the same decision every adapter's guidance write makes; an exact resume and a handover keep an unrecorded block.
|
||||
const decision = decideManagedBlock(existing ?? "", item.blockId, source, records, guidance.targetPath, { unrecorded: "keep", sourcePath: item.sourcePath });
|
||||
if (decision.note) repair.kept.push(decision.note);
|
||||
if (decision.write && decision.standing !== "current") {
|
||||
toWrite.push({ blockId: item.blockId, content: source, refresh: true });
|
||||
continue;
|
||||
}
|
||||
if (decision.standing === "current") recordManagedBlockWrite(records, guidance.targetPath, item.blockId, source, repair.kept);
|
||||
// A block left as it is (current, edited or unrecorded) keeps one copy of each distinct text, in OpenRig's markers.
|
||||
pruneKeptManagedBlock(fs, guidance.targetPath, item.blockId, source, records, repair.kept);
|
||||
else if (decision.standing === "current") recordManagedBlockWrite(records, guidance.targetPath, item.blockId, source, repair.kept);
|
||||
else if (decision.write) toWrite.push({ blockId: item.blockId, content: source, refresh: true });
|
||||
continue;
|
||||
}
|
||||
if (state === "incomplete") {
|
||||
@@ -316,9 +313,6 @@ export function restoreMissingGuidance(guidance: SeatGuidance, fs: ManagedBlockM
|
||||
if (toWrite.length === 0) return repair;
|
||||
// A file that doesn't exist yet holds no text to lose. An existing one is written only if the
|
||||
// next rig down would remove exactly what is added.
|
||||
// Read again: removing a kept block's repeats above may have changed the file.
|
||||
try { existing = fs.exists(guidance.targetPath) ? fs.readFile(guidance.targetPath) : null; }
|
||||
catch (err) { repair.gaps.push(`could not read ${guidance.targetPath}: ${(err as Error).message}`); return repair; }
|
||||
if (existing !== null && !appendIsCleanupSafe(existing, toWrite)) {
|
||||
for (const block of toWrite) {
|
||||
repair.gaps.push(`${block.blockId}: not written, because ${guidance.targetPath} has a managed-block marker that the next rig down would strip together with other text, so adding blocks there could cost that text`);
|
||||
@@ -327,7 +321,7 @@ export function restoreMissingGuidance(guidance: SeatGuidance, fs: ManagedBlockM
|
||||
}
|
||||
for (const block of toWrite) {
|
||||
try {
|
||||
if (!mergeManagedBlock(fs, guidance.targetPath, block.blockId, block.content, { warnings: repair.gaps })) continue;
|
||||
mergeManagedBlock(fs, guidance.targetPath, block.blockId, block.content);
|
||||
if (records) recordManagedBlockWrite(records, guidance.targetPath, block.blockId, block.content, repair.kept);
|
||||
(block.refresh ? repair.refreshed : repair.restored).push(block.blockId);
|
||||
} catch (err) {
|
||||
|
||||
@@ -26,14 +26,6 @@ export interface RosterMember extends Reference {
|
||||
hosted?: boolean;
|
||||
/** A hosted member's trigger, rung when a row lands for it. */
|
||||
wake?: RosterWake;
|
||||
/** How often a hosted member is expected to check its queue, in minutes, as its person set it up (an hourly schedule
|
||||
* is 60). Stated, not observed: absent means it checks only when a person prompts it. */
|
||||
check_in_minutes?: number;
|
||||
/** Its person set it to check in when asked, not on a schedule. */
|
||||
check_in_on_request?: true;
|
||||
/** Why an authored `check_in_minutes` can't be used. The roster still loads; the member's check-in status reads
|
||||
* unknown with this reason. */
|
||||
check_in_problem?: string;
|
||||
}
|
||||
export interface Roster {
|
||||
version: 1;
|
||||
@@ -73,19 +65,9 @@ export function parseRoster(value: unknown): Roster {
|
||||
|| (m.hosted !== undefined && typeof m.hosted !== "boolean")) {
|
||||
throw new Error("Each member needs seat, host, capabilities, engagement, use_when and why (optional caveat, and hosted: true or false)");
|
||||
}
|
||||
// A cadence that can't be used never stops the roster loading: the member's check-in status reads unknown instead.
|
||||
const cadence = m.check_in_minutes, onRequest = m.check_in_on_request;
|
||||
const cadenceProblem = cadence === undefined && onRequest === undefined ? null
|
||||
: m.hosted !== true ? "check_in_minutes and check_in_on_request are for a connected assistant (a member marked hosted: true)"
|
||||
: onRequest !== undefined && onRequest !== true ? `check_in_on_request ${JSON.stringify(onRequest)} isn't true`
|
||||
: onRequest === true && cadence !== undefined ? "it states both check_in_minutes and check_in_on_request"
|
||||
: cadence !== undefined && (!Number.isInteger(cadence) || (cadence as number) <= 0) ? `check_in_minutes ${JSON.stringify(cadence)} isn't a positive whole number of minutes`
|
||||
: null;
|
||||
return { seat: m.seat, host: m.host, capabilities: m.capabilities, engagement: m.engagement,
|
||||
use_when: m.use_when as string, why: m.why as string, ...(m.caveat === undefined ? {} : { caveat: m.caveat as string }),
|
||||
...(m.hosted === true ? { hosted: true } : {}), ...(m.wake === undefined ? {} : { wake: parseWake(m.wake) }),
|
||||
...(cadenceProblem ? { check_in_problem: cadenceProblem }
|
||||
: { ...(cadence === undefined ? {} : { check_in_minutes: cadence as number }), ...(onRequest === true ? { check_in_on_request: true as const } : {}) }) };
|
||||
...(m.hosted === true ? { hosted: true } : {}), ...(m.wake === undefined ? {} : { wake: parseWake(m.wake) }) };
|
||||
});
|
||||
return { version: 1, id: value.id as string, name: value.name as string, purpose: value.purpose as string,
|
||||
curator: { seat: value.curator.seat, host: value.curator.host }, updated_at: value.updated_at as string, members };
|
||||
@@ -121,17 +103,6 @@ export function hostedWakeFor(destination: string, folder: string, selfHostId: s
|
||||
return null;
|
||||
}
|
||||
|
||||
/** The hosted members the rosters list on this host, one per seat: where two rosters list the same seat, the first by
|
||||
* file name wins. Empty when this host's id is unknown. */
|
||||
export function hostedMembersAt(folder: string, selfHostId: string | null): RosterMember[] {
|
||||
if (!selfHostId) return [];
|
||||
const bySeat = new Map<string, RosterMember>();
|
||||
for (const roster of readRosters(folder).rosters) {
|
||||
for (const m of roster.members) if (m.hosted === true && m.host === selfHostId && !bySeat.has(m.seat)) bySeat.set(m.seat, m);
|
||||
}
|
||||
return [...bySeat.values()];
|
||||
}
|
||||
|
||||
/** Whether a roster on this host lists this address as a hosted member, with or without a trigger. */
|
||||
export function isHostedMemberAt(destination: string, folder: string, selfHostId: string | null): boolean {
|
||||
if (!selfHostId) return false;
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { inputReadDiagnostic, type InputReadDiagnostic } from "./input-read-diagnostic.js";
|
||||
import { hasOtherSeatHistory, isStoppedSeatPredecessor, resolveCurrentSessionAddress } from "./session-registry.js";
|
||||
import { AsyncLocalStorage } from "node:async_hooks";
|
||||
import { randomUUID } from "node:crypto";
|
||||
@@ -29,7 +28,7 @@ interface Lease {
|
||||
}
|
||||
|
||||
export class DeliveryGuardError extends Error {
|
||||
constructor(readonly code: string, message: string, readonly inputReadFailure?: InputReadDiagnostic) { super(message); }
|
||||
constructor(readonly code: string, message: string) { super(message); }
|
||||
|
||||
// Hono's error protocol also preserves this typed refusal on lifecycle routes.
|
||||
getResponse(): Response {
|
||||
@@ -441,14 +440,14 @@ export class SeatDeliveryGuard {
|
||||
this.checkInput(name);
|
||||
return observed;
|
||||
};
|
||||
const unknown = (reading: ComposerInput | null) => new DeliveryGuardError("draft_input_unknown", "The current input cannot be read. Automatic delivery is held.", inputReadDiagnostic("draft-aware", reading));
|
||||
const unknown = () => new DeliveryGuardError("draft_input_unknown", "The current input cannot be read. Automatic delivery is held.");
|
||||
let input = await read();
|
||||
// Claude may still be redrawing this lease's own paste: one bounded re-read before refusing its Enter.
|
||||
if (submit && lease.staged && (!input || input.state === "unknown")) { await this.sleep(OWN_INPUT_REREAD_MS); input = await read(); }
|
||||
if (!input || input.state === "unknown") {
|
||||
// No Enter was sent, so the paste stays there unsubmitted, and the next delivery may recognize it as OpenRig's.
|
||||
if (submit && lease.staged) this.leftovers.set(target.nodeId, { target: lease.target, humanEpoch: lease.humanEpoch, ...lease.staged });
|
||||
throw unknown(input);
|
||||
throw unknown();
|
||||
}
|
||||
if (leftover === "recognize" && !submit && this.ownsLeftover(target, input)) { lease.ownLeftover = "recognized"; return; }
|
||||
if (typeof leftover === "object" && lease.ownLeftover === "recognized") {
|
||||
@@ -467,7 +466,7 @@ export class SeatDeliveryGuard {
|
||||
lease.ownLeftover = "submitted";
|
||||
await this.sleep(OWN_INPUT_REREAD_MS);
|
||||
const after = await read();
|
||||
if (!after || after.state === "unknown") throw unknown(after);
|
||||
if (!after || after.state === "unknown") throw unknown();
|
||||
input = after;
|
||||
}
|
||||
}
|
||||
@@ -490,7 +489,7 @@ export class SeatDeliveryGuard {
|
||||
: uncertainty === "long"
|
||||
? "OpenRig couldn't confirm ownership of Codex's collapsed long paste. Send a shorter message; nothing was pasted. Turning this seat's typing guard off explicitly disables draft protection."
|
||||
: "OpenRig couldn't confirm the complete message would stay visible in this Codex viewport. Open a larger terminal or shorten the message, then retry this request; nothing was pasted.";
|
||||
throw new DeliveryGuardError("draft_input_unknown", message, inputReadDiagnostic("draft-aware", input, `paste_${uncertainty}`));
|
||||
throw new DeliveryGuardError("draft_input_unknown", message);
|
||||
}
|
||||
lease.inputFrame = input.frame;
|
||||
}
|
||||
|
||||
@@ -141,22 +141,6 @@ export class SeatIdentityReconciler {
|
||||
`).all() as RunningSeatRow[];
|
||||
}
|
||||
|
||||
/** Nodes whose latest session `rig unclaim` released (detached, origin claimed). Unclaim leaves the process running,
|
||||
* so the node's last recorded process stays on record: a fresh launch reads it before reopening a tmux server, and
|
||||
* must not start a second occupant beside a process that still runs. An older schema without `origin` keeps none. */
|
||||
private releasedSeats(): string[] {
|
||||
try {
|
||||
return (this.db.prepare(`
|
||||
SELECT n.id as node_id FROM nodes n
|
||||
JOIN sessions s ON s.node_id = n.id
|
||||
AND s.id = (SELECT s2.id FROM sessions s2 WHERE s2.node_id = n.id ORDER BY s2.id DESC LIMIT 1)
|
||||
WHERE s.status = 'detached' AND s.origin = 'claimed'
|
||||
`).all() as Array<{ node_id: string }>).map((r) => r.node_id);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** Reconcile every running tmux-bound seat once and persist the verdicts. */
|
||||
async reconcileAll(): Promise<void> {
|
||||
// Skip ticks while actual reads are pending; never release on a deadline
|
||||
@@ -172,8 +156,8 @@ export class SeatIdentityReconciler {
|
||||
|
||||
private async reconcileSweep(generation: number): Promise<void> {
|
||||
const seats = this.runningSeats();
|
||||
// Prune verdicts for nodes no longer running (keep the table bounded), except a released seat's.
|
||||
this.store.pruneExcept([...seats.map((s) => s.node_id), ...this.releasedSeats()]);
|
||||
// Prune verdicts for nodes no longer running (keep the table bounded).
|
||||
this.store.pruneExcept(seats.map((s) => s.node_id));
|
||||
if (seats.length === 0) return;
|
||||
|
||||
const observedAt = this.now().toISOString();
|
||||
|
||||
@@ -55,8 +55,6 @@ import { sessionCommunityHarnessSelect } from "./session-registry.js";
|
||||
import { seatLaunchAdditionsReport } from "./seat-launch-additions.js";
|
||||
import { seatSkillPaths } from "./seat-skill-storage.js";
|
||||
import { resolveSeatKind, seatProcessOwner } from "./seat-kinds.js";
|
||||
import { recordedProcessStillRunning } from "./reconciler.js";
|
||||
import { readProcessTable, type ProcessTableRow } from "./process-table.js";
|
||||
|
||||
/**
|
||||
* S5 (OPR.0.5.4.7) — the seat-lifecycle verb surface: set-model, single-seat stop,
|
||||
@@ -112,8 +110,6 @@ export interface SeatLifecycleDeps {
|
||||
eventBus: EventBus;
|
||||
tmuxAdapter: TmuxAdapter;
|
||||
listProcesses?: NativeProcessLister;
|
||||
/** The OS process table, read before reopening a released seat's tmux server; injectable for tests. */
|
||||
readProcesses?: () => Promise<ProcessTableRow[]>;
|
||||
nodeLauncher?: NodeLauncher;
|
||||
startupOrchestrator?: StartupOrchestrator;
|
||||
runtimeAdapters?: Record<string, RuntimeAdapter>;
|
||||
@@ -342,7 +338,6 @@ export class SeatLifecycleService {
|
||||
private readonly eventBus: EventBus;
|
||||
private readonly tmuxAdapter: TmuxAdapter;
|
||||
private readonly listProcesses?: NativeProcessLister;
|
||||
private readonly readProcesses: () => Promise<ProcessTableRow[]>;
|
||||
private readonly nodeLauncher: NodeLauncher | null;
|
||||
private readonly startupOrchestrator: StartupOrchestrator | null;
|
||||
private readonly runtimeAdapters: Record<string, RuntimeAdapter>;
|
||||
@@ -365,7 +360,6 @@ export class SeatLifecycleService {
|
||||
this.eventBus = deps.eventBus;
|
||||
this.tmuxAdapter = deps.tmuxAdapter;
|
||||
this.listProcesses = deps.listProcesses;
|
||||
this.readProcesses = deps.readProcesses ?? readProcessTable;
|
||||
this.nodeLauncher = deps.nodeLauncher ?? null;
|
||||
this.startupOrchestrator = deps.startupOrchestrator ?? null;
|
||||
this.runtimeAdapters = deps.runtimeAdapters ?? {};
|
||||
@@ -1581,34 +1575,17 @@ export class SeatLifecycleService {
|
||||
// probe sees unavailable transport instead of positive absence. Reopen an
|
||||
// empty server only when the persisted state proves this seat's managed
|
||||
// occupant was deliberately stopped and no other managed row remains live.
|
||||
// `rig unclaim` is as deliberate for an adopted session: it marks the row
|
||||
// detached and clears the binding, and the person ends the process and its
|
||||
// tmux session, which can be the server's last session too.
|
||||
// The probe below still decides absence; a recreated session or failed
|
||||
// transport remains a refusal.
|
||||
if ("code" in canonicalProbe) {
|
||||
const latest = this.latestSession(node.id);
|
||||
const hasCurrentBinding = this.sessionRegistry.getBindingForNode(node.id) !== null;
|
||||
const released = latest?.status === "detached" && latest.origin === "claimed";
|
||||
const stoppedManagedOccupant = latest !== null
|
||||
&& latest.session_name === canonicalSessionName
|
||||
&& ((latest.status === "exited" && latest.origin !== "claimed") || released)
|
||||
&& latest.status === "exited"
|
||||
&& latest.origin !== "claimed"
|
||||
&& !hasCurrentBinding
|
||||
&& this.nonTerminalSessions(node.id).length === 0;
|
||||
// Unclaim doesn't end the process. While the released session's recorded process still runs, its server may
|
||||
// only have lost its socket: a server started there would read the session absent and launch a second occupant
|
||||
// beside it. So keep the refusal, as the reconciler keeps such a session (the same evidence).
|
||||
const stillRuns = stoppedManagedOccupant && released
|
||||
? await recordedProcessStillRunning(this.db, { nodeId: node.id, sessionName: canonicalSessionName },
|
||||
() => this.readProcesses().catch(() => null))
|
||||
: null;
|
||||
if (stillRuns) {
|
||||
return {
|
||||
...canonicalProbe,
|
||||
guidance: `This seat's released session last ran as process ${stillRuns.pid}, which is still running, so it may be `
|
||||
+ "on a tmux server this daemon can't reach. End that process, then launch again.",
|
||||
};
|
||||
}
|
||||
if (stoppedManagedOccupant) {
|
||||
try {
|
||||
const restored = await this.tmuxAdapter.startServer();
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { inputReadDiagnostic, type InputReadDiagnostic } from "./input-read-diagnostic.js";
|
||||
import { expectedConversation, sameConversation, resolveExpectedConversation, type ExpectedConversation } from "./expected-conversation.js";
|
||||
import type { ContextUsageStore } from "./context-usage-store.js";
|
||||
import { physicalSessionName } from "./session-registry.js";
|
||||
@@ -2177,7 +2176,6 @@ export interface SendResult {
|
||||
heldInputBox?: InputBoxAnnotation["status"];
|
||||
/** The existing preserve-input reading, without the draft or suggestion text. No extra capture. */
|
||||
heldInputBoxReading?: Pick<InputBoxAnnotation, "status" | "confidence" | "evidence" | "cursor">;
|
||||
inputReadFailure?: InputReadDiagnostic;
|
||||
}
|
||||
|
||||
/** One reading of a Claude Code or Codex input box: `exact` (the sent text, word boundaries kept), `ambiguous`
|
||||
@@ -2825,7 +2823,6 @@ export class SessionTransport {
|
||||
sent: written ? true : attempted ? undefined : false,
|
||||
inputPhase: written ? "written" : attempted ? "attempted" : "not_reached",
|
||||
reason: (error as { code?: string }).code ?? "expected_identity_unavailable", error: (error as Error).message,
|
||||
...(error instanceof DeliveryGuardError && error.inputReadFailure ? { inputReadFailure: error.inputReadFailure } : {}),
|
||||
...(receiptUnconfirmed ? { warning: "Conditional input receipt could not be finalized; inspect before retrying." } : {}) };
|
||||
}
|
||||
}
|
||||
@@ -2947,20 +2944,17 @@ export class SessionTransport {
|
||||
if ((error as { inputWritten?: boolean }).inputWritten === true) {
|
||||
// Only a throw before the Enter key reached the adapter positively means Enter was never sent.
|
||||
const enter = (error as { enterAttempted?: boolean }).enterAttempted === true ? "the submission (Enter) outcome is unconfirmed" : "Enter was not sent";
|
||||
return { ok: false, sessionName, sent: true, outcome: "failed", inputPhase: "written",
|
||||
...(error instanceof DeliveryGuardError && error.inputReadFailure ? { inputReadFailure: error.inputReadFailure } : {}), reason: (error as { code?: string }).code ?? "submit_refused",
|
||||
return { ok: false, sessionName, sent: true, outcome: "failed", inputPhase: "written", reason: (error as { code?: string }).code ?? "submit_refused",
|
||||
error: `Text was written to '${sessionName}' but ${enter}: ${(error as Error).message}` };
|
||||
}
|
||||
// A throw after the text write was issued: whether it landed is unknown, so it is not "nothing sent" either.
|
||||
if ((error as { inputAttempted?: boolean }).inputAttempted === true) {
|
||||
return { ok: false, sessionName, outcome: "failed", inputPhase: "attempted",
|
||||
...(error instanceof DeliveryGuardError && error.inputReadFailure ? { inputReadFailure: error.inputReadFailure } : {}), reason: (error as { code?: string }).code ?? "delivery_indeterminate",
|
||||
return { ok: false, sessionName, outcome: "failed", inputPhase: "attempted", reason: (error as { code?: string }).code ?? "delivery_indeterminate",
|
||||
error: `A text write to '${sessionName}' was issued, but its result was lost: ${(error as Error).message}` };
|
||||
}
|
||||
const noInputCode = error instanceof OutboxHandlerError && error.code === "retained_quota_full" ? "retained_quota_full" as const
|
||||
: error instanceof DeliveryGuardError && error.code === "terminal_seat" ? "terminal_seat" as const : undefined;
|
||||
return { ok: false, sessionName, sent: false, reason: (error as { code?: string }).code ?? "guard_unavailable", error: (error as Error).message,
|
||||
...(error instanceof DeliveryGuardError && error.inputReadFailure ? { inputReadFailure: error.inputReadFailure } : {}),
|
||||
...(noInputCode ? { noInputCode, inputPhase: "not_reached" as const } : {}) };
|
||||
}
|
||||
}
|
||||
@@ -3356,7 +3350,7 @@ export class SessionTransport {
|
||||
}
|
||||
if (!submitResult.ok && !enterIssued && submitResult.code === CALLER_CANCELLED && cancel?.aborted) return callerCancelled(sessionName);
|
||||
if (!submitResult.ok) {
|
||||
return { ok: false, sessionName, ...(submitResult.inputReadFailure ? { inputReadFailure: submitResult.inputReadFailure } : {}), inputPhase: enterIssued ? "attempted" : "not_reached", ...(!enterIssued ? { sent: false } : {}), reason: submitResult.code?.startsWith("expected_identity_") ? submitResult.code : "submit_failed", outcome: "failed", error: enterIssued
|
||||
return { ok: false, sessionName, inputPhase: enterIssued ? "attempted" : "not_reached", ...(!enterIssued ? { sent: false } : {}), reason: submitResult.code?.startsWith("expected_identity_") ? submitResult.code : "submit_failed", outcome: "failed", error: enterIssued
|
||||
? `submitOnly: Enter reported a failure for '${sessionName}'; submission is unconfirmed: ${submitResult.message}`
|
||||
: `submitOnly: Enter was not sent to '${sessionName}': ${submitResult.message}` };
|
||||
}
|
||||
@@ -3706,7 +3700,7 @@ export class SessionTransport {
|
||||
heldInputBoxReading = { status, confidence, evidence, cursor };
|
||||
}
|
||||
throw new DeliveryGuardError(box?.status === "typed" ? "draft_input_busy" : "draft_input_unknown",
|
||||
`Managed input waits for an empty composer (it reads ${box?.status ?? "unknown"}). Your draft was left untouched; inspect the pane before retrying.`, inputReadDiagnostic("composer", box));
|
||||
`Managed input waits for an empty composer (it reads ${box?.status ?? "unknown"}). Your draft was left untouched; inspect the pane before retrying.`);
|
||||
}
|
||||
if (!clear) pastedOverInputBox = box?.status ?? "unknown";
|
||||
} : undefined;
|
||||
@@ -3741,7 +3735,6 @@ export class SessionTransport {
|
||||
ok: false,
|
||||
sessionName,
|
||||
reason: policyRefusal ? textResult.code : "send_failed",
|
||||
...(textResult.inputReadFailure ? { inputReadFailure: textResult.inputReadFailure } : {}),
|
||||
outcome: "failed",
|
||||
// The adapter's draft checks run before the paste (only file/buffer preparation precedes them), and its
|
||||
// after-paste observation cannot fail the write, so a draft_* receipt proves the target input was never reached.
|
||||
@@ -3810,7 +3803,6 @@ export class SessionTransport {
|
||||
ok: false,
|
||||
sessionName,
|
||||
reason: policyRefusal ? submitResult.code : "submit_failed",
|
||||
...(submitResult.inputReadFailure ? { inputReadFailure: submitResult.inputReadFailure } : {}),
|
||||
outcome: "failed",
|
||||
inputPhase: "written",
|
||||
error: policyRefusal
|
||||
|
||||
@@ -3,6 +3,7 @@ import { performance } from "node:perf_hooks";
|
||||
import { Worker } from "node:worker_threads";
|
||||
import type { MiddlewareHandler } from "hono";
|
||||
|
||||
export const SLOW_OPERATION_BARRIER_TIMEOUT_MS = 250;
|
||||
export const SLOW_OPERATION_THRESHOLD_MS = 250;
|
||||
export const SLOW_OPERATION_ROTATION_BYTES = 1024 * 1024;
|
||||
export const SLOW_OPERATION_ROTATION_COUNT = 3;
|
||||
@@ -88,6 +89,7 @@ interface SlowOpRecorderOptions {
|
||||
maxBytes?: number;
|
||||
rotationCount?: number;
|
||||
slowThresholdMs?: number;
|
||||
barrierTimeoutMs?: number;
|
||||
}
|
||||
|
||||
interface Span {
|
||||
@@ -139,7 +141,13 @@ const WORKER_SOURCE = String.raw`
|
||||
ok = false;
|
||||
error = caught instanceof Error ? caught.message : String(caught);
|
||||
}
|
||||
if (message.id) parentPort.postMessage({ id: message.id, ok, error });
|
||||
if (message.signal) {
|
||||
const state = new Int32Array(message.signal);
|
||||
Atomics.store(state, 0, ok ? 1 : 2);
|
||||
Atomics.notify(state, 0);
|
||||
} else if (message.id) {
|
||||
parentPort.postMessage({ id: message.id, ok, error });
|
||||
}
|
||||
});
|
||||
`;
|
||||
|
||||
@@ -149,6 +157,7 @@ export class SlowOpRecorder implements SlowOperationInstrumentation {
|
||||
private readonly maxBytes: number;
|
||||
private readonly rotationCount: number;
|
||||
private readonly slowThresholdMs: number;
|
||||
private readonly barrierTimeoutMs: number;
|
||||
private readonly pending = new Map<string, { resolve: () => void; reject: (error: unknown) => void }>();
|
||||
private degraded: SlowOperationSnapshot = { healthy: true };
|
||||
private degradedHandler?: (snapshot: Required<Pick<SlowOperationSnapshot, "reason" | "site">>) => void;
|
||||
@@ -172,6 +181,7 @@ export class SlowOpRecorder implements SlowOperationInstrumentation {
|
||||
this.maxBytes = options.maxBytes ?? SLOW_OPERATION_ROTATION_BYTES;
|
||||
this.rotationCount = options.rotationCount ?? SLOW_OPERATION_ROTATION_COUNT;
|
||||
this.slowThresholdMs = options.slowThresholdMs ?? SLOW_OPERATION_THRESHOLD_MS;
|
||||
this.barrierTimeoutMs = options.barrierTimeoutMs ?? SLOW_OPERATION_BARRIER_TIMEOUT_MS;
|
||||
this.worker = new Worker(WORKER_SOURCE, { eval: true, execArgv: [] });
|
||||
this.worker.unref();
|
||||
this.worker.on("message", (message: { id?: string; ok?: boolean }) => {
|
||||
@@ -210,10 +220,27 @@ export class SlowOpRecorder implements SlowOperationInstrumentation {
|
||||
|
||||
beginSyncSpan(site: string): Span {
|
||||
const span: Span = { spanId: randomUUID(), site, startedAt: performance.now() };
|
||||
// Queue before the operation without waiting on worker startup or fsync. The
|
||||
// worker can write even while the main thread is wedged; only flush/close
|
||||
// acknowledge durability. A crash before the worker appends can lose begin.
|
||||
this.appendAsync({ v: 1, ts: new Date().toISOString(), spanId: span.spanId, phase: "begin", site });
|
||||
const signal = new SharedArrayBuffer(Int32Array.BYTES_PER_ELEMENT);
|
||||
const state = new Int32Array(signal);
|
||||
try {
|
||||
this.worker.postMessage({
|
||||
type: "append",
|
||||
signal,
|
||||
logPath: this.logPath,
|
||||
maxBytes: this.maxBytes,
|
||||
rotationCount: this.rotationCount,
|
||||
record: { v: 1, ts: new Date().toISOString(), spanId: span.spanId, phase: "begin", site },
|
||||
});
|
||||
} catch {
|
||||
// Synchronous postMessage failure means the Worker is gone: degrade
|
||||
// through the terminal transition but still return the span so the
|
||||
// wrapped operation runs to completion (its value/error stays exact).
|
||||
this.handleTerminalFailure("recorder_post_failed", site);
|
||||
return span;
|
||||
}
|
||||
const wait = Atomics.wait(state, 0, 0, this.barrierTimeoutMs);
|
||||
if (wait === "timed-out") this.markDegraded("begin_barrier_timeout", site);
|
||||
else if (Atomics.load(state, 0) !== 1) this.markDegraded("begin_barrier_failed", site);
|
||||
return span;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import type { TmuxAdapter } from "../adapters/tmux.js";
|
||||
import { runAsyncSite } from "./sync-site-wrap.js";
|
||||
import { runSyncSite } from "./sync-site-wrap.js";
|
||||
|
||||
export interface TmuxOptionDefaultsDeps {
|
||||
tmuxAdapter: TmuxAdapter;
|
||||
@@ -17,11 +17,11 @@ export interface TmuxOptionDefaultsDeps {
|
||||
*/
|
||||
platform?: NodeJS.Platform;
|
||||
/**
|
||||
* OPR.0.4.6.02 S1 — asynchronous `command -v <bin>` probe for the linux
|
||||
* copy-command fallback chain. Defaults to a bounded asynchronous shell probe; injectable
|
||||
* OPR.0.4.6.02 S1 — cheap `command -v <bin>` probe for the linux
|
||||
* copy-command fallback chain. Defaults to a real shell probe; injectable
|
||||
* for deterministic tests.
|
||||
*/
|
||||
hasCommand?: (bin: string) => boolean | Promise<boolean>;
|
||||
hasCommand?: (bin: string) => boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -44,7 +44,7 @@ export class TmuxOptionDefaultsApplier {
|
||||
private tmuxAdapter: TmuxAdapter;
|
||||
private readTmuxOptionDefaults: () => { statusBar: boolean };
|
||||
private platform: NodeJS.Platform;
|
||||
private hasCommand: (bin: string) => boolean | Promise<boolean>;
|
||||
private hasCommand: (bin: string) => boolean;
|
||||
private serverDefaultsAsserted = false;
|
||||
|
||||
constructor(deps: TmuxOptionDefaultsDeps) {
|
||||
@@ -120,14 +120,7 @@ export class TmuxOptionDefaultsApplier {
|
||||
warnings.push(`tmux "set-clipboard" server option not set: ${clip.message}`);
|
||||
}
|
||||
|
||||
let copyCommand: string | null;
|
||||
try {
|
||||
copyCommand = await resolveCopyCommand(this.platform, this.hasCommand);
|
||||
} catch {
|
||||
this.serverDefaultsAsserted = false;
|
||||
warnings.push("Clipboard lookup interrupted (250 ms limit); copy-command left unchanged (OSC 52 if unset). Will retry on the next fresh session.");
|
||||
return;
|
||||
}
|
||||
const copyCommand = resolveCopyCommand(this.platform, this.hasCommand);
|
||||
if (copyCommand !== null) {
|
||||
const cc = await this.tmuxAdapter.setServerOption("copy-command", copyCommand);
|
||||
if (!cc.ok) {
|
||||
@@ -148,14 +141,14 @@ export class TmuxOptionDefaultsApplier {
|
||||
* Pure: platform + a `command -v` probe in, the command string (or null)
|
||||
* out. One table, no side effects — the applier decides whether to write.
|
||||
*/
|
||||
export async function resolveCopyCommand(
|
||||
export function resolveCopyCommand(
|
||||
platform: NodeJS.Platform,
|
||||
hasCommand: (bin: string) => boolean | Promise<boolean>,
|
||||
): Promise<string | null> {
|
||||
hasCommand: (bin: string) => boolean,
|
||||
): string | null {
|
||||
if (platform === "darwin") return "pbcopy";
|
||||
if (platform === "linux") {
|
||||
if (await hasCommand("wl-copy")) return "wl-copy";
|
||||
if (await hasCommand("xclip")) return "xclip -selection clipboard -i";
|
||||
if (hasCommand("wl-copy")) return "wl-copy";
|
||||
if (hasCommand("xclip")) return "xclip -selection clipboard -i";
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
@@ -163,18 +156,16 @@ export async function resolveCopyCommand(
|
||||
|
||||
/**
|
||||
* OPR.0.4.6.02 S1 — default `command -v <bin>` probe (POSIX shell builtin).
|
||||
* Returns true when the binary resolves on PATH. A killed probe rejects so
|
||||
* the applier can warn and retry; other failures read as absent.
|
||||
* Returns true when the binary resolves on PATH. Cheap + best-effort; any
|
||||
* failure (spawn error, non-shell env) reads as absent.
|
||||
*/
|
||||
async function defaultHasCommand(bin: string): Promise<boolean> {
|
||||
function defaultHasCommand(bin: string): boolean {
|
||||
try {
|
||||
return await runAsyncSite("tmux_options.command_v", () => new Promise<boolean>((resolve, reject) => {
|
||||
execFile("/bin/sh", ["-c", 'command -v "$1"', "--", bin], {
|
||||
timeout: 250, killSignal: "SIGKILL", maxBuffer: 1024,
|
||||
}, error => error?.killed ? reject(error) : resolve(!error));
|
||||
}));
|
||||
} catch (error) {
|
||||
if ((error as { killed?: boolean } | null)?.killed) throw error;
|
||||
const r = runSyncSite("tmux_options.command_v", () =>
|
||||
spawnSync(`command -v ${bin}`, { shell: true, stdio: "ignore" })
|
||||
);
|
||||
return r.status === 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -2,48 +2,76 @@ import { Hono, type Context } from "hono";
|
||||
import type { RigRepository } from "../domain/rig-repository.js";
|
||||
import { NetworkBindingError, NetworkBindingStore } from "../domain/network/network-bindings.js";
|
||||
import { networkClosedElsewhere } from "../domain/network/network-connector.js";
|
||||
import { NetworkInstallationStore, type AccountFetch } from "../domain/network/network-installations.js";
|
||||
import { getSelfHostId } from "../domain/hosts/fanout-contract.js";
|
||||
import { authBearerTokenMiddleware } from "../middleware/auth-bearer-token.js";
|
||||
import { OPENRIG_HOME } from "../openrig-compat.js";
|
||||
|
||||
/** A local seat's connections to a remote hub (`rig network`). Reads list every binding with its state and last contact;
|
||||
* the token is accepted on connect and never returned. Writes are gated like the hosts routes: enforced when an operator
|
||||
* bearer is configured, passed through on a loopback or tailnet-trusted daemon. */
|
||||
export function networkRoutes(opts?: { bearerToken?: string | null; home?: string }): Hono {
|
||||
/** A local seat's connections to a remote hub (`rig network`). Reads list every binding with its state and last contact,
|
||||
* and the account enrollments; tokens and keys are accepted and never returned. Writes are gated like the hosts routes:
|
||||
* enforced when an operator bearer is configured, passed through on a loopback or tailnet-trusted daemon. */
|
||||
export function networkRoutes(opts?: { bearerToken?: string | null; home?: string; accountFetch?: AccountFetch; selfHostId?: () => string | null; accountTimeoutMs?: number }): Hono {
|
||||
const router = new Hono();
|
||||
const requireAuth = authBearerTokenMiddleware({ expectedToken: opts?.bearerToken ?? null });
|
||||
router.use("/bindings", async (c, next) => (c.req.method === "GET" ? next() : requireAuth(c, next)));
|
||||
router.use("/bindings/:id/disconnect", requireAuth);
|
||||
router.use("/installations", requireAuth);
|
||||
|
||||
const store = (c: Context) => new NetworkBindingStore((c.get("rigRepo" as never) as RigRepository).db, opts?.home ?? OPENRIG_HOME);
|
||||
const home = opts?.home ?? OPENRIG_HOME;
|
||||
const db = (c: Context) => (c.get("rigRepo" as never) as RigRepository).db;
|
||||
const store = (c: Context) => new NetworkBindingStore(db(c), home);
|
||||
const installations = (c: Context) => new NetworkInstallationStore(db(c), home, { fetch: opts?.accountFetch, selfHostId: opts?.selfHostId ?? getSelfHostId, timeoutMs: opts?.accountTimeoutMs });
|
||||
const refused = (c: Context, error: unknown) => {
|
||||
if (error instanceof NetworkBindingError) return c.json({ error: error.code, message: error.message }, error.status);
|
||||
throw error;
|
||||
};
|
||||
const json = async (c: Context): Promise<Record<string, unknown> | null> => {
|
||||
try { return await c.req.json(); } catch { return null; }
|
||||
};
|
||||
|
||||
// The connector's own view (next pass, backoff, last pass) when it runs in this daemon; null otherwise.
|
||||
router.get("/bindings", (c) => {
|
||||
const connector = c.get("networkConnector" as never) as { snapshot(): unknown } | undefined;
|
||||
const db = (c.get("rigRepo" as never) as RigRepository).db;
|
||||
// Hub items someone else closed before a binding's result reached them: a conflict the person sees.
|
||||
const bindings = store(c).list().map((b) => ({ ...b, closedElsewhere: networkClosedElsewhere(db, b.id) }));
|
||||
return c.json({ bindings, connector: connector?.snapshot() ?? null });
|
||||
const enrolled = installations(c);
|
||||
const bindings = store(c).list().map((b) => ({ ...b, closedElsewhere: networkClosedElsewhere(db(c), b.id), accountRemoval: enrolled.removalOf(b) }));
|
||||
return c.json({ bindings, installations: enrolled.list(), connector: connector?.snapshot() ?? null });
|
||||
});
|
||||
|
||||
// With a hub URL and token, bind to that hub directly; with only a participant name, through the enrolled account.
|
||||
router.post("/bindings", async (c) => {
|
||||
let body: Record<string, unknown>;
|
||||
try { body = await c.req.json(); } catch { return c.json({ error: "invalid_body", message: "The body must be JSON." }, 400); }
|
||||
const body = await json(c);
|
||||
if (!body) return c.json({ error: "invalid_body", message: "The body must be JSON." }, 400);
|
||||
try {
|
||||
if (body.hubUrl === undefined && body.token === undefined && body.participantName !== undefined) {
|
||||
return c.json(await installations(c).connect(store(c), { session: body.session, name: body.participantName }), 201);
|
||||
}
|
||||
return c.json(store(c).create({ session: body.session, hubUrl: body.hubUrl, hubSeat: body.hubSeat, token: body.token, allowHttp: body.allowHttp }), 201);
|
||||
} catch (error) { return refused(c, error); }
|
||||
});
|
||||
|
||||
router.post("/bindings/:id/disconnect", async (c) => {
|
||||
let body: Record<string, unknown> = {};
|
||||
try { body = await c.req.json(); } catch { /* no reason given */ }
|
||||
router.post("/installations", async (c) => {
|
||||
const body = await json(c);
|
||||
if (!body) return c.json({ error: "invalid_body", message: "The body must be JSON." }, 400);
|
||||
try {
|
||||
return c.json(store(c).disconnect(c.req.param("id"), typeof body.reason === "string" ? body.reason : undefined));
|
||||
return c.json(await installations(c).enroll(store(c), { code: body.code, site: body.site, allowHttp: body.allowHttp, replace: body.replace }), 201);
|
||||
} catch (error) { return refused(c, error); }
|
||||
});
|
||||
|
||||
// The binding ends here first, then a binding made through the account is dropped there, which revokes its name.
|
||||
router.post("/bindings/:id/disconnect", async (c) => {
|
||||
const body = (await json(c)) ?? {};
|
||||
const bindings = store(c);
|
||||
const target = bindings.find(c.req.param("id"));
|
||||
if (!target) return refused(c, new NetworkBindingError("binding_not_found", `No network binding or connected seat is named '${c.req.param("id")}'.`, 404));
|
||||
let cleanup: unknown = null;
|
||||
try { bindings.disconnect(target.id, typeof body.reason === "string" ? body.reason : undefined); } catch (error) { cleanup = error; }
|
||||
try {
|
||||
const enrolled = installations(c);
|
||||
const ended = await enrolled.removeAtAccount(bindings, bindings.get(target.id)!);
|
||||
return cleanup ? refused(c, cleanup) : c.json({ ...ended, accountRemoval: enrolled.removalOf(ended) });
|
||||
} catch (error) { return refused(c, cleanup ?? error); }
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
@@ -273,7 +273,7 @@ packagesRoutes.post("/install", async (c) => {
|
||||
}
|
||||
|
||||
// Verify
|
||||
const verification = installVerifier.verify(result.installId, { skipped: result.notes?.length ?? 0 });
|
||||
const verification = installVerifier.verify(result.installId);
|
||||
if (!verification.passed) {
|
||||
eventBus.emit({ type: "package.install_failed", packageName: resolved.manifest.name, code: "verification_failed", message: "Post-apply verification failed" });
|
||||
return c.json({
|
||||
@@ -281,7 +281,6 @@ packagesRoutes.post("/install", async (c) => {
|
||||
code: "verification_failed",
|
||||
installId: result.installId,
|
||||
verification,
|
||||
...(result.notes?.length ? { notes: result.notes } : {}),
|
||||
}, 500);
|
||||
}
|
||||
|
||||
@@ -303,7 +302,6 @@ packagesRoutes.post("/install", async (c) => {
|
||||
conflicts: result.conflicts,
|
||||
verification,
|
||||
...(policyResult.rejected.length > 0 ? { policyRejected: policyResult.rejected } : {}),
|
||||
...(result.notes?.length ? { notes: result.notes } : {}),
|
||||
}, 201);
|
||||
});
|
||||
|
||||
|
||||
@@ -1441,11 +1441,6 @@ export function queueRoutes(): Hono {
|
||||
return c.json(items);
|
||||
});
|
||||
|
||||
// Each connected assistant on this host and whether it's checking its queue, with this daemon's own host id: the check-ins
|
||||
// are this host's, so a reader matches members to it without needing a terminal seat to learn it. A status only:
|
||||
// work addressed to an assistant that isn't checking in still queues and is rung as before. Precedes /:qitemId.
|
||||
app.get("/hosted-members", (c) => c.json({ hostId: getSelfHostId(), members: getRepo(c).hostedMemberCheckIns() }));
|
||||
|
||||
// GET /undelivered — surfaces pending create-path nudge failures plus active
|
||||
// human-notification episodes whose gateway delivery ledger failed or never posted.
|
||||
// Rig-scoped + bounded + compact-by-default, mirroring /overdue.
|
||||
|
||||
@@ -1049,9 +1049,30 @@ export async function createDaemon(opts?: DaemonOptions): Promise<DaemonResult>
|
||||
// (repo currently empty as of 2026-05-10; vendored
|
||||
// copy is the source of truth at v0).
|
||||
try {
|
||||
const { PluginVendorService, nodePluginVendorFs } = await import("./domain/plugin-vendor-service.js");
|
||||
const { PluginVendorService } = await import("./domain/plugin-vendor-service.js");
|
||||
const vendoredAssetsDir = nodePath.resolve(import.meta.dirname, "../assets/plugins");
|
||||
const userPluginsDir = getDefaultOpenRigPath("plugins");
|
||||
const realFs = {
|
||||
readFile: (p: string) => fs.readFileSync(p, "utf-8"),
|
||||
writeFile: (p: string, c: string) => fs.writeFileSync(p, c, "utf-8"),
|
||||
copyFileExclusive: (src: string, dest: string) => fs.copyFileSync(src, dest, fs.constants.COPYFILE_EXCL),
|
||||
exists: (p: string) => fs.existsSync(p),
|
||||
mkdirp: (p: string) => fs.mkdirSync(p, { recursive: true }),
|
||||
listFiles: (dir: string) => {
|
||||
const r: string[] = [];
|
||||
/** Collect relative file paths recursively. */
|
||||
function w(d: string, pre: string) {
|
||||
for (const e of fs.readdirSync(d, { withFileTypes: true })) {
|
||||
if (e.isDirectory()) w(nodePath.join(d, e.name), nodePath.join(pre, e.name));
|
||||
else r.push(pre ? nodePath.join(pre, e.name) : e.name);
|
||||
}
|
||||
}
|
||||
w(dir, "");
|
||||
return r;
|
||||
},
|
||||
statMode: (p: string) => fs.statSync(p).mode,
|
||||
chmod: (p: string, m: number) => fs.chmodSync(p, m),
|
||||
};
|
||||
const httpClient = async (url: string, opts?: { timeoutMs?: number }) => {
|
||||
const ctrl = new AbortController();
|
||||
const timer = setTimeout(() => ctrl.abort(), opts?.timeoutMs ?? 5000);
|
||||
@@ -1065,7 +1086,7 @@ export async function createDaemon(opts?: DaemonOptions): Promise<DaemonResult>
|
||||
const vendorService = new PluginVendorService({
|
||||
vendoredAssetsDir,
|
||||
userPluginsDir,
|
||||
fs: nodePluginVendorFs(),
|
||||
fs: realFs,
|
||||
httpClient,
|
||||
logger: (...args) => console.log("[openrig]", ...args),
|
||||
});
|
||||
|
||||
@@ -1387,38 +1387,6 @@ exports:
|
||||
expect(result.errors.some((e) => e.includes("skipped"))).toBe(true);
|
||||
});
|
||||
|
||||
it("a package whose only guidance entry is left as it is still imports the rig, with the note in warnings", async () => {
|
||||
writePkg(path.join(tmpDir, "test-pkg"), VALID_MANIFEST_YAML, { "skills/helper/SKILL.md": "# Helper" });
|
||||
const guidanceManifest = `
|
||||
schema_version: 1
|
||||
name: guidance-pkg
|
||||
version: "1.0.0"
|
||||
summary: Guidance only
|
||||
compatibility:
|
||||
runtimes: [claude-code]
|
||||
exports:
|
||||
guidance:
|
||||
- source: guidance/rules.md
|
||||
name: rules
|
||||
kind: claude_md
|
||||
supported_scopes: [project_shared]
|
||||
default_scope: project_shared
|
||||
merge_strategy: managed_block
|
||||
`.trim();
|
||||
writePkg(path.join(tmpDir, "guidance-pkg"), guidanceManifest, { "guidance/rules.md": "Follow these rules." });
|
||||
const damaged = "# Project\n\n<!-- BEGIN OpenRig MANAGED BLOCK: guidance-pkg -->\nhalf a block\n";
|
||||
fs.writeFileSync(path.join(tmpDir, "CLAUDE.md"), damaged);
|
||||
const specPath = writeSpec(SPEC_WITH_PACKAGES_YAML.replace(" - ./test-pkg", " - ./test-pkg\n - ./guidance-pkg"));
|
||||
|
||||
const result = await buildOrchestrator().bootstrap({ mode: "apply", sourceRef: specPath, autoApprove: true });
|
||||
|
||||
expect(fs.readFileSync(path.join(tmpDir, "CLAUDE.md"), "utf-8")).toBe(damaged);
|
||||
expect(result.errors).toEqual([]);
|
||||
expect(result.status).toBe("completed");
|
||||
expect(result.stages.find((s) => s.stage === "import_rig")?.status).not.toBe("skipped");
|
||||
expect(result.warnings).toContainEqual(expect.stringMatching(/^Package '\.\/guidance-pkg' \(claude-code\): guidance_block_markers_damaged: .*CLAUDE\.md: guidance-pkg has a BEGIN or END marker without its pair/));
|
||||
});
|
||||
|
||||
// === P7-T05: Bundle source tests ===
|
||||
|
||||
// T25: Real bundle bootstrap happy path
|
||||
|
||||
@@ -43,16 +43,6 @@ describe("detectCodexLimitBanner", () => {
|
||||
expect(detectCodexLimitBanner(BANNER)).toMatchObject({ resetText: "4:14 AM" });
|
||||
});
|
||||
|
||||
it("matches the typographic apostrophe Codex prints (U+2019)", () => {
|
||||
const pane = [
|
||||
"■ You’ve hit your usage limit. Upgrade to Pro (https://chatgpt.com/explore/pro), visit",
|
||||
"https://chatgpt.com/settings/usage to purchase more credits or try again at Nov 1st, 2026 12:30 AM.",
|
||||
].join("\n");
|
||||
expect(detectCodexLimitBanner(pane)).toMatchObject({
|
||||
resetText: "Nov 1st, 2026 12:30 AM",
|
||||
});
|
||||
});
|
||||
|
||||
it("tolerates leading indent but still requires the marker first", () => {
|
||||
expect(detectCodexLimitBanner(" ■ You've hit your usage limit. try again at 4:14 AM.")).toMatchObject({
|
||||
resetText: "4:14 AM",
|
||||
|
||||
@@ -3,9 +3,6 @@
|
||||
// per poll, filling the seat's held quota. Real SessionTransport, real draft-aware delivery and outbox on an
|
||||
// in-memory database; the tmux adapter records commands instead of running them.
|
||||
import Database from "better-sqlite3";
|
||||
import { Hono } from "hono";
|
||||
import { inputReadDiagnostic } from "../src/domain/input-read-diagnostic.js";
|
||||
import { compactionRoutes } from "../src/routes/compaction.js";
|
||||
import stringWidth from "string-width";
|
||||
import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
@@ -86,11 +83,7 @@ async function fixture(opts: { pane: string | null; mode?: "off" | "draft-aware"
|
||||
const effects = () => ({ compactPastes: writes.filter((w) => w.startsWith("paste:/compact")).length, prepPastes: writes.filter((w) => w.startsWith("paste:") && !w.startsWith("paste:/compact")).length, enters: writes.filter((w) => w === "enter").length });
|
||||
const compactRows = (state?: string) => (db.prepare("SELECT outbox_id AS id, delivery_state AS state FROM outbox_entries WHERE body LIKE '/compact%'").all() as Array<{ id: string; state: string }>)
|
||||
.filter((r) => !state || r.state === state);
|
||||
return { db, e, transport, tmux, writes, compactRows, rows, effects, stageMap, recover: () => {
|
||||
const next = new ClaudeCompactionEnforcer({ resolveClaudeCompactionPolicy: () => policy } as any, transport,
|
||||
{ openrigHome: home, now: () => clock, resolveOccupantGeneration: () => "g1" });
|
||||
next.recoverSeat(input); return next;
|
||||
}, body: () => body,
|
||||
return { db, e, transport, tmux, writes, compactRows, rows, effects, stageMap, body: () => body,
|
||||
onStage: (fn: () => void) => { onStage = fn; }, activity: (s: string) => { activity = s; }, draft: (text: string) => { body = text; } };
|
||||
}
|
||||
|
||||
@@ -368,95 +361,3 @@ it("the managed idle wait treats only a recognized draft according to the ordina
|
||||
expect(await f.e.triggerManualCompact(input, { operatorInitiated: true, skipMap: true })).toMatchObject({ triggered: true });
|
||||
expect(f.effects()).toMatchObject({ prepPastes: 1, compactPastes: 1, enters: 2 });
|
||||
});
|
||||
|
||||
|
||||
it.each(["preparation", "compact"] as const)("records the %s refusal through the existing transport without content or input", async stage => {
|
||||
const f = await fixture({ pane: "%1", mode: stage === "preparation" ? "draft-aware" : "off" });
|
||||
const send = f.transport.send.bind(f.transport);
|
||||
vi.spyOn(f.transport, "send").mockImplementation(async (s, text, opts) => {
|
||||
if (stage === "preparation") {
|
||||
vi.mocked(f.tmux.captureComposerSnapshot).mockResolvedValue({ ...frame("PRIVATE_INPUT https://private.invalid/gateway"), inMode: true });
|
||||
} else if (text.startsWith("/compact")) {
|
||||
vi.spyOn(f.transport as any, "captureInputBox").mockResolvedValue({ status: "unknown", confidence: "unknown", cursor: null,
|
||||
evidence: ["input box reads timed out"], input: { text: "PRIVATE_INPUT" }, hint: "https://private.invalid/gateway" });
|
||||
return send(s, text, { ...opts, preserveInput: true });
|
||||
}
|
||||
return send(s, text, opts);
|
||||
});
|
||||
await f.e.triggerManualCompact(input, { operatorInitiated: true, skipMap: true });
|
||||
const state = f.e.getPreparationState(seat)!;
|
||||
const expected = stage === "preparation"
|
||||
? { stage, check: "draft-aware", status: "unknown", confidence: null, reason: "pane_in_mode", timedOut: null,
|
||||
cursor: { present: true, valid: true, atEnd: false } }
|
||||
: { stage, check: "composer", status: "unknown", confidence: "unknown", reason: "read_timeout", timedOut: true,
|
||||
cursor: { present: false, valid: null, atEnd: null } };
|
||||
expect(state).toMatchObject({ inputReadFailure: expected, compact: "not_sent" });
|
||||
expect(f.effects()).toEqual({ compactPastes: 0, prepPastes: stage === "preparation" ? 0 : 1, enters: stage === "preparation" ? 0 : 1 });
|
||||
const record = readFileSync(join(dirname(state.mapPath), COMPACTION_RECORD_FILE), "utf8");
|
||||
expect(JSON.parse(record)).toMatchObject({ inputReadFailure: expected });
|
||||
const app = new Hono();
|
||||
app.use("*", async (c, next) => { c.set("compactionEnforcer" as never, f.e as never); c.set("db" as never, f.db as never); await next(); });
|
||||
app.route("/api/compaction", compactionRoutes());
|
||||
const response = await app.request(`/api/compaction/state?session=${seat}`);
|
||||
const body = await response.text();
|
||||
expect(response.status).toBe(200);
|
||||
expect(JSON.parse(body)).toMatchObject({ preparation: { inputReadFailure: expected } });
|
||||
expect(record + body).not.toMatch(/PRIVATE_INPUT|private\.invalid/);
|
||||
});
|
||||
|
||||
it("distinguishes an opted-in composer timeout from the separate draft-aware check", async () => {
|
||||
const f = await fixture({ pane: "%1", mode: "off" });
|
||||
const send = f.transport.send.bind(f.transport);
|
||||
vi.spyOn(f.transport as any, "captureInputBox").mockResolvedValue({ status: "unknown", confidence: "unknown", cursor: null,
|
||||
evidence: ["input box reads timed out"], input: { text: "PRIVATE_INPUT" }, suggestion: { text: "PRIVATE_SUGGESTION" }, hint: "https://private.invalid/gateway" });
|
||||
vi.spyOn(f.transport, "send").mockImplementation((s, text, opts) => send(s, text, { ...opts, preserveInput: true }));
|
||||
await f.e.triggerManualCompact(input, { operatorInitiated: true, skipMap: true });
|
||||
const state = f.e.getPreparationState(seat)!;
|
||||
expect(state).toMatchObject({ delivery: "not_sent", inputReadFailure: { stage: "preparation", check: "composer", reason: "read_timeout", timedOut: true,
|
||||
status: "unknown", confidence: "unknown", cursor: { present: false, valid: null, atEnd: null } } });
|
||||
const record = readFileSync(join(dirname(state.mapPath), COMPACTION_RECORD_FILE), "utf8");
|
||||
expect(record).not.toMatch(/PRIVATE_INPUT|PRIVATE_SUGGESTION|private\.invalid/);
|
||||
expect(f.writes).toEqual([]);
|
||||
});
|
||||
|
||||
|
||||
it("retains a held completed map's compact diagnostic across record recovery", async () => {
|
||||
const f = await fixture({ pane: "%1", mode: "off" });
|
||||
await f.e.maybeAutoCompact(input); f.stageMap();
|
||||
const send = f.transport.send.bind(f.transport);
|
||||
vi.spyOn(f.transport as any, "captureInputBox").mockResolvedValue({ status: "unknown", confidence: "unknown", cursor: null, evidence: ["cursor unavailable"] });
|
||||
vi.spyOn(f.transport, "send").mockImplementation((s, text, opts) => send(s, text, { ...opts, preserveInput: true }));
|
||||
expect(await f.e.maybeAutoCompact(input)).toMatchObject({ triggered: false, reason: "prepared_awaiting_window" });
|
||||
const state = f.e.getPreparationState(seat)!;
|
||||
expect(state).toMatchObject({ prepared: true, compact: "not_sent", inputReadFailure: { stage: "compact", check: "composer", reason: "cursor_unavailable", timedOut: null } });
|
||||
const recordPath = join(dirname(state.mapPath), COMPACTION_RECORD_FILE);
|
||||
const record = JSON.parse(readFileSync(recordPath, "utf8"));
|
||||
// A new enforcer in this process deliberately ignores this process's live record; model a predecessor daemon.
|
||||
writeFileSync(recordPath, JSON.stringify({ ...record, daemon: "previous-daemon" }));
|
||||
expect(f.recover().getPreparationState(seat)?.inputReadFailure).toEqual(state.inputReadFailure);
|
||||
expect(f.effects()).toEqual({ compactPastes: 0, prepPastes: 1, enters: 1 });
|
||||
});
|
||||
|
||||
it("records the same diagnostic on an owed restore step without advancing it", async () => {
|
||||
const f = await fixture({ pane: "%1", mode: "off" });
|
||||
await f.e.triggerManualCompact(input, { operatorInitiated: true, skipMap: true });
|
||||
await f.e.maybeAutoCompact(input);
|
||||
expect(f.e.getOwedRestore(seat)?.stage).toBe("restore_prompt");
|
||||
const before = f.effects(), send = f.transport.send.bind(f.transport);
|
||||
vi.spyOn(f.transport as any, "captureInputBox").mockResolvedValue({ status: "unknown", confidence: "unknown", cursor: null, evidence: ["cursor unavailable"] });
|
||||
vi.spyOn(f.transport, "send").mockImplementation((s, text, opts) => send(s, text, { ...opts, preserveInput: true }));
|
||||
await f.e.maybeAutoCompact(input);
|
||||
expect(f.e.getOwedRestore(seat)).toMatchObject({ stage: "restore_prompt", lastDelivery: { stage: "restore_prompt", reason: "draft_input_unknown",
|
||||
inputReadFailure: { check: "composer", reason: "cursor_unavailable", timedOut: null } } });
|
||||
expect(f.effects()).toEqual(before);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ state: "unknown", unknownReason: "invalid_cursor", cursor: { x: -1, y: 0, width: 80, height: 24 }, reason: "invalid_cursor", valid: false },
|
||||
{ state: "unknown", unknownReason: "ambiguous_composer", reason: "ambiguous_composer", valid: null },
|
||||
{ state: "unknown", unknownReason: "PRIVATE_INPUT https://private.invalid/gateway", reason: "capture_unavailable", valid: null },
|
||||
])("diagnostic vocabulary excludes content and reports existing cursor validity ($reason)", ({ reason, valid, ...reading }) => {
|
||||
const safe = inputReadDiagnostic("draft-aware", { ...reading, rows: ["PRIVATE_INPUT"], suggestionRows: ["PRIVATE_SUGGESTION"], frame: "https://private.invalid/gateway" } as any);
|
||||
expect(safe).toMatchObject({ reason, cursor: { valid } });
|
||||
expect(JSON.stringify(safe)).not.toMatch(/PRIVATE_INPUT|PRIVATE_SUGGESTION|private\.invalid|rows|frame/);
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { describeCheckIn, memberCheckInStatus, readWakeOutcome } from "../src/domain/connection-contract.js";
|
||||
import { readWakeOutcome } from "../src/domain/connection-contract.js";
|
||||
import { NUDGE_FAIL_PREFIX } from "../src/domain/policies/parked-owner-consumer.js";
|
||||
|
||||
describe("readWakeOutcome: every recorded nudge result reads as a delivery fact", () => {
|
||||
@@ -51,73 +51,3 @@ describe("readWakeOutcome: every recorded nudge result reads as a delivery fact"
|
||||
expect(readWakeOutcome(undefined)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("memberCheckInStatus: a hosted member's check-in status", () => {
|
||||
const now = new Date("2026-11-12T12:00:00.000Z");
|
||||
const seenAgo = (minutes: number) => ({ state: "seen" as const, at: new Date(now.getTime() - minutes * 60_000).toISOString() });
|
||||
const status = (checkInMinutes: number | null, checkIn: Parameters<typeof memberCheckInStatus>[0]["checkIn"], revokedAt?: string) =>
|
||||
memberCheckInStatus({ checkInMinutes, checkIn, now, ...(revokedAt ? { revokedAt } : {}) }).status;
|
||||
|
||||
it("with a stated cadence, one rule: checking in up to exactly twice it, overdue a moment after", () => {
|
||||
const at = (ms: number) => ({ state: "seen" as const, at: new Date(now.getTime() - ms).toISOString() });
|
||||
expect(status(60, seenAgo(5))).toBe("checking-in");
|
||||
expect(status(60, seenAgo(90))).toBe("checking-in"); // past one cadence is not yet overdue
|
||||
expect(status(60, at(2 * 60 * 60_000))).toBe("checking-in");
|
||||
expect(status(60, at(2 * 60 * 60_000 + 1))).toBe("check-in-overdue");
|
||||
expect(status(1, at(2 * 60_000 + 1))).toBe("check-in-overdue"); // any positive cadence works the same way
|
||||
});
|
||||
|
||||
it("a stated cadence that can't be used reads unknown with its reason, ahead of every other reading", () => {
|
||||
expect(memberCheckInStatus({ checkInMinutes: null, cadenceProblem: "check_in_minutes \"60\" isn't a positive whole number of minutes",
|
||||
checkIn: seenAgo(5), now })).toMatchObject({ status: "unknown", reason: expect.stringContaining("check_in_minutes") });
|
||||
expect(memberCheckInStatus({ checkInMinutes: 60, checkIn: { state: "unknown" }, now })).toMatchObject({ status: "unknown", reason: "its check-ins couldn't be read" });
|
||||
});
|
||||
|
||||
it("with no stated cadence it reads unknown with its last check-in, never on request, idle or overdue", () => {
|
||||
expect(memberCheckInStatus({ checkInMinutes: null, checkIn: seenAgo(5), now }))
|
||||
.toMatchObject({ status: "unknown", reason: "no check-in cadence declared", lastCheckInAt: seenAgo(5).at });
|
||||
expect(status(null, seenAgo(10_000))).toBe("unknown");
|
||||
});
|
||||
|
||||
it("on request only when its setup says so, whatever its last check-in's age", () => {
|
||||
const onRequest = (checkIn: Parameters<typeof memberCheckInStatus>[0]["checkIn"]) =>
|
||||
memberCheckInStatus({ checkInMinutes: null, onRequest: true, checkIn, now });
|
||||
expect(onRequest(seenAgo(5)).status).toBe("on-request");
|
||||
expect(onRequest(seenAgo(10_000))).toMatchObject({ status: "on-request", onRequest: true });
|
||||
expect(onRequest({ state: "never" }).status).toBe("no-check-in-yet");
|
||||
expect(memberCheckInStatus({ checkInMinutes: undefined, checkIn: seenAgo(5), now })).toMatchObject({ lastCheckInAt: seenAgo(5).at, expectedMinutes: null });
|
||||
});
|
||||
|
||||
it("no recorded check-in reads never, with or without a cadence; an unreadable record reads unknown, never idle or overdue", () => {
|
||||
expect(status(60, { state: "never" })).toBe("no-check-in-yet");
|
||||
expect(memberCheckInStatus({ checkInMinutes: null, checkIn: { state: "never" }, now })).toMatchObject({ status: "no-check-in-yet", expectedMinutes: null });
|
||||
expect(status(60, { state: "unknown" })).toBe("unknown");
|
||||
expect(status(60, { state: "seen", at: "not a time" })).toBe("unknown");
|
||||
});
|
||||
|
||||
it("disconnected comes only from a recorded revocation, ahead of everything else; silence never produces it", () => {
|
||||
expect(status(60, seenAgo(5), "2026-11-12T11:00:00.000Z")).toBe("disconnected");
|
||||
expect(status(60, seenAgo(100_000))).toBe("check-in-overdue");
|
||||
expect(status(null, { state: "never" })).not.toBe("disconnected");
|
||||
});
|
||||
});
|
||||
|
||||
describe("describeCheckIn: one line, with its evidence", () => {
|
||||
const base = { lastCheckInAt: "2026-11-12T13:05:00.000Z", expectedMinutes: 60, onRequest: false };
|
||||
it("overdue carries its last check-in, its expected cadence and the suspected reason", () => {
|
||||
expect(describeCheckIn({ ...base, status: "check-in-overdue" }))
|
||||
.toBe("overdue: last check-in 2026-11-12T13:05:00.000Z, expected every 60 min; its schedule may be off or waiting for an approval");
|
||||
expect(describeCheckIn({ ...base, status: "checking-in" })).toBe("checking in: last check-in 2026-11-12T13:05:00.000Z, expected every 60 min");
|
||||
});
|
||||
it("names what setup says when there's no check-in, and gives an unknown its reason and last check-in", () => {
|
||||
expect(describeCheckIn({ ...base, status: "no-check-in-yet", lastCheckInAt: null })).toBe("no check-in yet; expected every 60 min");
|
||||
expect(describeCheckIn({ ...base, status: "no-check-in-yet", lastCheckInAt: null, expectedMinutes: null, onRequest: true }))
|
||||
.toBe("no check-in yet; checks in when a person asks it");
|
||||
expect(describeCheckIn({ ...base, status: "no-check-in-yet", lastCheckInAt: null, expectedMinutes: null }))
|
||||
.toBe("no check-in yet; no check-in cadence declared");
|
||||
expect(describeCheckIn({ ...base, status: "on-request", expectedMinutes: null, onRequest: true }))
|
||||
.toBe("on request: it checks its queue when a person asks it; last check-in 2026-11-12T13:05:00.000Z");
|
||||
expect(describeCheckIn({ ...base, status: "unknown", expectedMinutes: null, reason: "no check-in cadence declared" }))
|
||||
.toBe("unknown (no check-in cadence declared); last check-in 2026-11-12T13:05:00.000Z");
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,7 @@ import { queueInboxCheckinsSchema } from "../../src/db/migrations/158_queue_inbo
|
||||
import { queueItemsHandedOffFromIndexSchema } from "../../src/db/migrations/159_queue_items_handed_off_from_index.js";
|
||||
import { networkBindingsSchema } from "../../src/db/migrations/160_network_bindings.js";
|
||||
import { networkReceiptsSchema } from "../../src/db/migrations/161_network_receipts.js";
|
||||
import { networkInstallationsSchema } from "../../src/db/migrations/163_network_installations.js";
|
||||
import { activityGrantsSchema } from "../../src/db/migrations/143_activity_grants.js";
|
||||
import { environmentBindingsSchema } from "../../src/db/migrations/144_environment_bindings.js";
|
||||
import { nodePermissionsToSchema } from "../../src/db/migrations/145_node_permissions_to.js";
|
||||
@@ -169,7 +170,7 @@ import fs from "node:fs";
|
||||
|
||||
/** Seam B R6: the canonical full-fixture migration list, exported so file-backed
|
||||
* DB-reopen tests migrate IDENTICALLY to createFullTestDb. */
|
||||
export const migrationsForFullTestDb = [coreSchema, bindingsSessionsSchema, eventsSchema, snapshotsSchema, checkpointsSchema, resumeMetadataSchema, nodeSpecFieldsSchema, packagesSchema, installJournalSchema, journalSeqSchema, bootstrapSchema, discoverySchema, discoveryFkFix, agentspecRebootSchema, startupContextSchema, chatMessagesSchema, podNamespaceSchema, contextUsageSchema, externalCliAttachmentSchema, rigServicesSchema, seatHandoverObservabilitySchema, nodeCodexConfigProfileSchema, nodePermissionPolicySchema, rigPermissionPolicySchema, nodePolicyProvenanceSchema, rigPolicyProvenanceSchema, streamItemsSchema, queueItemsSchema, queueTransitionsSchema, rigPolicySchema, rigArchiveSchema, resumeProvenanceSchema, resumeVerificationSchema, seatIdentityVerdictsSchema, selfHostIdentitySchema, occupantTenuresSchema, daemonLifecycleSchema, watchdogJobsSchema, occupantGenerationStampsSchema, projectionManifestSchema, watchdogTargetGenerationSchema, appliedLaunchObservationsSchema, appliedLaunchObservationInvalidationsSchema, threadSeatMapSchema, queueTransitionWakesSchema, nodeSessionSourceSchema, scopedOperatingPostureSchema, humanNotificationIntentSchema, reviewReadIndexesSchema, inventoryEventIndexesSchema, rigClaudeManagedBlockFileSchema, nodePermissionSelectionsSchema, humanReplyToSchema, humanQuestionsSchema, nodeEffortSchema, rigNonInterruptiveSchema, rigInstallRootSchema, threadPartMapSchema, queueTransitionsQitemIdOrderSchema, queueItemsRecoveryTagsSchema, resumeRotationSchema, sessionStopIntentSchema, nodeEffortChoiceSchema, modePostureCoexistSchema, seatEnvSchema, appliedLaunchDaemonOptOutSchema, sessionCommunityHarnessSchema, threadSeatMapChannelKeySchema, queueItemWakeInputBoxSchema, providerAccountReadingsSchema, humanQuietWindowsSchema, seatIdentityPanePidSchema, seatIdentityProcessEvidenceSchema, rigStartupLayersSchema, humanOperatingModesSchema, queueDelegatedDecisionSchema, nodeAdvisorSchema, operationCorrelationSchema, terminalPolicySchema, seatRuntimeSwitchesSchema, claudeTokenReadingsSchema, nodeLaunchRecipeSchema, nodeClaudeTokenRefSchema, nodeClaudePermissionsSchema, nodeCodexPermissionsSchema, escalationTargetRefsSchema, sessionAgentRunnerSchema, nodeAccountHomeSchema, nodeClaudeMcpSchema, activityGrantsSchema, environmentBindingsSchema, nodePermissionsToSchema, nodeStartupGuidanceFileSchema, managedBlockWritesSchema, streamIntakeCursorSchema, nodeLaunchWrapperSchema, piAccountSwitchesSchema, eventsRetentionMarksSchema, queueErasureSchema, queueErasureBatchesSchema, queueInboxCheckinsSchema, queueItemsHandedOffFromIndexSchema, networkBindingsSchema, networkReceiptsSchema, sessionAddressReadIndexesSchema];
|
||||
export const migrationsForFullTestDb = [coreSchema, bindingsSessionsSchema, eventsSchema, snapshotsSchema, checkpointsSchema, resumeMetadataSchema, nodeSpecFieldsSchema, packagesSchema, installJournalSchema, journalSeqSchema, bootstrapSchema, discoverySchema, discoveryFkFix, agentspecRebootSchema, startupContextSchema, chatMessagesSchema, podNamespaceSchema, contextUsageSchema, externalCliAttachmentSchema, rigServicesSchema, seatHandoverObservabilitySchema, nodeCodexConfigProfileSchema, nodePermissionPolicySchema, rigPermissionPolicySchema, nodePolicyProvenanceSchema, rigPolicyProvenanceSchema, streamItemsSchema, queueItemsSchema, queueTransitionsSchema, rigPolicySchema, rigArchiveSchema, resumeProvenanceSchema, resumeVerificationSchema, seatIdentityVerdictsSchema, selfHostIdentitySchema, occupantTenuresSchema, daemonLifecycleSchema, watchdogJobsSchema, occupantGenerationStampsSchema, projectionManifestSchema, watchdogTargetGenerationSchema, appliedLaunchObservationsSchema, appliedLaunchObservationInvalidationsSchema, threadSeatMapSchema, queueTransitionWakesSchema, nodeSessionSourceSchema, scopedOperatingPostureSchema, humanNotificationIntentSchema, reviewReadIndexesSchema, inventoryEventIndexesSchema, rigClaudeManagedBlockFileSchema, nodePermissionSelectionsSchema, humanReplyToSchema, humanQuestionsSchema, nodeEffortSchema, rigNonInterruptiveSchema, rigInstallRootSchema, threadPartMapSchema, queueTransitionsQitemIdOrderSchema, queueItemsRecoveryTagsSchema, resumeRotationSchema, sessionStopIntentSchema, nodeEffortChoiceSchema, modePostureCoexistSchema, seatEnvSchema, appliedLaunchDaemonOptOutSchema, sessionCommunityHarnessSchema, threadSeatMapChannelKeySchema, queueItemWakeInputBoxSchema, providerAccountReadingsSchema, humanQuietWindowsSchema, seatIdentityPanePidSchema, seatIdentityProcessEvidenceSchema, rigStartupLayersSchema, humanOperatingModesSchema, queueDelegatedDecisionSchema, nodeAdvisorSchema, operationCorrelationSchema, terminalPolicySchema, seatRuntimeSwitchesSchema, claudeTokenReadingsSchema, nodeLaunchRecipeSchema, nodeClaudeTokenRefSchema, nodeClaudePermissionsSchema, nodeCodexPermissionsSchema, escalationTargetRefsSchema, sessionAgentRunnerSchema, nodeAccountHomeSchema, nodeClaudeMcpSchema, activityGrantsSchema, environmentBindingsSchema, nodePermissionsToSchema, nodeStartupGuidanceFileSchema, managedBlockWritesSchema, streamIntakeCursorSchema, nodeLaunchWrapperSchema, piAccountSwitchesSchema, eventsRetentionMarksSchema, queueErasureSchema, queueErasureBatchesSchema, queueInboxCheckinsSchema, queueItemsHandedOffFromIndexSchema, networkBindingsSchema, networkReceiptsSchema, sessionAddressReadIndexesSchema, networkInstallationsSchema];
|
||||
|
||||
/**
|
||||
* P24 — the DECLARED exclusions for {@link migrationsForFullTestDb}. That list is deliberately a
|
||||
|
||||
@@ -234,48 +234,6 @@ describe("InstallEngine", () => {
|
||||
expect(fs.readFileSync(targetPath, "utf8")).toBe(`${prefix}<!-- BEGIN OpenRig MANAGED BLOCK: test-pkg -->\nUpdated guidance.\n<!-- END OpenRig MANAGED BLOCK: test-pkg -->\n# Footer\n`);
|
||||
});
|
||||
|
||||
it("keeps one copy of its block: the first is updated in place and the repeats go", () => {
|
||||
const pkg = seedPackage();
|
||||
const sourcePath = writeSource("guidance/AGENTS.md", "Updated guidance.");
|
||||
const targetPath = path.join(repoRoot, "AGENTS.md");
|
||||
const old = "<!-- BEGIN OpenRig MANAGED BLOCK: test-pkg -->\nOld content.\n<!-- END OpenRig MANAGED BLOCK: test-pkg -->";
|
||||
fs.writeFileSync(targetPath, `# Header\n\n${old}\n\n${old}\n# Footer\n`);
|
||||
const entry = makeEntry({ exportType: "guidance", classification: "managed_merge", targetPath, sourcePath });
|
||||
new InstallEngine(installRepo, realFs(tmpDir)).apply(makePolicy([entry]), makePlan([entry]), pkg.id, repoRoot);
|
||||
expect(fs.readFileSync(targetPath, "utf8")).toBe(
|
||||
"# Header\n\n<!-- BEGIN OpenRig MANAGED BLOCK: test-pkg -->\nUpdated guidance.\n<!-- END OpenRig MANAGED BLOCK: test-pkg -->\n# Footer\n");
|
||||
});
|
||||
|
||||
it("keeps a later copy someone edited, and every different edit, with the text between", () => {
|
||||
const pkg = seedPackage();
|
||||
const sourcePath = writeSource("guidance/AGENTS.md", "Updated guidance.");
|
||||
const targetPath = path.join(repoRoot, "AGENTS.md");
|
||||
const copy = (body: string) => `<!-- BEGIN OpenRig MANAGED BLOCK: test-pkg -->\n${body}\n<!-- END OpenRig MANAGED BLOCK: test-pkg -->`;
|
||||
fs.writeFileSync(targetPath, `# Header\n\n${copy("Old content.")}\n\nmine\n\n${copy("Old content, with my rule.")}\n\n${copy("Another edit.")}\n`);
|
||||
const entry = makeEntry({ exportType: "guidance", classification: "managed_merge", targetPath, sourcePath });
|
||||
new InstallEngine(installRepo, realFs(tmpDir)).apply(makePolicy([entry]), makePlan([entry]), pkg.id, repoRoot);
|
||||
expect(fs.readFileSync(targetPath, "utf8")).toBe(
|
||||
`# Header\n\n${copy("Updated guidance.")}\n\nmine\n\n${copy("Old content, with my rule.")}\n\n${copy("Another edit.")}\n`);
|
||||
});
|
||||
|
||||
it("installs the rest when a guidance block lost its END: that file is left as it is and named, with no copy added", () => {
|
||||
const pkg = seedPackage();
|
||||
const skillSource = writeSource("skills/foo/SKILL.md", "# Foo Skill");
|
||||
const skillTarget = path.join(repoRoot, ".agents/skills/foo/SKILL.md");
|
||||
const guidanceSource = writeSource("guidance/AGENTS.md", "Updated guidance.");
|
||||
const guidanceTarget = path.join(repoRoot, "AGENTS.md");
|
||||
const text = "# Header\n<!-- BEGIN OpenRig MANAGED BLOCK: test-pkg -->\nOld content.\n# Footer\n";
|
||||
fs.writeFileSync(guidanceTarget, text);
|
||||
const skill = makeEntry({ exportName: "foo", targetPath: skillTarget, sourcePath: skillSource });
|
||||
const guidance = makeEntry({ exportType: "guidance", classification: "managed_merge", targetPath: guidanceTarget, sourcePath: guidanceSource });
|
||||
const result = new InstallEngine(installRepo, realFs(tmpDir)).apply(makePolicy([skill, guidance]), makePlan([skill, guidance]), pkg.id, repoRoot);
|
||||
expect(fs.readFileSync(guidanceTarget, "utf8")).toBe(text);
|
||||
expect(fs.readFileSync(skillTarget, "utf8")).toBe("# Foo Skill");
|
||||
expect(result.applied.map((e) => e.targetPath)).toEqual([skillTarget]);
|
||||
expect(result.notes).toEqual([expect.stringMatching(/^guidance_block_markers_damaged: .*AGENTS\.md: test-pkg has a BEGIN or END marker without its pair/)]);
|
||||
expect(installRepo.getInstall(result.installId)?.status).toBe("applied");
|
||||
});
|
||||
|
||||
// Test 7: Backup created before overwrite
|
||||
it("backup created before overwrite", () => {
|
||||
const pkg = seedPackage();
|
||||
|
||||
@@ -1,195 +0,0 @@
|
||||
// A guidance file keeps exactly one copy of each OpenRig-managed block, however often OpenRig writes it: after an editor
|
||||
// or formatter touched its markers, after copies collected, and when a marker lost its pair. A person's edit is kept.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
managedBlockStanding,
|
||||
managedBlockText,
|
||||
mergeManagedBlock,
|
||||
type ManagedBlockMergeFsOps,
|
||||
} from "../src/domain/managed-blocks.js";
|
||||
import { managedBlockBodyHash, writeManagedGuidanceBlock, type ManagedBlockRecords } from "../src/domain/managed-block-records.js";
|
||||
import { restoreMissingGuidance } from "../src/domain/restore-guidance.js";
|
||||
|
||||
const FILE = "/repo/AGENTS.md";
|
||||
const SOURCE = "/specs/culture.md";
|
||||
|
||||
/** An in-memory guidance file and source, so nothing here touches a real instruction file. */
|
||||
function memory(files: Record<string, string>): ManagedBlockMergeFsOps & { files: Record<string, string> } {
|
||||
return {
|
||||
files,
|
||||
exists: (p) => p in files,
|
||||
readFile: (p) => files[p]!,
|
||||
writeFile: (p, content) => { files[p] = content; },
|
||||
};
|
||||
}
|
||||
|
||||
function recordsOf(entries: Record<string, string> = {}): ManagedBlockRecords & { entries: Record<string, string> } {
|
||||
return {
|
||||
entries,
|
||||
get: (file, id) => entries[`${file}#${id}`],
|
||||
set: (file, id, body) => { entries[`${file}#${id}`] = managedBlockBodyHash(body); },
|
||||
};
|
||||
}
|
||||
|
||||
const count = (text: string, id = "CULTURE.md") => text.split(`<!-- BEGIN OpenRig MANAGED BLOCK: ${id} -->`).length - 1;
|
||||
|
||||
describe("the writer keeps one copy", () => {
|
||||
it("reads markers a formatter rewrapped as the block, and replaces it in place at every launch", () => {
|
||||
const touched = "# Notes\n\n<!--\n BEGIN OpenRig MANAGED BLOCK: CULTURE.md\n-->\nold culture\n<!-- END OpenRig MANAGED BLOCK: CULTURE.md -->\n\nafter\n";
|
||||
const fs = memory({ [FILE]: touched });
|
||||
const sizes: number[] = [];
|
||||
const written: unknown[] = [];
|
||||
for (let launch = 0; launch < 4; launch++) {
|
||||
written.push(mergeManagedBlock(fs, FILE, "CULTURE.md", "new culture"));
|
||||
sizes.push(fs.files[FILE]!.length);
|
||||
}
|
||||
expect(fs.files[FILE]).toBe(`# Notes\n\n${managedBlockText("CULTURE.md", "new culture")}\n\nafter\n`);
|
||||
expect(new Set(sizes).size).toBe(1);
|
||||
expect(written).toEqual([true, true, true, true]);
|
||||
});
|
||||
|
||||
it("collapses copies that collected to the first, and keeps a person's text before, between and after them", () => {
|
||||
const block = managedBlockText("CULTURE.md", "culture");
|
||||
const fs = memory({ [FILE]: `# Notes\n\n${block}\n\nperson text\n\n${block}\n\n${block}\n\nthe end\n` });
|
||||
mergeManagedBlock(fs, FILE, "CULTURE.md", "culture");
|
||||
expect(fs.files[FILE]).toBe(`# Notes\n\n${block}\n\nperson text\n\nthe end\n`);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["a BEGIN whose END was lost", "# Notes\n\n<!-- BEGIN OpenRig MANAGED BLOCK: CULTURE.md -->\nculture\n<!-- END OpenRig MANAGED BLOCK -->\n"],
|
||||
["an END whose BEGIN was lost", "# Notes\n\n<!-- BEGIN MANAGED BLOCK: CULTURE.md -->\nculture\n<!-- END OpenRig MANAGED BLOCK: CULTURE.md -->\n"],
|
||||
])("adds no copy beside %s, at any launch, and names it", (_name, text) => {
|
||||
const fs = memory({ [FILE]: text });
|
||||
const warnings: string[] = [];
|
||||
const written: unknown[] = [];
|
||||
for (let launch = 0; launch < 3; launch++) written.push(mergeManagedBlock(fs, FILE, "CULTURE.md", "culture", { warnings }));
|
||||
expect(fs.files[FILE]).toBe(text);
|
||||
expect(warnings).toHaveLength(3);
|
||||
expect(written).toEqual([false, false, false]);
|
||||
expect(warnings[0]).toMatch(/^guidance_block_markers_damaged: \/repo\/AGENTS\.md: CULTURE\.md has a BEGIN or END marker without its pair/);
|
||||
});
|
||||
|
||||
it("still replaces a complete copy when a lost marker sits beside it, leaves that marker, and names it", () => {
|
||||
const lone = "<!-- BEGIN OpenRig MANAGED BLOCK: CULTURE.md -->\nstray\n";
|
||||
const fs = memory({ [FILE]: `${lone}\n${managedBlockText("CULTURE.md", "old")}\n` });
|
||||
const warnings: string[] = [];
|
||||
const written = mergeManagedBlock(fs, FILE, "CULTURE.md", "new", { warnings });
|
||||
expect(fs.files[FILE]).toBe(`${lone}\n${managedBlockText("CULTURE.md", "new")}\n`);
|
||||
expect(written).toBe(true);
|
||||
expect(warnings).toEqual([expect.stringMatching(/^guidance_block_marker_unpaired: \/repo\/AGENTS\.md: CULTURE\.md was written/)]);
|
||||
});
|
||||
|
||||
it("appends to a file with no marker at all, as before", () => {
|
||||
const fs = memory({ [FILE]: "# Notes\n" });
|
||||
mergeManagedBlock(fs, FILE, "CULTURE.md", "culture");
|
||||
expect(fs.files[FILE]).toBe(`# Notes\n\n\n${managedBlockText("CULTURE.md", "culture")}`);
|
||||
});
|
||||
|
||||
it("reads a lost marker as damaged, not absent", () => {
|
||||
expect(managedBlockStanding("<!-- BEGIN OpenRig MANAGED BLOCK: CULTURE.md -->\nculture\n", "CULTURE.md", "culture", undefined, managedBlockBodyHash))
|
||||
.toBe("damaged");
|
||||
expect(managedBlockStanding("# no block\n", "CULTURE.md", "culture", undefined, managedBlockBodyHash)).toBe("absent");
|
||||
});
|
||||
});
|
||||
|
||||
describe("a launch's guidance write keeps a person's edit and drops only repeats", () => {
|
||||
const launch = (fs: ManagedBlockMergeFsOps, records: ManagedBlockRecords, source: string, warnings: string[]) =>
|
||||
writeManagedGuidanceBlock(fs, FILE, "CULTURE.md", source, { records, sourcePath: SOURCE, warnings });
|
||||
|
||||
it("refreshes OpenRig's own copies to one", () => {
|
||||
const old = managedBlockText("CULTURE.md", "old culture");
|
||||
const fs = memory({ [FILE]: `# Notes\n\n${old}\n\n${old}\n` });
|
||||
const records = recordsOf({ [`${FILE}#CULTURE.md`]: managedBlockBodyHash("old culture") });
|
||||
expect(launch(fs, records, "new culture", [])).toBe(true);
|
||||
expect(fs.files[FILE]).toBe(`# Notes\n\n${managedBlockText("CULTURE.md", "new culture")}\n`);
|
||||
});
|
||||
|
||||
it("keeps copies a formatter changed as edits, and leaves one of them", () => {
|
||||
const formatted = managedBlockText("CULTURE.md", "- culture, reformatted");
|
||||
const fs = memory({ [FILE]: `# Notes\n\n${formatted}\n\n${formatted}\n\n${formatted}\n` });
|
||||
const records = recordsOf({ [`${FILE}#CULTURE.md`]: managedBlockBodyHash("* culture") });
|
||||
const warnings: string[] = [];
|
||||
expect(launch(fs, records, "* culture", warnings)).toBe(false);
|
||||
expect(fs.files[FILE]).toBe(`# Notes\n\n${formatted}\n`);
|
||||
expect(warnings.map((w) => w.split(":")[0])).toEqual(["guidance_block_edited", "guidance_block_copies_removed"]);
|
||||
});
|
||||
|
||||
it("keeps a person's edited copy and drops OpenRig's unedited copy beside it", () => {
|
||||
const edited = managedBlockText("CULTURE.md", "culture, with my change");
|
||||
const fs = memory({ [FILE]: `${managedBlockText("CULTURE.md", "culture")}\n\n${edited}\n` });
|
||||
const records = recordsOf({ [`${FILE}#CULTURE.md`]: managedBlockBodyHash("culture") });
|
||||
expect(launch(fs, records, "culture", [])).toBe(false);
|
||||
expect(fs.files[FILE]).toBe(`${edited}\n`);
|
||||
});
|
||||
|
||||
it("keeps two different edited copies: it can't tell which one is meant", () => {
|
||||
const text = `${managedBlockText("CULTURE.md", "edit one")}\n\n${managedBlockText("CULTURE.md", "edit two")}\n`;
|
||||
const fs = memory({ [FILE]: text });
|
||||
expect(launch(fs, recordsOf({ [`${FILE}#CULTURE.md`]: managedBlockBodyHash("culture") }), "culture", [])).toBe(false);
|
||||
expect(fs.files[FILE]).toBe(text);
|
||||
});
|
||||
|
||||
it("records nothing and adds no copy beside a lost marker", () => {
|
||||
const text = "<!-- BEGIN OpenRig MANAGED BLOCK: CULTURE.md -->\nculture\n";
|
||||
const fs = memory({ [FILE]: text });
|
||||
const records = recordsOf();
|
||||
const warnings: string[] = [];
|
||||
const written: unknown[] = [];
|
||||
for (let i = 0; i < 3; i++) written.push(launch(fs, records, "culture", warnings));
|
||||
expect(fs.files[FILE]).toBe(text);
|
||||
expect(written).toEqual([false, false, false]);
|
||||
expect(records.entries).toEqual({});
|
||||
expect(warnings.every((w) => w.startsWith("guidance_block_markers_damaged: "))).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps going when the file can't be written: the edited block is kept as decided, and the tidy is named", () => {
|
||||
const formatted = managedBlockText("CULTURE.md", "- culture, reformatted");
|
||||
const text = `${formatted}\n\n${formatted}\n`;
|
||||
const fs = memory({ [FILE]: text });
|
||||
fs.writeFile = () => { throw new Error("EACCES: permission denied"); };
|
||||
const warnings: string[] = [];
|
||||
expect(launch(fs, recordsOf({ [`${FILE}#CULTURE.md`]: managedBlockBodyHash("* culture") }), "* culture", warnings)).toBe(false);
|
||||
expect(fs.files[FILE]).toBe(text);
|
||||
expect(warnings.map((w) => w.split(":")[0])).toEqual(["guidance_block_edited", "guidance_block_copies_not_removed"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("restore keeps one copy", () => {
|
||||
it("removes repeats of a current block, and a second restore changes nothing", () => {
|
||||
const current = managedBlockText("CULTURE.md", "* culture");
|
||||
const fs = memory({ [FILE]: `# Notes\n\n${current}\n\nmine\n\n${current}\n`, [SOURCE]: "* culture" });
|
||||
const records = recordsOf({ [`${FILE}#CULTURE.md`]: managedBlockBodyHash("* culture") });
|
||||
const guidance = { targetPath: FILE, items: [{ blockId: "CULTURE.md", sourcePath: SOURCE }] };
|
||||
restoreMissingGuidance(guidance, fs, records);
|
||||
const once = fs.files[FILE];
|
||||
restoreMissingGuidance(guidance, fs, records);
|
||||
expect(once).toBe(`# Notes\n\n${current}\n\nmine\n`);
|
||||
expect(fs.files[FILE]).toBe(once);
|
||||
});
|
||||
|
||||
it("without write records keeps the block's text, and still removes its exact repeats and puts rewrapped markers back", () => {
|
||||
const rewrapped = "<!--\n BEGIN OpenRig MANAGED BLOCK: CULTURE.md -->\nmy edit\n<!-- END OpenRig MANAGED BLOCK: CULTURE.md -->";
|
||||
const fs = memory({ [FILE]: `${rewrapped}\n\n${managedBlockText("CULTURE.md", "my edit")}\n`, [SOURCE]: "* culture" });
|
||||
restoreMissingGuidance({ targetPath: FILE, items: [{ blockId: "CULTURE.md", sourcePath: SOURCE }] }, fs);
|
||||
expect(fs.files[FILE]).toBe(`${managedBlockText("CULTURE.md", "my edit")}\n`);
|
||||
});
|
||||
|
||||
it("removes a kept block's repeats, and puts back nothing beside a lost marker", () => {
|
||||
const formatted = managedBlockText("CULTURE.md", "- culture, reformatted");
|
||||
const fs = memory({
|
||||
[FILE]: `# Notes\n\n${formatted}\n\n${formatted}\n\n<!-- BEGIN OpenRig MANAGED BLOCK: ROLE.md -->\nrole\n`,
|
||||
[SOURCE]: "* culture",
|
||||
"/specs/role.md": "role",
|
||||
});
|
||||
const records = recordsOf({ [`${FILE}#CULTURE.md`]: managedBlockBodyHash("* culture") });
|
||||
const repair = restoreMissingGuidance({ targetPath: FILE, items: [
|
||||
{ blockId: "CULTURE.md", sourcePath: SOURCE },
|
||||
{ blockId: "ROLE.md", sourcePath: "/specs/role.md" },
|
||||
] }, fs, records);
|
||||
expect(fs.files[FILE]).toBe(`# Notes\n\n${formatted}\n\n<!-- BEGIN OpenRig MANAGED BLOCK: ROLE.md -->\nrole\n`);
|
||||
expect(count(fs.files[FILE]!)).toBe(1);
|
||||
expect(repair.restored).toEqual([]);
|
||||
expect(repair.gaps.join("\n")).toContain("ROLE.md: /repo/AGENTS.md holds only part of this block");
|
||||
expect(repair.kept.map((k) => k.split(":")[0])).toEqual(["guidance_block_edited", "guidance_block_copies_removed"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,481 @@
|
||||
// Enrolling in an account and connecting seats by name, through the daemon's real network routes, against an account
|
||||
// that answers the way the account's connector routes do: a one-time code for an installation key, then a participant
|
||||
// list that answers a bearer for each newly listed name and revokes a name dropped from it.
|
||||
import { afterEach, beforeEach, expect, it, vi } from "vitest";
|
||||
import Database from "better-sqlite3";
|
||||
import { Hono } from "hono";
|
||||
import { mkdtempSync, readdirSync, rmSync, statSync } from "node:fs";
|
||||
import { setTimeout as sleep } from "node:timers/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join } from "node:path";
|
||||
import { ALL_MIGRATIONS } from "../src/db/all-migrations.js";
|
||||
import { migrate } from "../src/db/migrate.js";
|
||||
import { RigRepository } from "../src/domain/rig-repository.js";
|
||||
import { readNetworkCredential, readPrivateToken } from "../src/domain/network/network-bindings.js";
|
||||
import { networkInstallationKeyFile, type AccountFetch } from "../src/domain/network/network-installations.js";
|
||||
import { networkRoutes } from "../src/routes/network.js";
|
||||
|
||||
/** Makes removing a path with this ending fail, the way a busy file does. */
|
||||
const failRemoval = vi.hoisted(() => ({ suffix: null as string | null }));
|
||||
vi.mock("node:fs", async (importOriginal) => {
|
||||
const real = await importOriginal<typeof import("node:fs")>();
|
||||
return {
|
||||
...real,
|
||||
rmSync: (...args: Parameters<typeof real.rmSync>) => {
|
||||
if (failRemoval.suffix && String(args[0]).endsWith(failRemoval.suffix)) throw Object.assign(new Error("EBUSY: resource busy or locked, rm"), { code: "EBUSY" });
|
||||
return real.rmSync(...args);
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const KEY = `oik1_cust_inst-1_${"k".repeat(43)}`;
|
||||
|
||||
class FakeAccount {
|
||||
readonly codes = new Set(["ec1.good", "ec1.next"]);
|
||||
/** Each enrollment is a new installation with its own key and an empty list. */
|
||||
enrollments = 0;
|
||||
key = KEY;
|
||||
readonly used = new Set<string>();
|
||||
instance: string | null = null;
|
||||
names = new Map<string, { gen: number; state: "connected" | "revoked" }>();
|
||||
readonly elsewhere = new Set(["taken"]);
|
||||
readonly puts: string[][] = [];
|
||||
calls = 0;
|
||||
readonly inits: Array<{ redirect: string; signal: AbortSignal }> = [];
|
||||
/** Makes the next call fail the way fetch does on a redirect it won't follow. */
|
||||
redirectNext = false;
|
||||
/** The next call never answers. */
|
||||
stallNext = false;
|
||||
/** The next participant list is applied, then its answer is lost. */
|
||||
loseNext = false;
|
||||
down = false;
|
||||
mcp = "https://account.example/mcp";
|
||||
/** The next participant lists the account can't apply, because something else changed the installation. */
|
||||
dropFails = 0;
|
||||
/** How long a participant list waits before it's applied, so concurrent requests overlap. */
|
||||
delayMs = 0;
|
||||
/** After this many applied lists, the account goes down. */
|
||||
downAfterPuts: number | null = null;
|
||||
|
||||
/** The owner removed this installation on the account page. */
|
||||
forget(): void { this.instance = null; }
|
||||
|
||||
readonly fetch: AccountFetch = async (url, init) => {
|
||||
this.calls++;
|
||||
this.inits.push({ redirect: init.redirect, signal: init.signal });
|
||||
if (this.down) throw new Error("connect ECONNREFUSED");
|
||||
if (this.redirectNext) { this.redirectNext = false; throw Object.assign(new TypeError("fetch failed"), { cause: new Error("unexpected redirect") }); }
|
||||
if (this.stallNext) { this.stallNext = false; await new Promise((_resolve, reject) => init.signal.addEventListener("abort", () => reject(init.signal.reason), { once: true })); }
|
||||
const path = new URL(url).pathname;
|
||||
const body = JSON.parse(init.body) as { code?: string; instance?: string; participants?: string[] };
|
||||
const reply = (status: number, json: unknown) => ({ status, json: async () => json });
|
||||
if (path === "/api/connector/enroll" && init.method === "POST") {
|
||||
if (this.used.has(body.code!)) return reply(409, { error: "code_not_current" });
|
||||
if (!this.codes.has(body.code!)) return reply(400, { error: "code_not_current" });
|
||||
this.used.add(body.code!);
|
||||
this.instance = body.instance!;
|
||||
this.enrollments++;
|
||||
this.key = `${KEY}${this.enrollments}`;
|
||||
this.names = new Map();
|
||||
return reply(200, { installation: { id: `inst-${this.enrollments}`, name: "lab" }, key: this.key });
|
||||
}
|
||||
if (path === "/api/connector/identities" && init.method === "PUT") {
|
||||
if (init.headers.Authorization !== `Bearer ${this.key}` || body.instance !== this.instance) return reply(401, { error: "not_enrolled" });
|
||||
if (this.delayMs) await sleep(this.delayMs);
|
||||
const list = body.participants!;
|
||||
if (list.some((n) => this.elsewhere.has(n))) return reply(409, { error: "name_taken" });
|
||||
if (this.dropFails > 0) { this.dropFails--; return reply(409, { error: "drop_failed" }); }
|
||||
this.puts.push(list);
|
||||
for (const [name, entry] of this.names) if (!list.includes(name)) entry.state = "revoked";
|
||||
const participants = list.map((name) => {
|
||||
const entry = this.names.get(name);
|
||||
if (entry?.state === "connected") return { name, state: "connected" };
|
||||
const gen = (entry?.gen ?? 0) + 1;
|
||||
this.names.set(name, { gen, state: "connected" });
|
||||
return { name, state: "connected", bearer: `bear-${name}-${gen}` };
|
||||
});
|
||||
if (this.downAfterPuts !== null && this.puts.length >= this.downAfterPuts) this.down = true;
|
||||
if (this.loseNext) { this.loseNext = false; throw new Error("socket hang up"); }
|
||||
return reply(200, { participants, mcp: this.mcp, rig: "acct" });
|
||||
}
|
||||
return reply(404, { error: "not_found" });
|
||||
};
|
||||
}
|
||||
|
||||
let db: Database.Database;
|
||||
let home: string;
|
||||
let rigs: RigRepository;
|
||||
let account: FakeAccount;
|
||||
let app: Hono;
|
||||
let seq = 0;
|
||||
let hostId: string | null = "host-a";
|
||||
beforeEach(() => {
|
||||
hostId = "host-a";
|
||||
db = new Database(":memory:");
|
||||
migrate(db, ALL_MIGRATIONS);
|
||||
rigs = new RigRepository(db);
|
||||
home = mkdtempSync(join(tmpdir(), "openrig-network-account-"));
|
||||
account = new FakeAccount();
|
||||
app = new Hono();
|
||||
app.use("*", async (c, next) => { c.set("rigRepo" as never, rigs as never); await next(); });
|
||||
app.route("/api/network", networkRoutes({ home, accountFetch: account.fetch, selfHostId: () => hostId, accountTimeoutMs: 1000 }));
|
||||
});
|
||||
afterEach(() => { failRemoval.suffix = null; db.close(); rmSync(home, { recursive: true, force: true }); });
|
||||
|
||||
function seat(session: string): void {
|
||||
const rig = rigs.listRigs().find((r) => r.name === "lab") ?? rigs.createRig("lab");
|
||||
const node = rigs.addNode(rig.id, `n${++seq}`, { runtime: "claude-code" });
|
||||
db.prepare("INSERT INTO sessions (id, node_id, session_name, status) VALUES (?, ?, ?, 'running')").run(`s${seq}`, node.id, session);
|
||||
}
|
||||
const post = async (path: string, body: unknown) => {
|
||||
const res = await app.request(`/api/network${path}`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) });
|
||||
return { status: res.status, data: await res.json() as Record<string, any> };
|
||||
};
|
||||
const enroll = () => post("/installations", { code: "ec1.good", site: "https://account.example" });
|
||||
const connect = (session: string, name: string) => post("/bindings", { session, participantName: name });
|
||||
const replace = () => post("/installations", { code: "ec1.next", site: "https://account.example", replace: true });
|
||||
const status = async () => (await (await app.request("/api/network/bindings")).json()) as {
|
||||
bindings: Array<{ id: string; state: string; accountRemoval: string | null; accountRemovedAt: string | null }>; installations: Array<{ installationId: string }> };
|
||||
|
||||
it("enrolling trades the code for a key kept privately; a used code is refused with its reason; a second enroll at the site sends nothing", async () => {
|
||||
const enrolled = await enroll();
|
||||
expect(enrolled).toMatchObject({ status: 201, data: { site: "https://account.example/", installationId: "inst-1", name: "lab" } });
|
||||
expect(enrolled.data.id).toMatch(/^neti-[0-9a-f]{20}$/);
|
||||
expect(account.instance).toBe("host-a");
|
||||
const keyFile = networkInstallationKeyFile(home, enrolled.data.id);
|
||||
expect(statSync(keyFile).mode & 0o777).toBe(0o600);
|
||||
expect(readPrivateToken(keyFile, () => "missing")).toBe(account.key);
|
||||
const listed = await (await app.request("/api/network/bindings")).text();
|
||||
expect(listed).toContain(enrolled.data.id);
|
||||
expect(listed).not.toContain(KEY);
|
||||
|
||||
const calls = account.calls;
|
||||
expect(await enroll()).toMatchObject({ status: 409, data: { error: "already_enrolled" } });
|
||||
expect(account.calls).toBe(calls);
|
||||
expect(await post("/installations", { code: "ec1.good", site: "http://other.example", allowHttp: false })).toMatchObject({ status: 400, data: { error: "http_not_allowed" } });
|
||||
expect(account.calls).toBe(calls);
|
||||
|
||||
// The same code at another site: the account says it's spent.
|
||||
expect(await post("/installations", { code: "ec1.good", site: "https://second.example" })).toMatchObject({ status: 409, data: { error: "code_not_current" } });
|
||||
});
|
||||
|
||||
it("connecting by name lists it with the account and binds to the account's MCP front with its bearer; a second seat keeps the first listed", async () => {
|
||||
seat("a@lab");
|
||||
seat("b@lab");
|
||||
const enrolled = await enroll();
|
||||
const alpha = await connect("a@lab", "alpha");
|
||||
expect(alpha).toMatchObject({ status: 201, data: { localSession: "a@lab", hubUrl: "https://account.example/mcp", hubSeat: "alpha@acct",
|
||||
installationId: enrolled.data.id, participantName: "alpha", state: "active" } });
|
||||
expect(readNetworkCredential(home, alpha.data.id)).toBe("bear-alpha-1");
|
||||
const beta = await connect("b@lab", "beta");
|
||||
expect(beta.status).toBe(201);
|
||||
expect(account.puts.at(-1)).toEqual(["alpha", "beta"]);
|
||||
expect(readNetworkCredential(home, beta.data.id)).toBe("bear-beta-1");
|
||||
expect(account.names.get("alpha")).toEqual({ gen: 1, state: "connected" });
|
||||
});
|
||||
|
||||
it("a lost answer to the participant list is recovered on the next connect: the name is dropped and listed again for a fresh bearer", async () => {
|
||||
seat("a@lab");
|
||||
await enroll();
|
||||
account.loseNext = true;
|
||||
expect(await connect("a@lab", "alpha")).toMatchObject({ status: 503, data: { error: "account_unavailable" } });
|
||||
expect(account.names.get("alpha")).toEqual({ gen: 1, state: "connected" });
|
||||
const again = await connect("a@lab", "alpha");
|
||||
expect(again.status).toBe(201);
|
||||
expect(account.puts.slice(-2)).toEqual([[], ["alpha"]]);
|
||||
expect(readNetworkCredential(home, again.data.id)).toBe("bear-alpha-2");
|
||||
});
|
||||
|
||||
it("one deadline covers all of a command's account calls: a connect that needs three lists runs out of time instead of waiting for each", async () => {
|
||||
seat("a@lab");
|
||||
await enroll();
|
||||
account.loseNext = true;
|
||||
await connect("a@lab", "alpha");
|
||||
// Each list now takes 600 ms against this daemon's 1 s deadline, and recovering the lost answer needs three.
|
||||
account.delayMs = 600;
|
||||
const late = await connect("a@lab", "alpha");
|
||||
expect(late).toMatchObject({ status: 503, data: { error: "account_unavailable" } });
|
||||
expect(late.data.message).toContain("this command's time with the account ran out");
|
||||
expect(account.puts.at(-1)).toEqual([]);
|
||||
});
|
||||
|
||||
it("a name another connection holds is refused, and a seat that can't be connected sends nothing to the account", async () => {
|
||||
seat("a@lab");
|
||||
await enroll();
|
||||
expect(await connect("a@lab", "taken")).toMatchObject({ status: 409, data: { error: "name_taken" } });
|
||||
const calls = account.calls;
|
||||
expect(await connect("nobody@lab", "alpha")).toMatchObject({ status: 404, data: { error: "seat_not_found" } });
|
||||
expect(await connect("a@lab", "Not A Name")).toMatchObject({ status: 400, data: { error: "invalid_name" } });
|
||||
expect(account.calls).toBe(calls);
|
||||
const listed = await (await app.request("/api/network/bindings")).json() as { bindings: unknown[] };
|
||||
expect(listed.bindings).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("disconnect drops the name at the account; when the account can't be reached the binding still ends, and the retry it names drops it", async () => {
|
||||
seat("a@lab");
|
||||
seat("b@lab");
|
||||
await enroll();
|
||||
const alpha = await connect("a@lab", "alpha");
|
||||
await connect("b@lab", "beta");
|
||||
account.down = true;
|
||||
const failed = await post("/bindings/a@lab/disconnect", {});
|
||||
expect(failed).toMatchObject({ status: 502, data: { error: "account_update_unconfirmed" } });
|
||||
expect(failed.data.message).toContain("couldn't confirm that the account dropped alpha");
|
||||
expect(failed.data.message).toContain(`Run \`rig network disconnect ${alpha.data.id}\` again.`);
|
||||
expect(account.names.get("alpha")!.state).toBe("connected");
|
||||
// Status keeps saying it's unconfirmed until an answered list shows otherwise.
|
||||
const status = await (await app.request("/api/network/bindings")).json() as { bindings: Array<{ id: string; accountRemoval: string | null }> };
|
||||
expect(status.bindings.find((b) => b.id === alpha.data.id)!.accountRemoval).toBe("unconfirmed");
|
||||
account.down = false;
|
||||
const ended = await post(`/bindings/${alpha.data.id}/disconnect`, {});
|
||||
expect(ended).toMatchObject({ status: 200, data: { state: "disconnected", accountRemoval: "confirmed" } });
|
||||
expect(ended.data.accountRemovedAt).not.toBeNull();
|
||||
expect(account.puts.at(-1)).toEqual(["beta"]);
|
||||
expect(account.names.get("alpha")!.state).toBe("revoked");
|
||||
expect(account.names.get("beta")!.state).toBe("connected");
|
||||
});
|
||||
|
||||
it("a bind that fails here after the account listed the name has the account drop it again", async () => {
|
||||
seat("a@lab");
|
||||
await enroll();
|
||||
// The account answers a plain-http front, which this binding has no --allow-http choice for.
|
||||
account.mcp = "http://account.example/mcp";
|
||||
expect(await connect("a@lab", "alpha")).toMatchObject({ status: 400, data: { error: "http_not_allowed" } });
|
||||
expect(account.puts).toEqual([["alpha"], []]);
|
||||
expect(account.names.get("alpha")!.state).toBe("revoked");
|
||||
});
|
||||
|
||||
it("a participant list the account couldn't apply is retried once, and a second failure says to try again, not that the name is taken", async () => {
|
||||
seat("a@lab");
|
||||
seat("b@lab");
|
||||
await enroll();
|
||||
account.dropFails = 1;
|
||||
expect((await connect("a@lab", "alpha")).status).toBe(201);
|
||||
expect(account.names.get("alpha")).toEqual({ gen: 1, state: "connected" });
|
||||
account.dropFails = 2;
|
||||
expect(await connect("b@lab", "beta")).toMatchObject({ status: 503, data: { error: "account_busy" } });
|
||||
const listed = await (await app.request("/api/network/bindings")).json() as { bindings: Array<{ localSession: string }> };
|
||||
expect(listed.bindings.map((b) => b.localSession)).toEqual(["a@lab"]);
|
||||
});
|
||||
|
||||
it("the installation key goes only to the enrolled site: a redirect isn't followed with it, and a stalled call ends", async () => {
|
||||
seat("a@lab");
|
||||
account.redirectNext = true;
|
||||
expect(await enroll()).toMatchObject({ status: 502, data: { error: "account_redirected" } });
|
||||
expect(account.inits.every((i) => i.redirect === "error" && i.signal instanceof AbortSignal)).toBe(true);
|
||||
expect(account.used.size).toBe(0);
|
||||
await enroll();
|
||||
account.stallNext = true;
|
||||
const stalled = await connect("a@lab", "alpha");
|
||||
expect(stalled).toMatchObject({ status: 503, data: { error: "account_unavailable" } });
|
||||
expect(account.inits.every((i) => i.redirect === "error")).toBe(true);
|
||||
});
|
||||
|
||||
it("concurrent account writes keep every active name: two connects started together, and a disconnect racing a connect", async () => {
|
||||
seat("a@lab"); seat("b@lab"); seat("c@lab");
|
||||
await enroll();
|
||||
account.delayMs = 20;
|
||||
const [alpha, beta] = await Promise.all([connect("a@lab", "alpha"), connect("b@lab", "beta")]);
|
||||
expect([alpha.status, beta.status]).toEqual([201, 201]);
|
||||
expect(account.names.get("alpha")!.state).toBe("connected");
|
||||
expect(account.names.get("beta")!.state).toBe("connected");
|
||||
const [dropped, gamma] = await Promise.all([post(`/bindings/${alpha.data.id}/disconnect`, {}), connect("c@lab", "gamma")]);
|
||||
expect(dropped).toMatchObject({ status: 200, data: { accountRemoval: "confirmed" } });
|
||||
expect(gamma.status).toBe(201);
|
||||
expect(account.names.get("alpha")!.state).toBe("revoked");
|
||||
expect(account.names.get("beta")!.state).toBe("connected");
|
||||
expect(account.names.get("gamma")!.state).toBe("connected");
|
||||
expect(readNetworkCredential(home, gamma.data.id)).toBe(`bear-gamma-1`);
|
||||
});
|
||||
|
||||
it("enroll --replace repairs an installation the account forgot: the old key works until the new code is accepted, and old bindings are ended and named", async () => {
|
||||
seat("a@lab"); seat("b@lab");
|
||||
const first = await enroll();
|
||||
const alpha = await connect("a@lab", "alpha");
|
||||
account.forget();
|
||||
const refused = await connect("b@lab", "beta");
|
||||
expect(refused).toMatchObject({ status: 401, data: { error: "not_enrolled" } });
|
||||
expect(refused.data.message).toContain("rig network enroll <code> --site https://account.example/ --replace");
|
||||
expect(await enroll()).toMatchObject({ status: 409, data: { error: "already_enrolled" } });
|
||||
|
||||
// A refused new code changes nothing.
|
||||
const keyFile = networkInstallationKeyFile(home, first.data.id);
|
||||
const oldKey = readPrivateToken(keyFile, () => "missing");
|
||||
expect(await post("/installations", { code: "ec1.spent", site: "https://account.example", replace: true })).toMatchObject({ status: 400 });
|
||||
expect(readPrivateToken(keyFile, () => "missing")).toBe(oldKey);
|
||||
|
||||
const replaced = await post("/installations", { code: "ec1.next", site: "https://account.example", replace: true });
|
||||
expect(replaced).toMatchObject({ status: 201, data: { id: first.data.id, installationId: "inst-2",
|
||||
replaced: { endedBindings: [{ id: alpha.data.id, localSession: "a@lab", participantName: "alpha" }] } } });
|
||||
expect(readPrivateToken(keyFile, () => "missing")).toBe(account.key);
|
||||
const status = await (await app.request("/api/network/bindings")).json() as { bindings: Array<{ id: string; state: string; accountRemoval: string | null }> };
|
||||
expect(status.bindings.find((b) => b.id === alpha.data.id)).toMatchObject({ state: "disconnected", accountRemoval: "old-installation" });
|
||||
expect((await connect("b@lab", "beta")).status).toBe(201);
|
||||
});
|
||||
|
||||
it("a lost installation key is repaired the same way, and a failed replace keeps the old enrollment working", async () => {
|
||||
seat("a@lab"); seat("b@lab");
|
||||
const first = await enroll();
|
||||
await connect("a@lab", "alpha");
|
||||
// A refused replace leaves the old key in use.
|
||||
expect(await post("/installations", { code: "ec1.spent", site: "https://account.example", replace: true })).toMatchObject({ status: 400 });
|
||||
expect((await connect("b@lab", "beta")).status).toBe(201);
|
||||
|
||||
rmSync(join(networkInstallationKeyFile(home, first.data.id), ".."), { recursive: true });
|
||||
seat("c@lab");
|
||||
const missing = await connect("c@lab", "gamma");
|
||||
expect(missing).toMatchObject({ status: 404, data: { error: "credential_missing" } });
|
||||
expect(missing.data.message).toContain("--replace");
|
||||
expect((await post("/installations", { code: "ec1.next", site: "https://account.example", replace: true })).status).toBe(201);
|
||||
expect((await connect("c@lab", "gamma")).status).toBe(201);
|
||||
});
|
||||
|
||||
it("a replace that fails here after the account accepted the new code puts the old key back", async () => {
|
||||
const first = await enroll();
|
||||
const keyFile = networkInstallationKeyFile(home, first.data.id);
|
||||
const oldKey = readPrivateToken(keyFile, () => "missing");
|
||||
db.exec("CREATE TRIGGER fail_replace BEFORE UPDATE ON network_installations BEGIN SELECT RAISE(ABORT, 'disk I/O error'); END");
|
||||
const res = await app.request("/api/network/installations", { method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ code: "ec1.next", site: "https://account.example", replace: true }) });
|
||||
expect(res.status).toBe(500);
|
||||
expect(account.used.has("ec1.next")).toBe(true);
|
||||
expect(readPrivateToken(keyFile, () => "missing")).toBe(oldKey);
|
||||
expect(readdirSync(dirname(keyFile))).toEqual(["key.json"]);
|
||||
const status = await (await app.request("/api/network/bindings")).json() as { installations: Array<{ installationId: string }> };
|
||||
expect(status.installations.map((i) => i.installationId)).toEqual(["inst-1"]);
|
||||
});
|
||||
|
||||
it("an unresolved host id reads as try again, not enroll again, and sends nothing", async () => {
|
||||
seat("a@lab");
|
||||
await enroll();
|
||||
hostId = null;
|
||||
const calls = account.calls;
|
||||
expect(await connect("a@lab", "alpha")).toMatchObject({ status: 503, data: { error: "instance_unknown" } });
|
||||
expect(account.calls).toBe(calls);
|
||||
});
|
||||
|
||||
it("a failed bind whose drop at the account also fails says the account may still list the name", async () => {
|
||||
seat("a@lab");
|
||||
await enroll();
|
||||
account.mcp = "http://account.example/mcp";
|
||||
account.downAfterPuts = 1;
|
||||
const refused = await connect("a@lab", "alpha");
|
||||
expect(refused).toMatchObject({ status: 400, data: { error: "http_not_allowed" } });
|
||||
expect(refused.data.message).toContain("OpenRig couldn't confirm the account dropped alpha again");
|
||||
expect(account.names.get("alpha")!.state).toBe("connected");
|
||||
});
|
||||
|
||||
it("an unconfirmed removal is confirmed by a later answered list that no longer has the name", async () => {
|
||||
seat("a@lab"); seat("b@lab");
|
||||
await enroll();
|
||||
const alpha = await connect("a@lab", "alpha");
|
||||
account.down = true;
|
||||
expect((await post(`/bindings/${alpha.data.id}/disconnect`, {})).data.error).toBe("account_update_unconfirmed");
|
||||
account.down = false;
|
||||
expect((await connect("b@lab", "beta")).status).toBe(201);
|
||||
const status = await (await app.request("/api/network/bindings")).json() as { bindings: Array<{ id: string; accountRemoval: string | null }> };
|
||||
expect(status.bindings.find((b) => b.id === alpha.data.id)!.accountRemoval).toBe("confirmed");
|
||||
});
|
||||
|
||||
it("a name-only connect without an enrollment says how to enroll", async () => {
|
||||
seat("a@lab");
|
||||
const refused = await connect("a@lab", "alpha");
|
||||
expect(refused).toMatchObject({ status: 404, data: { error: "not_enrolled" } });
|
||||
expect(refused.data.message).toContain("rig network enroll <code> --site <site>");
|
||||
expect(account.calls).toBe(0);
|
||||
});
|
||||
|
||||
it("an unconfirmed drop from before a replace stays with the old installation: the new installation's lists never confirm it", async () => {
|
||||
seat("a@lab"); seat("b@lab");
|
||||
await enroll();
|
||||
const alpha = await connect("a@lab", "alpha");
|
||||
account.down = true;
|
||||
expect(await post(`/bindings/${alpha.data.id}/disconnect`, {})).toMatchObject({ status: 502, data: { error: "account_update_unconfirmed" } });
|
||||
account.down = false;
|
||||
expect(await replace()).toMatchObject({ status: 201, data: { installationId: "inst-2", replaced: { endedBindings: [] } } });
|
||||
const alphaNow = async () => (await status()).bindings.find((b) => b.id === alpha.data.id);
|
||||
expect(await alphaNow()).toMatchObject({ accountRemoval: "old-installation", accountRemovedAt: null });
|
||||
|
||||
// The new installation's list doesn't have alpha, which was never listed there.
|
||||
expect((await connect("b@lab", "beta")).status).toBe(201);
|
||||
expect(account.puts.at(-1)).toEqual(["beta"]);
|
||||
expect(await alphaNow()).toMatchObject({ accountRemoval: "old-installation", accountRemovedAt: null });
|
||||
// Disconnecting it again sends nothing this key could use and claims no drop.
|
||||
const puts = account.puts.length;
|
||||
expect(await post(`/bindings/${alpha.data.id}/disconnect`, {})).toMatchObject({ status: 200, data: { accountRemoval: "old-installation", accountRemovedAt: null } });
|
||||
expect(account.puts.length).toBe(puts);
|
||||
});
|
||||
|
||||
it("a plain local failure to save the binding, with the account's drop failing too, still says the account may list the name", async () => {
|
||||
seat("a@lab");
|
||||
await enroll();
|
||||
db.exec("CREATE TRIGGER fail_bind BEFORE INSERT ON network_bindings BEGIN SELECT RAISE(ABORT, 'disk I/O error'); END");
|
||||
account.downAfterPuts = 1;
|
||||
const failed = await connect("a@lab", "alpha");
|
||||
expect(failed).toMatchObject({ status: 500, data: { error: "binding_write_failed" } });
|
||||
expect(failed.data.message).toContain("disk I/O error");
|
||||
expect(failed.data.message).toContain("couldn't confirm the account dropped alpha again");
|
||||
expect(JSON.stringify(failed.data)).not.toContain("bear-alpha");
|
||||
expect(account.names.get("alpha")!.state).toBe("connected");
|
||||
});
|
||||
|
||||
it("a replace whose wait for the installation's turn uses up its deadline sends no code and changes nothing", async () => {
|
||||
seat("a@lab");
|
||||
const first = await enroll();
|
||||
const keyFile = networkInstallationKeyFile(home, first.data.id);
|
||||
const oldKey = readPrivateToken(keyFile, () => "missing");
|
||||
// A list that overruns holds the installation's turn past the replace's 1 s deadline.
|
||||
account.delayMs = 1800;
|
||||
const slow = connect("a@lab", "alpha");
|
||||
await sleep(50);
|
||||
const late = await replace();
|
||||
expect(late).toMatchObject({ status: 503, data: { error: "account_unavailable" } });
|
||||
expect(late.data.message).toContain("The code wasn't sent");
|
||||
expect(account.used.has("ec1.next")).toBe(false);
|
||||
expect(readPrivateToken(keyFile, () => "missing")).toBe(oldKey);
|
||||
await slow;
|
||||
});
|
||||
|
||||
it("a replace that fails here with only the key file missing leaves no key behind, as before it, and says what the account now has", async () => {
|
||||
seat("a@lab");
|
||||
const first = await enroll();
|
||||
const keyFile = networkInstallationKeyFile(home, first.data.id);
|
||||
rmSync(keyFile);
|
||||
db.exec("CREATE TRIGGER fail_replace BEFORE UPDATE ON network_installations BEGIN SELECT RAISE(ABORT, 'disk I/O error'); END");
|
||||
const failed = await replace();
|
||||
expect(failed).toMatchObject({ status: 500, data: { error: "replace_failed" } });
|
||||
expect(failed.data.message).toContain("nothing here changed");
|
||||
expect(failed.data.message).toContain("a new installation whose key nothing here holds");
|
||||
expect(readdirSync(dirname(keyFile))).toEqual([]);
|
||||
expect((await status()).installations.map((i) => i.installationId)).toEqual(["inst-1"]);
|
||||
db.exec("DROP TRIGGER fail_replace");
|
||||
expect(await connect("a@lab", "alpha")).toMatchObject({ status: 404, data: { error: "credential_missing" } });
|
||||
});
|
||||
|
||||
it("a replace whose old binding can't be ended changes nothing here: the switch and the bindings' end commit together", async () => {
|
||||
seat("a@lab");
|
||||
const first = await enroll();
|
||||
const alpha = await connect("a@lab", "alpha");
|
||||
const keyFile = networkInstallationKeyFile(home, first.data.id);
|
||||
const oldKey = readPrivateToken(keyFile, () => "missing");
|
||||
db.exec("CREATE TRIGGER fail_end BEFORE UPDATE OF state ON network_bindings BEGIN SELECT RAISE(ABORT, 'disk I/O error'); END");
|
||||
expect(await replace()).toMatchObject({ status: 500, data: { error: "replace_failed" } });
|
||||
expect(readPrivateToken(keyFile, () => "missing")).toBe(oldKey);
|
||||
expect(readdirSync(dirname(keyFile))).toEqual(["key.json"]);
|
||||
const now = await status();
|
||||
expect(now.installations.map((i) => i.installationId)).toEqual(["inst-1"]);
|
||||
expect(now.bindings.find((b) => b.id === alpha.data.id)).toMatchObject({ state: "active" });
|
||||
});
|
||||
|
||||
it("a replace whose old key's backup can't be removed after the switch still ends the old bindings, and reports the cleanup", async () => {
|
||||
seat("a@lab");
|
||||
const first = await enroll();
|
||||
const alpha = await connect("a@lab", "alpha");
|
||||
failRemoval.suffix = "key.json.prev";
|
||||
const replaced = await replace();
|
||||
failRemoval.suffix = null;
|
||||
expect(replaced).toMatchObject({ status: 201, data: { installationId: "inst-2", replaced: { endedBindings: [{ id: alpha.data.id, participantName: "alpha" }] } } });
|
||||
expect(replaced.data.replaced.problems).toEqual([expect.stringMatching(/backup at .*key\.json\.prev couldn't be removed \(EBUSY/)]);
|
||||
expect(readPrivateToken(networkInstallationKeyFile(home, first.data.id), () => "missing")).toBe(account.key);
|
||||
expect((await status()).bindings.find((b) => b.id === alpha.data.id)).toMatchObject({ state: "disconnected", accountRemoval: "old-installation" });
|
||||
});
|
||||
@@ -90,8 +90,7 @@ describe("openrig-core plugin — manifest shape (HG-2.2)", () => {
|
||||
// Hook + skills wiring
|
||||
expect(manifest["skills"]).toBe("./skills");
|
||||
expect(manifest["hooks"]).toBe("./hooks/claude.json");
|
||||
// An https URL: Claude's plugin directory drops a `github:` shorthand from its listing.
|
||||
expect(manifest["repository"]).toBe("https://github.com/mvschwarz/openrig-plugins");
|
||||
expect(manifest["repository"]).toMatch(/github:mvschwarz\/openrig-plugins/);
|
||||
expect(manifest["license"]).toBeDefined();
|
||||
});
|
||||
|
||||
|
||||
@@ -322,32 +322,6 @@ roles:
|
||||
expect(claudeMd).toContain("# Existing content");
|
||||
});
|
||||
|
||||
it("POST /api/packages/install whose only guidance entry is left as it is → 201 with the note, verified", async () => {
|
||||
writePkg(pkgDir, GUIDANCE_MANIFEST_YAML, {
|
||||
"guidance/rules.md": "Follow these rules.",
|
||||
});
|
||||
const damaged = "# Existing content\n\n<!-- BEGIN OpenRig MANAGED BLOCK: guidance-pkg -->\nhalf a block\n";
|
||||
fs.writeFileSync(path.join(targetDir, "CLAUDE.md"), damaged);
|
||||
|
||||
const res = await app.request("/api/packages/install", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
sourceRef: pkgDir,
|
||||
targetRoot: targetDir,
|
||||
runtime: "claude-code",
|
||||
allowMerge: true,
|
||||
}),
|
||||
});
|
||||
|
||||
expect(fs.readFileSync(path.join(targetDir, "CLAUDE.md"), "utf-8")).toBe(damaged);
|
||||
expect(res.status).toBe(201);
|
||||
const body = await res.json();
|
||||
expect(body.applied).toEqual([]);
|
||||
expect(body.notes).toEqual([expect.stringMatching(/^guidance_block_markers_damaged: .*CLAUDE\.md: guidance-pkg has a BEGIN or END marker without its pair/)]);
|
||||
expect(body.verification.passed).toBe(true);
|
||||
});
|
||||
|
||||
// --- Test 8: POST /install mixed policy: skills approved, guidance rejected ---
|
||||
it("POST /api/packages/install mixed policy → 201 with applied + policyRejected", async () => {
|
||||
writePkg(pkgDir, MIXED_MANIFEST_YAML, {
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
// Vendoring copies a bundled plugin into ~/.openrig/plugins byte for byte, binary files included: a plugin's icon.png
|
||||
// must arrive exactly as it ships, and a text file must arrive as it always did.
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import nodePath from "node:path";
|
||||
import { createHash } from "node:crypto";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { nodePluginVendorFs, PluginVendorService } from "../src/domain/plugin-vendor-service.js";
|
||||
|
||||
const dirs: string[] = [];
|
||||
afterEach(() => { for (const dir of dirs.splice(0)) fs.rmSync(dir, { recursive: true, force: true }); });
|
||||
|
||||
/** A PNG header and bytes that aren't valid UTF-8, so a text round trip would change them. */
|
||||
const ICON = Buffer.concat([Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), Buffer.from([0xff, 0xfe, 0x80, 0x00, 0xc3, 0x28, 0xa0, 0xa1])]);
|
||||
const sha = (bytes: Buffer) => createHash("sha256").update(bytes).digest("hex");
|
||||
const httpClient = async () => ({ ok: false, status: 404 });
|
||||
|
||||
function scratch(): { assets: string; plugins: string } {
|
||||
const root = fs.mkdtempSync(nodePath.join(os.tmpdir(), "openrig-vendor-binary-"));
|
||||
dirs.push(root);
|
||||
return { assets: nodePath.join(root, "assets"), plugins: nodePath.join(root, "home", "plugins") };
|
||||
}
|
||||
|
||||
function shipPlugin(assets: string, version: string, icon = ICON): void {
|
||||
const plugin = nodePath.join(assets, "openrig");
|
||||
fs.mkdirSync(nodePath.join(plugin, ".claude-plugin"), { recursive: true });
|
||||
fs.mkdirSync(nodePath.join(plugin, "skills", "demo"), { recursive: true });
|
||||
fs.writeFileSync(nodePath.join(plugin, ".claude-plugin", "plugin.json"), JSON.stringify({ name: "openrig", version, description: "Team — work" }));
|
||||
fs.writeFileSync(nodePath.join(plugin, ".claude-plugin", "icon.png"), icon);
|
||||
fs.writeFileSync(nodePath.join(plugin, "skills", "demo", "SKILL.md"), "# Demo — a skill\n");
|
||||
fs.writeFileSync(nodePath.join(plugin, "skills", "demo", "badge.png"), icon);
|
||||
}
|
||||
|
||||
const service = (assets: string, plugins: string) =>
|
||||
new PluginVendorService({ vendoredAssetsDir: assets, userPluginsDir: plugins, fs: nodePluginVendorFs(), httpClient });
|
||||
|
||||
describe("vendoring a plugin with a binary file", () => {
|
||||
it("installs the icon byte for byte, and its text files as before", async () => {
|
||||
const { assets, plugins } = scratch();
|
||||
shipPlugin(assets, "0.1.0");
|
||||
await service(assets, plugins).ensureVendored("openrig");
|
||||
const installed = fs.readFileSync(nodePath.join(plugins, "openrig", ".claude-plugin", "icon.png"));
|
||||
expect(sha(installed)).toBe(sha(ICON));
|
||||
expect(fs.readFileSync(nodePath.join(plugins, "openrig", "skills", "demo", "SKILL.md"), "utf-8")).toBe("# Demo — a skill\n");
|
||||
});
|
||||
|
||||
it("leaves an identical install alone at the same version, and refreshes a newer one exactly, keeping the old bytes", async () => {
|
||||
const { assets, plugins } = scratch();
|
||||
shipPlugin(assets, "0.1.0");
|
||||
await service(assets, plugins).ensureVendored("openrig");
|
||||
await service(assets, plugins).ensureVendored("openrig");
|
||||
const backups = nodePath.join(nodePath.dirname(plugins), "backups");
|
||||
expect(fs.existsSync(backups)).toBe(false);
|
||||
|
||||
const newer = Buffer.concat([ICON, Buffer.from([0xfd, 0xbe])]);
|
||||
fs.rmSync(assets, { recursive: true });
|
||||
shipPlugin(assets, "0.2.0", newer);
|
||||
await service(assets, plugins).ensureVendored("openrig");
|
||||
expect(sha(fs.readFileSync(nodePath.join(plugins, "openrig", ".claude-plugin", "icon.png")))).toBe(sha(newer));
|
||||
const kept = fs.readdirSync(nodePath.join(backups, "managed-refresh"))
|
||||
.map((id) => nodePath.join(backups, "managed-refresh", id, ".claude-plugin", "icon.png"))
|
||||
.filter((path) => fs.existsSync(path));
|
||||
expect(kept.map((path) => sha(fs.readFileSync(path)))).toEqual([sha(ICON)]);
|
||||
});
|
||||
|
||||
it("projects a skill's binary file into a global skill root byte for byte", () => {
|
||||
const { assets, plugins } = scratch();
|
||||
shipPlugin(assets, "0.1.0");
|
||||
const root = nodePath.join(nodePath.dirname(plugins), "global-skills");
|
||||
service(assets, plugins).ensureSkillDirGlobally(nodePath.join(assets, "openrig", "skills", "demo"), "demo", "0.1.0", [root]);
|
||||
expect(sha(fs.readFileSync(nodePath.join(root, "demo", "badge.png")))).toBe(sha(ICON));
|
||||
expect(fs.readFileSync(nodePath.join(root, "demo", "SKILL.md"), "utf-8")).toBe("# Demo — a skill\n");
|
||||
});
|
||||
});
|
||||
@@ -112,40 +112,6 @@ describe("Slice-04 C3 — seat-keyed Claude provider_usage discovery (production
|
||||
}
|
||||
});
|
||||
|
||||
it("a live seat whose only cache is malformed reports unreadable_cache through real getReadModel", async () => {
|
||||
const root = fs.mkdtempSync(nodePath.join(os.tmpdir(), "provider-c3-unreadable-"));
|
||||
try {
|
||||
const usageDir = nodePath.join(root, "provider-usage");
|
||||
fs.mkdirSync(usageDir, { recursive: true });
|
||||
const seatSession = "dev-impl@test-rig";
|
||||
fs.writeFileSync(nodePath.join(usageDir, `${seatSession}.json`), "{ not json");
|
||||
|
||||
const db = createFullTestDb();
|
||||
seedClaudeSeat(db, seatSession);
|
||||
const svc = new ProviderServiceImpl({
|
||||
db,
|
||||
listRigs: () => [{ id: "rig-1" }],
|
||||
env: emptyCodexHomeEnv(),
|
||||
now: () => ASOF,
|
||||
collectClaudeSignals: () => collectClaudeSignalsFromProviderUsageDirectory(usageDir, () => ASOF),
|
||||
});
|
||||
const model = await svc.getReadModel();
|
||||
const claude = model.signals.filter((signal) => signal.provider === "claude");
|
||||
|
||||
expect(claude).toHaveLength(1);
|
||||
expect(claude[0]).toMatchObject({
|
||||
seatSession,
|
||||
sourceClass: "unknown",
|
||||
authority: "unknown",
|
||||
unknownReason: "claude_statusline_cache_unreadable",
|
||||
automationUse: "do_not_automate",
|
||||
});
|
||||
expect(claude[0]!.usedPercent).toBeUndefined();
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("an exited latest Claude session plus matching stale cache emits zero Claude signals", async () => {
|
||||
const root = fs.mkdtempSync(nodePath.join(os.tmpdir(), "provider-c3-exited-"));
|
||||
try {
|
||||
|
||||
@@ -76,20 +76,12 @@ describe("collectClaudeStatuslineSignals — reader → provider_statusline / ex
|
||||
expect(sigs[0]!.unknownReason).toBe(CLAUDE_UNKNOWN_REASON.empty_reading);
|
||||
});
|
||||
|
||||
it("malformed cache JSON → unknown(unreadable_cache), never throws", () => {
|
||||
it("malformed cache JSON → unknown(empty_reading), never throws", () => {
|
||||
const sigs = collectClaudeStatuslineSignals(readerDeps({
|
||||
listClaudeSeats: () => [{ seatSession: "dev-impl@rig" }],
|
||||
readCacheRaw: () => "{ not json",
|
||||
}));
|
||||
expect(sigs[0]!.sourceClass).toBe("unknown");
|
||||
expect(sigs[0]!.unknownReason).toBe(CLAUDE_UNKNOWN_REASON.unreadable_cache);
|
||||
});
|
||||
|
||||
it("a valid cache with no windows keeps empty_reading (distinct from an unreadable cache)", () => {
|
||||
const sigs = collectClaudeStatuslineSignals(readerDeps({
|
||||
listClaudeSeats: () => [{ seatSession: "dev-impl@rig" }],
|
||||
readCacheRaw: () => JSON.stringify({ seatSession: "dev-impl@rig", asOf: CACHE_ASOF }),
|
||||
}));
|
||||
expect(sigs[0]!.unknownReason).toBe(CLAUDE_UNKNOWN_REASON.empty_reading);
|
||||
});
|
||||
});
|
||||
@@ -119,33 +111,14 @@ describe("0.5.8 provider-usage compatibility bridge", () => {
|
||||
const malformedCanonical = collectClaudeSignalsFromProviderUsageDirectory(canonical, () => ASOF, legacy);
|
||||
expect(malformedCanonical.find((signal) => signal.seatSession === "legacy@rig")?.usedPercent).toBeUndefined();
|
||||
expect(malformedCanonical.find((signal) => signal.seatSession === "legacy@rig")?.unknownReason)
|
||||
.toBe(CLAUDE_UNKNOWN_REASON.unreadable_cache);
|
||||
.toBe(CLAUDE_UNKNOWN_REASON.empty_reading);
|
||||
|
||||
fs.rmSync(path.join(canonical, "[email protected]"));
|
||||
fs.mkdirSync(path.join(canonical, "[email protected]"));
|
||||
const wrongTypeCanonical = collectClaudeSignalsFromProviderUsageDirectory(canonical, () => ASOF, legacy);
|
||||
expect(wrongTypeCanonical.find((signal) => signal.seatSession === "legacy@rig")?.usedPercent).toBeUndefined();
|
||||
expect(wrongTypeCanonical.find((signal) => signal.seatSession === "legacy@rig")?.unknownReason)
|
||||
.toBe(CLAUDE_UNKNOWN_REASON.unreadable_cache);
|
||||
.toBe(CLAUDE_UNKNOWN_REASON.empty_reading);
|
||||
fs.rmSync(home, { recursive: true, force: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe("collectClaudeSignalsFromProviderUsageDirectory — a malformed-only cache stays visible", () => {
|
||||
it("reports unreadable_cache for a seat known only from a malformed file's name", () => {
|
||||
const home = fs.mkdtempSync(path.join(os.tmpdir(), "provider-unreadable-"));
|
||||
const directory = path.join(home, "state", "provider-usage");
|
||||
fs.mkdirSync(directory, { recursive: true });
|
||||
fs.writeFileSync(path.join(directory, "[email protected]"), "{ not json");
|
||||
try {
|
||||
const signals = collectClaudeSignalsFromProviderUsageDirectory(directory, () => ASOF);
|
||||
expect(signals).toHaveLength(1);
|
||||
expect(signals[0]!.seatSession).toBe("broken@rig");
|
||||
expect(signals[0]!.sourceClass).toBe("unknown");
|
||||
expect(signals[0]!.unknownReason).toBe(CLAUDE_UNKNOWN_REASON.unreadable_cache);
|
||||
expect(signals[0]!.usedPercent).toBeUndefined();
|
||||
} finally {
|
||||
fs.rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { CLAUDE_UNKNOWN_REASON, claudeStatuslineSignals } from "../src/domain/provider/provider-signals.js";
|
||||
import { claudeStatuslineSignals } from "../src/domain/provider/provider-signals.js";
|
||||
|
||||
// Slice-04 (OPR.0.5.0.4) — Claude statusline lane normalization (§2 Claude lane + the
|
||||
// 2026-07-31 RESEARCH verdict). Proof item 6: the statusline sidecar's rate_limits object
|
||||
@@ -68,21 +68,5 @@ describe("claudeStatuslineSignals — Claude lane honesty", () => {
|
||||
expect(sigs[0].sourceClass).toBe("unknown");
|
||||
expect(sigs[0].usedPercent).toBeUndefined();
|
||||
expect(sigs[0].automationUse).toBe("do_not_automate");
|
||||
expect(sigs[0].unknownReason).toBe(CLAUDE_UNKNOWN_REASON.empty_reading);
|
||||
});
|
||||
|
||||
it("an unreadable cache gets its own reason, not the empty-reading one", () => {
|
||||
const sigs = claudeStatuslineSignals({
|
||||
seatSession: "dev-impl@rig",
|
||||
cachePresent: true,
|
||||
cacheUnreadable: true,
|
||||
asOf: ASOF,
|
||||
});
|
||||
expect(sigs).toHaveLength(1);
|
||||
expect(sigs[0].sourceClass).toBe("unknown");
|
||||
expect(sigs[0].unknownReason).toBe(CLAUDE_UNKNOWN_REASON.unreadable_cache);
|
||||
expect(sigs[0].unknownReason).not.toBe(CLAUDE_UNKNOWN_REASON.empty_reading);
|
||||
expect(sigs[0].usedPercent).toBeUndefined();
|
||||
expect(sigs[0].automationUse).toBe("do_not_automate");
|
||||
});
|
||||
});
|
||||
@@ -11,7 +11,7 @@ import { ALL_MIGRATIONS } from "../src/db/all-migrations.js";
|
||||
import { QueueRepository, type QueueItem } from "../src/domain/queue-repository.js";
|
||||
import { EventBus } from "../src/domain/event-bus.js";
|
||||
import { queueRoutes } from "../src/routes/queue.js";
|
||||
import type { RosterMember, RosterWake } from "../src/domain/rosters.js";
|
||||
import type { RosterWake } from "../src/domain/rosters.js";
|
||||
import { createStuckSweepStatus, runStuckSweep, STUCK_SWEEP_FINDING_TAG, unclaimedBacklogTag } from "../src/domain/queue-stuck-sweep.js";
|
||||
|
||||
const MEMBER = "helper-a@hub";
|
||||
@@ -23,13 +23,12 @@ describe("stuck sweep: a hosted member that polls for its work", () => {
|
||||
let repo: QueueRepository;
|
||||
|
||||
function makeRepo(opts: { hostedWake?: (d: string) => RosterWake | null; resolveSessionAddresses?: (n: string) => string[];
|
||||
validateRig?: (d: string) => boolean; hostedMembers?: () => RosterMember[] } = {}): QueueRepository {
|
||||
validateRig?: (d: string) => boolean } = {}): QueueRepository {
|
||||
return new QueueRepository(db, bus, {
|
||||
validateRig: opts.validateRig ?? (() => true),
|
||||
loadHumanRegistry: () => ({ ok: true, entities: [] }),
|
||||
isHostedMember: (d) => d === MEMBER,
|
||||
hostedWake: opts.hostedWake ?? (() => null),
|
||||
hostedMembers: opts.hostedMembers ?? (() => []),
|
||||
...(opts.resolveSessionAddresses ? { resolveSessionAddresses: opts.resolveSessionAddresses } : {}),
|
||||
});
|
||||
}
|
||||
@@ -115,88 +114,16 @@ describe("stuck sweep: a hosted member that polls for its work", () => {
|
||||
expect(more).toEqual([]);
|
||||
expect(notice!.destinationSession).toBe(PERSON);
|
||||
expect(notice!.sourceSession).toBe(MEMBER);
|
||||
expect(notice!.body).toContain(`check-ins: unknown (no check-in cadence declared); no check-in since ${at.toISOString()}`);
|
||||
expect(notice!.body).toContain(`check-ins: no check-in observed since ${at.toISOString()}`);
|
||||
expect(notice!.body).toContain("no read of its own inbox has been observed in that time. Ask its owner to check it's running, or route the work to another seat");
|
||||
for (const row of rows) expect(notice!.body).toContain(`- ${row.qitemId}, unclaimed since ${row.tsCreated}, from lead@hub: work for ${MEMBER}`);
|
||||
expect(notice!.body).not.toMatch(/dead/i);
|
||||
});
|
||||
|
||||
/** The member as its roster lists it, with the cadence its person stated at setup (or none). */
|
||||
const asListed = (checkInMinutes?: number): RosterMember[] => [{ seat: MEMBER, host: "hub", capabilities: ["research"],
|
||||
engagement: ["delegate"], use_when: "research", why: "it researches", hosted: true,
|
||||
...(checkInMinutes === undefined ? {} : { check_in_minutes: checkInMinutes }) }];
|
||||
|
||||
it("a stated cadence decides only the notice's check-in line, never when the notice is filed: overdue past twice it, checking in within it", async () => {
|
||||
const at = new Date(Date.now() - 3 * 3_600_000);
|
||||
repo = makeRepo({ hostedMembers: () => asListed(60) });
|
||||
repo.recordInboxCheckIn(MEMBER, at);
|
||||
await work(MEMBER, { ageMinutes: 61 });
|
||||
await sweep();
|
||||
expect(notices()[0]!.body).toContain(`check-ins: overdue: last check-in ${at.toISOString()}, expected every 60 min; its schedule may be off or waiting for an approval`);
|
||||
expect(notices()[0]!.body).toContain("why: nobody has claimed these rows, and it hasn't checked in as often as its setup expects.");
|
||||
|
||||
// The same three-hour-old check-in is on time for a member expected every four hours, though older than the
|
||||
// unclaimed age that judges a member with no stated cadence.
|
||||
db.close();
|
||||
db = new Database(":memory:");
|
||||
migrate(db, ALL_MIGRATIONS);
|
||||
bus = new EventBus(db);
|
||||
repo = makeRepo({ hostedMembers: () => asListed(240) });
|
||||
seedSeat("lead@hub");
|
||||
repo.recordInboxCheckIn(MEMBER, at);
|
||||
await work(MEMBER, { ageMinutes: 61 });
|
||||
await sweep();
|
||||
expect(notices()[0]!.body).toContain("check-ins: checking in: its last check-in is within twice its expected 240 min");
|
||||
});
|
||||
|
||||
it("the notice says what the hosted-members read says: an unusable cadence reads unknown with its reason, even with a fresh check-in", async () => {
|
||||
const member = { ...asListed()[0]!, check_in_problem: "check_in_minutes \"60\" isn't a positive whole number of minutes" };
|
||||
repo = makeRepo({ hostedMembers: () => [member] });
|
||||
repo.recordInboxCheckIn(MEMBER, new Date(Date.now() - 5 * 60_000));
|
||||
await work(MEMBER, { ageMinutes: 61 });
|
||||
await sweep();
|
||||
expect(repo.hostedMemberCheckIns()[0]).toMatchObject({ status: "unknown", reason: member.check_in_problem });
|
||||
expect(notices()[0]!.body).toContain(`check-ins: unknown (${member.check_in_problem}); it read its own inbox within the last 60 min`);
|
||||
expect(notices()[0]!.body).not.toContain("checking in");
|
||||
});
|
||||
|
||||
it("check-in status is a status, never a refusal: work for an overdue or never-seen member queues and is recorded as before", async () => {
|
||||
repo = makeRepo({ hostedMembers: () => asListed(60) });
|
||||
repo.recordInboxCheckIn(MEMBER, new Date(Date.now() - 5 * 3_600_000));
|
||||
const overdue = await repo.create({ sourceSession: "lead@hub", destinationSession: MEMBER, body: "work", summary: "work" });
|
||||
const unlisted = makeRepo();
|
||||
const baseline = await unlisted.create({ sourceSession: "lead@hub", destinationSession: MEMBER, body: "work", summary: "work" });
|
||||
expect(repo.getById(overdue.qitemId)).toMatchObject({ state: "pending", destinationSession: MEMBER });
|
||||
expect(repo.getById(overdue.qitemId)!.lastNudgeResult).toBe(unlisted.getById(baseline.qitemId)!.lastNudgeResult);
|
||||
expect(repo.hostedMemberCheckIns().map((m) => m.status)).toEqual(["check-in-overdue"]);
|
||||
});
|
||||
|
||||
it("the hosted-members read gives each member's status, last check-in and stated cadence", async () => {
|
||||
const members = [...asListed(60), { ...asListed()[0]!, seat: "helper-b@hub", check_in_on_request: true as const },
|
||||
{ ...asListed(30)[0]!, seat: "helper-c@hub" }, { ...asListed()[0]!, seat: "helper-d@hub" }];
|
||||
repo = makeRepo({ hostedMembers: () => members });
|
||||
const at = new Date(Date.now() - 10 * 60_000);
|
||||
repo.recordInboxCheckIn(MEMBER, at);
|
||||
repo.recordInboxCheckIn("helper-b@hub", at);
|
||||
repo.recordInboxCheckIn("helper-d@hub", at);
|
||||
const res = await appFor(repo).request("/api/queue/hosted-members");
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { hostId: unknown; members: unknown };
|
||||
expect(body).toHaveProperty("hostId");
|
||||
expect(body.members).toEqual([
|
||||
{ seat: MEMBER, rung: false, status: "checking-in", lastCheckInAt: at.toISOString(), expectedMinutes: 60, onRequest: false, reporter: "openrig-observed" },
|
||||
{ seat: "helper-b@hub", rung: false, status: "on-request", lastCheckInAt: at.toISOString(), expectedMinutes: null, onRequest: true, reporter: "openrig-observed" },
|
||||
{ seat: "helper-c@hub", rung: false, status: "no-check-in-yet", lastCheckInAt: null, expectedMinutes: 30, onRequest: false, reporter: "openrig-observed" },
|
||||
// States neither a cadence nor on request: unknown, with its last check-in, never on request, idle or overdue.
|
||||
{ seat: "helper-d@hub", rung: false, status: "unknown", lastCheckInAt: at.toISOString(), expectedMinutes: null, onRequest: false,
|
||||
reporter: "openrig-observed", reason: "no check-in cadence declared" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("a member that never checked in says so", async () => {
|
||||
await work(MEMBER, { ageMinutes: 61 });
|
||||
await sweep();
|
||||
expect(notices()[0]!.body).toContain("check-ins: no check-in yet; no check-in cadence declared");
|
||||
expect(notices()[0]!.body).toContain("check-ins: no check-in observed yet");
|
||||
});
|
||||
|
||||
it("polling without claiming: old work still surfaces, and the member's check-ins neither reset its age nor rewrite the notice", async () => {
|
||||
@@ -205,8 +132,8 @@ describe("stuck sweep: a hosted member that polls for its work", () => {
|
||||
await poll();
|
||||
await sweep();
|
||||
const [notice] = notices();
|
||||
expect(notice!.body).toContain("check-ins: unknown (no check-in cadence declared); it read its own inbox within the last 60 min");
|
||||
expect(notice!.body).toContain("it's reading its own inbox, but this work is still unclaimed");
|
||||
expect(notice!.body).toContain("check-ins: checking in: it read its own inbox within the last 60 min");
|
||||
expect(notice!.body).toContain("it's checking in, but this work is still unclaimed");
|
||||
expect(notice!.body).toContain(`- ${old.qitemId}, unclaimed since ${old.tsCreated}`);
|
||||
expect(notice!.body).not.toContain(young.qitemId);
|
||||
expect(perRow()).toEqual([]);
|
||||
@@ -259,7 +186,7 @@ describe("stuck sweep: a hosted member that polls for its work", () => {
|
||||
expect(errors.mock.calls.filter((call) => String(call[0]).includes("inbox check-in not recorded"))).toHaveLength(1);
|
||||
expect(repo.lastInboxCheckIn(MEMBER)).toEqual({ state: "unknown" });
|
||||
await sweep();
|
||||
expect(notices()[0]!.body).toContain("check-ins: unknown (its check-ins couldn't be read)");
|
||||
expect(notices()[0]!.body).toContain("check-ins: unknown: its check-ins couldn't be read");
|
||||
});
|
||||
|
||||
it("after a restart the stale check-in is still known, and the first new read records at once", async () => {
|
||||
|
||||
@@ -1767,36 +1767,6 @@ describe("S02 standing stuck sweep — both halves, routed findings, quiet-but-o
|
||||
expect(await findingsFor(row.qitemId)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it.each(["", ".", ",", ";", ":", ")"])("CUSTODY PUNCTUATION: a closing note accepts the exact target followed by %j", async (punctuation) => {
|
||||
const row = await mkRow();
|
||||
const target = "qitem-20990101000000-punctuation@remote-host";
|
||||
repo.update({ qitemId: row.qitemId, actorSession: "worker@r", state: "done",
|
||||
closureReason: "handed_off_to", closureTarget: target,
|
||||
transitionNote: `custody-verified: ${target}${punctuation} registered-host read confirmed the successor` });
|
||||
await runSweep();
|
||||
expect(await findingsFor(row.qitemId)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it.each(["x.", ".other.", ":123)"])("CUSTODY PUNCTUATION: extra target characters %j do not verify a prefix", async (suffix) => {
|
||||
const row = await mkRow();
|
||||
const target = "qitem-20990101000000-punctuation@remote-host";
|
||||
repo.update({ qitemId: row.qitemId, actorSession: "worker@r", state: "done",
|
||||
closureReason: "handed_off_to", closureTarget: target,
|
||||
transitionNote: `custody-verified: ${target}${suffix} registered-host read confirmed another successor` });
|
||||
await runSweep();
|
||||
expect(await findingsFor(row.qitemId)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("CUSTODY PUNCTUATION: a shortened target is not an exact target", async () => {
|
||||
const row = await mkRow();
|
||||
const target = "qitem-20990101000000-punctuation@remote-host";
|
||||
repo.update({ qitemId: row.qitemId, actorSession: "worker@r", state: "done",
|
||||
closureReason: "handed_off_to", closureTarget: target,
|
||||
transitionNote: `custody-verified: ${target.slice(0, -1)}. checked elsewhere` });
|
||||
await runSweep();
|
||||
expect(await findingsFor(row.qitemId)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("DISPOSITION CLOSES THE OPEN FINDING: verification landing after the finding minted auto-closes it on the next sweep, and the finding taught the recipe", async () => {
|
||||
const row = await mkRow();
|
||||
const missing = "qitem-20990101000000-precon02";
|
||||
|
||||
@@ -16,7 +16,6 @@ import { SeatLifecycleService } from "../src/domain/seat-lifecycle-service.js";
|
||||
import { getNodeInventory } from "../src/domain/node-inventory.js";
|
||||
import { PsProjectionService } from "../src/domain/ps-projection.js";
|
||||
import { SeatIdentityStore } from "../src/domain/seat-identity-store.js";
|
||||
import { SeatIdentityReconciler } from "../src/domain/seat-identity-reconciler.js";
|
||||
import type { RuntimeAdapter } from "../src/domain/runtime-adapter.js";
|
||||
import type { ProjectionPlan } from "../src/domain/projection-planner.js";
|
||||
import type { TmuxAdapter, TmuxResult } from "../src/adapters/tmux.js";
|
||||
@@ -25,7 +24,6 @@ import { deriveOriented } from "../src/domain/startup-proof.js";
|
||||
import { deriveRehydrateSessionIdByNode } from "../src/domain/active-occupant.js";
|
||||
import { readFreshOccupantRelations } from "../src/domain/fresh-occupant-relation.js";
|
||||
import type { NativeProcessLister, NativeProcessRow } from "../src/domain/native-process-lineage.js";
|
||||
import type { ProcessTableRow } from "../src/domain/process-table.js";
|
||||
|
||||
import { ClaudeCodeAdapter } from "../src/adapters/claude-code-adapter.js";
|
||||
import { CLAUDE_BYPASS_CONSENT } from "./fixtures/claude-bypass-consent.js";
|
||||
@@ -61,7 +59,6 @@ describe("SeatLifecycleService.launchFresh", () => {
|
||||
let activitySwaps: Array<{ nodeId: string; generation: string }>;
|
||||
let service: SeatLifecycleService;
|
||||
let listProcesses: NativeProcessLister;
|
||||
let readProcesses: () => Promise<ProcessTableRow[]>;
|
||||
|
||||
beforeEach(() => {
|
||||
db = createFullTestDb();
|
||||
@@ -134,7 +131,6 @@ describe("SeatLifecycleService.launchFresh", () => {
|
||||
listProcesses = async () => [{ pid: 4242, ppid: 1, pgid: 4242, tpgid: 4242,
|
||||
executableName: "codex", command: "/opt/native/codex -m model", argv: ["/opt/native/codex", "-m", "model"],
|
||||
startedAt: "Sat Jan 1 12:00:00 2000" }];
|
||||
readProcesses = async () => [];
|
||||
runtimeAdapters = { "claude-code": adapter, codex: { ...adapter, runtime: "codex" } };
|
||||
service = new SeatLifecycleService({
|
||||
db,
|
||||
@@ -143,7 +139,6 @@ describe("SeatLifecycleService.launchFresh", () => {
|
||||
eventBus,
|
||||
tmuxAdapter: tmux,
|
||||
listProcesses: () => listProcesses(),
|
||||
readProcesses: () => readProcesses(),
|
||||
nodeLauncher,
|
||||
startupOrchestrator,
|
||||
runtimeAdapters,
|
||||
@@ -848,105 +843,6 @@ describe("SeatLifecycleService.launchFresh", () => {
|
||||
expect(alive.has(seat.sessionName)).toBe(true);
|
||||
});
|
||||
|
||||
// `rig unclaim` releases an adopted session (its row detached, its binding cleared) and leaves its process running;
|
||||
// the person then ends it and its tmux session. When that was the server's last session, tmux exits with it.
|
||||
const releaseAdopted = (seat: ReturnType<typeof seedSeat>) => {
|
||||
sessionRegistry.markDetached(seat.session!.id);
|
||||
sessionRegistry.clearBinding(seat.node.id);
|
||||
};
|
||||
|
||||
it("reopens an empty tmux server after rig unclaim released the seat's last session", async () => {
|
||||
const seat = seedSeat({ adopted: true });
|
||||
const server = modelServerLifetime();
|
||||
releaseAdopted(seat);
|
||||
alive.delete(seat.sessionName);
|
||||
livePanes.delete(seat.sessionName);
|
||||
server.up = false;
|
||||
|
||||
const result = await service.launchFresh({ seatRef: seat.sessionName, fresh: true, reason: "start a new occupant" });
|
||||
|
||||
expect(result).toMatchObject({ ok: true, status: "ready", sessionName: seat.sessionName });
|
||||
expect(server).toEqual({ up: true, starts: 1 });
|
||||
expect(tmux.createSession).toHaveBeenCalledTimes(1);
|
||||
expect(killed).toEqual([]);
|
||||
});
|
||||
|
||||
it("still refuses a released seat's fresh launch when its session is live behind a transport blip", async () => {
|
||||
const seat = seedSeat({ adopted: true });
|
||||
const server = modelServerLifetime();
|
||||
releaseAdopted(seat);
|
||||
// The released process is still running in its session; only the first probe fails.
|
||||
vi.mocked(tmux.probeSession).mockResolvedValueOnce({ state: "transport_unavailable", cause: "probe blip" });
|
||||
|
||||
const result = await service.launchFresh({ seatRef: seat.sessionName, fresh: true, reason: "start a new occupant" });
|
||||
|
||||
expect(result).toMatchObject({ ok: false, code: "unmanaged_session_collision" });
|
||||
expect(server.starts).toBe(0);
|
||||
expect(tmux.createSession).not.toHaveBeenCalled();
|
||||
expect(killed).toEqual([]);
|
||||
expect(alive.has(seat.sessionName)).toBe(true);
|
||||
});
|
||||
|
||||
// Unclaim doesn't end the process, and tmux can lose its socket while its server keeps running: the probe then
|
||||
// can't reach the server, and a server started at that path is a new, empty one. So the model keeps the two apart:
|
||||
// `alive` and `server.up` are what the socket answers, and the process table is what still runs.
|
||||
const DAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
|
||||
const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
|
||||
const lstart = (d: Date) => `${DAYS[d.getDay()]} ${MONTHS[d.getMonth()]} ${String(d.getDate()).padStart(2, " ")} `
|
||||
+ `${[d.getHours(), d.getMinutes(), d.getSeconds()].map((n) => String(n).padStart(2, "0")).join(":")} ${d.getFullYear()}`;
|
||||
const processRow = (pid: number, ppid: number, startedAt: Date, command: string): ProcessTableRow =>
|
||||
({ pid, ppid, pgid: pid, tpgid: -1, executableName: command.split(" ")[0]!, startedAt: lstart(startedAt), command });
|
||||
const observed = new Date(Date.now() - 60_000);
|
||||
const releasedWithLostSocket = async () => {
|
||||
const seat = seedSeat({ adopted: true });
|
||||
const server = modelServerLifetime();
|
||||
// Recorded while the adopted seat ran; then released, and the daemon's identity sweep (every 5 s) runs.
|
||||
new SeatIdentityStore(db).upsert({
|
||||
nodeId: seat.node.id, verdict: "verified", evidenceSource: null, reason: null,
|
||||
evidence: { registeredPane: "%old", observedPid: 5151, observedPanePid: 5150, observedCommand: "claude", matchedLayer: 1 },
|
||||
sessionName: seat.sessionName, observedAt: observed.toISOString(),
|
||||
});
|
||||
releaseAdopted(seat);
|
||||
const blindPoll = { listSessions: async () => [] } as unknown as ConstructorParameters<typeof SeatIdentityReconciler>[0]["tmux"];
|
||||
await new SeatIdentityReconciler({ db, tmux: blindPoll }).reconcileAll();
|
||||
alive.delete(seat.sessionName);
|
||||
livePanes.delete(seat.sessionName);
|
||||
server.up = false;
|
||||
return { seat, server };
|
||||
};
|
||||
|
||||
it("keeps refusing a released seat's fresh launch while its recorded process still runs behind a lost socket", async () => {
|
||||
const { seat } = await releasedWithLostSocket();
|
||||
const before = new Date(observed.getTime() - 10 * 60_000);
|
||||
readProcesses = async () => [processRow(5150, 900, before, "zsh"), processRow(900, 1, before, "tmux new-session")];
|
||||
|
||||
const result = await service.launchFresh({ seatRef: seat.sessionName, fresh: true, reason: "start a new occupant" });
|
||||
|
||||
expect(result).toMatchObject({ ok: false, code: "tmux_probe_failed",
|
||||
guidance: expect.stringContaining("last ran as process 5150, which is still running") });
|
||||
expect(tmux.startServer).not.toHaveBeenCalled();
|
||||
expect(tmux.createSession).not.toHaveBeenCalled();
|
||||
expect(killed).toEqual([]);
|
||||
expect(sessionRegistry.getSessionsForRig(seat.rig.id).map((s) => s.status)).toEqual(["detached"]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ name: "its recorded process has ended", rows: async () => [] as ProcessTableRow[] },
|
||||
{ name: "its pid now belongs to a later process",
|
||||
rows: async () => [processRow(5150, 1, new Date(observed.getTime() + 30_000), "vim")] },
|
||||
{ name: "the process table can't be read", rows: async (): Promise<ProcessTableRow[]> => { throw new Error("ps failed"); } },
|
||||
])("reopens the server for a released seat when $name", async ({ rows }) => {
|
||||
const { seat, server } = await releasedWithLostSocket();
|
||||
readProcesses = rows;
|
||||
|
||||
const result = await service.launchFresh({ seatRef: seat.sessionName, fresh: true, reason: "start a new occupant" });
|
||||
|
||||
expect(result).toMatchObject({ ok: true, status: "ready", sessionName: seat.sessionName });
|
||||
expect(server).toEqual({ up: true, starts: 1 });
|
||||
expect(tmux.createSession).toHaveBeenCalledTimes(1);
|
||||
expect(killed).toEqual([]);
|
||||
});
|
||||
|
||||
it("proves older non-terminal rows absent on the restored server before superseding them", async () => {
|
||||
const seat = seedSeat({ clean: true });
|
||||
const older = sessionRegistry.registerSession(seat.node.id, "r00-dev-impl@fresh-rig");
|
||||
|
||||
@@ -440,28 +440,6 @@ describe("SeatIdentityReconciler.reconcileAll", () => {
|
||||
db.close();
|
||||
});
|
||||
|
||||
it("keeps a released seat's recorded process through the prune, and still prunes a stopped one", async () => {
|
||||
// rig unclaim leaves the latest row detached with origin claimed, and the process running. A fresh launch reads
|
||||
// that process's record before reopening a tmux server, so the 5 s sweep mustn't drop it.
|
||||
const db = createFullTestDb();
|
||||
seedSeat(db, { nodeId: "n1", sessionName: "s1@rig", pane: "%1", status: "detached" });
|
||||
db.prepare("UPDATE sessions SET origin = 'claimed' WHERE node_id = 'n1'").run();
|
||||
seedSeat(db, { nodeId: "n2", sessionName: "s2@rig", pane: "%2", status: "exited" });
|
||||
const store = new SeatIdentityStore(db);
|
||||
for (const [nodeId, sessionName, pid] of [["n1", "s1@rig", 5151], ["n2", "s2@rig", 6161]] as const) {
|
||||
store.upsert({ nodeId, verdict: "verified", evidenceSource: null, reason: null,
|
||||
evidence: { registeredPane: "%1", observedPid: pid, observedPanePid: pid - 1, observedCommand: "claude", matchedLayer: 1 },
|
||||
sessionName, observedAt: NOW().toISOString() });
|
||||
}
|
||||
const rec = new SeatIdentityReconciler({ db, tmux: makeTmux({ sessions: [] }), now: NOW });
|
||||
|
||||
await rec.reconcileAll();
|
||||
|
||||
expect(store.getProcessEvidence("n1")).toEqual({ sessionName: "s1@rig", pid: 5151, panePid: 5150, observedAt: NOW().toISOString() });
|
||||
expect(store.getForNode("n2")).toBeNull();
|
||||
db.close();
|
||||
});
|
||||
|
||||
it("liveness is NOT derived from heartbeats — a dead pane stays non-green regardless of any heartbeat", async () => {
|
||||
// The reconciler never reads queue/classifier/hook state; a seat whose pane
|
||||
// is gone yields pane_missing even if (hypothetically) work/heartbeats exist.
|
||||
|
||||
@@ -5,7 +5,7 @@ import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import type { Worker } from "node:worker_threads";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { Hono } from "hono";
|
||||
import { createDaemon } from "../src/startup.js";
|
||||
import { createSlowOpRequestMiddleware } from "../src/domain/slow-op-recorder.js";
|
||||
@@ -67,37 +67,12 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("SlowOpRecorder locked instrumentation contract", () => {
|
||||
it("returns promptly while begin acknowledgements are delayed", async () => {
|
||||
const mod = await loadRecorderModule();
|
||||
if (!mod) throw new Error("recorder module missing");
|
||||
const logPath = path.join(tempDir(), "pending.jsonl");
|
||||
const recorder = new mod.SlowOpRecorder({ logPath });
|
||||
const worker: Worker = (recorder as any).worker;
|
||||
const post = worker.postMessage;
|
||||
const held: unknown[] = [];
|
||||
worker.postMessage = message => {
|
||||
if (message?.record?.phase === "begin") held.push(message);
|
||||
else post.call(worker, message);
|
||||
};
|
||||
try {
|
||||
const start = performance.now();
|
||||
const values = [1, 2, 3].map(value => recorder.runSync("test.delayed.begin", () => value));
|
||||
const elapsedMs = performance.now() - start;
|
||||
expect(values).toEqual([1, 2, 3]);
|
||||
expect(held).toHaveLength(3);
|
||||
expect(elapsedMs).toBeLessThan(100);
|
||||
} finally {
|
||||
worker.postMessage = post;
|
||||
for (const message of held) post.call(worker, message);
|
||||
await recorder.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("pins the absolute boundary, rotation, filename, and secret-safe record contract", async () => {
|
||||
const mod = await loadRecorderModule();
|
||||
expect(mod, "slow-op-recorder production module is missing").not.toBeNull();
|
||||
if (!mod) return;
|
||||
|
||||
expect(mod.SLOW_OPERATION_BARRIER_TIMEOUT_MS).toBe(250);
|
||||
expect(mod.SLOW_OPERATION_THRESHOLD_MS).toBe(250);
|
||||
expect(mod.SLOW_OPERATION_ROTATION_BYTES).toBe(1024 * 1024);
|
||||
expect(mod.SLOW_OPERATION_ROTATION_COUNT).toBe(3);
|
||||
@@ -110,6 +85,9 @@ describe("SlowOpRecorder locked instrumentation contract", () => {
|
||||
process.env.OPENRIG_TEST_SECRET = secret;
|
||||
try {
|
||||
const value = recorder.runSync("test.sync.boundary", () => {
|
||||
const records = fs.readFileSync(logPath, "utf8");
|
||||
expect(records).toContain('"phase":"begin"');
|
||||
expect(records).toContain('"site":"test.sync.boundary"');
|
||||
return 41;
|
||||
});
|
||||
expect(value).toBe(41);
|
||||
@@ -129,34 +107,88 @@ describe("SlowOpRecorder locked instrumentation contract", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("flushes a queued open span and exposes acknowledged write failure in health", async () => {
|
||||
it("leaves a durable open span after the begin barrier and exposes fail-visible degradation", async () => {
|
||||
const mod = await loadRecorderModule();
|
||||
if (!mod) throw new Error("recorder module missing");
|
||||
expect(mod, "slow-op-recorder production module is missing").not.toBeNull();
|
||||
if (!mod) return;
|
||||
|
||||
const dir = tempDir();
|
||||
const logPath = path.join(dir, "open.jsonl");
|
||||
const logPath = path.join(dir, "slow-operations.jsonl");
|
||||
const recorder = new mod.SlowOpRecorder({ logPath });
|
||||
recorder.beginSyncSpan("test.sync.wedge");
|
||||
await recorder.flush();
|
||||
expect(await readRecords(logPath)).toMatchObject([{ phase: "begin", site: "test.sync.wedge" }]);
|
||||
const span = recorder.beginSyncSpan("test.sync.wedge");
|
||||
expect(span).toBeTruthy();
|
||||
const records = await readRecords(logPath);
|
||||
expect(records).toHaveLength(1);
|
||||
expect(records[0]).toMatchObject({ phase: "begin", site: "test.sync.wedge" });
|
||||
expect(recorder.snapshot()).toMatchObject({ healthy: true });
|
||||
await recorder.close();
|
||||
|
||||
const degraded = new mod.SlowOpRecorder({ logPath: dir });
|
||||
const degraded = new mod.SlowOpRecorder({
|
||||
logPath: path.join(dir, "degraded.jsonl"),
|
||||
barrierTimeoutMs: 0,
|
||||
});
|
||||
const oldNoKernel = process.env.OPENRIG_NO_KERNEL;
|
||||
process.env.OPENRIG_NO_KERNEL = "1";
|
||||
const daemon = await createTestDaemon({ dbPath: ":memory:", tmuxExec: async () => "", cmuxExec: async () => "", slowOpRecorder: degraded } as never);
|
||||
const daemon = await createTestDaemon({
|
||||
dbPath: ":memory:",
|
||||
tmuxExec: async () => "",
|
||||
cmuxExec: async () => "",
|
||||
slowOpRecorder: degraded,
|
||||
} as never);
|
||||
try {
|
||||
expect(degraded.runSync("test.sync.degraded", () => "continued")).toBe("continued");
|
||||
await expect(degraded.flush()).rejects.toBeInstanceOf(mod.SlowOpRecorderWriteError);
|
||||
expect(degraded.snapshot()).toMatchObject({ healthy: false, reason: "recorder_write_failed", site: "recorder.worker" });
|
||||
const response = await daemon.app.request("/healthz");
|
||||
expect(await response.json()).toMatchObject({ status: "ok", slowOperations: { healthy: false, reason: "recorder_write_failed" } });
|
||||
const worker: Worker = (degraded as any).worker;
|
||||
const postMessage = worker.postMessage;
|
||||
let intercepted = 0;
|
||||
let withheldSignal: SharedArrayBuffer | undefined;
|
||||
try {
|
||||
worker.postMessage = (message, transferList) => {
|
||||
if (message?.type === "append"
|
||||
&& message.record?.phase === "begin"
|
||||
&& message.record?.site === "test.sync.degraded"
|
||||
&& message.signal instanceof SharedArrayBuffer) {
|
||||
intercepted++;
|
||||
const { signal, ...withoutSignal } = message;
|
||||
withheldSignal = signal;
|
||||
// Append through the real worker, but withhold this acknowledgement.
|
||||
return postMessage.call(worker, withoutSignal, transferList);
|
||||
}
|
||||
return postMessage.call(worker, message, transferList);
|
||||
};
|
||||
expect(degraded.runSync("test.sync.degraded", () => "continued")).toBe("continued");
|
||||
} finally {
|
||||
worker.postMessage = postMessage;
|
||||
}
|
||||
expect(intercepted).toBe(1);
|
||||
expect(worker.postMessage).toBe(postMessage);
|
||||
expect(withheldSignal).toBeInstanceOf(SharedArrayBuffer);
|
||||
expect(new Int32Array(withheldSignal!)[0]).toBe(0);
|
||||
expect(degraded.snapshot()).toMatchObject({
|
||||
healthy: false,
|
||||
reason: "begin_barrier_timeout",
|
||||
site: "test.sync.degraded",
|
||||
});
|
||||
|
||||
const healthResponse = await daemon.app.request("/healthz");
|
||||
expect(await healthResponse.json()).toMatchObject({
|
||||
status: "ok",
|
||||
slowOperations: {
|
||||
healthy: false,
|
||||
reason: "begin_barrier_timeout",
|
||||
site: "test.sync.degraded",
|
||||
},
|
||||
});
|
||||
const observations = daemon.deps.streamStore!.list();
|
||||
expect(observations).toHaveLength(1);
|
||||
expect(observations[0]!.body).toContain("recorder_write_failed");
|
||||
expect(observations[0]!.body).toContain("begin_barrier_timeout");
|
||||
expect(observations[0]!.body).toContain("test.sync.degraded");
|
||||
await degraded.flush();
|
||||
const degradedRecords = await readRecords(path.join(dir, "degraded.jsonl"));
|
||||
expect(degradedRecords.filter((record) => record.phase === "begin"
|
||||
&& record.site === "test.sync.degraded")).toHaveLength(1);
|
||||
} finally {
|
||||
daemon.eventLoopMonitor.stop();
|
||||
daemon.db.close();
|
||||
await degraded.close().catch(() => {});
|
||||
await degraded.close();
|
||||
if (oldNoKernel === undefined) delete process.env.OPENRIG_NO_KERNEL;
|
||||
else process.env.OPENRIG_NO_KERNEL = oldNoKernel;
|
||||
}
|
||||
@@ -179,17 +211,20 @@ describe("SlowOpRecorder locked instrumentation contract", () => {
|
||||
});
|
||||
expect(value).toBe("continued");
|
||||
expect(ran).toBe(true);
|
||||
await expect(recorder.flush()).rejects.toBeInstanceOf(mod.SlowOpRecorderWriteError);
|
||||
expect(recorder.snapshot()).toEqual({ healthy: false, reason: "recorder_write_failed", site: "recorder.worker" });
|
||||
expect(recorder.snapshot()).toEqual({
|
||||
healthy: false,
|
||||
reason: "begin_barrier_failed",
|
||||
site: "test.sync.observer_failure",
|
||||
});
|
||||
} finally {
|
||||
// This recorder's log path is intentionally unwritable (to force the
|
||||
// asynchronously acknowledged write failure above), so close() now correctly rejects on the
|
||||
// begin-barrier failure above), so close() now correctly rejects on the
|
||||
// latched lost-write durability — tolerate it in teardown.
|
||||
await recorder.close().catch(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
it("writes a queued begin while the main thread is wedged, before a later kill", async () => {
|
||||
it("durably records an open span before a main-thread sync wedge is killed", async () => {
|
||||
const dir = tempDir();
|
||||
const logPath = path.join(dir, "crash.jsonl");
|
||||
fs.writeFileSync(logPath, "", { mode: 0o600 });
|
||||
@@ -243,7 +278,6 @@ describe("SlowOpRecorder locked instrumentation contract", () => {
|
||||
});
|
||||
});
|
||||
expect(callbackEvidence).toEqual({ isMainThread: true, threadId: 0 });
|
||||
await vi.waitFor(async () => expect(await readRecords(logPath)).toMatchObject([{ phase: "begin", site: "test.sync.crash" }]));
|
||||
child.kill("SIGKILL");
|
||||
await once(child, "exit");
|
||||
|
||||
@@ -317,7 +351,7 @@ describe("SlowOpRecorder locked instrumentation contract", () => {
|
||||
expect(JSON.stringify(requests)).not.toContain("must-not-appear");
|
||||
});
|
||||
|
||||
it("keeps the accepted slow-operation site labels", () => {
|
||||
it("wraps all nine accepted sync calls without moving them off the main thread", () => {
|
||||
const srcRoot = path.resolve(import.meta.dirname, "../src");
|
||||
for (const [relativePath, sites] of expectedSites) {
|
||||
const source = fs.readFileSync(path.join(srcRoot, relativePath), "utf8");
|
||||
@@ -462,7 +496,7 @@ describe("SlowOpRecorder locked instrumentation contract", () => {
|
||||
expect((await app.request("/definitely-missing")).status).toBe(404);
|
||||
});
|
||||
|
||||
it("drives the asynchronous tmux command-v site through a hermetic daemon", async () => {
|
||||
it("drives the real tmux command-v spawnSync site through a hermetic daemon", async () => {
|
||||
const mod = await loadRecorderModule();
|
||||
expect(mod, "slow-op-recorder production module is missing").not.toBeNull();
|
||||
if (!mod) return;
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { TmuxAdapter } from "../src/adapters/tmux.js";
|
||||
import { TmuxOptionDefaultsApplier } from "../src/domain/tmux-option-defaults.js";
|
||||
|
||||
const execute = vi.hoisted(() => vi.fn());
|
||||
vi.mock("node:child_process", () => ({ execFile: execute }));
|
||||
beforeEach(() => { execute.mockReset(); });
|
||||
|
||||
describe("clipboard discovery deadline", () => {
|
||||
it.each([null, "custom-copy"])("warns and retries a killed default probe, preserving %j", async initial => {
|
||||
let copyCommand = initial;
|
||||
const setServerOption = vi.fn(async (name: string, value: string) => {
|
||||
if (name === "copy-command") copyCommand = value;
|
||||
return { ok: true };
|
||||
});
|
||||
const adapter = { setSessionOption: async () => ({ ok: true }), setServerOption } as unknown as TmuxAdapter;
|
||||
execute.mockImplementationOnce((_file, _args, options, callback) => {
|
||||
expect(options).toMatchObject({ timeout: 250, killSignal: "SIGKILL" });
|
||||
callback(Object.assign(new Error("probe deadline"), { killed: true }));
|
||||
}).mockImplementationOnce((_file, _args, _options, callback) => callback(null));
|
||||
const applier = new TmuxOptionDefaultsApplier({ tmuxAdapter: adapter, platform: "linux" });
|
||||
const warnings = await applier.applyToFreshSession("first@test");
|
||||
expect(warnings).toHaveLength(1);
|
||||
expect(warnings[0]).toMatch(/clipboard.*250 ms/i);
|
||||
expect(warnings[0]).toMatch(/retry/i);
|
||||
expect(copyCommand).toBe(initial);
|
||||
expect(execute).toHaveBeenCalledTimes(1);
|
||||
expect(await applier.applyToFreshSession("next@test")).toEqual([]);
|
||||
expect(copyCommand).toBe("wl-copy");
|
||||
expect(execute).toHaveBeenCalledTimes(2);
|
||||
expect(await applier.applyToFreshSession("later@test")).toEqual([]);
|
||||
expect(execute).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("memoizes confirmed absence without a deadline warning", async () => {
|
||||
execute.mockImplementation((_file, _args, _options, callback) => callback(Object.assign(new Error("absent"), { code: 1 })));
|
||||
const setServerOption = vi.fn(async () => ({ ok: true }));
|
||||
const adapter = { setSessionOption: async () => ({ ok: true }), setServerOption } as unknown as TmuxAdapter;
|
||||
const applier = new TmuxOptionDefaultsApplier({ tmuxAdapter: adapter, platform: "linux" });
|
||||
expect(await applier.applyToFreshSession("first@test")).toEqual([]);
|
||||
expect(await applier.applyToFreshSession("next@test")).toEqual([]);
|
||||
expect(execute).toHaveBeenCalledTimes(2);
|
||||
expect(setServerOption).not.toHaveBeenCalledWith("copy-command", expect.anything());
|
||||
});
|
||||
});
|
||||
@@ -23,24 +23,6 @@ function mockAdapter(overrides?: {
|
||||
}
|
||||
|
||||
describe("TmuxOptionDefaultsApplier", () => {
|
||||
it("awaits an asynchronous clipboard lookup without holding unrelated work", async () => {
|
||||
const { adapter, setServerOption } = mockAdapter();
|
||||
let release!: (present: boolean) => void;
|
||||
const pending = new Promise<boolean>(resolve => { release = resolve; });
|
||||
const probe = vi.fn((bin: string) => bin === "wl-copy" ? pending : Promise.resolve(true));
|
||||
let finished = false;
|
||||
const applier = new TmuxOptionDefaultsApplier({ tmuxAdapter: adapter, platform: "linux", hasCommand: probe });
|
||||
const applied = applier.applyToFreshSession("async-clipboard@test").then(value => { finished = true; return value; });
|
||||
try {
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
expect(finished).toBe(false);
|
||||
expect(setServerOption).not.toHaveBeenCalledWith("copy-command", expect.anything());
|
||||
} finally { release(false); }
|
||||
expect(await applied).toEqual([]);
|
||||
expect(probe.mock.calls.map(([bin]) => bin)).toEqual(["wl-copy", "xclip"]);
|
||||
expect(setServerOption).toHaveBeenCalledWith("copy-command", "xclip -selection clipboard -i");
|
||||
});
|
||||
|
||||
it("sets mouse on + status off (default) on the given session via SESSION scope only", async () => {
|
||||
const { adapter, setSessionOption, setServerOption } = mockAdapter();
|
||||
// default reader (omitted) → statusBar false.
|
||||
@@ -127,20 +109,20 @@ describe("TmuxOptionDefaultsApplier", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveCopyCommand (pure per-platform table)", async () => {
|
||||
it("darwin → pbcopy", async () => {
|
||||
expect(await resolveCopyCommand("darwin", () => false)).toBe("pbcopy");
|
||||
describe("resolveCopyCommand (pure per-platform table)", () => {
|
||||
it("darwin → pbcopy", () => {
|
||||
expect(resolveCopyCommand("darwin", () => false)).toBe("pbcopy");
|
||||
});
|
||||
it("linux → wl-copy when present", async () => {
|
||||
expect(await resolveCopyCommand("linux", (b) => b === "wl-copy")).toBe("wl-copy");
|
||||
it("linux → wl-copy when present", () => {
|
||||
expect(resolveCopyCommand("linux", (b) => b === "wl-copy")).toBe("wl-copy");
|
||||
});
|
||||
it("linux → xclip when wl-copy absent but xclip present", async () => {
|
||||
expect(await resolveCopyCommand("linux", (b) => b === "xclip")).toBe("xclip -selection clipboard -i");
|
||||
it("linux → xclip when wl-copy absent but xclip present", () => {
|
||||
expect(resolveCopyCommand("linux", (b) => b === "xclip")).toBe("xclip -selection clipboard -i");
|
||||
});
|
||||
it("linux → null when neither present (OSC 52 fallback)", async () => {
|
||||
expect(await resolveCopyCommand("linux", () => false)).toBeNull();
|
||||
it("linux → null when neither present (OSC 52 fallback)", () => {
|
||||
expect(resolveCopyCommand("linux", () => false)).toBeNull();
|
||||
});
|
||||
it("other platforms → null", async () => {
|
||||
expect(await resolveCopyCommand("win32", () => true)).toBeNull();
|
||||
it("other platforms → null", () => {
|
||||
expect(resolveCopyCommand("win32", () => true)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { AttentionRead, AttentionItem } from "@openrig/daemon/attention";
|
||||
import type { Action, FleetSnapshot, ViewState } from "../types.js";
|
||||
import { listItem, wrapDetailLines, type ContentLine } from "../detail.js";
|
||||
import { displayTime } from "../time.js";
|
||||
|
||||
/** The bounded, confirmed-delivery or bridge-acceptance projection served by S02; no client classification. */
|
||||
export interface DeliveredHumanUpdates {
|
||||
@@ -12,8 +11,6 @@ export interface DeliveredHumanUpdates {
|
||||
truncated: boolean;
|
||||
}
|
||||
|
||||
const BRIDGE_ACCEPTED_SCOPE = " · accepted by bridge";
|
||||
|
||||
function isBridgeAcceptedUpdate(q: DeliveredHumanUpdates["items"][number]): boolean {
|
||||
return q.deliveryOutcome === "accepted" || q.deliveryReceipt.startsWith("relay-owner-notification-accepted");
|
||||
}
|
||||
@@ -41,13 +38,13 @@ export function composeHumanUpdates(attention: AttentionRead | null, updates: De
|
||||
const baseScope = projects.length === 1 ? `project ${projects[0]} (queue tag)` : "instance · project unknown";
|
||||
const item: AttentionItem = { id: `human-update:${q.qitemId}`, kind: "update", summary: q.summary || q.body.trim().split(/\r?\n/).find(Boolean) || (accepted ? "Accepted by bridge" : "Delivered update"),
|
||||
recipient: q.destinationSession, urgency: "update", unblocks: null, at: q.deliveredAt,
|
||||
scope: accepted ? `${baseScope}${BRIDGE_ACCEPTED_SCOPE}` : baseScope, project: null,
|
||||
scope: accepted ? `${baseScope} · accepted by bridge` : baseScope, project: null,
|
||||
source: `/api/queue/${encodeURIComponent(q.qitemId)}` };
|
||||
if (!read.items.some(current => current.id === `queue-update:${q.qitemId}`)) read.items.push(item);
|
||||
if (wanted === item.id && read.detail?.item.id !== item.id) {
|
||||
read.detailError = null;
|
||||
read.detail = { item, lines: [q.body, ...(q.humanDetail ? ["Supplemental detail:", q.humanDetail] : []),
|
||||
`From: ${q.sourceSession}`, `Delivery receipt: ${q.deliveryReceipt}`, `Queue: ${q.qitemId}`, `Evidence: ${q.evidenceRef ?? "none recorded"}`],
|
||||
`From: ${q.sourceSession}`, accepted ? `Accepted by bridge: ${q.deliveredAt}` : `Delivered: ${q.deliveredAt}`, `Delivery receipt: ${q.deliveryReceipt}`, `Queue: ${q.qitemId}`, `Evidence: ${q.evidenceRef ?? "none recorded"}`],
|
||||
files: q.evidenceRef?.startsWith("/") ? [{ label: "Update evidence", path: q.evidenceRef }] : [] };
|
||||
}
|
||||
}
|
||||
@@ -74,10 +71,7 @@ export function attentionLines(state: ViewState, snap: FleetSnapshot, width: num
|
||||
if (d.item.recipient) lines.push({ text: `To: ${d.item.recipient}` });
|
||||
if (d.item.id.startsWith("human-update:") || d.item.id.startsWith("queue-update:")) lines.push({ text: "No action needed" });
|
||||
if (d.item.unblocks) lines.push({ text: `Unblocks: ${d.item.unblocks}` });
|
||||
// Times are drawn in the person's zone (ui.timezone), as the header's are; a delivered update's time is when it was
|
||||
// delivered or accepted by the bridge.
|
||||
const when = !d.item.id.startsWith("human-update:") ? "Observed" : d.item.scope.endsWith(BRIDGE_ACCEPTED_SCOPE) ? "Accepted by bridge" : "Delivered";
|
||||
lines.push({ text: `Scope: ${d.item.scope}` }, { text: `${when}: ${displayTime(d.item.at, state.timeZone)}` });
|
||||
lines.push({ text: `Scope: ${d.item.scope}` }, { text: `Observed: ${d.item.at ?? "unknown"}` });
|
||||
lines.push(...questionLines(d.item).map(text => ({ text })));
|
||||
lines.push(...d.lines.map(text => ({ text })));
|
||||
for (const f of d.files) lines.push(listItem(f.label, { type: "attention-source", path: f.path }));
|
||||
@@ -130,10 +124,10 @@ export function attentionLines(state: ViewState, snap: FleetSnapshot, width: num
|
||||
if (i.humanQuestions?.length) lines.push({ text: ` Questions: ${i.humanQuestions.length}, ${unansweredCount(i)} unanswered` });
|
||||
if (i.id.startsWith("human-update:") || i.id.startsWith("queue-update:")) lines.push({ text: " No action needed" });
|
||||
if (i.unblocks) lines.push({ text: ` Unblocks: ${i.unblocks}` });
|
||||
lines.push({ text: ` ${i.scope} · ${displayTime(i.at, state.timeZone)}` });
|
||||
lines.push({ text: ` ${i.scope} · ${i.at ?? "time unknown"}` });
|
||||
}
|
||||
}
|
||||
lines.push({ text: "" }, { text: `Read at ${displayTime(read.readAt, state.timeZone)}` }, ...read.sources.map(s => ({ text: `${s.source}: ${s.state} · ${s.detail}` })));
|
||||
lines.push({ text: "" }, { text: `Read at ${read.readAt}` }, ...read.sources.map(s => ({ text: `${s.source}: ${s.state} · ${s.detail}` })));
|
||||
}
|
||||
lines.push({ text: "" }, { text: "Viewing is not approval. Each request's detail says whether it went to Slack and where to answer it." });
|
||||
return wrapDetailLines(lines, width);
|
||||
|
||||
@@ -287,10 +287,8 @@ function toAgentRow(node: NodeInventoryRead): AgentRow {
|
||||
: node.activityState?.display === "idle"
|
||||
? "idle"
|
||||
: (node.terminalActive === true || (node.terminalActive == null && node.agentActivity?.state === "running") ? "active"
|
||||
// The daemon's activity word decides when it's served (as in `rig ps`): it said unknown, so a quiet
|
||||
// screen stays unknown. A community harness gives no idle signal either. Only an older daemon that
|
||||
// serves no activity word keeps the screen fallback.
|
||||
: node.communityHarness || node.activityState ? "unknown" : "idle"))
|
||||
// A community harness gives no idle signal: a quiet screen is unknown, not idle.
|
||||
: node.communityHarness ? "unknown" : "idle"))
|
||||
: (node.sessionStatus ?? "unknown"),
|
||||
live: node.lifecycleState === "running",
|
||||
canRun: node.lifecycleState !== "running"
|
||||
|
||||
@@ -50,24 +50,6 @@ it("renders an unsent local FYI without inventing an approval or a delivery rece
|
||||
expect(text).not.toContain("Delivery receipt:");
|
||||
});
|
||||
|
||||
it("draws Feed times in the person's zone, as the header does, and redraws them when the zone changes", () => {
|
||||
const item = current(), attention = read([item]);
|
||||
attention.detail = { item, lines: ["Current queue body"], files: [] };
|
||||
const merged = composeHumanUpdates(attention, delivered("other"), item.id)!;
|
||||
const draw = (timeZone: string, attentionOpen: string | null) => attentionLines(
|
||||
{ ...createViewState({ instanceId: "owned-fixture", timeZone }).get(), section: "needs" as const, attentionOpen },
|
||||
{ ...emptySnapshot(), attentionRead: merged }, 140).map(l => l.text).join("\n");
|
||||
const london = draw("Europe/London", null);
|
||||
expect(london).toContain("instance · 2026-10-01 13:00:00 GMT+1");
|
||||
expect(london).toContain("instance · project unknown · 2026-10-08 13:00:00 GMT+1");
|
||||
expect(london).toContain("Read at 2026-10-09 13:00:00 GMT+1");
|
||||
expect(london).not.toMatch(/\d{4}-\d{2}-\d{2}T\d{2}:/);
|
||||
// The same read, drawn after the zone setting arrives: nothing is re-read, and nothing keeps the earlier zone.
|
||||
const angeles = draw("America/Los_Angeles", item.id);
|
||||
expect(angeles).toContain("Observed: 2026-10-01 05:00:00 PDT");
|
||||
expect(angeles).not.toMatch(/\d{4}-\d{2}-\d{2}T\d{2}:|GMT\+1/);
|
||||
});
|
||||
|
||||
it("does not retain delivered history beyond a successful empty receipt window when only current updates fail", () => {
|
||||
const prior = composeHumanUpdates(read([]), delivered())!;
|
||||
const failed = read([]);
|
||||
@@ -173,9 +155,8 @@ it("labels relay bridge acceptance as accepted by the bridge rather than Deliver
|
||||
],
|
||||
};
|
||||
const openedBridge = composeHumanUpdates(read([]), bridgeUpdates, "human-update:bridge-fyi")!;
|
||||
const detailState = (id: string) => ({ ...createViewState({ instanceId: "owned-fixture", timeZone: "UTC" }).get(), section: "needs" as const, attentionOpen: id });
|
||||
const bridgeDetailLines = attentionLines(detailState("human-update:bridge-fyi"), { ...emptySnapshot(), attentionRead: openedBridge }, 140).map(l => l.text).join("\n");
|
||||
expect(bridgeDetailLines).toContain("Accepted by bridge: 2026-10-08 14:00:00 UTC");
|
||||
const bridgeDetailLines = openedBridge.detail?.lines.join("\n") ?? "";
|
||||
expect(bridgeDetailLines).toContain("Accepted by bridge: 2026-10-08T14:00:00Z");
|
||||
expect(bridgeDetailLines).not.toContain("Delivered:");
|
||||
expect(openedBridge.sources.find(s => s.source === "delivered updates")?.detail).toContain("accepted by the bridge");
|
||||
expect(openedBridge.sources.find(s => s.source === "delivered updates")?.detail).not.toContain("confirmed delivered updates");
|
||||
@@ -187,8 +168,8 @@ it("labels relay bridge acceptance as accepted by the bridge rather than Deliver
|
||||
|
||||
// Slack delivered wording remains unchanged
|
||||
const openedSlack = composeHumanUpdates(read([]), delivered("slack-fyi"), "human-update:slack-fyi")!;
|
||||
const slackDetailLines = attentionLines(detailState("human-update:slack-fyi"), { ...emptySnapshot(), attentionRead: openedSlack }, 140).map(l => l.text).join("\n");
|
||||
expect(slackDetailLines).toContain("Delivered: 2026-10-08 12:00:00 UTC");
|
||||
const slackDetailLines = openedSlack.detail?.lines.join("\n") ?? "";
|
||||
expect(slackDetailLines).toContain("Delivered: 2026-10-08T12:00:00Z");
|
||||
expect(slackDetailLines).not.toContain("Accepted by bridge:");
|
||||
expect(openedSlack.sources.find(s => s.source === "delivered updates")?.detail).toBe(
|
||||
"Latest 20 confirmed delivered updates; retained receipt window.",
|
||||
|
||||
@@ -710,7 +710,7 @@ describe("snapshot hydration over the §4.A reads (Phase 2)", () => {
|
||||
expect(output.lines.find((line) => /\bmismatch\s/.test(line))).not.toContain("run ▸");
|
||||
expect(output.lines.find((line) => /\bmissing\s/.test(line))).not.toContain("run ▸");
|
||||
});
|
||||
it("shows a quiet seat the daemon reads as unknown as unknown, never idle, for a community harness and a built-in runtime alike", async () => {
|
||||
it("shows a quiet community harness as unknown, never idle, and leaves built-in rows as they were", async () => {
|
||||
const base = (FIXTURES["/api/rigs/01JRIG/nodes"] as Array<Record<string, unknown>>)[0]!;
|
||||
const quiet = { startupStatus: "ready", sessionStatus: "running", terminalActive: false, agentActivity: { state: "unknown" },
|
||||
activityState: { activity: "unknown", display: "unknown", needsInput: { count: 0, reason: null }, decidedBy: null } };
|
||||
@@ -727,28 +727,8 @@ describe("snapshot hydration over the §4.A reads (Phase 2)", () => {
|
||||
"dev.app-quiet": "unknown",
|
||||
"dev.app-moving": "active",
|
||||
"dev.app-prompt": "needs_input",
|
||||
"dev.pi-quiet": "unknown",
|
||||
"dev.claude-quiet": "unknown",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the daemon's idle and working words, and the screen fallback only for a daemon that serves no activity word", async () => {
|
||||
const base = (FIXTURES["/api/rigs/01JRIG/nodes"] as Array<Record<string, unknown>>)[0]!;
|
||||
const running = { startupStatus: "ready", sessionStatus: "running", runtime: "claude-code", agentActivity: { state: "unknown" } };
|
||||
const served = (display: string) => ({ activity: display === "idle" ? "idle-at-prompt" : display, display, needsInput: { count: 0, reason: null }, decidedBy: null });
|
||||
const nodes = [
|
||||
{ ...base, ...running, logicalId: "dev.served-idle", terminalActive: false, activityState: served("idle") },
|
||||
{ ...base, ...running, logicalId: "dev.served-working", terminalActive: false, activityState: served("working") },
|
||||
{ ...base, ...running, logicalId: "dev.unknown-moving", terminalActive: true, activityState: served("unknown") },
|
||||
{ ...base, ...running, logicalId: "dev.legacy-quiet", terminalActive: false, activityState: undefined },
|
||||
];
|
||||
const snap = await hydrateSnapshot(fixtureClient({}, { "/api/rigs/01JRIG/nodes": nodes }));
|
||||
const statuses = Object.fromEntries(myrig(snap).pods[0]!.agents.map((agent) => [agent.name, agent.status]));
|
||||
expect(statuses).toEqual({
|
||||
"dev.served-idle": "idle",
|
||||
"dev.served-working": "active",
|
||||
"dev.unknown-moving": "active",
|
||||
"dev.legacy-quiet": "idle",
|
||||
"dev.pi-quiet": "idle",
|
||||
"dev.claude-quiet": "idle",
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ import fs from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { test } from "node:test";
|
||||
import { packEntry } from "./npm-pack-json.mjs";
|
||||
|
||||
const script = fs.readFileSync(new URL("./build-package.sh", import.meta.url), "utf8");
|
||||
const assetsBranch = script.match(/^if \[ -d "\$DAEMON_DIR\/assets" \]; then\n[\s\S]*?^fi$/m)?.[0];
|
||||
@@ -130,7 +129,7 @@ test("offline npm pack carries staged asset bytes and executability, with npm's
|
||||
assertStaged(f);
|
||||
const staged = snapshot(f.staged);
|
||||
fs.writeFileSync(join(f.cli, "package.json"), JSON.stringify({ name: "openrig-assets-fixture", version: "1.0.0", private: true, files: ["daemon"] }));
|
||||
const packed = packEntry(execFileSync("bash", ["-c", 'umask 0022; exec npm pack --offline --ignore-scripts --json --pack-destination "$1"', "asset-pack", f.root], { cwd: f.cli, encoding: "utf8", timeout: 30_000, stdio: ["ignore", "pipe", "pipe"] }));
|
||||
const packed = JSON.parse(execFileSync("bash", ["-c", 'umask 0022; exec npm pack --offline --ignore-scripts --json --pack-destination "$1"', "asset-pack", f.root], { cwd: f.cli, encoding: "utf8", timeout: 30_000, stdio: ["ignore", "pipe", "pipe"] }))[0];
|
||||
const files = new Map(packed.files.map(file => [file.path, file]));
|
||||
for (const [name, bytes] of Object.entries(contents)) {
|
||||
const entry = `daemon/assets/${name}`;
|
||||
|
||||
@@ -2,14 +2,14 @@ import { execSync } from "node:child_process";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { packEntry } from "./npm-pack-json.mjs";
|
||||
|
||||
test("npm pack of @openrig/cli includes scripts/check-abi.mjs in tarball", () => {
|
||||
const output = execSync("npm pack --dry-run --json 2>/dev/null", {
|
||||
cwd: "packages/cli",
|
||||
encoding: "utf-8",
|
||||
});
|
||||
const files = packEntry(output).files?.map((f) => f.path) ?? [];
|
||||
const entries = JSON.parse(output);
|
||||
const files = entries[0]?.files?.map((f) => f.path) ?? [];
|
||||
|
||||
assert.ok(
|
||||
files.some((f) => f.includes("scripts/check-abi.mjs")),
|
||||
|
||||
@@ -19,7 +19,6 @@ import {
|
||||
scanInternalLeaks,
|
||||
} from "./internal-leak-scanner.mjs";
|
||||
import { LEAK_GUARD_RULES_ENV, leakGuardRulesPath } from "./leak-guard-rules.mjs";
|
||||
import { packEntries } from "./npm-pack-json.mjs";
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
const FIX = "Genericize the content, cite its public home, or re-home it to the internal pack root.";
|
||||
@@ -156,22 +155,13 @@ function derivePackageArtifacts(packageRoot) {
|
||||
if (packed.status !== 0) {
|
||||
throw new Error(`npm artifact enumeration failed:\n${packed.stderr || packed.stdout || `exit ${packed.status}`}`);
|
||||
}
|
||||
let entries = [];
|
||||
let parsed;
|
||||
try {
|
||||
// One parse, in the shared reader: a JSON string (even one holding a serialized list) is not a shape npm prints.
|
||||
entries = packEntries(packed.stdout);
|
||||
parsed = JSON.parse(packed.stdout);
|
||||
} catch (error) {
|
||||
if (error instanceof SyntaxError) throw new Error(`npm artifact enumeration did not return JSON: ${error.message}`);
|
||||
// not a shape npm prints: reported below as no files array
|
||||
throw new Error(`npm artifact enumeration did not return JSON: ${error.message}`);
|
||||
}
|
||||
// One package is packed here; scanning only the first of several would issue a receipt for part of the artifact.
|
||||
if (entries.length > 1) {
|
||||
throw new Error(
|
||||
`npm artifact enumeration listed ${entries.length} packages where one was packed; `
|
||||
+ "run the gate for one package (check --package-root and any npm workspaces setting)",
|
||||
);
|
||||
}
|
||||
const rawFiles = entries[0]?.files;
|
||||
const rawFiles = parsed?.[0]?.files;
|
||||
if (!Array.isArray(rawFiles)) throw new Error("npm artifact enumeration returned no files array");
|
||||
const files = rawFiles.map((entry) => entry?.path).filter((path) => typeof path === "string");
|
||||
if (files.length !== rawFiles.length || files.length === 0) {
|
||||
|
||||
@@ -5,7 +5,6 @@ import { execFileSync, spawnSync } from "node:child_process";
|
||||
import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
chmodSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
@@ -406,44 +405,6 @@ test("exact-line allowances reach content and derived scans and are recorded in
|
||||
});
|
||||
});
|
||||
|
||||
test("npm output that is a JSON string is refused, even when the string holds a serialized list", () => {
|
||||
withFixture(({ root, repo, rules, review, receipt }) => {
|
||||
write(join(repo, "public/guide.md"), "Generic product guidance.\n");
|
||||
commitAll(repo, "string output fixture");
|
||||
writeFileSync(review, JSON.stringify({ surfaces: [] }));
|
||||
const bin = join(root, "bin");
|
||||
const serialized = JSON.stringify([{ files: [{ path: "package.json" }, { path: "public/guide.md" }] }]);
|
||||
write(join(bin, "npm"), `#!${process.execPath}\nprocess.stdout.write(${JSON.stringify(JSON.stringify(serialized))});\n`);
|
||||
chmodSync(join(bin, "npm"), 0o755);
|
||||
|
||||
const result = runGate({ repo, rules, review, receipt, cutSha: gitHead(repo),
|
||||
env: { ...process.env, PATH: `${bin}:${process.env.PATH}` } });
|
||||
assert.notEqual(result.status, 0);
|
||||
assert.match(result.stderr, /npm artifact enumeration returned no files array/);
|
||||
});
|
||||
});
|
||||
|
||||
test("npm output that lists more than one package is refused before any scan or receipt", () => {
|
||||
const first = { name: "first", files: [{ path: "package.json" }, { path: "public/guide.md" }] };
|
||||
const second = { name: "second", files: [{ path: "package.json" }, { path: "public/other.md" }] };
|
||||
for (const printed of [[first, second], { first, second }]) {
|
||||
withFixture(({ root, repo, rules, review, receipt }) => {
|
||||
write(join(repo, "public/guide.md"), "Generic product guidance.\n");
|
||||
commitAll(repo, "two package output fixture");
|
||||
writeFileSync(review, JSON.stringify({ surfaces: [] }));
|
||||
const bin = join(root, "bin");
|
||||
write(join(bin, "npm"), `#!${process.execPath}\nprocess.stdout.write(${JSON.stringify(JSON.stringify(printed))});\n`);
|
||||
chmodSync(join(bin, "npm"), 0o755);
|
||||
|
||||
const result = runGate({ repo, rules, review, receipt, cutSha: gitHead(repo),
|
||||
env: { ...process.env, PATH: `${bin}:${process.env.PATH}` } });
|
||||
assert.notEqual(result.status, 0);
|
||||
assert.match(result.stderr, /npm artifact enumeration listed 2 packages where one was packed; run the gate for one package \(check --package-root and any npm workspaces setting\)/);
|
||||
assert.equal(existsSync(receipt), false);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test("the release ceremony names the gate and its durable receipt", () => {
|
||||
const pkg = JSON.parse(readFileSync("package.json", "utf8"));
|
||||
assert.equal(pkg.scripts["gate:substance"], "node scripts/check-substance-gate.mjs");
|
||||
@@ -459,7 +420,7 @@ function writeGateHarness(root, source, invocationPath) {
|
||||
mkdirSync(root, { recursive: true });
|
||||
const gate = join(root, "check-substance-gate.mjs");
|
||||
writeFileSync(gate, source);
|
||||
for (const module of ["internal-leak-scanner.mjs", "leak-guard-rules.mjs", "npm-pack-json.mjs"]) {
|
||||
for (const module of ["internal-leak-scanner.mjs", "leak-guard-rules.mjs"]) {
|
||||
writeFileSync(join(root, module), readFileSync(resolve("scripts", module)));
|
||||
}
|
||||
writeFileSync(join(root, "check-internal-leak-guard.mjs"), [
|
||||
@@ -513,7 +474,7 @@ function withFixture(run) {
|
||||
}
|
||||
}
|
||||
|
||||
function runGate({ repo, rules, review, receipt, cutSha, gate = GATE, env = process.env }) {
|
||||
function runGate({ repo, rules, review, receipt, cutSha, gate = GATE }) {
|
||||
return spawnSync(process.execPath, [
|
||||
gate,
|
||||
"--repo", repo,
|
||||
@@ -524,7 +485,7 @@ function runGate({ repo, rules, review, receipt, cutSha, gate = GATE, env = proc
|
||||
"--receipt", receipt,
|
||||
"--judge", "dev-qa@fixture",
|
||||
"--cut-sha", cutSha,
|
||||
], { encoding: "utf8", env });
|
||||
], { encoding: "utf8" });
|
||||
}
|
||||
|
||||
function write(path, content) {
|
||||
|
||||
@@ -4,7 +4,6 @@ import { tmpdir } from "node:os";
|
||||
import { delimiter, join } from "node:path";
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { packEntry } from "./npm-pack-json.mjs";
|
||||
|
||||
test("the CLI build carries the unchanged repository LICENSE and README into npm pack", () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "cli-license-"));
|
||||
@@ -27,14 +26,14 @@ test("the CLI build carries the unchanged repository LICENSE and README into npm
|
||||
execFileSync("npm", ["run", "build"], { cwd: cli, env, stdio: "pipe" });
|
||||
const source = readFileSync(join(root, "LICENSE"));
|
||||
assert.deepEqual(readFileSync(join(cli, "LICENSE")), source, "build must stage the repository license verbatim");
|
||||
const packed = packEntry(execFileSync("npm", ["pack", "--ignore-scripts", "--json"], { cwd: cli, env, encoding: "utf8", stdio: ["pipe", "pipe", "pipe"] }));
|
||||
assert.ok(packed.files.some((file) => file.path === "LICENSE"), "npm archive must include LICENSE");
|
||||
const bytes = execFileSync("tar", ["-xOf", join(cli, packed.filename), "package/LICENSE"]);
|
||||
const packed = JSON.parse(execFileSync("npm", ["pack", "--ignore-scripts", "--json"], { cwd: cli, env, encoding: "utf8", stdio: ["pipe", "pipe", "pipe"] }));
|
||||
assert.ok(packed[0].files.some((file) => file.path === "LICENSE"), "npm archive must include LICENSE");
|
||||
const bytes = execFileSync("tar", ["-xOf", join(cli, packed[0].filename), "package/LICENSE"]);
|
||||
assert.deepEqual(bytes, source, "packed license must equal the existing root bytes");
|
||||
// npm shows the package-root README on the registry page; without it the page reads "No README data found".
|
||||
const readme = readFileSync(join(root, "README.md"));
|
||||
assert.ok(packed.files.some((file) => file.path === "README.md"), "npm archive must include README.md");
|
||||
const readmeBytes = execFileSync("tar", ["-xOf", join(cli, packed.filename), "package/README.md"]);
|
||||
assert.ok(packed[0].files.some((file) => file.path === "README.md"), "npm archive must include README.md");
|
||||
const readmeBytes = execFileSync("tar", ["-xOf", join(cli, packed[0].filename), "package/README.md"]);
|
||||
assert.deepEqual(readmeBytes, readme, "packed README must equal the existing root bytes");
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
// `npm pack --json` prints an array of entries up to npm 11 and, from npm 12, an object keyed by package name
|
||||
// ("the --json output of `npm pack` and `npm publish` have changed",
|
||||
// https://github.com/npm/cli/releases/tag/v12.0.0). Every reader of that output goes through here, so both shapes
|
||||
// give the same entries.
|
||||
|
||||
/** The entries `npm pack --json` printed, in order, from its output text or its parsed value. */
|
||||
export function packEntries(output) {
|
||||
const parsed = typeof output === "string" ? JSON.parse(output) : output;
|
||||
const entries = Array.isArray(parsed)
|
||||
? parsed
|
||||
: parsed !== null && typeof parsed === "object" ? Object.values(parsed) : null;
|
||||
if (!entries || !entries.every((entry) => entry !== null && typeof entry === "object" && !Array.isArray(entry))) {
|
||||
throw new Error("npm pack --json printed neither an array of entries nor an object of per-package entries");
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
/** The one entry of a `npm pack --json` run that packed one package; any other count throws. */
|
||||
export function packEntry(output) {
|
||||
const entries = packEntries(output);
|
||||
if (entries.length !== 1) {
|
||||
throw new Error(`npm pack --json printed ${entries.length} entries where one package was packed`);
|
||||
}
|
||||
return entries[0];
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import { packEntries, packEntry } from "./npm-pack-json.mjs";
|
||||
|
||||
const entry = { id: "@openrig/[email protected]", name: "@openrig/cli", version: "1.2.3", filename: "openrig-cli-1.2.3.tgz",
|
||||
files: [{ path: "LICENSE" }, { path: "package.json" }] };
|
||||
|
||||
test("npm 10 and 11: an array of entries", () => {
|
||||
assert.deepEqual(packEntries(JSON.stringify([entry])), [entry]);
|
||||
assert.deepEqual(packEntry(JSON.stringify([entry])), entry);
|
||||
});
|
||||
|
||||
test("npm 12: an object of entries keyed by package name", () => {
|
||||
assert.deepEqual(packEntries(JSON.stringify({ "@openrig/cli": entry })), [entry]);
|
||||
assert.deepEqual(packEntry(JSON.stringify({ "@openrig/cli": entry })), entry);
|
||||
});
|
||||
|
||||
test("both shapes keep every entry in order, and a parsed value reads like its text", () => {
|
||||
const other = { ...entry, name: "@openrig/acp", filename: "openrig-acp-1.2.3.tgz" };
|
||||
assert.deepEqual(packEntries([entry, other]), [entry, other]);
|
||||
assert.deepEqual(packEntries({ "@openrig/cli": entry, "@openrig/acp": other }), [entry, other]);
|
||||
});
|
||||
|
||||
test("packEntry refuses any count but one", () => {
|
||||
assert.throws(() => packEntry("[]"), /0 entries/);
|
||||
assert.throws(() => packEntry("{}"), /0 entries/);
|
||||
assert.throws(() => packEntry(JSON.stringify([entry, entry])), /2 entries/);
|
||||
});
|
||||
|
||||
test("output that is neither shape throws", () => {
|
||||
for (const output of ["null", "\"text\"", "3", JSON.stringify(entry), JSON.stringify([[entry]]), JSON.stringify({ a: null })]) {
|
||||
assert.throws(() => packEntries(output), /neither an array of entries nor an object of per-package entries/, output);
|
||||
}
|
||||
assert.throws(() => packEntries("not json"), SyntaxError);
|
||||
});
|
||||
@@ -8,7 +8,6 @@ import { createHash } from 'node:crypto';
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { packEntry } from './npm-pack-json.mjs';
|
||||
|
||||
const LISTING = ['packages/acp/registry/agent.json', 'packages/acp/registry/icon.svg'];
|
||||
const readJson = (root, rel) => JSON.parse(readFileSync(join(root, rel), 'utf8'));
|
||||
@@ -36,8 +35,8 @@ export function packRelease(root, dest) {
|
||||
mkdirSync(dest, { recursive: true });
|
||||
// The CLI is packed as the release producer has packed it; the launcher isn't a workspace, so it's packed by path.
|
||||
// --ignore-scripts skips pack's prepack and postpack, but npm 10 still runs a `prepare`; neither package has one.
|
||||
const pack = target => packEntry(execFileSync('npm', ['pack', ...target, '--ignore-scripts', '--json', '--pack-destination', dest],
|
||||
{ cwd: root, encoding: 'utf8' }));
|
||||
const pack = target => JSON.parse(execFileSync('npm', ['pack', ...target, '--ignore-scripts', '--json', '--pack-destination', dest],
|
||||
{ cwd: root, encoding: 'utf8' }))[0];
|
||||
const artifacts = [pack(['--workspace', 'packages/cli']), pack(['./packages/acp'])].map(packed => {
|
||||
const path = join(dest, packed.filename);
|
||||
return { name: packed.name, version: packed.version, path, size: packed.size, sha256: sha256(path), integrity: packed.integrity };
|
||||
|
||||
@@ -4,7 +4,6 @@ import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { execFileSync, spawn, spawnSync } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { packEntry } from "./npm-pack-json.mjs";
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const cli = path.join(root, "packages", "cli");
|
||||
@@ -48,8 +47,8 @@ async function assertDelayedSplits(entry, pkgDir) {
|
||||
|
||||
try {
|
||||
execFileSync("npm", ["run", "build:package"], { cwd: root, stdio: "inherit" });
|
||||
const packed = packEntry(execFileSync("npm", ["pack", "--ignore-scripts", "--json", "--pack-destination", temp], { cwd: cli, encoding: "utf8" }));
|
||||
const tarball = path.join(temp, packed.filename);
|
||||
const packed = JSON.parse(execFileSync("npm", ["pack", "--ignore-scripts", "--json", "--pack-destination", temp], { cwd: cli, encoding: "utf8" }));
|
||||
const tarball = path.join(temp, packed[0].filename);
|
||||
execFileSync("tar", ["-xzf", tarball, "-C", temp]);
|
||||
|
||||
const pkgDir = path.join(temp, "package");
|
||||
@@ -69,7 +68,7 @@ try {
|
||||
});
|
||||
assert.equal(launched.status, 0, launched.stderr || `packed TUI exited ${launched.status}`);
|
||||
await assertDelayedSplits(entry, pkgDir);
|
||||
console.log(`packed TUI launch PASS: ${packed.filename}`);
|
||||
console.log(`packed TUI launch PASS: ${packed[0].filename}`);
|
||||
} finally {
|
||||
fs.rmSync(temp, { recursive: true, force: true });
|
||||
}
|
||||
Reference in new issue
Block a user