mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-11 11:41:38 -10:00
Compare commits
26
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6a40cde3cd | ||
|
|
4c8813b4b6 | ||
|
|
4aa38abf22 | ||
|
|
03850f2b6f | ||
|
|
04980d9f45 | ||
|
|
ceae26774e | ||
|
|
adb5555f08 | ||
|
|
aa75c28f58 | ||
|
|
86c7af6a76 | ||
|
|
898a342bd2 | ||
|
|
5f72f3934c | ||
|
|
16e8b7fb31 | ||
|
|
2be19e641d | ||
|
|
01b80517a9 | ||
|
|
a95065c0da | ||
|
|
81d80a1f2b | ||
|
|
8f1dbb8b05 | ||
|
|
d9292d5b04 | ||
|
|
9d096d2145 | ||
|
|
c818bfe1a5 | ||
|
|
a58db3ac33 | ||
|
|
9887add016 | ||
|
|
382b3b036a | ||
|
|
d382e5b317 | ||
|
|
b8e7318480 | ||
|
|
25d4e711fc |
No files matched your search
@@ -328,7 +328,7 @@ OpenRig is a multi-agent harness — it manages the system that coding agents fo
|
||||
- **Connect** Slack through an app you create in your own workspace; the experimental `rig slack manifest` prints that app's manifest ([setup guide](docs/reference/slack-app-setup.md))
|
||||
- **Evolve** running topologies with `rig grow`, `rig shrink`, `rig launch`, `rig remove`
|
||||
|
||||
Every agent runs in a tmux session you can attach to, inspect, and work with directly.
|
||||
Every terminal seat runs in a tmux session you can attach to, inspect, and work with directly. [Hosted members](docs/reference/hosted-members.md) join through OpenRig's MCP server and have no pane.
|
||||
|
||||
## Teams
|
||||
|
||||
|
||||
@@ -175,8 +175,14 @@ The standing detector in `queue-stuck-sweep.ts` creates findings through the
|
||||
queue repository, with `evidenceRef: rig queue show <source-qitem-id>` pointing
|
||||
to the underlying durable work row. Unclaimed and undelivered findings go to
|
||||
the destination's orchestrator when one is derivable, otherwise to the
|
||||
destination itself, and that seat may be dead. Overdue claims go to their
|
||||
destination. Repeated detections reuse the existing finding without changing
|
||||
destination itself, and that seat may be dead. A wake that failed (`failed:…`)
|
||||
at a recorded agent seat with no `delegates_to` parent or `escalates_to` route is
|
||||
the exception: its finding tries the row's creator, then the operator agent
|
||||
(`agents.operator_session`), skipping an unset, confirmed-missing, self or
|
||||
unaccepted address, a bound terminal seat, and OpenRig's own machinery
|
||||
(`daemon@kernel` or a `@system` actor), so the finding's own wake isn't sent to the seat that just failed to
|
||||
take one. If neither can be used, it stays with the original seat and says why.
|
||||
Overdue claims go to their destination. Repeated detections reuse the existing finding without changing
|
||||
its route; when the source condition resolves, the sweep closes its own finding.
|
||||
Previously, falling out of a reporting query could itself close a delivery or
|
||||
unclaimed finding. An unconfirmed retry, a fresh human-notification post window,
|
||||
|
||||
@@ -32,13 +32,12 @@ Two independent naming axes meet in this layer. They must not be conflated or
|
||||
blanket-replaced: `rigged` → `rig` is a per-occurrence question, not a global
|
||||
substitution.
|
||||
|
||||
**Axis 1 — MCP tool names are `rig_*`.** `packages/cli/src/mcp-server.ts`
|
||||
registers **24** tools, all named `rig_*`
|
||||
(`grep -c 'server.tool(' packages/cli/src/mcp-server.ts`), including
|
||||
`rig_rig_nodes` (`mcp-server.ts:290`), `rig_send` (`:307`),
|
||||
`rig_capture` (`:341`), `rig_chatroom_send` (`:366`) and `rig_chatroom_watch`
|
||||
(`:402`), the five queue tools `rig_queue_create`, `_show`, `_list`, `_claim`
|
||||
and `_update`, and `rig_telemetry_events`. No `rigged_*` name other than the tmux keys below remains in product
|
||||
**Axis 1 — MCP tool names are `rig_*`.** Every tool
|
||||
`packages/cli/src/mcp-server.ts` registers is named `rig_*`, among them
|
||||
`rig_rig_nodes`, `rig_send`, `rig_capture`, `rig_chatroom_send`,
|
||||
`rig_chatroom_watch`, the `rig_queue_*` tools and `rig_telemetry_events`.
|
||||
Count them with `grep -c 'server.registerTool(' packages/cli/src/mcp-server.ts`;
|
||||
each registration depends on the caller's access, so a caller can see fewer. No `rigged_*` name other than the tmux keys below remains in product
|
||||
source: **0** hits
|
||||
(`git grep -n 'rigged_' -- ':(glob)packages/*/src/**' | grep -v -c '@rigged_'`).
|
||||
|
||||
|
||||
@@ -15,7 +15,8 @@ last-updated: 2026-11-09
|
||||
|
||||
# CLI Reference — Registered Commands and Options
|
||||
|
||||
Verified against source commit `e1dedbf531b6db9961b4c77c0a74564e58274a50`.
|
||||
Verified against the source commit in this page's `last-verified-against-source`
|
||||
frontmatter.
|
||||
The inventory below comes from the actual Commander tree returned by
|
||||
[`createProgram()`](../../packages/cli/src/index.ts), not a grep of command
|
||||
strings or an installed CLI from a different commit.
|
||||
|
||||
@@ -21,7 +21,7 @@ it never reads an unsupported capability as idle, working, ready or verified.
|
||||
| Activity | partly | Read from the screen only; there are no activity hooks. Work and attention the screen shows are kept, but a screen that looks idle reads `unknown`, never idle, because a quiet prompt can be an approval or a pause. A [runner preset](#runner-presets-runner-true)'s own reports decide it instead. |
|
||||
| Attention | partly | Read from the screen: a permission question or numbered choice that OpenRig recognizes reads as needing attention, and `rig send` won't type into it. A prompt it doesn't recognize reads `unknown`. |
|
||||
| Approval | no | Approvals stay in the harness's own pane. OpenRig relays none, so a person answers them there. A [runner preset](#runner-presets-runner-true) is the exception: its runner submits the agent's permission requests to OpenRig, which asks a person, and the default is permit (section 7 of the [harness and runner contract](harness-runner-contract.md)). |
|
||||
| Cancel | no | OpenRig has no verb that cancels a running turn. `rig seat stop` and `rig down` end the seat's tmux session; anything the harness runs outside its pane, such as a gateway, is its own, and the preset's `limits` should say what keeps running. Restarting in place and switching harness are refused. |
|
||||
| Cancel | no | OpenRig has no verb that cancels a running turn. A [runner preset](#runner-presets-runner-true) whose runner follows the contract takes `rig send <seat> "/abort" --raw` as its cancel, but not while the seat needs input, and only as a request (see [Cancel](harness-runner-contract.md#6-cancel-requested-isnt-the-same-as-done)). To a plain preset, `/abort` is ordinary input. Say in the preset's `limits` whether the harness has a cancel and how to use it. `rig seat stop` and `rig down` end the seat's tmux session; anything the harness runs outside its pane, such as a gateway, is its own, and the preset's `limits` should say what keeps running. Restarting in place and switching harness are refused. |
|
||||
| Transport | yes | The seat is a tmux pane like any other, so `rig capture`, `rig send` and `--host` work as they do for any seat. |
|
||||
|
||||
**Not placed:** guidance, skills and startup files (a launch warns that they weren't delivered), and the model: set it in the harness's own
|
||||
|
||||
@@ -241,8 +241,15 @@ permission or input handling in existing built-in runtimes or require every poss
|
||||
|
||||
- **On `/abort` or Ctrl-C:** send the agent's cancel (for ACP, the `session/cancel` notification, which has no reply),
|
||||
and record when you sent it.
|
||||
- A person cancels by typing `/abort` in the seat's pane. A `/abort` sent with `rig send` arrives inside a From/To
|
||||
envelope, so it's an ordinary prompt.
|
||||
- **Sending it:** a person types `/abort` in the seat's pane. From another seat or a script,
|
||||
`rig send <seat> "/abort" --raw` pastes exactly `/abort`, with no From/To envelope, and presses Enter, so a runner
|
||||
that follows section 5 gets the control. 0.7.8's native runs typed it in the pane; a raw send hasn't been run
|
||||
natively. An ordinary `rig send` wraps its text in a From/To envelope, so there `/abort` is an ordinary prompt.
|
||||
- **Not while the seat needs input:** a raw send is still refused while the seat reads needs input
|
||||
(`target_needs_input`), for example while the runner holds a permission request for a person (section 7). Type
|
||||
`/abort` in the pane then.
|
||||
- **A sent `/abort` is a request.** A typed control can't name the turn it was meant for: the runner cancels whatever
|
||||
turn is running when it reads it. Whether a turn was cancelled is the runner's `Stop` / `cancelled` below.
|
||||
- **Report "cancelled" only when the agent says so:** for ACP, when `session/prompt` returns `stopReason: "cancelled"`.
|
||||
Then post `Stop` / `cancelled`.
|
||||
- **Whether the provider stopped work or spend is unknown.** Don't claim it.
|
||||
|
||||
+19
-15
@@ -54,22 +54,22 @@ What an open does, in order:
|
||||
1. Each viewer is recorded first (`$OPENRIG_HOME/private/herdr-app/clients/`), then started by herdr-core outside
|
||||
every tab (`hc run --bg --no-parent`, labelled `openrig.*`). A local viewer runs the view's tile for its seat, as in
|
||||
[terminal sessions](terminal-sessions.md): it shows the seat. An interactive cell starts with the read-only tile and
|
||||
takes no input until herdr-app has shown the view (step 3); then its writable tile takes over, and the first thing
|
||||
you type attaches it. A read-only cell only shows. A seat on another host opens as its own ssh attachment, writable
|
||||
takes no input until herdr-app has shown the view (step 3); then its writable tile takes over and attaches to the
|
||||
seat with plain tmux. A read-only cell only shows. A seat on another host opens as its own ssh attachment, writable
|
||||
or read-only as that view composes it. If the seat's session ends, the viewer says so and waits; it never leaves a
|
||||
shell in the cell.
|
||||
2. One atomic write of `layouts/rig.json`: your current layout, with OpenRig's stack beside your tab block. Your own
|
||||
layout file is never written. A `rig.json` OpenRig didn't write is never overwritten.
|
||||
3. The Rig layout is shown, the view is selected once herdr-app confirms it shows the new file, and the keyboard
|
||||
goes to the first live cell. Once herdr-app has acknowledged the layout with the view, OpenRig writes the gate file
|
||||
of each of the view's interactive cells, on every page (including pages the strip hasn't shown yet, since the strip
|
||||
moves between pages on its own), and each cell takes input a fraction of a second later (it checks every 0.2 s).
|
||||
Anything that reached a cell before that, typed or written to its terminal (`hc send`), is dropped, not delivered
|
||||
late. The open reports a view herdr-app
|
||||
hasn't confirmed as unconfirmed, not opened; its viewers keep running and it stays in the Rig layout, and its cells
|
||||
take input once herdr-app shows it. OpenRig keeps watching for 10 minutes, across a daemon restart; if herdr-app
|
||||
hasn't shown it by then, each interactive cell says so, takes no input, and names the command that opens the view
|
||||
again (`rig terminal open <view> --provider herdr-app`).
|
||||
of each local interactive cell, on every page (including pages the strip hasn't shown yet, since the strip moves
|
||||
between pages on its own). These cells take input a fraction of a second later (they check every 0.2 s). Anything
|
||||
that reached a local interactive cell before that, typed or written to its terminal (`hc send`), is dropped, not
|
||||
delivered late. Remote cells run their ssh attachments from the start and don't wait for this gate. The open
|
||||
reports a view herdr-app hasn't confirmed as unconfirmed, not opened; its viewers keep running and it stays in the
|
||||
Rig layout, and its local interactive cells take input once herdr-app shows it. OpenRig keeps watching for 10
|
||||
minutes, across a daemon restart; if herdr-app hasn't shown it by then, each local interactive cell says so, takes
|
||||
no input, and names the command that opens the view again (`rig terminal open <view> --provider herdr-app`).
|
||||
|
||||
Other layouts and other herdr-app windows don't show Rig views. Each window (app home) has its own.
|
||||
|
||||
@@ -79,11 +79,15 @@ Other layouts and other herdr-app windows don't show Rig views. Each window (app
|
||||
- Entering a view again (a click in the strip, or herdr-app restarting with it shown) returns the keyboard to the cell
|
||||
you were last in there, else the first live cell.
|
||||
- **Your tabs** gives the keyboard back to your own pane.
|
||||
- OpenRig's own rewrites (another view opening or closing, a cell ending) never move the keyboard, whether you are in
|
||||
a view or in your tabs.
|
||||
- Typing in an interactive cell, once the view is shown, attaches its tile to the seat; after 30 seconds without a key
|
||||
it goes back to showing it. When the seat's session is replaced (a handover, a fresh launch, a restart), the tile follows the same seat to
|
||||
its new session, as every OpenRig tile does.
|
||||
- Apart from an open, OpenRig's own rewrites (another view closing, a cell ending) leave the keyboard where it is,
|
||||
whether you are in a view or in your tabs, with one exception: when the cell you were last in ends while its view is
|
||||
shown, the keyboard goes to that view's first live cell, if one is left.
|
||||
- Once the view is shown, an interactive cell for a local seat attaches its tile to the seat with plain tmux, as a
|
||||
writable tile does ([terminal sessions](terminal-sessions.md#replacing-a-seats-session)). Until then it only shows
|
||||
the seat, and anything typed in it is dropped. A cell for a seat on another host is its ssh attachment from the
|
||||
start, so an interactive one takes input as soon as it runs. When the seat's session is replaced (a handover, a fresh
|
||||
launch, a restart), the tile follows the same seat to its new session, except an ssh tile on an isolated local tmux
|
||||
server ([terminal sessions](terminal-sessions.md#replacing-a-seats-session)).
|
||||
|
||||
## What OpenRig keeps in the app home
|
||||
|
||||
|
||||
@@ -1088,6 +1088,20 @@ and returns `edge_not_found` (HTTP 404, exit 1), so a repeated removal reports n
|
||||
Rig names resolve only among unarchived rigs, and an ambiguous name requires an
|
||||
explicit ID. These commands target the local daemon, as `rig add` does.
|
||||
|
||||
Managed Claude compaction also uses the delegation parent for two diagnostic notices: repeated
|
||||
unsent preparation attempts and repeated restore-delivery failures. If the seat has no recorded
|
||||
parent session (including when it is outside the recorded topology), these notices go to the
|
||||
configured registered human operator, or the default registered person when no operator is set.
|
||||
If no registered person can be selected, OpenRig creates no notice rather than inventing a
|
||||
destination: `rig compact <seat> --state` reports a restore-delivery notice failure, while a
|
||||
preparation notice is tried again on later polls. A parent is the source of a `delegates_to` edge
|
||||
to the seat; declare one when an agent should receive these notices. With multiple parents, the
|
||||
parent session with the latest creation time is selected; `escalates_to` is not used for these
|
||||
notices. The restore-delivery notice to a person omits local paths and
|
||||
points to `rig compact <seat> --state` on the host; the preparation notice includes its state-file
|
||||
evidence reference and attempt details. Treat these notices and any chat relay accordingly. Existing
|
||||
notices retain their destination when the topology changes.
|
||||
|
||||
A `delegates_to` edge also decides routing. A seat's stuck-row findings and recovery go to its most
|
||||
recently started parent. A person's top-level Slack message in the rig's mapped channel goes to the rig's
|
||||
representative, its one delegation root, unless that channel names an `inbound` seat; with no single root it
|
||||
|
||||
@@ -74,7 +74,11 @@ New tiles keep their place when a seat's tmux session is replaced. They wait up
|
||||
to 30 seconds for the same session name and managed seat identity to return,
|
||||
then show the new session with the original read-only setting. This uses the
|
||||
shared tile command for Herdr, cmux, Rex and plain tmux, including SSH tiles.
|
||||
In-place handover and restart keep showing the same session.
|
||||
In-place handover and restart keep showing the same session. The exception is
|
||||
an SSH tile when the local tmux server is isolated: it runs
|
||||
`rig terminal attach` on the seat's host (`--read-only` for a read-only tile),
|
||||
which attaches with tmux once (`tmux attach -r` for a read-only tile). It
|
||||
doesn't follow a replaced session or a restarted tmux server; reopen the view.
|
||||
|
||||
Writable tiles attach immediately, as in 0.7.10. Their visible terminal sizes
|
||||
the agent's PTY under tmux's normal client sizing policy, and resizing the
|
||||
@@ -362,20 +366,25 @@ labels are not that provenance. Other providers/platforms remain unsupported
|
||||
for ensure-viewer until qualified; ordinary open retains its existing behavior.
|
||||
|
||||
A tmux window has one size, and a tmux client narrower than it sees only the
|
||||
left part of each line. So a tile shows its seat without attaching to it: about
|
||||
once a second it reads the seat's screen with `tmux capture-pane`, redraws only
|
||||
after tmux records new activity, and lets the tile's own terminal wrap each
|
||||
line. A tile narrower than the agent's window still shows whole lines, and
|
||||
showing a seat never changes its size. A rule drawn across the window is cut to
|
||||
the tile's width. A read-only tile only shows the seat. In a writable tile, the
|
||||
first thing you type or paste attaches the tile and goes through tmux as it
|
||||
would in any attached client: a paste arrives as one paste, and tmux's prefix
|
||||
works (prefix then `[` to scroll back, for example). The tile sizes the window
|
||||
while you use it, as any client you type in does; after 30 seconds without a
|
||||
key it detaches and shows the seat again. A tile that's only showing has no
|
||||
scrollback of its own. If the seat's tmux server restarts, a tile shows the
|
||||
seat again only when the same managed seat returns. The printed manual attach commands
|
||||
keep `-f ignore-size`. Observers of a Herdr terminal do not themselves resize
|
||||
left part of each line. So a read-only tile shows its seat without attaching to
|
||||
it: about once a second it reads the seat's screen with `tmux capture-pane`,
|
||||
redraws only after tmux records new activity, and lets the tile's own terminal
|
||||
wrap each line. A read-only tile narrower than the agent's window still shows
|
||||
whole lines, and showing a seat never changes its size. A rule drawn across the
|
||||
window is cut to the tile's width. A read-only tile has no scrollback of its
|
||||
own. (A read-only SSH tile on an isolated local server attaches read-only
|
||||
instead; see [Replacing a seat's session](#replacing-a-seats-session).) A
|
||||
writable tile attaches with plain tmux when it opens (see
|
||||
[Replacing a seat's session](#replacing-a-seats-session)): keys and paste go
|
||||
through tmux as in any attached client, tmux's prefix works (prefix then `[` to
|
||||
scroll back, for example), and the tile sizes the window while it's the active
|
||||
client. If the seat's tmux server restarts, a tile shows the seat again only
|
||||
when the same managed seat returns. The printed manual attach commands are
|
||||
`tmux attach -t` for a writable tile and `tmux attach -r -t` for a read-only
|
||||
one, with a local seat's tmux server (`-L <name>` or `-S <path>`) when it
|
||||
isn't tmux's default socket. For a seat on another host they run over `ssh -t`, and
|
||||
when the local tmux server is isolated, as `rig terminal attach` on that host.
|
||||
Observers of a Herdr terminal do not themselves resize
|
||||
it; a controlling browser can. No cross-version resize guarantee is implied.
|
||||
|
||||
To type into a seat without changing its size, send the text through OpenRig
|
||||
|
||||
@@ -164,8 +164,8 @@ or removing its state when isolation or a deferred migration pins it. Ordinary
|
||||
an old advisory socket is not used to redirect it. Cleanup kills only registered
|
||||
session names on the selected server.
|
||||
|
||||
Dedicated selection requires POSIX tmux. On Windows, psmux retains its existing
|
||||
server behavior; requesting `isolated` produces a status note and a pending choice.
|
||||
Dedicated selection requires POSIX tmux. On native Windows (not a supported platform;
|
||||
the [README](../../README.md) covers WSL2), psmux retains its existing server behavior; requesting `isolated` produces a status note and a pending choice.
|
||||
|
||||
## Two instances on one machine
|
||||
|
||||
|
||||
@@ -79,6 +79,8 @@ Keys:
|
||||
ui.timezone TUI IANA timezone (default America/Los_Angeles; reopen TUI after changing)
|
||||
ui.enabled serve the web UI and its terminal WebSocket (default off; restart the daemon)
|
||||
ui.preview.* refresh_interval_seconds, max_pins, default_lines
|
||||
ui.terminal.max_live_terminals
|
||||
cap on simultaneously-live web-client terminals (effective default 2)
|
||||
recovery.* auto_drive_provider_prompts, provider_auth_env_allowlist
|
||||
agents.* advisor_session, operator_session
|
||||
feed.subscriptions.* action_required, approvals, shipped, progress, audit_log
|
||||
@@ -101,7 +103,7 @@ Keys:
|
||||
permission prompts still reach a person,
|
||||
choice prompts follow this setting; unavailable human delivery stays a visible row;
|
||||
an unreadable registry does not suppress eligibility; delivery still needs a ready route)
|
||||
retention.* enabled, transitions_days, watchdog_days,
|
||||
retention.* enabled, transitions_days, watchdog_days, usage_samples_days,
|
||||
watchdog_keep_per_job, batch_size
|
||||
retention.events_days delete the history-only event types (views, snapshots, watchdog firings, queue events) older
|
||||
than N days; 0 keeps them (default). Removed events no longer replay from /api/events, leave
|
||||
|
||||
@@ -172,7 +172,9 @@ function defaultDoctorDeps(): DoctorDeps {
|
||||
* Null when ssh can't resolve the target or a ProxyJump/ProxyCommand sits in between, since no
|
||||
* direct dial stands for that transport. */
|
||||
export function resolveSshEndpoint(host: SshHostEntry, timeoutMs = 1500): Promise<{ hostname: string; port: number } | null> {
|
||||
const args = [...(host.user ? ["-l", host.user] : []), "-G", host.target];
|
||||
// `--` before the target: a destination that begins with `-` is fed to ssh as the host, not
|
||||
// read as an option. ssh answers an invalid hostname on its own.
|
||||
const args = [...(host.user ? ["-l", host.user] : []), "-G", "--", host.target];
|
||||
return new Promise((resolve) => {
|
||||
execFile("ssh", args, { timeout: timeoutMs }, (error, stdout) => {
|
||||
if (error) return resolve(null);
|
||||
|
||||
@@ -64,6 +64,8 @@ interface WhoamiResult {
|
||||
resolvedBy: string;
|
||||
identity: WhoamiIdentity & Record<string, unknown>;
|
||||
peers: WhoamiPeer[];
|
||||
/** #2871: present only while `rig up` is still creating the team. */
|
||||
rosterState?: "building";
|
||||
edges: { outgoing: WhoamiEdge[]; incoming: WhoamiEdge[] };
|
||||
transcript: { enabled: boolean; path: string | null; tailCommand: string | null };
|
||||
contextUsage?: {
|
||||
@@ -103,6 +105,8 @@ function projectCompactWhoami(data: Record<string, unknown>, bounded?: BoundedPe
|
||||
}),
|
||||
// KEEP: openrig-user SKILL.md documents peersNote as a required recovery field.
|
||||
peersNote: bounded ? bounded.note : data["peersNote"],
|
||||
// #2871: while `rig up` is still creating the team, say so, as the daemon does.
|
||||
...(data["rosterState"] !== undefined ? { rosterState: data["rosterState"] } : {}),
|
||||
...(bounded ? { peersOmitted: bounded.omitted } : {}),
|
||||
// edges already carry only kind + to/from {logicalId, sessionName}.
|
||||
edges: data["edges"],
|
||||
@@ -143,9 +147,14 @@ export function boundPeers(data: Record<string, unknown>, bound: number, allPeer
|
||||
const ordered = [0, 1, 2].flatMap((t) => peers.filter((peer) => tier(peer) === t));
|
||||
const shown = ordered.slice(0, bound);
|
||||
const omitted = peers.length - shown.length;
|
||||
const note = `peers = ${shown.length} of this rig's ${peers.length} (roster excluding self; edge peers first, then your pod, then roster order; `
|
||||
// #2871: while `rig up` is still building the team, the daemon's note opens with that; keep it first, and don't call
|
||||
// the edges complete.
|
||||
const daemonNote = typeof data["peersNote"] === "string" ? data["peersNote"] : "";
|
||||
const building = data["rosterState"] === "building";
|
||||
const opening = building && daemonNote.includes("peers = ") ? daemonNote.slice(0, daemonNote.indexOf("peers = ")) : "";
|
||||
const note = `${opening}peers = ${shown.length} of this rig's ${peers.length} (roster excluding self; edge peers first, then your pod, then roster order; `
|
||||
+ `bound output.whoami_peers=${bound}); ${omitted} not shown; all: ${allPeersCommand}; `
|
||||
+ "edges = directional relationships (always complete); use `rig ps --nodes` for node inventory including self + live state";
|
||||
+ `edges = directional relationships (${building ? "the ones written so far" : "always complete"}); use \`rig ps --nodes\` for node inventory including self + live state`;
|
||||
return { peers: shown, omitted, note };
|
||||
}
|
||||
|
||||
@@ -415,6 +424,10 @@ left out. --all-peers lists every one (today's compact output).`)
|
||||
console.log(`Transport: ${id.attachmentType === "external_cli" ? "external_cli (outbound only)" : id.attachmentType}`);
|
||||
console.log(`Resolved: via ${data.resolvedBy.replace(/_/g, " ")}`);
|
||||
|
||||
if (data.rosterState === "building") {
|
||||
console.log("");
|
||||
console.log("Team: still being built by `rig up`: the peers and edges below are the ones created so far, and more may come. Run `rig whoami` again once it finishes.");
|
||||
}
|
||||
if (data.peers.length > 0) {
|
||||
console.log("");
|
||||
// OPR.99.0.6.1: name the contract on the header so peers cannot be
|
||||
|
||||
@@ -84,6 +84,7 @@ export interface RiggedConfig {
|
||||
/** Serve the web UI and its terminal WebSocket. Off by default; the daemon reads it at start. */
|
||||
enabled: boolean;
|
||||
timezone: string;
|
||||
terminal: { maxLiveTerminals: number };
|
||||
preview: {
|
||||
refreshIntervalSeconds: number;
|
||||
maxPins: number;
|
||||
@@ -218,6 +219,7 @@ export interface RiggedConfig {
|
||||
enabled: boolean;
|
||||
transitionsDays: number;
|
||||
watchdogDays: number;
|
||||
usageSamplesDays: number;
|
||||
watchdogKeepPerJob: number;
|
||||
streamDays: number;
|
||||
streamMaxItems: number;
|
||||
@@ -306,6 +308,10 @@ const DEFAULTS = {
|
||||
ui: {
|
||||
enabled: false,
|
||||
timezone: "America/Los_Angeles",
|
||||
// The daemon store leaves its own resolver value unset for this key and the web
|
||||
// client falls back to 2 (settings-browser names that fallback), so the CLI reports
|
||||
// the same effective default rather than inventing a third answer.
|
||||
terminal: { maxLiveTerminals: 2 },
|
||||
preview: {
|
||||
refreshIntervalSeconds: 3,
|
||||
maxPins: 4,
|
||||
@@ -416,6 +422,8 @@ const DEFAULTS = {
|
||||
enabled: true,
|
||||
transitionsDays: 30,
|
||||
watchdogDays: 14,
|
||||
// Usage samples age out after this many days; the daemon's own default.
|
||||
usageSamplesDays: 14,
|
||||
watchdogKeepPerJob: 50,
|
||||
streamDays: 30,
|
||||
streamMaxItems: 20000,
|
||||
@@ -499,6 +507,9 @@ export const VALID_KEYS = [
|
||||
"ui.preview.default_lines",
|
||||
"ui.timezone",
|
||||
"ui.enabled",
|
||||
// The legacy web client's cap on simultaneously-live terminals. The daemon store
|
||||
// resolves it (SETTINGS_VALID_KEYS), so `rig config` must accept it too.
|
||||
"ui.terminal.max_live_terminals",
|
||||
"recovery.auto_drive_provider_prompts",
|
||||
"recovery.provider_auth_env_allowlist",
|
||||
// V1 Phase 4 SC-29 exception — allowlist-only additions.
|
||||
@@ -568,6 +579,8 @@ export const VALID_KEYS = [
|
||||
"retention.enabled",
|
||||
"retention.transitions_days",
|
||||
"retention.watchdog_days",
|
||||
// How long the daemon keeps usage samples; lockstep with the daemon twin.
|
||||
"retention.usage_samples_days",
|
||||
"retention.watchdog_keep_per_job",
|
||||
// Slice 22 outcome 9 — stream retention: unreferenced items older than N days or beyond the newest N.
|
||||
"retention.stream_days",
|
||||
@@ -643,6 +656,7 @@ export const ENV_MAP: Record<ValidKey, { primary: string; legacy?: string }> = {
|
||||
"ui.preview.max_pins": { primary: "OPENRIG_UI_PREVIEW_MAX_PINS" },
|
||||
"ui.timezone": { primary: "OPENRIG_UI_TIMEZONE" },
|
||||
"ui.enabled": { primary: "OPENRIG_UI_ENABLED" },
|
||||
"ui.terminal.max_live_terminals": { primary: "OPENRIG_UI_TERMINAL_MAX_LIVE_TERMINALS" },
|
||||
"ui.preview.default_lines": { primary: "OPENRIG_UI_PREVIEW_DEFAULT_LINES" },
|
||||
"recovery.auto_drive_provider_prompts": { primary: "OPENRIG_RECOVERY_AUTO_DRIVE_PROVIDER_PROMPTS" },
|
||||
"recovery.provider_auth_env_allowlist": { primary: "OPENRIG_RECOVERY_PROVIDER_AUTH_ENV_ALLOWLIST" },
|
||||
@@ -699,6 +713,7 @@ export const ENV_MAP: Record<ValidKey, { primary: string; legacy?: string }> = {
|
||||
"retention.enabled": { primary: "OPENRIG_RETENTION_ENABLED" },
|
||||
"retention.transitions_days": { primary: "OPENRIG_RETENTION_TRANSITIONS_DAYS" },
|
||||
"retention.watchdog_days": { primary: "OPENRIG_RETENTION_WATCHDOG_DAYS" },
|
||||
"retention.usage_samples_days": { primary: "OPENRIG_RETENTION_USAGE_SAMPLES_DAYS" },
|
||||
"retention.watchdog_keep_per_job": { primary: "OPENRIG_RETENTION_WATCHDOG_KEEP_PER_JOB" },
|
||||
"retention.stream_days": { primary: "OPENRIG_RETENTION_STREAM_DAYS" },
|
||||
"retention.stream_max_items": { primary: "OPENRIG_RETENTION_STREAM_MAX_ITEMS" },
|
||||
@@ -758,6 +773,7 @@ const KEY_TO_PATH: Record<ValidKey, string[]> = {
|
||||
"ui.preview.max_pins": ["ui", "preview", "maxPins"],
|
||||
"ui.timezone": ["ui", "timezone"],
|
||||
"ui.enabled": ["ui", "enabled"],
|
||||
"ui.terminal.max_live_terminals": ["ui", "terminal", "maxLiveTerminals"],
|
||||
"ui.preview.default_lines": ["ui", "preview", "defaultLines"],
|
||||
"recovery.auto_drive_provider_prompts": ["recovery", "autoDriveProviderPrompts"],
|
||||
"recovery.provider_auth_env_allowlist": ["recovery", "providerAuthEnvAllowlist"],
|
||||
@@ -808,6 +824,7 @@ const KEY_TO_PATH: Record<ValidKey, string[]> = {
|
||||
"retention.enabled": ["retention", "enabled"],
|
||||
"retention.transitions_days": ["retention", "transitionsDays"],
|
||||
"retention.watchdog_days": ["retention", "watchdogDays"],
|
||||
"retention.usage_samples_days": ["retention", "usageSamplesDays"],
|
||||
"retention.watchdog_keep_per_job": ["retention", "watchdogKeepPerJob"],
|
||||
"retention.stream_days": ["retention", "streamDays"],
|
||||
"retention.stream_max_items": ["retention", "streamMaxItems"],
|
||||
@@ -1088,6 +1105,7 @@ const KEY_CONSTRAINTS: Partial<Record<ValidKey, (raw: string, coerced: string |
|
||||
new Intl.DateTimeFormat("en-US", { timeZone: value });
|
||||
} catch { throw new Error("Invalid ui.timezone: use an IANA timezone such as America/Los_Angeles or Europe/London"); }
|
||||
},
|
||||
"ui.terminal.max_live_terminals": positiveIntegerConstraint("ui.terminal.max_live_terminals"),
|
||||
"health.context_pressure.warning_percent": percentageConstraint("health.context_pressure.warning_percent"),
|
||||
"health.context_pressure.critical_percent": percentageConstraint("health.context_pressure.critical_percent"),
|
||||
"policies.idle_gate_qitem.scan_interval_seconds": positiveIntegerConstraint("policies.idle_gate_qitem.scan_interval_seconds"),
|
||||
@@ -1174,6 +1192,12 @@ const KEY_CONSTRAINTS: Partial<Record<ValidKey, (raw: string, coerced: string |
|
||||
throw new Error(`Invalid value for retention.watchdog_days: must be an integer >= 1, got "${raw}"`);
|
||||
}
|
||||
},
|
||||
// Same bound and message as the daemon twin's KEY_CONSTRAINTS entry.
|
||||
"retention.usage_samples_days": (raw, coerced) => {
|
||||
if (!/^-?\d+$/.test((raw ?? "").trim()) || typeof coerced !== "number" || !Number.isInteger(coerced) || coerced < 1) {
|
||||
throw new Error(`Invalid value for retention.usage_samples_days: must be an integer >= 1, got "${raw}"`);
|
||||
}
|
||||
},
|
||||
"retention.watchdog_keep_per_job": (raw, coerced) => {
|
||||
if (!/^-?\d+$/.test((raw ?? "").trim()) || typeof coerced !== "number" || !Number.isInteger(coerced) || coerced < 0) {
|
||||
throw new Error(`Invalid value for retention.watchdog_keep_per_job: must be an integer >= 0, got "${raw}"`);
|
||||
@@ -1397,6 +1421,7 @@ export class ConfigStore {
|
||||
ui: {
|
||||
enabled: v("ui.enabled") as boolean,
|
||||
timezone: v("ui.timezone") as string,
|
||||
terminal: { maxLiveTerminals: v("ui.terminal.max_live_terminals") as number },
|
||||
preview: {
|
||||
refreshIntervalSeconds: v("ui.preview.refresh_interval_seconds") as number,
|
||||
maxPins: v("ui.preview.max_pins") as number,
|
||||
@@ -1487,6 +1512,7 @@ export class ConfigStore {
|
||||
enabled: v("retention.enabled") as boolean,
|
||||
transitionsDays: v("retention.transitions_days") as number,
|
||||
watchdogDays: v("retention.watchdog_days") as number,
|
||||
usageSamplesDays: v("retention.usage_samples_days") as number,
|
||||
watchdogKeepPerJob: v("retention.watchdog_keep_per_job") as number,
|
||||
streamDays: v("retention.stream_days") as number,
|
||||
streamMaxItems: v("retention.stream_max_items") as number,
|
||||
|
||||
@@ -101,7 +101,12 @@ export async function runCrossHostCommand(
|
||||
? `OPENRIG_SESSION_NAME=${shellQuote(opts.originTriple)} ${quotedArgv}`
|
||||
: quotedArgv;
|
||||
const remoteCommandLine = `sh -lc ${shellQuote(innerCommand)}`;
|
||||
const fullArgs = [...sshOpts, sshHost.target, remoteCommandLine];
|
||||
// SEP-1: `--` ends ssh's own option parsing, so a target that begins with `-` reaches ssh as the
|
||||
// destination instead of being read as one more option. That misreading is the harm: `-V`, for
|
||||
// instance, is ssh's own version flag, parsed and exited 0 before any connection, which this
|
||||
// executor would count as a success with no remote command run. With the separator, ssh rejects
|
||||
// the name itself.
|
||||
const fullArgs = [...sshOpts, "--", sshHost.target, remoteCommandLine];
|
||||
|
||||
const spawn = opts.spawn ?? (nodeSpawn as unknown as SpawnFn);
|
||||
const child = spawn("ssh", fullArgs);
|
||||
|
||||
@@ -31,7 +31,7 @@ export async function checkSeatAttention(client: DaemonClient, seats: readonly s
|
||||
const check: AttentionCheck = { attention: [], unknown: [] };
|
||||
const byRig = new Map<string, Set<string>>();
|
||||
for (const seat of seats) {
|
||||
const at = seat.lastIndexOf("@");
|
||||
const at = seat.indexOf("@");
|
||||
if (at <= 0 || at === seat.length - 1) { check.unknown.push({ seat, why: "not a seat@rig name" }); continue; }
|
||||
const rig = seat.slice(at + 1);
|
||||
if (!byRig.has(rig)) byRig.set(rig, new Set());
|
||||
|
||||
@@ -149,6 +149,9 @@ describe("ConfigStore — extended namespaces (User Settings v0)", () => {
|
||||
"files.allowlist", "progress.scan_roots",
|
||||
"ui.preview.refresh_interval_seconds", "ui.preview.max_pins", "ui.preview.default_lines", "ui.timezone",
|
||||
"ui.enabled",
|
||||
// The legacy web client's cap on simultaneously-live terminals; the daemon store
|
||||
// resolves it, so `rig config` must accept it too.
|
||||
"ui.terminal.max_live_terminals",
|
||||
"recovery.auto_drive_provider_prompts",
|
||||
"recovery.provider_auth_env_allowlist",
|
||||
// V1 attempt-3 Phase 4 — Advisor/Operator placeholders.
|
||||
@@ -213,6 +216,7 @@ describe("ConfigStore — extended namespaces (User Settings v0)", () => {
|
||||
"retention.enabled",
|
||||
"retention.transitions_days",
|
||||
"retention.watchdog_days",
|
||||
"retention.usage_samples_days", // how long usage samples are kept
|
||||
"retention.watchdog_keep_per_job",
|
||||
"retention.stream_days", // slice 22 outcome 9: stream retention
|
||||
"retention.stream_max_items",
|
||||
@@ -887,6 +891,44 @@ describe("ConfigStore — extended namespaces (User Settings v0)", () => {
|
||||
expect(() => store.set("ui.preview.refresh_interval_seconds", "soon")).toThrow(/expected a number/);
|
||||
});
|
||||
|
||||
// Both keys below were resolved by the daemon store and served by `GET /api/config`,
|
||||
// while `rig config get|set` rejected them as "Unknown config key". Their siblings in
|
||||
// the same families — every other ui.* and retention.* key — were already settable, so
|
||||
// this was drift between the two lockstep lists, not a deliberate CLI-only subset.
|
||||
it("ui.terminal.max_live_terminals is settable, numeric and a positive integer", () => {
|
||||
const store = new ConfigStore(configPath);
|
||||
store.set("ui.terminal.max_live_terminals", "3");
|
||||
expect(store.get("ui.terminal.max_live_terminals")).toBe(3);
|
||||
expect(JSON.parse(readFileSync(configPath, "utf-8")).ui.terminal.maxLiveTerminals).toBe(3);
|
||||
for (const raw of ["0", "-1", "2.5", "2abc"]) {
|
||||
expect(() => store.set("ui.terminal.max_live_terminals", raw), `must reject ${raw}`).toThrow();
|
||||
}
|
||||
});
|
||||
|
||||
it("retention.usage_samples_days defaults to 14 and keeps an integer >= 1", () => {
|
||||
const store = new ConfigStore(configPath);
|
||||
expect(store.resolveWithSource("retention.usage_samples_days"))
|
||||
.toMatchObject({ value: 14, source: "default", defaultValue: 14 });
|
||||
store.set("retention.usage_samples_days", "30");
|
||||
expect(store.get("retention.usage_samples_days")).toBe(30);
|
||||
for (const raw of ["0", "-1", "1.5"]) {
|
||||
expect(() => store.set("retention.usage_samples_days", raw), `must reject ${raw}`).toThrow();
|
||||
}
|
||||
});
|
||||
|
||||
it("both recovered keys honour their OPENRIG_* environment override", () => {
|
||||
const store = new ConfigStore(configPath);
|
||||
process.env.OPENRIG_UI_TERMINAL_MAX_LIVE_TERMINALS = "7";
|
||||
process.env.OPENRIG_RETENTION_USAGE_SAMPLES_DAYS = "9";
|
||||
try {
|
||||
expect(store.resolveWithSource("ui.terminal.max_live_terminals")).toMatchObject({ value: 7, source: "env" });
|
||||
expect(store.resolveWithSource("retention.usage_samples_days")).toMatchObject({ value: 9, source: "env" });
|
||||
} finally {
|
||||
delete process.env.OPENRIG_UI_TERMINAL_MAX_LIVE_TERMINALS;
|
||||
delete process.env.OPENRIG_RETENTION_USAGE_SAMPLES_DAYS;
|
||||
}
|
||||
});
|
||||
|
||||
// Constraint gap (Slice 27 residue): daemon.port had no KEY_CONSTRAINTS
|
||||
// entry, so `rig config set daemon.port 99999` stored 99999 and the next
|
||||
// daemon start died with a raw ERR_SOCKET_BAD_PORT from serve(). The
|
||||
@@ -979,6 +1021,24 @@ describe("ConfigStore — extended namespaces (User Settings v0)", () => {
|
||||
expect(cfg.ui.preview.defaultLines).toBe(50);
|
||||
});
|
||||
|
||||
it("RiggedConfig carries both recovered settings, saved values included", () => {
|
||||
const store = new ConfigStore(configPath);
|
||||
const defaults = store.resolve();
|
||||
expect(defaults.ui.terminal.maxLiveTerminals).toBe(2);
|
||||
expect(defaults.retention.usageSamplesDays).toBe(14);
|
||||
store.set("ui.terminal.max_live_terminals", "5");
|
||||
store.set("retention.usage_samples_days", "21");
|
||||
const saved = store.resolve();
|
||||
expect(saved.ui.terminal.maxLiveTerminals).toBe(5);
|
||||
expect(saved.retention.usageSamplesDays).toBe(21);
|
||||
// `rig config --json` prints exactly this object, so a saved value reaches the
|
||||
// structured output and not only `rig config get`.
|
||||
expect(JSON.parse(JSON.stringify(saved))).toMatchObject({
|
||||
ui: { terminal: { maxLiveTerminals: 5 } },
|
||||
retention: { usageSamplesDays: 21 },
|
||||
});
|
||||
});
|
||||
|
||||
it("recovery.auto_drive_provider_prompts defaults false and coerces booleans", () => {
|
||||
const store = new ConfigStore(configPath);
|
||||
expect(store.get("recovery.auto_drive_provider_prompts")).toBe(false);
|
||||
|
||||
@@ -156,8 +156,10 @@ describe("runCrossHostCommand", () => {
|
||||
expect(capture.command).toBe("ssh");
|
||||
// D13 supersession: the remote command now runs under `sh -lc` (login-shell PATH
|
||||
// resolution) — the quoted argv rides inside one further quoting layer.
|
||||
// SEP-1: `--` ends ssh's own option parsing before the target.
|
||||
expect(capture.args).toEqual([
|
||||
"-o", "ConnectTimeout=10",
|
||||
"--",
|
||||
"vm-test.local",
|
||||
"sh -lc ''\\''rig'\\'' '\\''send'\\'' '\\''dev-impl@rig'\\'' '\\''hello world'\\'' '\\''--verify'\\'''",
|
||||
]);
|
||||
@@ -202,6 +204,29 @@ describe("runCrossHostCommand", () => {
|
||||
expect(capture.args?.[1]).toBe("ops");
|
||||
});
|
||||
|
||||
it("SEP-1 — `--` separates the options from the target, whatever shape the target has", async () => {
|
||||
const capture: { command?: string; args?: readonly string[] } = {};
|
||||
const spawn = mockSpawnFor({ exitCode: 0, capture });
|
||||
await runCrossHostCommand({ id: "vm-test", transport: "ssh", target: "-oProxyCommand=touch /tmp/nothing" }, ["rig", "ps"], { spawn });
|
||||
const args = capture.args ?? [];
|
||||
const separator = args.indexOf("--");
|
||||
expect(separator).toBeGreaterThan(0);
|
||||
// The whole target reaches ssh as one argument after the separator, so an option-shaped
|
||||
// destination is a host ssh rejects rather than an option it obeys.
|
||||
expect(args[separator + 1]).toBe("-oProxyCommand=touch /tmp/nothing");
|
||||
// Everything we pass as an option stays before it.
|
||||
expect(separator).toBeGreaterThan(args.indexOf("-o"));
|
||||
expect(separator).toBeLessThan(args.length - 1);
|
||||
});
|
||||
|
||||
it("SEP-1 — the separator sits after -l <user> too", async () => {
|
||||
const capture: { command?: string; args?: readonly string[] } = {};
|
||||
const spawn = mockSpawnFor({ exitCode: 0, capture });
|
||||
await runCrossHostCommand(HOST_WITH_USER, ["rig", "ps"], { spawn });
|
||||
const args = capture.args ?? [];
|
||||
expect(args.slice(0, args.indexOf("--"))).toEqual(["-l", "ops", "-o", "ConnectTimeout=10"]);
|
||||
});
|
||||
|
||||
it("happy path returns stdout/stderr verbatim", async () => {
|
||||
const spawn = mockSpawnFor({
|
||||
exitCode: 0,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// The opt-in wake loop in the openrig-user skill, run exactly as shipped: extracted from the skill, executed by sh,
|
||||
// calling the built `rig` CLI against the daemon's real queue routes over HTTP. It must wake on new owned work,
|
||||
// survive failed polls, and end at its deadline.
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { describe, it, type TestContext } from "vitest";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
@@ -30,62 +30,48 @@ function shippedLoop(): string {
|
||||
return match[1]!;
|
||||
}
|
||||
|
||||
describe.skipIf(process.platform === "win32")("the skill's opt-in wake loop", () => {
|
||||
it("has a built CLI to run the loop with", () => {
|
||||
// Not a silent skip: the loop runs against this checkout's built `rig`.
|
||||
expect(fs.existsSync(CLI), `build the CLI first (npm run build): ${fileURLToPath(CLI)} is missing`).toBe(true);
|
||||
async function fixture(onTestFinished: TestContext["onTestFinished"]) {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "wake-loop-"));
|
||||
let db: Database.Database | undefined;
|
||||
let server: ServerType | undefined;
|
||||
onTestFinished(async () => {
|
||||
try {
|
||||
if (server) await new Promise<void>((resolve) => server!.close(() => resolve()));
|
||||
} finally {
|
||||
try { db?.close(); }
|
||||
finally { fs.rmSync(root, { recursive: true, force: true }); }
|
||||
}
|
||||
});
|
||||
|
||||
let db: Database.Database;
|
||||
let server: ServerType;
|
||||
let url: string;
|
||||
let root: string;
|
||||
let failLists = 0;
|
||||
let failListsAfter = Infinity; // every list request after this many fails, as if the daemon stopped
|
||||
let listCalls = 0;
|
||||
let queue: QueueRepository;
|
||||
|
||||
beforeEach(async () => {
|
||||
root = fs.mkdtempSync(path.join(os.tmpdir(), "wake-loop-"));
|
||||
fs.mkdirSync(path.join(root, "bin"));
|
||||
// The agent's `rig`: this checkout's built CLI.
|
||||
fs.writeFileSync(path.join(root, "bin", "rig"), `#!/bin/sh\nexec ${JSON.stringify(process.execPath)} ${JSON.stringify(fileURLToPath(CLI))} "$@"\n`, { mode: 0o755 });
|
||||
db = createDb(":memory:");
|
||||
migrate(db, ALL_MIGRATIONS);
|
||||
queue = new QueueRepository(db, new EventBus(db), { validateRig: () => true });
|
||||
const inert = async () => ({ ok: false, error: "inert fixture" });
|
||||
const { app } = createTestApp(db, {
|
||||
appDeps: {
|
||||
sessionTransport: { resolveSessions: async () => ({ ok: false, code: "not_found", error: "inert fixture" }), send: inert, capture: inert, broadcast: inert } as never,
|
||||
queueRepo: queue,
|
||||
},
|
||||
});
|
||||
failLists = 0;
|
||||
failListsAfter = Infinity;
|
||||
listCalls = 0;
|
||||
server = await new Promise<ServerType>((resolve) => {
|
||||
const s = serve({
|
||||
fetch: (request: Request) => {
|
||||
if (new URL(request.url).pathname === "/api/queue/list") {
|
||||
listCalls++;
|
||||
if (failLists > 0 || listCalls > failListsAfter) {
|
||||
if (failLists > 0) failLists--;
|
||||
return new Response(JSON.stringify({ error: "fixture: daemon busy" }), { status: 503 });
|
||||
}
|
||||
fs.mkdirSync(path.join(root, "bin"));
|
||||
// The agent's `rig`: this checkout's built CLI.
|
||||
fs.writeFileSync(path.join(root, "bin", "rig"), `#!/bin/sh\nexec ${JSON.stringify(process.execPath)} ${JSON.stringify(fileURLToPath(CLI))} "$@"\n`, { mode: 0o755 });
|
||||
db = createDb(":memory:");
|
||||
migrate(db, ALL_MIGRATIONS);
|
||||
const queue = new QueueRepository(db, new EventBus(db), { validateRig: () => true });
|
||||
const inert = async () => ({ ok: false, error: "inert fixture" });
|
||||
const { app } = createTestApp(db, {
|
||||
appDeps: {
|
||||
sessionTransport: { resolveSessions: async () => ({ ok: false, code: "not_found", error: "inert fixture" }), send: inert, capture: inert, broadcast: inert } as never,
|
||||
queueRepo: queue,
|
||||
},
|
||||
});
|
||||
const state = { failLists: 0, failListsAfter: Infinity, listCalls: 0 };
|
||||
server = await new Promise<ServerType>((resolve) => {
|
||||
const s = serve({
|
||||
fetch: (request: Request) => {
|
||||
if (new URL(request.url).pathname === "/api/queue/list") {
|
||||
state.listCalls++;
|
||||
if (state.failLists > 0 || state.listCalls > state.failListsAfter) {
|
||||
if (state.failLists > 0) state.failLists--;
|
||||
return new Response(JSON.stringify({ error: "fixture: daemon busy" }), { status: 503 });
|
||||
}
|
||||
return app.fetch(request);
|
||||
},
|
||||
port: 0, hostname: "127.0.0.1",
|
||||
}, () => resolve(s));
|
||||
});
|
||||
url = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
db.close();
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
return app.fetch(request);
|
||||
},
|
||||
port: 0, hostname: "127.0.0.1",
|
||||
}, () => resolve(s));
|
||||
});
|
||||
const url = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
|
||||
|
||||
/** Run the shipped loop as a background command would: its own process, this seat's identity, short timings. */
|
||||
function runLoop(max: number, seat: string | null = DESK) {
|
||||
@@ -106,26 +92,38 @@ describe.skipIf(process.platform === "win32")("the skill's opt-in wake loop", ()
|
||||
const assign = (destination: string) => queue.create({
|
||||
sourceSession: "lead@team", destinationSession: destination, body: "Summarise the failing test.", nudge: false,
|
||||
} as never);
|
||||
return { runLoop, assign, queue, state };
|
||||
}
|
||||
|
||||
it("wakes with the new owned item once it is assigned", async () => {
|
||||
describe.skipIf(process.platform === "win32").concurrent("the skill's opt-in wake loop", () => {
|
||||
it("has a built CLI to run the loop with", ({ expect }) => {
|
||||
// Not a silent skip: the loop runs against this checkout's built `rig`.
|
||||
expect(fs.existsSync(CLI), `build the CLI first (npm run build): ${fileURLToPath(CLI)} is missing`).toBe(true);
|
||||
});
|
||||
|
||||
it("wakes with the new owned item once it is assigned", async ({ expect, onTestFinished }) => {
|
||||
const { runLoop, assign } = await fixture(onTestFinished);
|
||||
const waiting = runLoop(30);
|
||||
setTimeout(() => { assign(DESK); }, 2_000);
|
||||
const timer = setTimeout(() => { assign(DESK); }, 2_000);
|
||||
onTestFinished(() => clearTimeout(timer));
|
||||
const result = await waiting;
|
||||
expect(result.code).toBe(0);
|
||||
expect(result.stdout).toMatch(/^NEW WORK: .*"qitemId"/m);
|
||||
expect(result.seconds).toBeLessThan(15);
|
||||
}, 40_000);
|
||||
|
||||
it("keeps waiting through failed polls and wakes when the daemon answers again", async () => {
|
||||
it("keeps waiting through failed polls and wakes when the daemon answers again", async ({ expect, onTestFinished }) => {
|
||||
const { runLoop, assign, state } = await fixture(onTestFinished);
|
||||
assign(DESK);
|
||||
failLists = 3;
|
||||
state.failLists = 3;
|
||||
const result = await runLoop(30);
|
||||
expect(result.code).toBe(0);
|
||||
expect(listCalls).toBeGreaterThanOrEqual(4);
|
||||
expect(state.listCalls).toBeGreaterThanOrEqual(4);
|
||||
expect(result.stdout).toMatch(/^NEW WORK: /m);
|
||||
}, 40_000);
|
||||
|
||||
it("doesn't wake for owned work that is already claimed", async () => {
|
||||
it("doesn't wake for owned work that is already claimed", async ({ expect, onTestFinished }) => {
|
||||
const { runLoop, assign, queue } = await fixture(onTestFinished);
|
||||
const item = await assign(DESK);
|
||||
queue.claim({ qitemId: item.qitemId, destinationSession: DESK } as never);
|
||||
const result = await runLoop(4);
|
||||
@@ -134,7 +132,8 @@ describe.skipIf(process.platform === "win32")("the skill's opt-in wake loop", ()
|
||||
expect(result.stdout).toContain("No new work before the deadline");
|
||||
}, 40_000);
|
||||
|
||||
it("says it couldn't check, and exits non-zero, when every poll failed", async () => {
|
||||
it("says it couldn't check, and exits non-zero, when every poll failed", async ({ expect, onTestFinished }) => {
|
||||
const { runLoop, assign } = await fixture(onTestFinished);
|
||||
assign(DESK);
|
||||
const result = await runLoop(3, null); // no seat identity: every `rig queue list --owned` fails
|
||||
expect(result.code).not.toBe(0);
|
||||
@@ -143,23 +142,26 @@ describe.skipIf(process.platform === "win32")("the skill's opt-in wake loop", ()
|
||||
expect(result.stdout).toContain("Couldn't check for work: every poll failed.");
|
||||
}, 40_000);
|
||||
|
||||
it("reports when checking stopped working, and exits non-zero, after an early success and failures to the deadline", async () => {
|
||||
failListsAfter = 1; // one empty successful poll, then the daemon stops answering
|
||||
it("reports when checking stopped working, and exits non-zero, after an early success and failures to the deadline", async ({ expect, onTestFinished }) => {
|
||||
const { runLoop, state } = await fixture(onTestFinished);
|
||||
state.failListsAfter = 1; // one empty successful poll, then the daemon stops answering
|
||||
const result = await runLoop(4);
|
||||
expect(result.code).not.toBe(0);
|
||||
expect(result.stdout).not.toContain("No new work");
|
||||
expect(result.stdout).toMatch(/Checking stopped working at \d{2}:\d{2}:\d{2}Z: every poll since then failed\./);
|
||||
}, 40_000);
|
||||
|
||||
it("stays quiet when a failed poll is followed by a successful one", async () => {
|
||||
failLists = 2; // two failures, then the daemon answers again (with no work)
|
||||
it("stays quiet when a failed poll is followed by a successful one", async ({ expect, onTestFinished }) => {
|
||||
const { runLoop, state } = await fixture(onTestFinished);
|
||||
state.failLists = 2; // two failures, then the daemon answers again (with no work)
|
||||
const result = await runLoop(4);
|
||||
expect(result.code).toBe(0);
|
||||
expect(result.stdout).toContain("No new work before the deadline");
|
||||
expect(result.stdout).not.toContain("Checking stopped working");
|
||||
}, 40_000);
|
||||
|
||||
it("ignores work assigned to another seat and ends at its deadline", async () => {
|
||||
it("ignores work assigned to another seat and ends at its deadline", async ({ expect, onTestFinished }) => {
|
||||
const { runLoop, assign } = await fixture(onTestFinished);
|
||||
assign("impl@team");
|
||||
const result = await runLoop(4);
|
||||
expect(result.code).toBe(0);
|
||||
|
||||
@@ -98,6 +98,18 @@ it("a seat that isn't in this daemon's inventory is unknown, never 'fine'", asyn
|
||||
{ seat: "x@elsewhere", why: "no rig named elsewhere on this daemon" }]);
|
||||
});
|
||||
|
||||
it("finds attention for a seat whose rig name contains @", async () => {
|
||||
const { client: c } = client({
|
||||
"/api/ps": { status: 200, data: [{ rigId: "r1", rigName: "team@east" }] },
|
||||
"/api/rigs/r1/nodes": { status: 200, data: [
|
||||
{ canonicalSessionName: "lead@team@east", needsAttention: true, startupStatus: "attention_required", latestError: LOGIN },
|
||||
] },
|
||||
});
|
||||
expect(await checkSeatAttention(c, ["lead@team@east"])).toEqual({
|
||||
attention: [{ seat: "lead@team@east", reason: LOGIN, waitingOnPerson: false }], unknown: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("an unreadable kernel status is reported, not read as no held seat", async () => {
|
||||
const { client: c } = client({ "/api/kernel/status": { status: 503, data: { error: "kernel_boot_tracker_unavailable" } } });
|
||||
expect(await checkKernelAttention(c)).toEqual({ attention: [],
|
||||
|
||||
@@ -214,6 +214,30 @@ describe("rig whoami --json: bounded peers (output.compact, output.whoami_peers)
|
||||
for (const p of BIG_PEERS) expect(out).toContain(p.logicalId);
|
||||
});
|
||||
|
||||
// #2871: while `rig up` is still building the team, every form of the answer keeps saying so.
|
||||
it("a team still being built: rosterState and the daemon's opening survive bounding, and edges aren't called complete", async () => {
|
||||
const BUILDING = "rosterState = building: `rig up` is still creating this team's seats and edges, so the peers and edges "
|
||||
+ "below are only the ones created so far and more may come; run `rig whoami` again once `rig up` finishes. ";
|
||||
served = { ...response(BIG_PEERS), rosterState: "building", peersNote: BUILDING + DAEMON_NOTE };
|
||||
// Switch off: the daemon's own note and state.
|
||||
let out = JSON.parse(await run("--json"));
|
||||
expect(out.rosterState).toBe("building");
|
||||
expect(out.peersNote).toBe(BUILDING + DAEMON_NOTE);
|
||||
// Switch on: the bounded note opens the same way, and names the edges as the ones written so far.
|
||||
vi.stubEnv("OPENRIG_OUTPUT_COMPACT", "1");
|
||||
out = JSON.parse(await run("--json"));
|
||||
expect(out.rosterState).toBe("building");
|
||||
expect(out.peersOmitted).toBe(15);
|
||||
expect(out.peersNote).toBe(
|
||||
BUILDING
|
||||
+ "peers = 8 of this rig's 23 (roster excluding self; edge peers first, then your pod, then roster order; "
|
||||
+ "bound output.whoami_peers=8); 15 not shown; all: rig whoami --json --all-peers --node-id 'node-1'; "
|
||||
+ "edges = directional relationships (the ones written so far); use `rig ps --nodes` for node inventory including self + live state",
|
||||
);
|
||||
// Plain text says it too.
|
||||
expect(await run()).toContain("Team: still being built by `rig up`");
|
||||
});
|
||||
|
||||
it("the full-list command names the identity the call used: --session, quoted", () => {
|
||||
const bounded = boundPeers(response(BIG_PEERS), 2, "rig whoami --json --all-peers --session 'dev-impl@big'");
|
||||
expect(bounded.note).toContain("all: rig whoami --json --all-peers --session 'dev-impl@big';");
|
||||
|
||||
@@ -44,10 +44,13 @@ machine and have things happen.
|
||||
SQLite. The daemon's kernel-boot path short-circuits
|
||||
`already-managed` — no re-instantiation, no fresh agents. Member
|
||||
tmux sessions survive a daemon-restart-only and the reconciler
|
||||
marks them healthy. A HOST reboot drops tmux entirely; the
|
||||
reconciler then marks the kernel's member sessions detached, and
|
||||
the operator pod owns the agent-restart workflow ("bring my
|
||||
sessions back online") per "Incomplete setup and restart":
|
||||
marks them healthy. A HOST reboot drops tmux entirely. After a host
|
||||
reboot, the next `rig daemon start` attempts to restore the kernel
|
||||
when startup reconciliation newly marks at least one seat detached
|
||||
and every kernel seat is detached and still bound. A deliberately
|
||||
stopped kernel, or one whose seats were all already recorded as
|
||||
detached before that start, stays down until
|
||||
`rig up kernel --existing`. See "Incomplete setup and restart":
|
||||
`rig context get reference/getting-started.md#incomplete-setup-and-restart`.
|
||||
Other rigs are NOT auto-instantiated by the daemon at any point —
|
||||
they require explicit operator-initiated `rig up` / `rig restore`
|
||||
|
||||
@@ -11,9 +11,12 @@ summary: >
|
||||
subsequent daemon-restarts the rig record persists in SQLite and the
|
||||
daemon's kernel-boot path short-circuits already-managed without
|
||||
re-creating the rig. Member tmux sessions survive daemon-restart-only;
|
||||
a HOST reboot drops tmux and the reconciler marks sessions detached,
|
||||
at which point the operator pod owns the agent-restart workflow
|
||||
through the current operator workflow. Dual-runtime variant; Claude Code +
|
||||
a HOST reboot drops tmux. The next daemon start attempts to restore the
|
||||
kernel when startup reconciliation newly marks at least one seat
|
||||
detached and every kernel seat is detached and still bound; a
|
||||
deliberately stopped kernel, or one whose seats were all already
|
||||
recorded as detached, stays down until rig up kernel --existing.
|
||||
Dual-runtime variant; Claude Code +
|
||||
Codex both authenticated.
|
||||
|
||||
culture_file: culture/CULTURE.md
|
||||
|
||||
@@ -74,17 +74,49 @@ export function daemonTmuxControlStatus(): { requested: boolean } & TmuxControlS
|
||||
return { requested: true, ...(daemonControl.status?.() ?? { state: "off" }) };
|
||||
}
|
||||
|
||||
function shellWord(value: string): string {
|
||||
return /^[A-Za-z0-9_@%+=:,./-]+$/.test(value) ? value : "'" + value.replace(/'/g, "'\"'\"'") + "'";
|
||||
function shellWord(value: string, force = false): string {
|
||||
return !force && /^[A-Za-z0-9_@%+=:,./-]+$/.test(value) ? value : "'" + value.replace(/'/g, "'\"'\"'") + "'";
|
||||
}
|
||||
|
||||
/** `tmux`, with the daemon's server flags when it isn't on the default server. */
|
||||
function tmuxOnDaemonServer(): string {
|
||||
export function tmuxOnDaemonServer(): string {
|
||||
const server = daemonServerArgs.map((arg, i) => (i % 2 === 0 ? arg : shellWord(arg))).join(" ");
|
||||
return `tmux${server ? ` ${server}` : ""}`;
|
||||
}
|
||||
|
||||
/** `tmux attach -t <session>`, with the daemon's server flags when it isn't on the default server. */
|
||||
/** A session name as tmux must receive it.
|
||||
*
|
||||
* Callers hand this a raw session name, not an argument tmux has already escaped, so the name is
|
||||
* encoded exactly once here, for the tmux rules that apply at this layer.
|
||||
*
|
||||
* `cmd_parse_from_arguments` drops a final `;` as a command separator and consumes a backslash
|
||||
* sitting immediately in front of it. One more backslash goes before that separator, whether or
|
||||
* not one is already there: a session named `report;` goes as `report\;` and one named `report\;`
|
||||
* goes as `report\\;`, so the two stay different all the way to tmux.
|
||||
*
|
||||
* `cmd_find_parse_target` reads several spellings as target markers rather than as part of a name:
|
||||
* a leading `=` is the exact-match prefix, a leading `@` a window target, a leading `%` a pane
|
||||
* target, and the words `~` and `{marked}` mean the marked target. One more `=` in front turns
|
||||
* each of those back into a session name, because the marker is consumed and the rest is looked up
|
||||
* as the name. A name starting with `$` is the one that cannot be rescued this way:
|
||||
* `cmd_find_get_session` reads it as a session id before the exact-match flag is consulted. */
|
||||
function tmuxSessionWord(session: string): string {
|
||||
const markedTarget = session === "~" || session === "{marked}";
|
||||
let word = /^[=@%]/.test(session) || markedTarget ? `=${session}` : session;
|
||||
if (word.endsWith(";")) word = `${word.slice(0, -1)}\\;`;
|
||||
return word;
|
||||
}
|
||||
|
||||
/** `tmux attach -t <session>`, with the daemon's server flags when it isn't on the default server.
|
||||
*
|
||||
* The session is encoded for tmux first and then quoted like any other shell word: callers type
|
||||
* this line into a fresh terminal (the CMUX panes) or hand it to a person to run, and a session
|
||||
* name is not guaranteed to be shell-safe — an adopted session's name comes from outside
|
||||
* OpenRig. `shellWord` leaves the ordinary `name@rig` form byte-identical. `=` is the one
|
||||
* character it cannot leave bare: zsh expands a word beginning with `=` to the path of that
|
||||
* command, and with MAGIC_EQUAL_SUBST it can expand one inside a `name=value` word, so any
|
||||
* session word carrying `=` is forced into quotes. A shell passes those quotes through. */
|
||||
export function tmuxAttachCommand(session: string): string {
|
||||
return `${tmuxOnDaemonServer()} attach -t ${session}`;
|
||||
const word = tmuxSessionWord(session);
|
||||
return `${tmuxOnDaemonServer()} attach -t ${shellWord(word, word.includes("="))}`;
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { inspectComposerInput } from "../domain/session-transport.js";
|
||||
import { parentSessionEnvKeys, removeParentSessionEnv } from "../domain/parent-session-env.js";
|
||||
import { addSeatRuntimeDir, seatRuntimeDir } from "../domain/seat-runtime-dir.js";
|
||||
import type { TmuxControlExec } from "./tmux-control.js";
|
||||
import { tmuxOnDaemonServer } from "./tmux-server.js";
|
||||
|
||||
export type ExecFn = (cmd: string) => Promise<string>;
|
||||
|
||||
@@ -277,7 +278,9 @@ function parentSessionEnvWarning(target: string, err: unknown): string {
|
||||
/** Shell-quote a string using single quotes (POSIX-safe). */
|
||||
/** One pane's text from a batched capture, with the time its chunk was read (#309 review). `text` is null for a
|
||||
* session that is gone or an empty capture. */
|
||||
export interface PaneCapture { text: string | null; capturedAt: Date; /** The listing lacked the session. */ missing?: true }
|
||||
export interface PaneCapture { text: string | null; capturedAt: Date; /** The listing lacked the session. */ missing?: true;
|
||||
/** The pane and its process that this capture read, from the same tmux call (#2515). */
|
||||
pane?: { id: string; pid: number } }
|
||||
|
||||
/** The most sessions one batched capture chains (#308). Not a byte bound: a chunk whose output overflows exec's buffer
|
||||
* fails and is split in half. */
|
||||
@@ -544,7 +547,7 @@ export class TmuxAdapter {
|
||||
}
|
||||
}
|
||||
|
||||
private async guardedInput(target: string, write: (pane: string, beforeWrite: () => void) => Promise<TmuxResult>, allowAbsent = false, inputKind: "control" | "text" | "keys" | "respawn" = "control"): Promise<TmuxResult> {
|
||||
private async guardedInput(target: string, write: (pane: string, beforeWrite: () => void, nodeId?: string) => Promise<TmuxResult>, allowAbsent = false, inputKind: "control" | "text" | "keys" | "respawn" = "control"): Promise<TmuxResult> {
|
||||
const guard = this.deliveryGuard;
|
||||
if (!guard) return write(target, () => {});
|
||||
try {
|
||||
@@ -594,7 +597,7 @@ export class TmuxAdapter {
|
||||
}
|
||||
// Revalidate registry/occupant after the asynchronous observation. Write
|
||||
// to the immutable pane ID, not a session name which could be recycled.
|
||||
return guard.input(identity, () => write(pane, () => guard.checkInput(identity)));
|
||||
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) };
|
||||
@@ -1308,16 +1311,26 @@ export class TmuxAdapter {
|
||||
}
|
||||
}
|
||||
|
||||
/** With a delivery guard, terminate by immutable tmux session ID; checked callers validate before detach and kill. */
|
||||
/** With a delivery guard, terminate by immutable tmux session ID; checked callers validate before detach and kill.
|
||||
* A seat's session is stopped only when it carries that seat's OpenRig marker, and each effect runs only while that
|
||||
* id still names a session on the server it was read from: a replaced server reuses ids for other terminals. */
|
||||
async killSession(name: string, beforeEffect?: () => void): Promise<TmuxResult> {
|
||||
let stopPhase: StopPhase = "none";
|
||||
if (this.deliveryGuard) {
|
||||
const result = await this.guardedInput(name, async (pane, beforeWrite) => {
|
||||
const stdout = await this.run(["tmux", "display-message", "-p", "-t", pane, "#{session_id}"], `tmux display-message -p -t ${shellQuote(pane)} '#{session_id}'`);
|
||||
const sessionId = stdout.trim();
|
||||
if (!/^\$\d+$/.test(sessionId)) return { ok: false, code: "guard_target_unknown", message: "Cannot establish immutable session identity; no session killed." };
|
||||
const result = await this.guardedInput(name, async (pane, beforeWrite, nodeId) => {
|
||||
// The session id and its server (pid and start time) in one read, so the effects below can be held to both.
|
||||
const stdout = await this.run(["tmux", "display-message", "-p", "-t", pane, "#{session_id}|#{pid}|#{start_time}"],
|
||||
`tmux display-message -p -t ${shellQuote(pane)} '#{session_id}|#{pid}|#{start_time}'`);
|
||||
const [sessionId = "", pid = "", start = ""] = stdout.trim().split("|");
|
||||
if (!/^\$\d+$/.test(sessionId) || !/^\d+$/.test(pid) || !/^\d+$/.test(start)) {
|
||||
return { ok: false, code: "guard_target_unknown", message: "Cannot establish immutable session identity; no session killed." };
|
||||
}
|
||||
if (nodeId) {
|
||||
const unproven = await this.unprovenSeatSession(name, sessionId, nodeId);
|
||||
if (unproven) return unproven;
|
||||
}
|
||||
const kill = async () => {
|
||||
const result = await this.killSessionUnchecked(sessionId, beforeEffect && (() => { beforeWrite(); beforeEffect(); }), phase => { stopPhase = phase; });
|
||||
const result = await this.killSessionUnchecked(sessionId, beforeEffect && (() => { beforeWrite(); beforeEffect(); }), phase => { stopPhase = phase; }, { pid, start });
|
||||
if (result.ok) { this.freshProbes.delete(name); this.freshProbes.delete(pane); this.freshManaged.delete(name); }
|
||||
return result;
|
||||
};
|
||||
@@ -1330,16 +1343,48 @@ export class TmuxAdapter {
|
||||
return this.killSessionUnchecked(name);
|
||||
}
|
||||
|
||||
/** The session a seat's stop would end is the seat's own: every session OpenRig creates for a seat carries its node's
|
||||
* OPENRIG_NODE_ID. One without it (adopted, bound or discovered from outside), or with another seat's, is left
|
||||
* running and named; nothing is detached or killed. */
|
||||
private async unprovenSeatSession(name: string, sessionId: string, nodeId: string): Promise<TmuxResult | null> {
|
||||
let marker: string | null = null;
|
||||
try {
|
||||
const out = (await this.run(["tmux", "show-environment", "-t", sessionId, "OPENRIG_NODE_ID"])).trim();
|
||||
if (out.startsWith("OPENRIG_NODE_ID=")) marker = out.slice("OPENRIG_NODE_ID=".length);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
if (!/unknown variable/i.test(message)) {
|
||||
return { ok: false, code: "guard_target_unknown", message: `'${name}' was left running: its OpenRig marker couldn't be read (${message.trim()}), so nothing was detached or killed.` };
|
||||
}
|
||||
}
|
||||
if (marker === nodeId) return null;
|
||||
const why = marker === null ? "doesn't carry this seat's OpenRig marker (OPENRIG_NODE_ID)" : `carries another seat's OpenRig marker (OPENRIG_NODE_ID=${marker})`;
|
||||
return { ok: false, code: "seat_target_unproven", message: `'${name}' was left running: its tmux session ${sessionId} ${why}, so it can't be shown `
|
||||
+ "to be this seat's terminal, and nothing was detached or killed. A session OpenRig didn't create, such as an adopted one, has no marker. "
|
||||
// On the daemon's own tmux server: a person's plain `tmux` can reach another server, and a namesake there.
|
||||
+ `If it is this seat's, look first with \`${tmuxOnDaemonServer()} attach -t ${shellQuote(`=${name}`)}\`, then stop it with `
|
||||
+ `\`${tmuxOnDaemonServer()} kill-session -t ${shellQuote(`=${name}`)}\` and run the command again.` };
|
||||
}
|
||||
|
||||
/** Keep effect checks outside command catches so a selection refusal retains its code and phase. */
|
||||
private async killSessionUnchecked(name: string, beforeEffect?: () => void, phase?: (value: StopPhase) => void): Promise<TmuxResult> {
|
||||
private async killSessionUnchecked(name: string, beforeEffect?: () => void, phase?: (value: StopPhase) => void,
|
||||
server?: { pid: string; start: string }): Promise<TmuxResult> {
|
||||
// The guarded path passes the immutable `$N` id, which stays as it is; a bare name is made exact.
|
||||
const session = exactTarget(name, "session");
|
||||
// With its server, an effect runs only while this id is that session on that server: tmux checks and acts in one
|
||||
// command, so a server replaced since the read (whose ids name other terminals) is left alone.
|
||||
const changed = "openrig-stop-target-changed";
|
||||
const effect = (verb: string, flag: string): string[] => server
|
||||
? ["tmux", "if-shell", "-F", "-t", session,
|
||||
`#{&&:#{==:#{session_id},${session}},#{&&:#{==:#{pid},${server.pid}},#{==:#{start_time},${server.start}}}}`,
|
||||
`${verb} ${flag} '${session}'`, `display-message -p ${changed}`]
|
||||
: ["tmux", verb, flag, session];
|
||||
// Detach first so `detach-on-destroy off` cannot switch views onto another session.
|
||||
beforeEffect?.();
|
||||
phase?.("detach_attempted");
|
||||
try {
|
||||
await this.run(["tmux", "detach-client", "-s", session],
|
||||
`tmux detach-client -s ${shellQuote(session)}`);
|
||||
const out = await this.run(effect("detach-client", "-s"), server ? undefined : `tmux detach-client -s ${shellQuote(session)}`);
|
||||
if (out.includes(changed)) return { ok: false, code: "guard_target_changed", message: `${session} is no longer that session on the tmux server it was read from (the server may have been replaced); nothing was detached or killed.` };
|
||||
} catch (err) {
|
||||
// tmux 3.7 says "no current client" when nothing is attached (and for a missing session, which the kill classifies).
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
@@ -1349,8 +1394,8 @@ export class TmuxAdapter {
|
||||
beforeEffect?.();
|
||||
phase?.("kill_attempted");
|
||||
try {
|
||||
await this.run(["tmux", "kill-session", "-t", session],
|
||||
`tmux kill-session -t ${shellQuote(session)}`);
|
||||
const out = await this.run(effect("kill-session", "-t"), server ? undefined : `tmux kill-session -t ${shellQuote(session)}`);
|
||||
if (out.includes(changed)) return { ok: false, code: "guard_target_changed", message: `${session} is no longer that session on the tmux server it was read from (the server may have been replaced); it was detached, but nothing was killed.` };
|
||||
const pane = this.freshProbes.get(name);
|
||||
this.freshProbes.delete(name);
|
||||
if (pane) this.freshProbes.delete(pane);
|
||||
@@ -1781,12 +1826,14 @@ export class TmuxAdapter {
|
||||
const captureChunk = async (chunk: string[]): Promise<void> => {
|
||||
const nonce = randomUUID().replace(/-/g, "");
|
||||
const mark = (k: number) => `__openrig_capture_${nonce}_${k}__`;
|
||||
// The marker line also names the pane and its process that the capture after it reads, in the same call.
|
||||
const header = (k: number) => `${mark(k)} #{pane_id} #{pane_pid}`;
|
||||
const argv = ["tmux", ...chunk.flatMap((t, k) => [
|
||||
...(k > 0 ? [";"] : []),
|
||||
"display-message", "-p", "-t", `=${t}:`, mark(k), ";", "capture-pane", "-p", "-t", `=${t}:`, "-S", `-${lines}`,
|
||||
"display-message", "-p", "-t", `=${t}:`, header(k), ";", "capture-pane", "-p", "-t", `=${t}:`, "-S", `-${lines}`,
|
||||
])];
|
||||
const legacy = "tmux " + chunk.map((t, k) =>
|
||||
`display-message -p -t ${shellQuote(`=${t}:`)} ${shellQuote(mark(k))} \\; capture-pane -p -t ${shellQuote(`=${t}:`)} -S -${lines}`,
|
||||
`display-message -p -t ${shellQuote(`=${t}:`)} ${shellQuote(header(k))} \\; capture-pane -p -t ${shellQuote(`=${t}:`)} -S -${lines}`,
|
||||
).join(" \\; ");
|
||||
let output: string;
|
||||
let capturedAt: Date;
|
||||
@@ -1830,14 +1877,18 @@ export class TmuxAdapter {
|
||||
return; // a single session that failed: read per target by the caller
|
||||
}
|
||||
const segments: Array<string[] | null> = chunk.map(() => null);
|
||||
const panes: Array<PaneCapture["pane"]> = chunk.map(() => undefined);
|
||||
const marker = new RegExp(`^__openrig_capture_${nonce}_(\\d+)__(?: (.*))?$`);
|
||||
let cur = -1;
|
||||
const outLines = output.split("\n");
|
||||
if (outLines.length && outLines[outLines.length - 1] === "") outLines.pop(); // the final newline, not a line
|
||||
for (const line of outLines) {
|
||||
const m = line.startsWith(`__openrig_capture_${nonce}_`) ? /_(\d+)__$/.exec(line) : null;
|
||||
if (m && line === mark(Number(m[1]))) {
|
||||
const m = line.startsWith(`__openrig_capture_${nonce}_`) ? marker.exec(line) : null;
|
||||
if (m && Number(m[1]) < chunk.length) {
|
||||
cur = Number(m[1]);
|
||||
segments[cur] = [];
|
||||
const identity = /^(%\d+) (\d+)$/.exec(m[2] ?? "");
|
||||
panes[cur] = identity ? { id: identity[1]!, pid: Number(identity[2]) } : undefined;
|
||||
} else if (cur >= 0) {
|
||||
segments[cur]!.push(line);
|
||||
}
|
||||
@@ -1847,7 +1898,7 @@ export class TmuxAdapter {
|
||||
if (seg == null) return; // its marker never printed: read per target
|
||||
// capture-pane prints its lines newline-terminated, as the per-target read returns them
|
||||
const text = seg.length ? seg.join("\n") + "\n" : "";
|
||||
out.set(t, { text: text === "" ? null : text, capturedAt });
|
||||
out.set(t, { text: text === "" ? null : text, capturedAt, ...(panes[k] ? { pane: panes[k] } : {}) });
|
||||
});
|
||||
};
|
||||
const unique = [...new Set(targets)];
|
||||
|
||||
@@ -89,5 +89,6 @@ export function permissionDeciderFor(
|
||||
const kernel = seatState(db, KERNEL_OPERATOR_SESSION);
|
||||
if (kernel.state === "missing" || (kernel.state === "unknown" && !kernel.recorded)) return null;
|
||||
return { session: KERNEL_OPERATOR_SESSION, source: "kernel-operator", ...kernel,
|
||||
note: receiver.session ? `${receiver.session}, the receiving agent, ${receiver.session === requester ? "is the seat asking" : "isn't on this host"}` : undefined };
|
||||
// A setting that's set but ignored keeps saying so, rather than reading as unset.
|
||||
note: receiver.session ? `${receiver.session}, the receiving agent, ${receiver.session === requester ? "is the seat asking" : "isn't on this host"}` : receiver.note };
|
||||
}
|
||||
@@ -110,6 +110,29 @@ export class EventBus {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a writer whose failure the caller handles, in its own savepoint (a transaction outside one). If it throws
|
||||
* anywhere, even after a nested write of its own was released, everything it wrote rolls back, the event tokens it
|
||||
* persisted leave the active envelope too, and the caller's transaction can still commit its fallback. With no
|
||||
* envelope and no transaction open (a direct answer from Slack), it runs in a notify envelope of its own, so its events
|
||||
* reach subscribers only after it commits, and a throw delivers none of them.
|
||||
*/
|
||||
discardOnThrow<T>(writer: () => T): T {
|
||||
const envelope = this.activeEnvelope;
|
||||
if (!envelope && !this.db.inTransaction) return this.withNotifyEnvelope(() => writer());
|
||||
const persisted = envelope ? new Set(envelope.persisted) : null;
|
||||
const registered = envelope ? new Set(envelope.registered) : null;
|
||||
try {
|
||||
return this.db.transaction(writer)();
|
||||
} catch (err) {
|
||||
if (envelope && persisted && registered) {
|
||||
for (const token of [...envelope.persisted]) if (!persisted.has(token)) envelope.persisted.delete(token);
|
||||
for (const token of [...envelope.registered]) if (!registered.has(token)) envelope.registered.delete(token);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run one caller-owned synchronous transaction under the W2b notification
|
||||
* envelope. Every token persisted through EventBus during the callback must
|
||||
|
||||
@@ -5,7 +5,7 @@ import type { RosterWake } from "./rosters.js";
|
||||
import { resolveSecret } from "./gateway/slack/secrets.js";
|
||||
import { safeDecodeURIComponent, validateOutboundUrl } from "./mission-control/outbound-url-validator.js";
|
||||
|
||||
/** How long a trigger may take to answer before the ring counts as failed. */
|
||||
/** How long a trigger may take to answer. After that the ring is unconfirmed, not failed: the trigger may have it. */
|
||||
export const HOSTED_WAKE_TIMEOUT_MS = 10_000;
|
||||
|
||||
export interface HostedWakeRing {
|
||||
@@ -66,8 +66,20 @@ export async function ringHostedWake(
|
||||
} catch (err) {
|
||||
// An error's text can carry the URL, so only a fixed classification is recorded: the timeout, or the error code.
|
||||
const timedOut = err instanceof Error && (err.name === "TimeoutError" || err.name === "AbortError");
|
||||
if (timedOut) return failed(urlRef, `didn't answer within ${opts.timeoutMs ?? HOSTED_WAKE_TIMEOUT_MS} ms`);
|
||||
// The POST may have reached the trigger and started a run, so the ring is unknown, like an unconfirmed send: it isn't
|
||||
// rung again blindly (see hostedRingMayHaveArrived), and the member's claim is still the pickup.
|
||||
if (timedOut) {
|
||||
return { classified: "indeterminate", nudgeResult: `indeterminate:webhook (${urlRef}) didn't answer within ${opts.timeoutMs ?? HOSTED_WAKE_TIMEOUT_MS} ms, `
|
||||
+ "so it may have the ring; it isn't rung again, and pickup is the member's claim on this row" };
|
||||
}
|
||||
const code = (err as { cause?: { code?: unknown } } | null)?.cause?.code;
|
||||
return failed(urlRef, `couldn't be reached (${typeof code === "string" && /^[A-Z][A-Z0-9_]*$/.test(code) ? code : "network error"})`);
|
||||
}
|
||||
}
|
||||
|
||||
/** A ring the trigger accepted, or one it may have received before it stopped answering. Either can start a run, so
|
||||
* recovery doesn't ring that wake intent again. A ring that never left (no URL, an invalid one, a refused connection)
|
||||
* is neither. */
|
||||
export function hostedRingMayHaveArrived(nudgeResult: string): boolean {
|
||||
return nudgeResult.startsWith("wake-accepted:webhook ") || nudgeResult.startsWith("indeterminate:webhook ");
|
||||
}
|
||||
@@ -818,6 +818,9 @@ export class MissionControlWriteContract {
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
// A runner's permission request isn't closed by an answer: it's handed to the receiving agent, or kept. Say which.
|
||||
const final = after as QueueItem | null;
|
||||
const runnerNotice = !answer.message && final && runnerPermissionOrigin(final) ? runnerAnswerNotice(final) : undefined;
|
||||
return {
|
||||
actionId: actionEntry!.actionId,
|
||||
verb: actionVerb,
|
||||
@@ -827,7 +830,7 @@ export class MissionControlWriteContract {
|
||||
notifyAttempted: false,
|
||||
notifyResult: null,
|
||||
auditedAt: evaluatedAt,
|
||||
answer,
|
||||
answer: runnerNotice ? { ...answer, message: runnerNotice } : answer,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -960,6 +963,18 @@ function verbToClosure(input: MissionControlActionInput): ClosureMapping {
|
||||
}
|
||||
}
|
||||
|
||||
/** What became of a person's answer on a runner's permission request: handed to the receiving agent, or kept (the
|
||||
* person's own receipt says why). Undefined for a request the answer closed (no agent wired here). */
|
||||
function runnerAnswerNotice(ask: QueueItem): string | undefined {
|
||||
if (ask.state === "handed-off" && ask.handedOffTo) {
|
||||
return `Your answer on ${ask.qitemId} went to ${ask.handedOffTo}, the receiving agent, which decides. The runner waits until then; nothing has run. A note to you says the agent's recorded state.`;
|
||||
}
|
||||
if (ask.state === "pending" || ask.state === "in-progress" || ask.state === "blocked") {
|
||||
return `Your answer is kept on ${ask.qitemId}, and the request is still waiting: the tool hasn't run, and nothing was permitted or refused. A note to you says why it wasn't passed to a receiving agent. To decide directly: rig seat decide-permission ${ask.qitemId} --option <id>.`;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function snapshotQitem(q: QueueItem): Record<string, unknown> {
|
||||
return {
|
||||
qitemId: q.qitemId,
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
/** Typed non-queue gates take precedence over custom queue IDs. */
|
||||
export const TYPED_GATE_BLOCKER_PREFIXES = ["fold:", "auth:", "external:"] as const;
|
||||
|
||||
export function typedGateBlockerPrefix(value: string): string | null {
|
||||
return TYPED_GATE_BLOCKER_PREFIXES.find((prefix) => value.startsWith(prefix)) ?? null;
|
||||
}
|
||||
|
||||
export function isTypedGateBlocker(value: unknown): boolean {
|
||||
if (typeof value !== "string") return false;
|
||||
const prefix = typedGateBlockerPrefix(value);
|
||||
return prefix != null && value.slice(prefix.length).trim().length > 0;
|
||||
}
|
||||
@@ -72,6 +72,8 @@ import type { QueueWakeFacts } from "./draft-aware-delivery.js";
|
||||
import { derivePickup, type PickupReceipt } from "./queue-pickup.js";
|
||||
import { withRecoveryIndex } from "./queue-recovery.js";
|
||||
import { lastMeaningfulTransitionMany, meaningfulFor, readWaitingView, WaitingStatements, type WaitingView, type WaitingActivityReader, type WaitingReads } from "./queue-waiting.js";
|
||||
import { isTypedGateBlocker, typedGateBlockerPrefix } from "./queue-blocker.js";
|
||||
export { TYPED_GATE_BLOCKER_PREFIXES, isTypedGateBlocker, typedGateBlockerPrefix } from "./queue-blocker.js";
|
||||
import { isOpenRigSender, OPENRIG_SENDER, wrapPaneEnvelope } from "../lib/pane-envelope.js";
|
||||
import { isBoundTerminalSeat } from "./terminal-seat.js";
|
||||
import { renderQueueHandoffNudge } from "./queue-nudge-text.js";
|
||||
@@ -84,7 +86,7 @@ import { classifyDestination, type DestinationClass } from "./gateway/destinatio
|
||||
import { join } from "node:path";
|
||||
import { SettingsStore } from "./user-settings/settings-store.js";
|
||||
import { hostedWakeFor, isHostedMemberAt, type RosterWake } from "./rosters.js";
|
||||
import { ringHostedWake } from "./hosted-wake.js";
|
||||
import { hostedRingMayHaveArrived, ringHostedWake } from "./hosted-wake.js";
|
||||
import { effectiveGateRoles } from "./gate-predicate.js";
|
||||
import {
|
||||
computeClosureRequiredAt,
|
||||
@@ -180,22 +182,8 @@ const AUTO_UNPARK_BLOCKER_TAG_PREFIX = "queue:auto-unpark:blocker-ref:";
|
||||
/** The actor on the transition routeToFallback writes; the row's own history is how a later reroute finds the line it wrote. */
|
||||
const FALLBACK_ACTOR = "system:queue-fallback";
|
||||
|
||||
/** 0.5.1-53 Atom 1a — typed non-qitem gate blocker prefixes. A park may be gated on a fold / auth /
|
||||
* external condition that is NOT a qitem and NOT a human seat; these prefixes make such a gate a
|
||||
* first-class, compact-visible, downstream-classifiable blocker (the ruling detail rides a transition). */
|
||||
export const TYPED_GATE_BLOCKER_PREFIXES = ["fold:", "auth:", "external:"] as const;
|
||||
|
||||
/** The actor a terminal act is recorded as when it names no responder: explicitly unattributed, never a person. */
|
||||
export const UNATTRIBUTED_ACTOR = "human@host";
|
||||
export function typedGateBlockerPrefix(value: string): string | null {
|
||||
return TYPED_GATE_BLOCKER_PREFIXES.find((p) => value.startsWith(p)) ?? null;
|
||||
}
|
||||
/** A well-formed typed gate blocker: a recognized prefix AND a non-empty gate body. */
|
||||
export function isTypedGateBlocker(value: unknown): boolean {
|
||||
if (typeof value !== "string") return false;
|
||||
const p = typedGateBlockerPrefix(value);
|
||||
return p != null && value.slice(p.length).trim().length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* #165: a claim is the destination acting on the row, which is stronger evidence than
|
||||
@@ -1677,8 +1665,8 @@ export class QueueRepository {
|
||||
// The level can change while this send waits for the seat's lease. Under the lease, each member is admitted again with
|
||||
// its own source and urgency; only those with the first member's outcome go with this send, and the others go back
|
||||
// to their state before this claim (never marked delivered, held or failed) for their own delivery.
|
||||
// A hosted member's trigger already accepted the ring for exactly these intents (tagged below): recovery doesn't ring
|
||||
// it again, since each ring can start a run. Any other wake rings: a new intent, or one claimed but never sent.
|
||||
// A hosted member's trigger already accepted the ring for exactly these intents, or may have before it stopped
|
||||
// answering (tagged below): recovery doesn't ring it again, since each ring can start a run. Any other wake rings: a new intent, or one claimed but never sent.
|
||||
if (group.every((entry) => entry.tags?.includes(HOSTED_WAKE_ACCEPTED_TAG))) {
|
||||
for (const entry of group) this.outbox.finalizeDelivery(entry.outboxId, "indeterminate");
|
||||
return "indeterminate";
|
||||
@@ -1719,8 +1707,8 @@ export class QueueRepository {
|
||||
const wakeEvent = this.db.transaction(() => {
|
||||
this.recordNudgeAttempt(qitemId, nudgeResult);
|
||||
this.outbox!.finalizeDelivery(intent.outboxId, finalState);
|
||||
// The acceptance belongs to this intent, so recovery can tell this wake already rang.
|
||||
if (outcome.nudgeResult.startsWith("wake-accepted:")) {
|
||||
// The acceptance (or a ring that may have arrived) belongs to this intent, so recovery can tell this wake already rang.
|
||||
if (hostedRingMayHaveArrived(outcome.nudgeResult)) {
|
||||
this.db.prepare(`UPDATE outbox_entries SET tags = json_insert(COALESCE(tags, '[]'), '$[#]', ?)
|
||||
WHERE outbox_id = ? AND NOT EXISTS (SELECT 1 FROM json_each(COALESCE(outbox_entries.tags, '[]')) WHERE value = ?)`)
|
||||
.run(HOSTED_WAKE_ACCEPTED_TAG, intent.outboxId, HOSTED_WAKE_ACCEPTED_TAG);
|
||||
@@ -4257,7 +4245,9 @@ export class QueueRepository {
|
||||
: answer && typeof answer === "object" && "text" in answer ? `wrote "${String(answer.text).trim()}"`
|
||||
: "answered without a choice or words";
|
||||
const person = input.actorSession;
|
||||
try { this.handoffNow({
|
||||
// A handoff that fails after persisting its events rolls them back with its savepoint: discardOnThrow takes their
|
||||
// tokens out of the caller's notify envelope too, so the kept answer below still commits.
|
||||
try { this.eventBus.discardOnThrow(() => this.handoffNow({
|
||||
qitemId: item.qitemId, fromSession: person, toSession: decider,
|
||||
summary: `Decide: ${item.summary ?? "a runner's permission request"} (${person} answered)`,
|
||||
transitionNote: `runner-permission-answer-passed: ${person}'s answer goes to ${decider}, the receiving agent, to decide while the runner waits`,
|
||||
@@ -4273,7 +4263,7 @@ export class QueueRepository {
|
||||
"Act on a clear answer at once: a clear yes or an allow click is the allow option, a clear no or a reject click the reject option. Don't ask them to confirm a clear answer. If their words are unclear or conditional, ask them back, naming this request, then decide.",
|
||||
"Until it's decided (by you, or by anyone with that command), the runner keeps waiting, with no timeout that refuses it; if its turn ends first, nothing is sent.",
|
||||
].join("\n"),
|
||||
}); } catch (error) {
|
||||
})); } catch (error) {
|
||||
// Kept, and committed with the answer's own write (a throw here would roll it back): the request stays open, and
|
||||
// the person is told it couldn't be passed on. Never a default permit.
|
||||
console.error(`[queue] a runner permission answer couldn't be handed to ${decider}; it's kept and the request stays open`, error);
|
||||
@@ -4282,7 +4272,9 @@ export class QueueRepository {
|
||||
// The person learns who decides and in what recorded state, so a delay is shown: one update per request.
|
||||
const toldId = `qitem-runner-permission-passed-${item.qitemId.replace(/^qitem-runner-permission-/, "").replace(/^qitem-/, "")}`;
|
||||
setImmediate(() => {
|
||||
// The request the person answered is what they'd judge (convention C3: a note to a person names its evidence).
|
||||
void this.create({ qitemId: toldId, sourceSession: root.sourceSession, destinationSession: person, humanIntent: "update",
|
||||
evidenceRef: `rig queue show ${root.qitemId}`,
|
||||
summary: `Your answer went to ${decider} to decide; ${root.sourceSession} is waiting`,
|
||||
body: [`Your answer to ${root.sourceSession}'s permission request ${root.qitemId} went to ${decider}, which decides it while ${root.sourceSession} waits. Nothing runs until it decides.`,
|
||||
...(chosen?.describe ? [`Receiving agent: ${chosen.describe}`] : []),
|
||||
@@ -4305,7 +4297,11 @@ export class QueueRepository {
|
||||
if (item.state === "done" || item.state === "failed") {
|
||||
this.db.prepare("UPDATE queue_items SET human_answers = ? WHERE qitem_id = ?").run(JSON.stringify(answers), item.qitemId);
|
||||
this.transitionLog.append({ qitemId: item.qitemId, state: item.state, actorSession: input.actorSession, transitionNote: input.transitionNote });
|
||||
return { persistedEvents: [] as PersistedEvent[] };
|
||||
// The row's state doesn't change, but its decision does: a runner waiting on this request reads it now, and
|
||||
// Steering refreshes.
|
||||
return { persistedEvents: [this.eventBus.persistWithinTransaction({ type: "queue.updated", qitemId: item.qitemId,
|
||||
fromState: item.state, toState: item.state, closureReason: null, closureTarget: null,
|
||||
actorSession: input.actorSession, summary: item.summary ?? null })] };
|
||||
}
|
||||
if (item.state !== "pending" && item.state !== "in-progress" && item.state !== "blocked") return null;
|
||||
this.db.prepare("UPDATE queue_items SET human_answers = ? WHERE qitem_id = ?").run(JSON.stringify(answers), item.qitemId);
|
||||
@@ -4329,6 +4325,7 @@ export class QueueRepository {
|
||||
// After the answer's own write settles: one receipt per request (its id is the request's), told to who answered.
|
||||
setImmediate(() => {
|
||||
void this.create({ qitemId: receiptId, sourceSession: requester, destinationSession: person, humanIntent: "update",
|
||||
evidenceRef: `rig queue show ${item.qitemId}`,
|
||||
summary: `Your answer to ${requester}'s permission request is kept; the tool hasn't run`,
|
||||
body: [`Your answer to ${requester}'s permission request ${item.qitemId} is kept, and the request is still waiting: ${unreachable ? `it couldn't be passed to ${unreachable}, the receiving agent, just now (answer again to retry)` : `no receiving agent on this host can read it before the tool runs (none is set, or the only one is ${requester} itself)`}. The tool hasn't run, and nothing was permitted or refused.`,
|
||||
`To decide now: rig seat decide-permission ${item.qitemId} --option <id>${options.length ? ` (options: ${options.map((o) => `${o.id}, ${o.label}`).join("; ")})` : ""}. Or set up a receiving agent that isn't ${requester}: rig config set agents.operator_session <seat>@<rig>.`,
|
||||
@@ -5844,7 +5841,7 @@ export class QueueRepository {
|
||||
* their qitem blockers' last meaningful transitions too. */
|
||||
private listingReads(rows: QueueItemRow[], includeWaiting: boolean): ListingReads {
|
||||
const ids = rows.map((r) => r.qitem_id);
|
||||
if (includeWaiting) for (const r of rows) if (r.blocked_on?.startsWith("qitem-")) ids.push(r.blocked_on);
|
||||
if (includeWaiting) for (const r of rows) if (r.blocked_on) ids.push(r.blocked_on);
|
||||
// Each owner's activity once per listing: a row and its waiting view (and every other row to that owner) read the
|
||||
// same seat state. ownerActivity reads a failure as null, so one session's failure stays that session's.
|
||||
const activities = new Map<string, ReturnType<WaitingActivityReader>>();
|
||||
|
||||
@@ -58,6 +58,12 @@ const NO_WAKE_ROUTE_RULES = [
|
||||
"the operator agent (agents.operator_session)",
|
||||
] as const;
|
||||
|
||||
/** How a failed wake's finding at a seat with no declared route names the rule that chose its recipient, in the order tried. */
|
||||
const FAILED_WAKE_ROUTE_RULES = [
|
||||
"the row's creator",
|
||||
"the operator agent (agents.operator_session)",
|
||||
] as const;
|
||||
|
||||
// S01 ladder marker vocabulary (the seam contract). S01 writes these transition-note
|
||||
// prefixes; the sweep derives "live ladder" from the latest marker. An attempt/rung is
|
||||
// LIVE, an exhausted marker hands the row back, and a later attempt starts a live cycle
|
||||
@@ -320,6 +326,12 @@ function defaultIsRegisteredHost(log: (line: string) => void): (hostId: string)
|
||||
* Use the closing transition, including archived history; later same-state notes
|
||||
* and delivery receipts do not transfer responsibility. A newer cancellation
|
||||
* without closure metadata still supersedes an older closing actor. */
|
||||
/** OpenRig's own machinery (the daemon, or a `@system` actor such as this sweep): it creates and closes rows, but it isn't
|
||||
* a seat that can act on a finding. */
|
||||
function isMachinerySession(session: string): boolean {
|
||||
return session === "daemon@kernel" || session.endsWith("@system");
|
||||
}
|
||||
|
||||
function custodyFindingRoute(db: Database.Database, queueRepo: QueueRepository, row: QueueItem): string {
|
||||
// Unknown identity cannot establish a new owner. The caller retains this row's
|
||||
// established destination if the registry needed for classification is unavailable.
|
||||
@@ -338,7 +350,7 @@ function custodyFindingRoute(db: Database.Database, queueRepo: QueueRepository,
|
||||
ORDER BY transition_id DESC LIMIT 1`,
|
||||
).get(row.qitemId, row.qitemId) as { actor_session: string } | undefined;
|
||||
for (const session of [closure?.actor_session, row.sourceSession]) {
|
||||
if (!session || session === "daemon@kernel" || session.endsWith("@system")) continue;
|
||||
if (!session || isMachinerySession(session)) continue;
|
||||
if (!queueRepo.isHumanDestination(session) && queueRepo.acceptsDestination(session)) {
|
||||
return session;
|
||||
}
|
||||
@@ -684,7 +696,10 @@ export async function runStuckSweep(deps: StuckSweepDeps): Promise<StuckSweepRes
|
||||
// Every yield and every awaited write lets other code run: indexes read before it are dropped, and `othersRan`
|
||||
// tells a list read before it that its rows need a fresh read.
|
||||
let othersRan = 0;
|
||||
const othersMayHaveRun = (): void => { othersRan++; recoveries.invalidate(); successors?.invalidate(); };
|
||||
/** Each seat's agent-seat reading (agentSeatNode, below), dropped with the indexes: another writer can declare a
|
||||
* seat's delegation parent while the pass yields or awaits a write. */
|
||||
const agentSeats = new Map<string, { logicalId: string; rig: string } | null>();
|
||||
const othersMayHaveRun = (): void => { othersRan++; recoveries.invalidate(); successors?.invalidate(); agentSeats.clear(); };
|
||||
// A successor index whose build took at least half of a stretch that yielded by elapsed time is not rebuilt for
|
||||
// the rest of the pass: the yield drops it, the next stretch would pay the build again and end the same way after a
|
||||
// few rows, so the rows would pay about one build each instead of one per STUCK_SWEEP_YIELD_EVERY_ROWS. Those rows
|
||||
@@ -1274,8 +1289,6 @@ export async function runStuckSweep(deps: StuckSweepDeps): Promise<StuckSweepRes
|
||||
c.why += `. This finding goes to ${recipient.seat}, ${NO_WAKE_ROUTE_RULES[recipient.chosen]}`;
|
||||
c.route = recipient.seat;
|
||||
};
|
||||
/** The two re-routes, in order, for one candidate. */
|
||||
const routeCandidate = (c: Candidate): void => { routeAroundMissing(c); routeAroundNoWake(c); };
|
||||
for (const c of candidates) {
|
||||
if (dueToYield()) await yieldLoop();
|
||||
routeAroundMissing(c);
|
||||
@@ -1299,10 +1312,9 @@ export async function runStuckSweep(deps: StuckSweepDeps): Promise<StuckSweepRes
|
||||
// (below). A finding per row stays for a person's address or a terminal seat, for a declared route that failed (its
|
||||
// finding names why, and a declared delegation parent with no session is one), and for a row that already has a
|
||||
// recovery.
|
||||
const agentSeats = new Map<string, { logicalId: string; rig: string } | null>();
|
||||
/** An agent seat's node: one whose runtime isn't a terminal, with no declared delegation parent, at an address that
|
||||
* isn't a person's. An address the human registry can't classify counts as a person's. null for anything else. Read
|
||||
* once per seat per pass. */
|
||||
* once per seat until other code may have run (agentSeats). */
|
||||
const agentSeatNode = (session: string): { logicalId: string; rig: string } | null => {
|
||||
if (agentSeats.has(session)) return agentSeats.get(session)!;
|
||||
const nodeId = resolveSessionNodeId(deps.db, session);
|
||||
@@ -1319,6 +1331,30 @@ export async function runStuckSweep(deps: StuckSweepDeps): Promise<StuckSweepRes
|
||||
agentSeats.set(session, agent);
|
||||
return agent;
|
||||
};
|
||||
// A wake that failed at a live agent seat with no delegation parent or escalates_to made its finding that same seat's,
|
||||
// so the finding's own wake went to the seat that had just failed to take one, onto whatever was in its input box.
|
||||
// The row's creator can act on it (resend, reroute or ask), as the waiting and parked-owner findings already route;
|
||||
// for a seat that sent itself the work, a row OpenRig's own machinery created, or a creator that is a bound terminal
|
||||
// seat (nothing there can claim it, as for the no-wake rule), the operator agent can. A declared
|
||||
// route that failed keeps its fallback, and a finding the two re-routes above moved keeps its recipient. A wake that
|
||||
// found no route never reached the seat, so a seat launched since keeps its own finding, whose wake is the one it missed.
|
||||
const routeAroundFailedWake = (c: Candidate): void => {
|
||||
if (c.kind !== "undelivered-wake" || !startsWithCI(c.row.lastNudgeResult, "failed:") || c.noWake
|
||||
|| c.route !== c.row.destinationSession
|
||||
|| routeNotes.has(c.row.qitemId) || agentSeatNode(c.route) === null) return;
|
||||
const choices = [c.row.sourceSession, (deps.resolveRecoveryAgentSeat ?? resolveRecoveryAgentSeat)()];
|
||||
const chosen = choices.findIndex((seat) => !!seat && seat !== c.route && !isMachinerySession(seat)
|
||||
&& !sessionMissing(seat) && deps.queueRepo.acceptsDestination(seat) && !isBoundTerminalSeat(deps.db, seat));
|
||||
if (chosen < 0) {
|
||||
c.why += `; ${c.route} declares no delegation parent or escalates_to, and its creator and the operator agent (agents.operator_session) are each unset, missing, this seat, OpenRig's own machinery, a terminal seat or not an address this queue takes, so this finding stays addressed to it`;
|
||||
return;
|
||||
}
|
||||
c.why += `; ${c.route} declares no delegation parent or escalates_to, so this finding goes to ${choices[chosen]}, ${FAILED_WAKE_ROUTE_RULES[chosen]}`;
|
||||
c.route = choices[chosen]!;
|
||||
};
|
||||
/** The three re-routes, in order, for one candidate. The third reads no earlier pass: a failed wake's finding is
|
||||
* routed only here, on the row as it is right before the finding is created. */
|
||||
const routeCandidate = (c: Candidate): void => { routeAroundMissing(c); routeAroundNoWake(c); routeAroundFailedWake(c); };
|
||||
const selfRoutedUnclaimed = (c: Candidate): boolean => c.kind === "unclaimed-obligation"
|
||||
&& c.route === c.row.destinationSession && !routeNotes.has(c.row.qitemId) && agentSeatNode(c.route) !== null;
|
||||
// A hosted member that polls for its work (no wake trigger) can't hear a finding, so each unclaimed row went to a
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { resolveEscalationOwner, hasConsumedEscalationHop } from "./escalation-owner.js";
|
||||
import { isTypedGateBlocker } from "./queue-blocker.js";
|
||||
import type Database from "better-sqlite3";
|
||||
import { resolvePickupThresholdMinutes } from "./queue-pickup.js";
|
||||
import { SettingsStore } from "./user-settings/settings-store.js";
|
||||
@@ -142,7 +143,7 @@ export function readWaitingView(db: Database.Database, id: string, readActivity?
|
||||
const row = held && held.qitem_id === id ? held : statements.statement(`SELECT qitem_id, source_session, destination_session, state, blocked_on, closure_required_at, tags
|
||||
FROM queue_items WHERE qitem_id = ?`).get(id) as WaitingRow | undefined;
|
||||
if (!row) return null;
|
||||
const blocker = row.blocked_on?.startsWith("qitem-") ? statements.statement(
|
||||
const blocker = row.blocked_on && !isTypedGateBlocker(row.blocked_on) ? statements.statement(
|
||||
"SELECT destination_session, state FROM queue_items WHERE qitem_id = ?",
|
||||
).get(row.blocked_on) as { destination_session: string; state: string } | undefined : undefined;
|
||||
let recoveryOwner: string | null = null;
|
||||
@@ -151,7 +152,7 @@ export function readWaitingView(db: Database.Database, id: string, readActivity?
|
||||
const subject = row.blocked_on ? blocker?.destination_session ?? row.blocked_on : row.destination_session;
|
||||
let observed: ReturnType<WaitingActivityReader> = null;
|
||||
try { observed = readActivity?.(subject) ?? null; } catch { /* unavailable remains unknown */ }
|
||||
const meaningful = meaningfulFor(db, row.blocked_on?.startsWith("qitem-") ? row.blocked_on : id, reads);
|
||||
const meaningful = meaningfulFor(db, blocker ? row.blocked_on! : id, reads);
|
||||
const sweepInterval = sweepSeconds();
|
||||
const view: WaitingView = {
|
||||
obligation: id, owner: row.destination_session, state: row.state, actionableSince: row.state === "pending" ? pendingSince(db, id, statements) : null,
|
||||
@@ -178,10 +179,11 @@ export function readWaitingView(db: Database.Database, id: string, readActivity?
|
||||
// and the wake-now marker is due now (#860).
|
||||
const timerDue = timer ? nextDueAt({ policy: timer.policy, specYaml: timer.spec_yaml, registeredAt: timer.registered_at,
|
||||
intervalSeconds: timer.interval_seconds, scanIntervalSeconds: timer.scan_interval_seconds, lastEvaluationAt: timer.last_evaluation_at }) : null;
|
||||
const liveBlocker = blocker && ["pending", "in-progress", "blocked"].includes(blocker.state);
|
||||
view.nextBackstop = timer ? {
|
||||
owner: row.destination_session, mechanism: `watchdog:${timer.job_id}`, intervalSeconds: timer.interval_seconds,
|
||||
dueAt: new Date(timerDue ?? Date.now()).toISOString(),
|
||||
} :{ owner: blocker?.destination_session ?? row.destination_session, mechanism: blocker ? "blocker-transition / queue-stuck-sweep" : NO_TIMED_BACKSTOP, dueAt: null, intervalSeconds: blocker ? sweepInterval : null };
|
||||
} :{ owner: liveBlocker ? blocker.destination_session : row.destination_session, mechanism: liveBlocker ? "blocker-transition / queue-stuck-sweep" : NO_TIMED_BACKSTOP, dueAt: null, intervalSeconds: liveBlocker ? sweepInterval : null };
|
||||
if (timer) {
|
||||
let notice: { at: string; deliveryStatus: string } | undefined;
|
||||
try {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { resolveEscalationOwner, ESCALATION_HOP_TAG, hasConsumedEscalationHop } from "./escalation-owner.js";
|
||||
import { tmuxAttachCommand } from "../adapters/tmux-server.js";
|
||||
import { shellQuote } from "../adapters/shell-quote.js";
|
||||
import type { HookReviewObservation } from "./kernel-hook-review.js";
|
||||
import type { ArbitratedSeatState } from "./activity-taxonomy.js";
|
||||
import { seatRuntimeHomeBySession } from "./seat-runtime-home.js";
|
||||
@@ -1447,7 +1446,7 @@ async function advancePromptRefusals(
|
||||
const route = !deps.resolveOrchestrator
|
||||
? resolveEscalationOwner(deps.db, dest, target => deps.queueRepo.acceptsDestination(target)) : null;
|
||||
const reason = (group.hookReview
|
||||
? `Seat ${dest} is stopped at Codex's "Hooks need review" (hook_trust_gate). Reach that pane on this host with ${tmuxAttachCommand(shellQuote(dest))}. Review the hooks there through Codex's hook review; approve them there only if you choose to. OpenRig does not approve or bypass this review. No input was sent.`
|
||||
? `Seat ${dest} is stopped at Codex's "Hooks need review" (hook_trust_gate). Reach that pane on this host with ${tmuxAttachCommand(dest)}. Review the hooks there through Codex's hook review; approve them there only if you choose to. OpenRig does not approve or bypass this review. No input was sent.`
|
||||
: "interactive prompt blocks outstanding work; no input was sent")
|
||||
+ (route && route.outcome !== "none" && route.outcome !== "parent" ? `; ${route.reason}` : "");
|
||||
// Every refusal at a choice prompt: the alert follows queue.wake_human_rung and holds for other choice alerts. Any other
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import type Database from "better-sqlite3";
|
||||
|
||||
// The rigs `rig up` is still building on this daemon: created, with their seats being created and launched one by one
|
||||
// and their edges still to be written. An agent that asks who's on its team in that window is told so, rather than
|
||||
// reading a partial roster as the whole team (#2871). In memory only, like the import guard beside it: after a daemon
|
||||
// restart nothing is marked, so the answer is today's, never a stuck "still being built".
|
||||
const building = new WeakMap<Database.Database, Set<string>>();
|
||||
|
||||
export function markRigBuilding(db: Database.Database, rigId: string): void {
|
||||
let rigs = building.get(db);
|
||||
if (!rigs) building.set(db, (rigs = new Set()));
|
||||
rigs.add(rigId);
|
||||
}
|
||||
|
||||
export function unmarkRigBuilding(db: Database.Database, rigId: string): void {
|
||||
building.get(db)?.delete(rigId);
|
||||
}
|
||||
|
||||
export function rigStillBeingBuilt(db: Database.Database, rigId: string): boolean {
|
||||
return building.get(db)?.has(rigId) ?? false;
|
||||
}
|
||||
@@ -21,6 +21,7 @@ import { LegacyRigSpecCodec as RigSpecCodec } from "./rigspec-codec.js"; // TODO
|
||||
import type { LegacyRigSpec as RigSpec, LegacyRigSpecEdge as RigSpecEdge, InstantiateOutcome, InstantiateResult } from "./types.js"; // TODO: AS-T08b — migrate to pod-aware RigSpec
|
||||
import { resolveLaunchCwd } from "./cwd-resolution.js";
|
||||
import type { RecordCreated } from "./operation-correlation.js";
|
||||
import { markRigBuilding, unmarkRigBuilding } from "./rigs-being-built.js";
|
||||
|
||||
// Only these edge kinds constrain launch order
|
||||
const LAUNCH_DEPENDENCY_KINDS = new Set(["delegates_to", "spawned_by"]);
|
||||
@@ -1412,7 +1413,7 @@ export class PodRigInstantiator {
|
||||
name = typeof parsed?.name === "string" ? parsed.name : null;
|
||||
} catch { /* the import below reports the parse error */ }
|
||||
}
|
||||
if (!name) return this.instantiateOnce(rigSpecYaml, rigRoot, opts);
|
||||
if (!name) return this.instantiateBuilding(rigSpecYaml, rigRoot, opts);
|
||||
let inFlight = importsInFlight.get(this.db);
|
||||
if (!inFlight) importsInFlight.set(this.db, (inFlight = new Set()));
|
||||
if (inFlight.has(name)) {
|
||||
@@ -1420,12 +1421,27 @@ export class PodRigInstantiator {
|
||||
}
|
||||
inFlight.add(name);
|
||||
try {
|
||||
return await this.instantiateOnce(rigSpecYaml, rigRoot, opts);
|
||||
return await this.instantiateBuilding(rigSpecYaml, rigRoot, opts);
|
||||
} finally {
|
||||
inFlight.delete(name);
|
||||
}
|
||||
}
|
||||
|
||||
/** {@link instantiateOnce}, with the new rig marked as still being built from its creation until every seat and edge
|
||||
* has been written (#2871), so a seat that asks who's on its team in between is told the team isn't complete yet. */
|
||||
private async instantiateBuilding(rigSpecYaml: string, rigRoot: string, opts?: Parameters<PodRigInstantiator["instantiate"]>[2]) {
|
||||
const marked: string[] = [];
|
||||
const recordCreated: RecordCreated = (rigId, nodeId) => {
|
||||
if (nodeId === undefined) { markRigBuilding(this.db, rigId); marked.push(rigId); }
|
||||
opts?.recordCreated?.(rigId, nodeId);
|
||||
};
|
||||
try {
|
||||
return await this.instantiateOnce(rigSpecYaml, rigRoot, { ...opts, recordCreated });
|
||||
} finally {
|
||||
for (const rigId of marked) unmarkRigBuilding(this.db, rigId);
|
||||
}
|
||||
}
|
||||
|
||||
private async instantiateOnce(rigSpecYaml: string, rigRoot: string, opts?: { nonInterruptive?: boolean; cwdOverride?: string; env?: SeatEnv; installRoot?: string; force?: boolean; recordCreated?: RecordCreated; prelaunchHook?: (rigId: string, replacedRigIds: readonly string[]) => Promise<{ ok: true; rollback?: () => Promise<void> } | { ok: false; code: string; message: string; retainRig?: boolean }> }): Promise<InstantiateOutcome> {
|
||||
// 1. Parse + validate
|
||||
let rigSpec: PodRigSpec;
|
||||
|
||||
@@ -331,8 +331,15 @@ export class RunnerPermissions {
|
||||
const changed = new Promise<void>((resolve) => { release = resolve; });
|
||||
let liveId = askId; // after a route or handoff, the colleague's row is where the answer lands
|
||||
const unsubscribe = this.deps.eventBus.subscribe((event) => {
|
||||
const changedId = (event as { type?: string; qitemId?: string }).type === "queue.updated" ? (event as { qitemId?: string }).qitemId : undefined;
|
||||
if (changedId === askId || changedId === liveId) release();
|
||||
const { type, qitemId } = event as { type?: string; qitemId?: string };
|
||||
// A handoff while this read waits (the person's answer going to the receiving agent) moves the request to a new
|
||||
// row: follow it, so the decision recorded there wakes this read rather than its next one.
|
||||
if (type === "queue.handed_off" && (qitemId === askId || qitemId === liveId)) {
|
||||
const asked = this.deps.queueRepo.getById(askId);
|
||||
liveId = (asked ? this.liveEnd(asked)?.qitemId : undefined) ?? liveId;
|
||||
return;
|
||||
}
|
||||
if (type === "queue.updated" && (qitemId === askId || qitemId === liveId)) release();
|
||||
});
|
||||
try {
|
||||
const now = this.withWords(askId, this.read(seat, askId));
|
||||
@@ -366,7 +373,7 @@ export class RunnerPermissions {
|
||||
const words = typed && typeof typed === "object" && typed.kind === "typed-reply" ? String(typed.text ?? "").trim() || null : null;
|
||||
const recorded = this.deps.queueRepo.recordPermissionDecision({ qitemId: live.qitemId, actorSession: decider, questionId: QUESTION_ID, optionId,
|
||||
transitionNote: `runner-permission-decided by ${decider}, an agent, not the person: ${optionId}${reason ? `, because ${clip(reason, MAX_AGENT_TEXT)}` : ""}${words ? `; the person wrote "${words}"` : typeof typed === "string" ? `; the person chose ${typed}` : ""}` });
|
||||
if (!recorded) return { ok: false, code: "not_recorded", message: `The decision couldn't be recorded on ${live.qitemId} (its row is ${live.state}). Nothing was decided; the request is still waiting.` };
|
||||
if (!recorded) return { ok: false, code: "not_recorded", message: `The decision couldn't be recorded on ${live.qitemId} (its row is ${live.state}). Nothing was decided. Read the request again.` };
|
||||
return this.read(null, askId);
|
||||
}
|
||||
|
||||
|
||||
@@ -15,9 +15,10 @@ import type {
|
||||
import { COMMUNITY_HARNESS_RUNG_INVENTORY, COMMUNITY_RUNNER_RUNG_INVENTORY, EVIDENCE_RUNG_RANK, runtimeRungInventory } from "./activity-taxonomy.js";
|
||||
import { recordedRunner, sessionCommunityHarnessSelect, sessionCommunityRunnerSelect } from "./session-registry.js";
|
||||
import { hookPrecedes } from "./agent-activity-store.js";
|
||||
import type { SeatStructuralActivityService } from "./seat-structural-activity-service.js";
|
||||
import type { SeatStructuralActivityService, StructuralObservation } from "./seat-structural-activity-service.js";
|
||||
import { DEFAULT_STRUCTURAL_STALE_MS } from "./seat-structural-activity-service.js";
|
||||
import { SettingsStore } from "./user-settings/settings-store.js";
|
||||
import { SeatIdentityStore } from "./seat-identity-store.js";
|
||||
|
||||
/** Default polling cadence: 1Hz. The default silence window is 3s, so
|
||||
* 1Hz polling gives at-most ~1s freshness lag on the cached observation (a slower cadence from
|
||||
@@ -222,14 +223,15 @@ export class SeatActivityService {
|
||||
const seat = this.ladder.get(seatNodeId);
|
||||
let codexFrameWorking = false;
|
||||
let codexFrameProofAt: string | undefined;
|
||||
const frame = seat?.inventory?.runtime === "codex" && this.structuralActivity
|
||||
? this.structuralActivity.getStructuralActivity(paneId) : null;
|
||||
if (seat?.inventory?.runtime === "codex" && this.structuralActivity) {
|
||||
const frame = this.structuralActivity.getStructuralActivity(paneId);
|
||||
const capturedAt = frame ? Date.parse(frame.observedAt) : NaN;
|
||||
const frameStarted = frame?.codexWorkingFrame ? Date.parse(frame.codexWorkingFrame.captureStartedAt) : NaN;
|
||||
const proofAt = frame?.codexWorkingFrame ? Date.parse(frame.codexWorkingFrame.proofAt) : NaN;
|
||||
const previousStarted = frame?.codexWorkingFrame?.previousCaptureStartedAt;
|
||||
codexFrameWorking = frame !== null && frame?.state !== "attention" && Number.isFinite(capturedAt)
|
||||
&& Number.isFinite(frameStarted) && frameStarted >= seat.inventorySinceMs
|
||||
&& Number.isFinite(frameStarted) && this.codexFrameQualifies(seat, frame!.codexWorkingFrame!.captureStartedAt, frame!.pane, "if-known")
|
||||
&& (previousStarted === undefined || Date.parse(previousStarted) >= seat.inventorySinceMs)
|
||||
&& Number.isFinite(proofAt) && proofAt <= capturedAt && proofAt <= observedAt.getTime()
|
||||
&& observedAt.getTime() - proofAt < DEFAULT_STRUCTURAL_STALE_MS && frameStarted <= proofAt;
|
||||
@@ -245,6 +247,23 @@ export class SeatActivityService {
|
||||
observedAt: paneOverridesQuietWindow ? codexFrameProofAt! : record.lastObservedAt,
|
||||
activity: isActiveWithinWindow || codexFrameWorking ? "working" : "idle-at-prompt",
|
||||
});
|
||||
// #2515: a Codex choice prompt can be answered on screen with no hook after it. The same structural frame, under
|
||||
// the working frame's boundary (captured after this seat's inventory began, and fresh), says whether a choice is
|
||||
// showing (1) or the composer is complete and empty (0). Any other frame (a draft, work, an unrecognised screen)
|
||||
// withdraws this source, so the prompt reads unknown again. A tracked tool approval keeps its own path.
|
||||
if (seat?.inventory?.runtime === "codex" && this.structuralActivity) {
|
||||
const prompt = frame && this.codexFrameQualifies(seat, frame.captureStartedAt, frame.pane, "required")
|
||||
? codexPromptObservation(frame, paneId) : null;
|
||||
if (prompt) {
|
||||
seat.structuralPrompt = { freshMs: Date.parse(frame!.checkedAt ?? frame!.observedAt),
|
||||
startedMs: Date.parse(frame!.captureStartedAt!), pane: frame!.pane! };
|
||||
this.reportEvidence({ seatNodeId, sessionName: paneId, rung: "needs-input-chrome", sourceId: CODEX_STRUCTURAL_PROMPT_SOURCE,
|
||||
seq, observedAt: prompt.observedAt, needsInput: prompt.needsInput });
|
||||
} else if (seat.sources.delete(CODEX_STRUCTURAL_PROMPT_SOURCE)) {
|
||||
seat.structuralPrompt = undefined;
|
||||
this.arbitrate(seat);
|
||||
}
|
||||
}
|
||||
if (this.selfReportReader && seat?.inventory?.rungs.some((r) => r.rung === "self-report")) {
|
||||
const evd = this.selfReportReader(paneId, seatNodeId);
|
||||
if (evd) this.reportEvidence(evd); // null = unreadable ⇒ the rung simply stales
|
||||
@@ -416,6 +435,7 @@ export class SeatActivityService {
|
||||
// auto-declared from their runtime's inventory (claude authoritative standing,
|
||||
// codex hooks-at-trial, generic sampling floor) — production-complete without
|
||||
// touching the launch machinery.
|
||||
const identities = new SeatIdentityStore(db);
|
||||
for (const r of tmuxRows) {
|
||||
const known = this.ladder.get(r.node_id);
|
||||
const inventory = runtimeRungInventory(r.runtime, r.community_harness, recordedRunner(r.community_runner));
|
||||
@@ -426,6 +446,15 @@ export class SeatActivityService {
|
||||
this.declareRungInventory({ seatNodeId: r.node_id, sessionName: r.session_name }, inventory);
|
||||
this.restoreDurableHook({ seatNodeId: r.node_id, sessionName: r.session_name }, inventory, r.runtime);
|
||||
}
|
||||
if (r.runtime === "codex") {
|
||||
// The occupant as OpenRig records it, from the identity reconciler's last verdict for this session: the
|
||||
// binding's pane it checked, and the pane process it verified as this session's Codex there. It follows
|
||||
// OpenRig's own in-place restart. No verdict (or a defensive read of a store without one) leaves both unknown.
|
||||
const verdict = identities.getForNode(r.node_id);
|
||||
const current = verdict?.sessionName === r.session_name ? verdict : null;
|
||||
this.recordCodexOccupant(r.node_id, { paneId: current?.evidence.registeredPane ?? null,
|
||||
pid: current?.verdict === "verified" && current.evidence.registeredPane ? current.evidence.observedPanePid ?? null : null });
|
||||
}
|
||||
}
|
||||
|
||||
// One tmux call reads every session's window activity for this tick; only with no batch at all does each seat
|
||||
@@ -495,12 +524,22 @@ export class SeatActivityService {
|
||||
this.sessionToSeat.set(binding.sessionName, binding.seatNodeId);
|
||||
seat.inventory = inventory;
|
||||
seat.inventorySinceMs = this.now().getTime();
|
||||
// #2515: a new inventory is a new occupant boundary, so the copied prompt evidence from before it goes too.
|
||||
seat.sources.delete(CODEX_STRUCTURAL_PROMPT_SOURCE);
|
||||
seat.structuralPrompt = undefined;
|
||||
seat.trust.clear();
|
||||
for (const decl of inventory.rungs) seat.trust.set(decl.rung, decl.initialTrust);
|
||||
seat.promotion.clear();
|
||||
this.arbitrate(seat);
|
||||
}
|
||||
|
||||
/** A Codex seat's occupant as OpenRig records it (#2515): its binding's pane and its verified pane process, or null
|
||||
* where unknown. The sweep refreshes it every tick; the next arbitration applies it. */
|
||||
recordCodexOccupant(seatNodeId: string, occupant: { paneId: string | null; pid: number | null }): void {
|
||||
const seat = this.ladder.get(seatNodeId);
|
||||
if (seat) seat.occupant = occupant;
|
||||
}
|
||||
|
||||
/** Adapters push rung evidence (hooks, self-report, chrome, sampling). Per-source
|
||||
* monotonic seq: stale or reordered reports are dropped — a late lower-seq event
|
||||
* never revives an idle seat (the SubagentStop class at the service layer). */
|
||||
@@ -875,8 +914,36 @@ export class SeatActivityService {
|
||||
* instantly. Chosen against our 1Hz/3s cadence: the 3s silence window already
|
||||
* absorbs sub-3s lulls; the 2-tick arbitration debounce absorbs the
|
||||
* window-boundary flap. */
|
||||
/** #2500's boundary for a Codex structural frame, shared by the working frame and the prompt evidence: captured after
|
||||
* this seat's current inventory began, and, when the capture names its pane, from the occupant's own pane and process
|
||||
* (`recordCodexOccupant`). A respawn, a reused pane id or another pane under the same name differs from them.
|
||||
* "required" refuses a frame that doesn't name its pane, or a seat whose process OpenRig hasn't verified. */
|
||||
private codexFrameQualifies(seat: SeatLadderState, startedAt: string | undefined, pane: StructuralObservation["pane"],
|
||||
identity: "required" | "if-known"): boolean {
|
||||
const started = startedAt ? Date.parse(startedAt) : NaN;
|
||||
if (!Number.isFinite(started) || started < seat.inventorySinceMs) return false;
|
||||
if (!pane) return identity !== "required";
|
||||
return this.fromCodexOccupant(seat, pane, identity);
|
||||
}
|
||||
|
||||
private fromCodexOccupant(seat: SeatLadderState, pane: { id: string; pid: number }, identity: "required" | "if-known"): boolean {
|
||||
const occupant = seat.occupant;
|
||||
if (occupant?.paneId && occupant.paneId !== pane.id) return false;
|
||||
if (occupant?.pid != null) return occupant.pid === pane.pid;
|
||||
return identity !== "required";
|
||||
}
|
||||
|
||||
private arbitrate(seat: SeatLadderState): void {
|
||||
this.checkContradiction(seat);
|
||||
// #2515: the copied Codex prompt evidence holds only while its frame would: fresh, from this inventory, and from the
|
||||
// occupant's pane and process. Read here, so a getter, a failed window read, a new inventory or a new occupant sees
|
||||
// it expire too.
|
||||
const prompt = seat.structuralPrompt;
|
||||
if (prompt && (this.now().getTime() - prompt.freshMs >= DEFAULT_STRUCTURAL_STALE_MS || prompt.startedMs < seat.inventorySinceMs
|
||||
|| !this.fromCodexOccupant(seat, prompt.pane, "required"))) {
|
||||
seat.sources.delete(CODEX_STRUCTURAL_PROMPT_SOURCE);
|
||||
seat.structuralPrompt = undefined;
|
||||
}
|
||||
const candidate = this.rawCandidate(seat);
|
||||
let nextActivity: ActivityValue = candidate?.activity ?? "unknown";
|
||||
let decidedBy: EvidenceRungId | null = candidate?.rung ?? null;
|
||||
@@ -998,6 +1065,21 @@ interface NeedsInputShape {
|
||||
reason: string | null;
|
||||
}
|
||||
|
||||
const CODEX_STRUCTURAL_PROMPT_SOURCE = "codex:structural-prompt";
|
||||
|
||||
/** What a qualified Codex structural frame says about a prompt: a choice or permission prompt on screen (1), or a
|
||||
* complete empty composer (0), observed at the capture's start. Null for anything else. */
|
||||
function codexPromptObservation(frame: StructuralObservation, sessionName: string):
|
||||
{ observedAt: string; needsInput: NeedsInputShape } | null {
|
||||
if (frame.state === "attention" && (frame.reason === "selection_prompt" || frame.reason === "permission_prompt")) {
|
||||
return { observedAt: frame.captureStartedAt!, needsInput: { count: 1, reason: frame.reason } };
|
||||
}
|
||||
if (frame.idleFrame?.runtime === "codex" && frame.idleFrame.sessionName === sessionName) {
|
||||
return { observedAt: frame.idleFrame.captureStartedAt, needsInput: { count: 0, reason: null } };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** One relay route's consecutive agreements toward promoting a trial rung. */
|
||||
interface PromotionRun {
|
||||
agreements: number;
|
||||
@@ -1015,6 +1097,10 @@ interface SeatLadderState {
|
||||
retired: boolean;
|
||||
inventory: AdapterRungInventory | null;
|
||||
inventorySinceMs: number;
|
||||
/** The Codex occupant's pane and process as OpenRig records them, refreshed by the sweep (#2515). */
|
||||
occupant?: { paneId: string | null; pid: number | null };
|
||||
/** What keeps the copied Codex prompt evidence valid where it's read: its frame's freshness, capture start and pane. */
|
||||
structuralPrompt?: { freshMs: number; startedMs: number; pane: { id: string; pid: number } };
|
||||
sources: Map<string, { latest: ActivityEvidence; latestActivity: ActivityEvidence | null }>;
|
||||
trust: Map<EvidenceRungId, RungTrust>;
|
||||
promotion: Map<EvidenceRungId, Map<EvidenceRoute | "untagged", PromotionRun>>;
|
||||
|
||||
@@ -14,6 +14,11 @@ export interface StructuralObservation {
|
||||
reason: string;
|
||||
evidence: string | null;
|
||||
observedAt: string;
|
||||
/** A lower bound taken before the capture's read, when known: a reader may use the frame only for the occupant
|
||||
* present by then. */
|
||||
captureStartedAt?: string;
|
||||
/** The pane and its process this capture read, when the batched capture reported them (#2515). */
|
||||
pane?: { id: string; pid: number };
|
||||
/** A complete idle frame read for this capture's target, not a generic footer or hook. */
|
||||
idleFrame?: { sessionName: string; runtime: "claude-code" | "codex"; captureStartedAt: string };
|
||||
/** A Codex working observation is usable only for the occupant present when capture began. */
|
||||
@@ -103,12 +108,14 @@ export class SeatStructuralActivityService {
|
||||
async pollSeat(sessionName: string, prefetched?: Map<string, PaneCapture> | null, runtime?: string | null, communityHarness?: string | null, prefetchedStartedAt?: Date): Promise<StructuralObservation | null> {
|
||||
let content: string | null;
|
||||
let observedAt: Date | null = null;
|
||||
let pane: StructuralObservation["pane"];
|
||||
// Completion can lag the bytes: use a lower bound taken before the actual read.
|
||||
const captureStartedAt = prefetched?.has(sessionName) ? prefetchedStartedAt : this.now();
|
||||
if (prefetched?.has(sessionName)) {
|
||||
const capture = prefetched.get(sessionName)!;
|
||||
content = capture.text;
|
||||
observedAt = capture.capturedAt; // the capture's own time, never the end of the sweep (#309 review)
|
||||
pane = capture.pane;
|
||||
} else {
|
||||
try {
|
||||
content = await this.tmuxAdapter.capturePaneContent(sessionName, this.captureLines);
|
||||
@@ -123,7 +130,9 @@ export class SeatStructuralActivityService {
|
||||
}
|
||||
const c = communityHarnessPaneActivity(classifyPaneActivity(content), communityHarness, content);
|
||||
const at = observedAt ?? this.now();
|
||||
const previous = this.latestBySession.get(sessionName);
|
||||
const latest = this.latestBySession.get(sessionName);
|
||||
// A counter or proof read from another pane or process says nothing about this one, so compare only within one.
|
||||
const previous = latest?.pane && pane && (latest.pane.id !== pane.id || latest.pane.pid !== pane.pid) ? undefined : latest;
|
||||
const seconds = runtime === "codex" && captureStartedAt && c.state !== "attention" ? codexWorkingElapsed(content) : null;
|
||||
const previousAge = previous ? at.getTime() - Date.parse(previous.observedAt) : NaN;
|
||||
const advanced = seconds !== null && previous?.codexWorkingClock && seconds > previous.codexWorkingClock.seconds
|
||||
@@ -141,6 +150,8 @@ export class SeatStructuralActivityService {
|
||||
reason: c.reason,
|
||||
evidence: c.evidence,
|
||||
observedAt: at.toISOString(),
|
||||
...(captureStartedAt ? { captureStartedAt: captureStartedAt.toISOString() } : {}),
|
||||
...(pane ? { pane } : {}),
|
||||
...(captureStartedAt && seconds !== null ? { codexWorkingClock: { seconds, captureStartedAt: captureStartedAt.toISOString() } } : {}),
|
||||
...(workingFrame ? { codexWorkingFrame: workingFrame } : {}),
|
||||
...(captureStartedAt && c.state === "agent_idle" && (runtime === "claude-code" || runtime === "codex")
|
||||
@@ -148,7 +159,7 @@ export class SeatStructuralActivityService {
|
||||
? { idleFrame: { sessionName, runtime, captureStartedAt: captureStartedAt.toISOString() } } : {}),
|
||||
};
|
||||
this.latestBySession.set(sessionName, obs);
|
||||
if (previous?.state !== obs.state || previous?.reason !== obs.reason) this.activityChanged?.(sessionName);
|
||||
if (latest?.state !== obs.state || latest?.reason !== obs.reason) this.activityChanged?.(sessionName);
|
||||
// #763: a Codex usage-limit banner is invisible to the hook vocabulary,
|
||||
// so the reactive tap never fires for it. Report what the seat shows, on
|
||||
// transition only (the recorder skips an already-reported banner). A poll
|
||||
|
||||
@@ -811,19 +811,22 @@ export class SliceIndexer {
|
||||
// --- internals ---
|
||||
|
||||
private readSliceLocations(): SliceLocation[] {
|
||||
return this.walkSliceLocations().filter((location) => !this.missionId || location.missionId === this.missionId);
|
||||
return this.walkSliceLocations(false, [], undefined, this.missionId || undefined);
|
||||
}
|
||||
|
||||
/** Every slice location under the roots, whatever mission this indexer is scoped to.
|
||||
* Directory names are deduplicated (first wins) unless `physical` asks for every folder. */
|
||||
/** Every slice location under the roots. A requested mission is filtered before
|
||||
* directory-name deduplication; otherwise the first matching name wins unless
|
||||
* `physical` asks for every folder. */
|
||||
private walkSliceLocations(
|
||||
physical = false,
|
||||
additionalRoots: readonly string[] = [],
|
||||
onReadError?: () => void,
|
||||
missionId?: string,
|
||||
): SliceLocation[] {
|
||||
const locations: SliceLocation[] = [];
|
||||
const seen = new Set<string>();
|
||||
const addLocation = (location: SliceLocation) => {
|
||||
if (missionId !== undefined && location.missionId !== missionId) return;
|
||||
if (!physical) {
|
||||
if (seen.has(location.name)) return;
|
||||
seen.add(location.name);
|
||||
@@ -1085,7 +1088,7 @@ export class SliceIndexer {
|
||||
const status = this.mapStatus(frontmatter["status"] as string | undefined);
|
||||
const railItem = this.extractRailItem(frontmatter, missionId);
|
||||
const qitemIds = this.matchQitems(name, served);
|
||||
const proofPacket = this.findProofPacket(name);
|
||||
const proofPacket = this.findProofPacket(name, this.missionId ? missionId : null);
|
||||
const lastActivityAt = this.computeLastActivity(slicePath, qitemIds, proofPacket);
|
||||
|
||||
return {
|
||||
@@ -1115,7 +1118,7 @@ export class SliceIndexer {
|
||||
const status = this.mapStatus(frontmatter["status"] as string | undefined);
|
||||
const railItem = this.extractRailItem(frontmatter, missionId);
|
||||
const qitemIds = this.matchQitems(name, served);
|
||||
const proofPacket = this.findProofPacket(name);
|
||||
const proofPacket = this.findProofPacket(name, this.missionId ? missionId : null);
|
||||
const lastActivityAt = this.computeLastActivity(slicePath, qitemIds, proofPacket);
|
||||
const commitRefs = this.extractCommitRefs(frontmatter);
|
||||
const files = this.listSliceFiles(slicePath);
|
||||
@@ -1502,7 +1505,34 @@ export class SliceIndexer {
|
||||
return this.membershipIndex;
|
||||
}
|
||||
|
||||
private findProofPacket(sliceName: string): SliceProofPacket | null {
|
||||
private discoverMissionIds(): Set<string> {
|
||||
const missionIds = new Set<string>();
|
||||
for (const root of this.sliceRoots()) {
|
||||
let entries: fs.Dirent[];
|
||||
try { entries = fs.readdirSync(root, { withFileTypes: true }); }
|
||||
catch { continue; }
|
||||
for (const entry of entries) {
|
||||
if ((!entry.isDirectory() && !entry.isSymbolicLink()) || entry.name.startsWith(".")) continue;
|
||||
// Project readers treat every non-dot directory or symlink entry as a mission;
|
||||
// ownership needs only its name and never follows a sibling link.
|
||||
missionIds.add(entry.name);
|
||||
}
|
||||
}
|
||||
return missionIds;
|
||||
}
|
||||
|
||||
private proofDirectoryMissionOwner(dirName: string, missionIds: ReadonlySet<string>): string | null {
|
||||
let owner: string | null = null;
|
||||
for (const candidate of missionIds) {
|
||||
if (!dirName.startsWith(candidate)) continue;
|
||||
const separator = dirName[candidate.length];
|
||||
if (separator !== "-" && separator !== "." && separator !== "_") continue;
|
||||
if (!owner || candidate.length > owner.length) owner = candidate;
|
||||
}
|
||||
return owner;
|
||||
}
|
||||
|
||||
private findProofPacket(sliceName: string, missionId: string | null): SliceProofPacket | null {
|
||||
if (!this.dogfoodEvidenceRoot) return null;
|
||||
let entries: fs.Dirent[];
|
||||
try {
|
||||
@@ -1511,19 +1541,26 @@ export class SliceIndexer {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Match strategy: token-based. Tokenize slice name on `-`, drop
|
||||
// trailing `vN` version tokens (real proof dirs typically don't
|
||||
// include the version suffix), then a dir matches if every remaining
|
||||
// token appears as a hyphen-bounded substring of the dir name. This
|
||||
// handles real-world dir-naming where phase indicators land at the
|
||||
// front (e.g. `pl005-phase-a-mission-control-queue-observability-...`)
|
||||
// even though the slice folder uses suffix form
|
||||
// (`mission-control-queue-observability-phase-a`). Latest mtime wins.
|
||||
// Qualified proof directories use the observed `<mission>-<slice>` form:
|
||||
// the literal mission ID must lead the name at a separator boundary. If
|
||||
// known mission IDs overlap, the longest prefix owns the directory. This
|
||||
// is a fixture-supported rule, not a historically documented contract.
|
||||
// Unqualified reads retain their legacy slice-only token match. Once a
|
||||
// qualified directory is attributed, slice tokens match against the
|
||||
// remainder after that mission prefix and separator (`vN` handling unchanged);
|
||||
// latest mtime remains the tie-break among same-mission matches.
|
||||
let knownMissionIds: Set<string> | null = null;
|
||||
if (missionId) {
|
||||
knownMissionIds = this.discoverMissionIds();
|
||||
knownMissionIds.add(missionId);
|
||||
}
|
||||
const sliceTokens = sliceName.split("-").filter((t) => t.length > 0 && !/^v\d+$/.test(t));
|
||||
const matches: { dirent: fs.Dirent; mtime: number }[] = [];
|
||||
for (const dirent of entries) {
|
||||
if (!dirent.isDirectory()) continue;
|
||||
const dirTokenSet = new Set(dirent.name.split(/[-._]/).filter((t) => t.length > 0));
|
||||
if (knownMissionIds && this.proofDirectoryMissionOwner(dirent.name, knownMissionIds) !== missionId) continue;
|
||||
const tokenSource = knownMissionIds && missionId ? dirent.name.slice(missionId.length + 1) : dirent.name;
|
||||
const dirTokenSet = new Set(tokenSource.split(/[-._]/).filter((t) => t.length > 0));
|
||||
const allTokensPresent = sliceTokens.every((t) => dirTokenSet.has(t));
|
||||
if (!allTokensPresent) continue;
|
||||
try {
|
||||
|
||||
@@ -24,7 +24,7 @@ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
||||
import { createHash } from "node:crypto";
|
||||
import type Database from "better-sqlite3";
|
||||
import type { WorkflowSpec, WorkflowExitKind, WorkflowAgentHarness, WorkflowGateSpec } from "./workflow-types.js";
|
||||
import { RETAINED_STATUS, type WorkflowSpecCache } from "./workflow-spec-cache.js";
|
||||
import { getFailedSpecRead, RETAINED_STATUS, type WorkflowSpecCache } from "./workflow-spec-cache.js";
|
||||
import type { EventBus } from "./event-bus.js";
|
||||
|
||||
export interface SpecLibraryWorkflowEntry {
|
||||
@@ -377,12 +377,13 @@ function isUnderDir(childPath: string, parentDir: string): boolean {
|
||||
// disappear since the last scan get removed via removeBySourcePath
|
||||
// (OQ-4 decision; deletion + audit log).
|
||||
//
|
||||
// OQ-3 timestamp check: a file is skipped when both its mtime/ctime are <=
|
||||
// the cache row's cached_at. A file can be restored or copied with an older
|
||||
// mtime, so ctime keeps the stat-only fast path honest. Files in the same
|
||||
// timestamp second still use the content hash, and other newer files are
|
||||
// re-parsed via the cache (which itself hashes the content and returns the
|
||||
// prior row when the hash matches).
|
||||
// OQ-3 timestamp check: a file is skipped when its mtime/ctime precede the
|
||||
// persisted cache observation. This stat-only hit assumes filesystem timestamps
|
||||
// and the daemon clock are comparable and that edits advance mtime or ctime. A
|
||||
// restored/copied file can have an older mtime, so ctime is included. Files in
|
||||
// the same timestamp second still use the content hash; other newer files are
|
||||
// re-parsed via the cache (which itself hashes the content and returns the prior
|
||||
// row when the hash matches).
|
||||
|
||||
export interface ScanWorkflowSpecFolderOpts {
|
||||
/** SQLite handle (used for direct lookups). */
|
||||
@@ -403,6 +404,8 @@ export interface ScanWorkflowSpecFolderOpts {
|
||||
eventBus?: EventBus;
|
||||
/** A user file with the same bytes and identity as an existing built-in row. */
|
||||
onBuiltinAlias?: (name: string, version: string, sourcePath: string, sourceHash: string) => void;
|
||||
/** A valid source whose hash was just read or whose persisted stat observation is safely aged. */
|
||||
onVerifiedSource?: (sourcePath: string, sourceHash: string) => void;
|
||||
}
|
||||
|
||||
export interface ScanWorkflowSpecFolderResult {
|
||||
@@ -468,14 +471,15 @@ export function scanWorkflowSpecFolder(
|
||||
// A path can hold several cached versions plus a diagnostic row (#503); the most
|
||||
// recently written row is the one that reflects the file's last scan.
|
||||
const cachedAt = opts.db
|
||||
.prepare(`SELECT cached_at, source_hash FROM workflow_specs WHERE source_path = ? AND status != '${RETAINED_STATUS}' ORDER BY cached_at DESC, rowid DESC LIMIT 1`)
|
||||
.get(filePath) as { cached_at: string; source_hash: string } | undefined;
|
||||
.prepare(`SELECT cached_at, source_hash, status FROM workflow_specs WHERE source_path = ? AND status != '${RETAINED_STATUS}' ORDER BY cached_at DESC, rowid DESC LIMIT 1`)
|
||||
.get(filePath) as { cached_at: string; source_hash: string; status: string } | undefined;
|
||||
if (cachedAt) {
|
||||
const cachedAtMs = Date.parse(cachedAt.cached_at);
|
||||
const cachedAtSec = Math.floor(cachedAtMs / 1000);
|
||||
const changeSec = Math.floor(changeMs / 1000);
|
||||
if (Number.isFinite(cachedAtMs) && Number.isFinite(changeMs)) {
|
||||
if (cachedAtSec > changeSec) {
|
||||
if (cachedAt.status === "valid") opts.onVerifiedSource?.(filePath, cachedAt.source_hash);
|
||||
result.skipped += 1;
|
||||
continue;
|
||||
}
|
||||
@@ -489,6 +493,7 @@ export function scanWorkflowSpecFolder(
|
||||
unchanged = createHash("sha256").update(readFileSync(filePath, "utf-8")).digest("hex") === cachedAt.source_hash;
|
||||
} catch { /* The existing readThrough path records the diagnostic. */ }
|
||||
if (unchanged) {
|
||||
if (cachedAt.status === "valid") opts.onVerifiedSource?.(filePath, cachedAt.source_hash);
|
||||
result.skipped += 1;
|
||||
continue;
|
||||
}
|
||||
@@ -505,25 +510,29 @@ export function scanWorkflowSpecFolder(
|
||||
&& isUnderDir(cached.sourcePath, opts.builtinDir)) {
|
||||
opts.onBuiltinAlias(cached.name, cached.version, filePath, cached.sourceHash);
|
||||
}
|
||||
if (cached.sourcePath === filePath) opts.onVerifiedSource?.(filePath, cached.sourceHash);
|
||||
result.valid += 1;
|
||||
} catch (err) {
|
||||
// Diagnostic: hash the raw content best-effort so we can detect
|
||||
// edits that fix the error (mtime alone is fine; this is for
|
||||
// bookkeeping). Use the empty string when content is unreadable.
|
||||
// Prefer the digest and timestamp captured by the failed read itself. A second path read here can observe a
|
||||
// repair that raced parse handling and falsely describe those replacement bytes as the bytes that failed.
|
||||
// Errors without captured text (missing/unreadable files) retain the previous best-effort path read.
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
let sourceHash = "";
|
||||
try {
|
||||
sourceHash = createHash("sha256").update(readFileSync(filePath, "utf-8")).digest("hex");
|
||||
} catch {
|
||||
// unreadable / disappeared between stat and read; treat as
|
||||
// empty hash so the next scan re-evaluates
|
||||
sourceHash = "";
|
||||
const failedRead = getFailedSpecRead(err);
|
||||
let sourceHash = failedRead?.sourceHash ?? "";
|
||||
if (!failedRead) {
|
||||
try {
|
||||
sourceHash = createHash("sha256").update(readFileSync(filePath, "utf-8")).digest("hex");
|
||||
} catch {
|
||||
// unreadable / disappeared between stat and read; treat as empty so the next scan re-evaluates
|
||||
sourceHash = "";
|
||||
}
|
||||
}
|
||||
try {
|
||||
opts.cache.writeDiagnostic({
|
||||
sourcePath: filePath,
|
||||
sourceHash,
|
||||
errorMessage: message,
|
||||
...(failedRead ? { cachedAt: failedRead.cachedAt } : {}),
|
||||
});
|
||||
} catch (writeErr) {
|
||||
// One file's diagnostic must not end the scan of the rest of the folder (#511).
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
// A view opens as:
|
||||
// 1. journal (each viewer with its op nonce, before anything starts);
|
||||
// 2. viewers started by herdr-core, outside every tab (`hc run --bg --no-parent`, openrig.* labels), each the
|
||||
// view's own tile for its seat (an interactive one mirrors the seat and attaches only while someone types in
|
||||
// it; a read-only one only shows it), each waiting after its tile ends instead of exiting (an exiting viewer
|
||||
// view's own tile for its seat (an interactive local one shows the seat read-only until its gate opens, then
|
||||
// attaches with plain tmux; a read-only one only shows it), each waiting after its tile ends instead of exiting (an exiting viewer
|
||||
// makes herdr collapse the layout), and never `--keep` (that would leave a shell behind in the cell);
|
||||
// 3. ONE atomic write of layouts/rig.json: the person's layout with OpenRig's stack beside their tabs, each view an
|
||||
// equal, padded grid with a title row per cell;
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { RigRepository } from "./rig-repository.js";
|
||||
import type { SessionRegistry } from "./session-registry.js";
|
||||
import type { TranscriptStore } from "./transcript-store.js";
|
||||
import { resolveWorkspaceContext, type WhoamiWorkspaceBlock } from "./workspace/workspace-resolver.js";
|
||||
import { rigStillBeingBuilt } from "./rigs-being-built.js";
|
||||
|
||||
export interface WhoamiResult {
|
||||
resolvedBy: "node_id" | "session_name";
|
||||
@@ -43,6 +44,9 @@ export interface WhoamiResult {
|
||||
* Additive note only — `peers[]` name and shape are unchanged.
|
||||
*/
|
||||
peersNote: string;
|
||||
/** Present only while `rig up` is still creating this rig's seats and edges (#2871): the peers and edges are the ones
|
||||
* created so far, and more may come. Absent once the rig is built, and for every rig not being built now. */
|
||||
rosterState?: "building";
|
||||
edges: {
|
||||
outgoing: Array<{ kind: string; to: { logicalId: string; sessionName: string | null } }>;
|
||||
incoming: Array<{ kind: string; from: { logicalId: string; sessionName: string | null } }>;
|
||||
@@ -141,6 +145,11 @@ interface WhoamiDeps {
|
||||
contextUsageStore?: import("./context-usage-store.js").ContextUsageStore;
|
||||
}
|
||||
|
||||
/** Said first in `peersNote` while the rig is still being built, so a seat doesn't plan around a teammate that hasn't been
|
||||
* created yet. Exported for the CLI's bounded note. */
|
||||
export const ROSTER_BUILDING_NOTE = "rosterState = building: `rig up` is still creating this team's seats and edges, so the peers and edges "
|
||||
+ "below are only the ones created so far and more may come; run `rig whoami` again once `rig up` finishes. ";
|
||||
|
||||
export class WhoamiService {
|
||||
private db: Database.Database;
|
||||
private rigRepo: RigRepository;
|
||||
@@ -356,11 +365,14 @@ export class WhoamiService {
|
||||
envOverride: query.targetRepoOverride ?? process.env["OPENRIG_TARGET_REPO"] ?? null,
|
||||
});
|
||||
|
||||
const building = rigStillBeingBuilt(this.db, nodeRow.rig_id);
|
||||
return {
|
||||
resolvedBy,
|
||||
identity,
|
||||
peers,
|
||||
peersNote: "peers = this rig's roster excluding self (no edge filter); edges = directional relationships; use `rig ps --nodes` for node inventory including self + live state",
|
||||
...(building ? { rosterState: "building" as const } : {}),
|
||||
peersNote: (building ? ROSTER_BUILDING_NOTE : "")
|
||||
+ "peers = this rig's roster excluding self (no edge filter); edges = directional relationships; use `rig ps --nodes` for node inventory including self + live state",
|
||||
edges: { outgoing, incoming },
|
||||
transcript,
|
||||
commands: { sendExamples, captureExamples },
|
||||
|
||||
@@ -76,6 +76,20 @@ export class WorkflowSpecError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
interface FailedSpecRead {
|
||||
sourceHash: string;
|
||||
cachedAt: string;
|
||||
}
|
||||
|
||||
// Keep capture evidence out of the original exception and out of process-global path state. The scanner can retrieve it
|
||||
// while handling this exact parse error, then persist it in the diagnostic row; WeakMap lifetime follows the exception.
|
||||
const failedSpecReads = new WeakMap<object, FailedSpecRead>();
|
||||
|
||||
export function getFailedSpecRead(error: unknown): FailedSpecRead | null {
|
||||
if ((typeof error !== "object" || error === null) && typeof error !== "function") return null;
|
||||
return failedSpecReads.get(error as object) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* OPR.0.4.6.WF1 FR-7 — THE closed keysets, EXPORTED as named
|
||||
* constants: parseWorkflowSpec is the ONLY seam that sees raw keys
|
||||
@@ -643,7 +657,10 @@ export class WorkflowSpecCache {
|
||||
* not previously cached).
|
||||
*/
|
||||
readThrough(sourcePath: string): WorkflowSpecRow {
|
||||
const { spec, sourceHash } = readSpecFile(sourcePath);
|
||||
// Stamp before opening the file. If an edit races the read, a later stat must not be hidden by a timestamp
|
||||
// assigned after that edit and after parse-error handling.
|
||||
const capturedAt = this.now().toISOString();
|
||||
const { spec, sourceHash } = readSpecFile(sourcePath, capturedAt);
|
||||
const named = this.db
|
||||
.prepare(
|
||||
`SELECT * FROM workflow_specs WHERE name = ? AND version = ?`,
|
||||
@@ -671,11 +688,18 @@ export class WorkflowSpecCache {
|
||||
.run(sourcePath, existing?.spec_id ?? "").changes
|
||||
: 0;
|
||||
if (existing && existing.status !== "error" && existing.status !== RETAINED_STATUS && !moved && existing.source_hash === sourceHash) {
|
||||
// A repair back to the cached bytes records this scan, so the next scan skips the file.
|
||||
if (cleared > 0) {
|
||||
const repairedAt = this.now().toISOString();
|
||||
this.db.prepare(`UPDATE workflow_specs SET cached_at = ? WHERE spec_id = ?`).run(repairedAt, existing.spec_id);
|
||||
existing.cached_at = repairedAt;
|
||||
// A read verifies the timestamp only for its own source path, even when another copy has matching bytes. Advance only forward:
|
||||
// this pre-read stamp is safe for stat-only skips, while a backwards clock must not replace a newer observation.
|
||||
const existingCachedAtMs = Date.parse(existing.cached_at);
|
||||
const capturedAtMs = Date.parse(capturedAt);
|
||||
const shouldAdvanceObservation = existing.source_path === sourcePath && Number.isFinite(capturedAtMs) && (
|
||||
Number.isFinite(existingCachedAtMs)
|
||||
? existingCachedAtMs < capturedAtMs
|
||||
: cleared > 0
|
||||
);
|
||||
if (shouldAdvanceObservation) {
|
||||
this.db.prepare(`UPDATE workflow_specs SET cached_at = ? WHERE spec_id = ?`).run(capturedAt, existing.spec_id);
|
||||
existing.cached_at = capturedAt;
|
||||
}
|
||||
// readThrough is file-authoritative: return the freshly parsed
|
||||
// file spec so validation sees non-column metadata such as
|
||||
@@ -691,7 +715,7 @@ export class WorkflowSpecCache {
|
||||
}
|
||||
return rowToWorkflowSpec(existing, spec);
|
||||
}
|
||||
const cachedAt = this.now().toISOString();
|
||||
const cachedAt = capturedAt;
|
||||
const purpose = spec.objective ?? null;
|
||||
const targetRig = spec.target?.rig ?? null;
|
||||
const rolesJson = JSON.stringify(spec.roles);
|
||||
@@ -940,8 +964,10 @@ export class WorkflowSpecCache {
|
||||
sourcePath: string;
|
||||
sourceHash: string;
|
||||
errorMessage: string;
|
||||
/** Capture time from the read that failed, when available. */
|
||||
cachedAt?: string;
|
||||
}): void {
|
||||
const cachedAt = this.now().toISOString();
|
||||
const cachedAt = opts.cachedAt ?? this.now().toISOString();
|
||||
const existing = this.db
|
||||
.prepare(`SELECT spec_id FROM workflow_specs WHERE source_path = ? AND status = 'error'`)
|
||||
.get(opts.sourcePath) as { spec_id: string } | undefined;
|
||||
@@ -1116,7 +1142,7 @@ function warnLegacyRehydrationOnce(name: string, version: string): void {
|
||||
);
|
||||
}
|
||||
|
||||
function readSpecFile(sourcePath: string): { spec: WorkflowSpec; sourceHash: string } {
|
||||
function readSpecFile(sourcePath: string, cachedAt?: string): { spec: WorkflowSpec; sourceHash: string } {
|
||||
if (!existsSync(sourcePath)) {
|
||||
throw new WorkflowSpecError(
|
||||
"spec_file_missing",
|
||||
@@ -1126,7 +1152,14 @@ function readSpecFile(sourcePath: string): { spec: WorkflowSpec; sourceHash: str
|
||||
}
|
||||
const raw = readFileSync(sourcePath, "utf-8");
|
||||
const sourceHash = createHash("sha256").update(raw).digest("hex");
|
||||
return { spec: parseWorkflowSpec(raw, sourcePath), sourceHash };
|
||||
try {
|
||||
return { spec: parseWorkflowSpec(raw, sourcePath), sourceHash };
|
||||
} catch (error) {
|
||||
if (cachedAt && ((typeof error === "object" && error !== null) || typeof error === "function")) {
|
||||
failedSpecReads.set(error as object, { sourceHash, cachedAt });
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function rowToWorkflowSpec(row: SpecRow, parsedSpec?: WorkflowSpec): WorkflowSpecRow {
|
||||
|
||||
@@ -335,7 +335,9 @@ export function hostsRoutes(opts?: { bearerToken?: string | null; humanRegistry?
|
||||
// Where ssh would connect for this host, from `ssh -G` (reads ssh config, no connection); null when
|
||||
// ssh can't resolve it or a ProxyJump/ProxyCommand sits in between. Twin of the CLI's resolveSshEndpoint.
|
||||
function resolveSshEndpoint(host: SshHostEntry, timeoutMs: number): Promise<{ hostname: string; port: number } | null> {
|
||||
const args = [...(host.user ? ["-l", host.user] : []), "-G", host.target];
|
||||
// `--` before the target, the same separator the CLI twin and the executor pass: a
|
||||
// destination that begins with `-` is read as the host, not as an option.
|
||||
const args = [...(host.user ? ["-l", host.user] : []), "-G", "--", host.target];
|
||||
return new Promise((resolve) => {
|
||||
execFile("ssh", args, { timeout: timeoutMs }, (error, stdout) => {
|
||||
if (error) return resolve(null);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { EventBus } from "../domain/event-bus.js";
|
||||
import { listProjects, selectedProject, projectMission, workSource, projectReadResponse } from "../domain/workspace/project-read.js";
|
||||
import { listProjects, selectedProject, projectMission, workSource, insideProject, projectReadResponse } from "../domain/workspace/project-read.js";
|
||||
import { inferCatalogProject, ProjectReadError } from "../domain/workspace/project-catalog.js";
|
||||
// Slice Story View v0 — HTTP routes.
|
||||
//
|
||||
@@ -166,15 +166,20 @@ export function slicesRoutes(): Hono {
|
||||
const abs = deps.projector.resolveProofAssetPath(slice.proofPacket, relPath);
|
||||
if (!abs) return c.json({ error: "proof_asset_not_found" }, 404);
|
||||
|
||||
let servedPath = abs;
|
||||
if (selected.qualified) {
|
||||
try { servedPath = insideProject(selected.projectRoot, abs); }
|
||||
catch (err) { return projectReadResponse(err); }
|
||||
}
|
||||
const contentType = inferContentType(abs);
|
||||
return fileAssetResponse(abs, contentType, c.req.header("Range"));
|
||||
return fileAssetResponse(servedPath, contentType, c.req.header("Range"));
|
||||
});
|
||||
|
||||
// 3) Doc serving for the Docs tab — markdown content of a single file
|
||||
// inside the slice folder. Path-traversal guarded by the projector.
|
||||
app.get("/:name/doc/*", (c) => {
|
||||
const name = c.req.param("name");
|
||||
const selected = selectSliceDeps(c, name);
|
||||
const selected = selectSliceDeps(c, name, { includeEvidenceRoot: false });
|
||||
if ("response" in selected) return selected.response;
|
||||
const { deps } = selected;
|
||||
const fullPath = c.req.path;
|
||||
@@ -205,10 +210,39 @@ export function slicesRoutes(): Hono {
|
||||
return app;
|
||||
}
|
||||
|
||||
const UNAVAILABLE_EVIDENCE_ROOT_CODES = new Set([
|
||||
"ENOENT",
|
||||
"EACCES",
|
||||
"EPERM",
|
||||
"ENOTDIR",
|
||||
"ELOOP",
|
||||
"EIO",
|
||||
"ENAMETOOLONG",
|
||||
"ESTALE",
|
||||
]);
|
||||
|
||||
export function qualifiedEvidenceRoot(projectRoot: string, missionsRoot: string): string | null {
|
||||
const candidate = path.join(path.dirname(missionsRoot), "dogfood-evidence");
|
||||
const relative = path.relative(path.resolve(projectRoot), path.resolve(candidate));
|
||||
if (relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) return null;
|
||||
try {
|
||||
const resolved = insideProject(projectRoot, candidate);
|
||||
fs.accessSync(resolved, fs.constants.R_OK | fs.constants.X_OK);
|
||||
fs.readdirSync(resolved);
|
||||
return resolved;
|
||||
} catch (err) {
|
||||
if (err instanceof ProjectReadError && err.code === "project_path_escape") return null;
|
||||
const code = (err as NodeJS.ErrnoException | null | undefined)?.code;
|
||||
if (typeof code === "string" && UNAVAILABLE_EVIDENCE_ROOT_CODES.has(code)) return null;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
function selectSliceDeps(
|
||||
c: Context,
|
||||
name: string,
|
||||
): { deps: SlicesRoutesDeps; qualified: boolean } | { response: Response } {
|
||||
opts: { includeEvidenceRoot?: boolean } = {},
|
||||
): { deps: SlicesRoutesDeps; qualified: false } | { deps: SlicesRoutesDeps; qualified: true; projectRoot: string } | { response: Response } {
|
||||
let deps = getDeps(c);
|
||||
if (!deps) return { response: c.json({ error: "slices_indexer_unavailable" }, 503) };
|
||||
try {
|
||||
@@ -218,9 +252,15 @@ function selectSliceDeps(
|
||||
if (!mission || !/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(name)) return { response: c.json({ error: "exact_mission_and_slice_required" }, 400) };
|
||||
const dir = projectMission(project, mission);
|
||||
workSource(project.root, path.join(dir, "slices", name));
|
||||
const indexer = new SliceIndexer({ db: deps.indexer.db, slicesRoot: project.missionsRoot, dogfoodEvidenceRoot: null, projectId: project.id, missionId: mission });
|
||||
const indexer = new SliceIndexer({
|
||||
db: deps.indexer.db,
|
||||
slicesRoot: project.missionsRoot,
|
||||
dogfoodEvidenceRoot: (opts.includeEvidenceRoot ?? true) ? qualifiedEvidenceRoot(project.root, project.missionsRoot) : null,
|
||||
projectId: project.id,
|
||||
missionId: mission,
|
||||
});
|
||||
deps = { indexer, projector: deps.projector.withIndexer(indexer) };
|
||||
return { deps, qualified: true };
|
||||
return { deps, qualified: true, projectRoot: project.root };
|
||||
} catch (err) { return { response: projectReadResponse(err) }; }
|
||||
}
|
||||
|
||||
|
||||
@@ -46,10 +46,12 @@ export function specLibraryRoutes(): Hono {
|
||||
const folder = c.get("workflowsFolderDir" as never) as string | undefined;
|
||||
const eventBus = c.get("eventBus" as never) as EventBus | undefined;
|
||||
const aliases: Array<{ name: string; version: string; sourcePath: string; sourceHash: string }> = [];
|
||||
const verifiedSourceHashes = new Map<string, string>();
|
||||
if (cache && folder) {
|
||||
try {
|
||||
scanWorkflowSpecFolder({ db, cache, folder, builtinDir: builtinDir ?? null, eventBus,
|
||||
onBuiltinAlias: (name, version, sourcePath, sourceHash) => aliases.push({ name, version, sourcePath, sourceHash }) });
|
||||
onBuiltinAlias: (name, version, sourcePath, sourceHash) => aliases.push({ name, version, sourcePath, sourceHash }),
|
||||
onVerifiedSource: (sourcePath, sourceHash) => verifiedSourceHashes.set(sourcePath, sourceHash) });
|
||||
} catch { /* best-effort — folder scan failure must not break Library list */ }
|
||||
}
|
||||
// Re-scan workflow specs on each list request — cheap (single
|
||||
@@ -70,7 +72,9 @@ export function specLibraryRoutes(): Hono {
|
||||
// Keep a source-specific link valid when an identical copy is edited and
|
||||
// becomes the cache's own user-file row. The normal row is listed once.
|
||||
if (folder && cache) {
|
||||
const sourceHashes = new Map<string, string>();
|
||||
// Reuse the scanner's persisted/stat-validated hash for ordinary user files. Alias copies intentionally keep
|
||||
// their separate two-file byte check above because they have no persisted row of their own.
|
||||
const sourceHashes = verifiedSourceHashes;
|
||||
for (const entry of [...workflowEntries]) {
|
||||
if (entry.sourceType !== "user_file" || entry.status === "error" || dirname(entry.sourcePath) !== folder) continue;
|
||||
try {
|
||||
|
||||
@@ -221,9 +221,17 @@ describe("auth-bearer-token middleware (PL-005 Phase B)", () => {
|
||||
|
||||
// Scenario 7: DNS-fails hostname, no bearer → THROW
|
||||
it("(7) DNS-unresolvable hostname with no bearer → THROW (HG-4)", async () => {
|
||||
await expect(
|
||||
assertBindAuthInvariant({ host: "this-host-does-not-exist.invalid", bearerToken: null }),
|
||||
).rejects.toThrow(AuthBearerTokenStartupError);
|
||||
const lookup = vi.spyOn(dns.promises, "lookup").mockRejectedValue(
|
||||
Object.assign(new Error("getaddrinfo ENOTFOUND this-host-does-not-exist.invalid"), { code: "ENOTFOUND" }),
|
||||
);
|
||||
try {
|
||||
await expect(
|
||||
assertBindAuthInvariant({ host: "this-host-does-not-exist.invalid", bearerToken: null }),
|
||||
).rejects.toThrow(AuthBearerTokenStartupError);
|
||||
expect(lookup).toHaveBeenCalledWith("this-host-does-not-exist.invalid", { all: false });
|
||||
} finally {
|
||||
lookup.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
// Existing-behavior preservation
|
||||
|
||||
@@ -25,6 +25,10 @@ describe("seat-stop.v1 checked stop", () => {
|
||||
let onDetach: (() => void) | undefined;
|
||||
let onKill: (() => void) | undefined;
|
||||
let killError: Error | undefined;
|
||||
let named: string | undefined;
|
||||
// A stop's effect, held inside tmux to the session id and server it read.
|
||||
const held = (inner: string) => "tmux if-shell -F -t '$7' '#{&&:#{==:#{session_id},$7},#{&&:#{==:#{pid},4242},#{==:#{start_time},1700000000}}}' "
|
||||
+ `'${inner.replace(/'/g, "'\"'\"'")}' 'display-message -p openrig-stop-target-changed'`;
|
||||
|
||||
beforeEach(() => {
|
||||
db = createFullTestDb();
|
||||
@@ -34,13 +38,19 @@ describe("seat-stop.v1 checked stop", () => {
|
||||
registry = new SessionRegistry(db);
|
||||
bus = new EventBus(db);
|
||||
commands = [];
|
||||
named = undefined;
|
||||
onProbe = onPanes = onDetach = onKill = undefined;
|
||||
killError = undefined;
|
||||
adapter = new TmuxAdapter(async command => {
|
||||
commands.push(command);
|
||||
if (command.includes("has-session")) { onProbe?.(); return ""; }
|
||||
if (command.includes("list-panes")) { onPanes?.(); return "%1|0|/project|120|40|1"; }
|
||||
if (command.includes("display-message")) return "$7";
|
||||
if (command.includes("list-panes")) { onPanes?.(); named = /=([^:']+):/.exec(command)?.[1] ?? named; return "%1|0|/project|120|40|1"; }
|
||||
// The named session carries its own seat's OpenRig marker.
|
||||
if (command.startsWith("tmux show-environment")) {
|
||||
const bound = db.prepare("SELECT node_id FROM bindings WHERE tmux_session = ?").get(named) as { node_id: string } | undefined;
|
||||
return `OPENRIG_NODE_ID=${bound?.node_id ?? ""}`;
|
||||
}
|
||||
if (command.startsWith("tmux display-message")) return "$7|4242|1700000000";
|
||||
if (command.includes("detach-client")) { onDetach?.(); return ""; }
|
||||
if (command.includes("kill-session")) { onKill?.(); if (killError) throw killError; return ""; }
|
||||
throw new Error(`unexpected tmux command: ${command}`);
|
||||
@@ -129,7 +139,7 @@ describe("seat-stop.v1 checked stop", () => {
|
||||
const result = await stop(a.name, expected(a.name));
|
||||
expect(result).toMatchObject({ ok: true, sessionName: a.name, sessionId: a.session.id });
|
||||
expect(commands.filter(command => command.includes("detach-client") || command.includes("kill-session"))).toEqual([
|
||||
"tmux detach-client -s '$7'", "tmux kill-session -t '$7'",
|
||||
held("detach-client -s '$7'"), held("kill-session -t '$7'"),
|
||||
]);
|
||||
expect(status(a.session.id)).toBe("exited");
|
||||
expect(registry.getBindingForNode(a.node.id)).toBeNull();
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { SeatActivityService } from "../src/domain/seat-activity-service.js";
|
||||
import { SeatStructuralActivityService } from "../src/domain/seat-structural-activity-service.js";
|
||||
import { CODEX_ACTIVITY_RUNG_INVENTORY } from "../src/domain/activity-taxonomy.js";
|
||||
import { classifyPromptAfterRefusal } from "../src/domain/queue-wake-ladder.js";
|
||||
|
||||
// Recorded Codex screens: two native choice prompts (directory trust on 0.156.1, the update prompt on 0.160.0), a complete
|
||||
// empty composer and a staged draft (0.160.0).
|
||||
const read = (path: string) => readFileSync(new URL(path, import.meta.url), "utf8");
|
||||
const TRUST = read("./fixtures/codex-trust-0.156.1.txt");
|
||||
const UPDATE = read("./fixtures/codex-update-0.160.0.txt");
|
||||
const IDLE = read("./fixtures/input-box/codex-0.160.0/idle.txt");
|
||||
const DRAFT = read("./fixtures/input-box/codex-0.160.0/staged-short.J.txt");
|
||||
// #2500's recorded working frame, as codex-working-queue-oracle.test.ts builds it.
|
||||
const RECORDED_WORKING = read("./fixtures/input-box/codex-0.160.0/after-enter-0.4s.J.txt");
|
||||
const WORKING = RECORDED_WORKING.slice(RECORDED_WORKING.indexOf("• Working"))
|
||||
.replace(/^• Working.*$/m, "◦ Working已(1h 56m 32s • esc to interrupt · 3 background terminals running")
|
||||
.replace(/^ GPT-.*$/m, " GPT-6.1 Terra high · D:\\workspace");
|
||||
type Pane = { id: string; pid: number } | undefined;
|
||||
type Occupant = { paneId: string | null; pid: number | null };
|
||||
|
||||
function fixture(first: string) {
|
||||
let time = Date.parse("2026-11-11T06:00:00.000Z"), screen = first, pane: Pane = { id: "%5", pid: 4100 };
|
||||
let windowRead: number | null = time / 1000 - 60;
|
||||
const session = "worker@fixture", node = "node-fixture";
|
||||
const structural = new SeatStructuralActivityService({ capturePaneContent: async () => screen } as never, () => new Date(time));
|
||||
const oracle = new SeatActivityService({ tmux: { readPaneLastActivity: async () => windowRead },
|
||||
defaultWindowSeconds: 3, now: () => new Date(time), structuralActivity: structural } as never);
|
||||
oracle.declareRungInventory({ seatNodeId: node, sessionName: session }, CODEX_ACTIVITY_RUNG_INVENTORY);
|
||||
/** The occupant as OpenRig records it (the binding's pane, the process the identity reconciler verified); the sweep
|
||||
* refreshes it every tick. */
|
||||
const occupant = (next: Occupant) => oracle.recordCodexOccupant?.(node, next);
|
||||
occupant({ paneId: "%5", pid: 4100 });
|
||||
/** The sweep's batched capture: the text with the pane and process tmux reported for it in the same call. */
|
||||
const capture = async () => structural.pollSeat(session, new Map([[session, { text: screen, capturedAt: new Date(time), ...(pane ? { pane } : {}) }]]),
|
||||
"codex", null, new Date(time));
|
||||
const tick = async (ms = 1000) => {
|
||||
time += ms;
|
||||
windowRead = time / 1000 - 60;
|
||||
await capture();
|
||||
await oracle.pollSeat(session);
|
||||
};
|
||||
return { oracle, node, session, structural, tick, capture, at: () => new Date(time).toISOString(),
|
||||
show: (next: string) => { screen = next; }, pane: (next: Pane) => { pane = next; }, occupant, advance: (ms: number) => { time += ms; },
|
||||
failWindowRead: () => { windowRead = null; },
|
||||
state: () => oracle.getSeatState(node)!,
|
||||
redeclare: () => oracle.declareRungInventory({ seatNodeId: node, sessionName: session }, CODEX_ACTIVITY_RUNG_INVENTORY),
|
||||
verdict: (refusedAt: string) => classifyPromptAfterRefusal(oracle.getSeatState(node), refusedAt) };
|
||||
}
|
||||
|
||||
describe("a Codex choice prompt read from the structural frame (#2515)", () => {
|
||||
it.each([["directory trust (0.156.1)", TRUST], ["update (0.160.0)", UPDATE]])(
|
||||
"a native %s choice reads as needs-input; once answered, an empty composer reads as a clear observed after the refusal", async (_name, choice) => {
|
||||
const f = fixture(choice);
|
||||
await f.tick();
|
||||
expect(f.state()).toMatchObject({ needsInput: { count: 1, reason: "selection_prompt" }, needsInputEvidence: { rung: "needs-input-chrome" } });
|
||||
const refusedAt = f.at();
|
||||
expect(f.verdict(refusedAt)).toBe("blocked");
|
||||
f.show(IDLE);
|
||||
await f.tick();
|
||||
expect(f.state()).toMatchObject({ needsInput: { count: 0, reason: null }, needsInputEvidence: { rung: "needs-input-chrome" } });
|
||||
expect(f.verdict(refusedAt)).toBe("clear");
|
||||
// The clear is observed at the capture's start: a refusal after it isn't cleared by it.
|
||||
expect(f.verdict(new Date(Date.parse(f.at()) + 1).toISOString())).toBe("unknown");
|
||||
});
|
||||
|
||||
it.each([["a staged draft", DRAFT], ["an unrecognised screen", "some provider output\nwith no composer"]])(
|
||||
"%s after the choice never clears: the prompt reads unknown again", async (_name, screen) => {
|
||||
const f = fixture(TRUST);
|
||||
await f.tick();
|
||||
const refusedAt = f.at();
|
||||
f.show(screen);
|
||||
await f.tick();
|
||||
expect(f.state().needsInputEvidence).toBeNull();
|
||||
expect(f.verdict(refusedAt)).toBe("unknown");
|
||||
});
|
||||
|
||||
it("a frame captured before the seat's current inventory isn't used (it may be another occupant's screen)", async () => {
|
||||
const f = fixture(IDLE);
|
||||
await f.capture();
|
||||
f.advance(500);
|
||||
// A new occupant's inventory is declared after that capture; no capture has happened since.
|
||||
f.redeclare();
|
||||
const refusedAt = new Date(Date.parse(f.at()) - 1000).toISOString();
|
||||
await f.oracle.pollSeat(f.session); // the sampler reads the cached frame; no new capture
|
||||
expect(f.state().needsInputEvidence).toBeNull();
|
||||
expect(f.verdict(refusedAt)).toBe("unknown");
|
||||
// The next capture is the current occupant's, and counts.
|
||||
await f.tick();
|
||||
expect(f.verdict(refusedAt)).toBe("clear");
|
||||
});
|
||||
|
||||
it("a tracked tool approval keeps its own path: an empty composer doesn't clear a pending tracked request", async () => {
|
||||
const f = fixture(IDLE);
|
||||
f.oracle.reportEvidence({ seatNodeId: f.node, sessionName: f.session, rung: "lifecycle-hooks", sourceId: "codex:hooks",
|
||||
seq: 1, observedAt: f.at(), activity: "working", permissionRequest: { id: "tracked-approval" } } as never);
|
||||
const refusedAt = f.at();
|
||||
await f.tick();
|
||||
// The structural zero is ignored while the request is tracked; its own probe decides it.
|
||||
expect(f.state().needsInputEvidence?.rung ?? null).not.toBe("needs-input-chrome");
|
||||
expect(f.verdict(refusedAt)).not.toBe("clear");
|
||||
});
|
||||
});
|
||||
|
||||
describe("the copied prompt evidence expires where it's read (#2952 R1-F1)", () => {
|
||||
const clearedAfterRefusal = async () => {
|
||||
const f = fixture(TRUST);
|
||||
await f.tick();
|
||||
const refusedAt = f.at();
|
||||
f.show(IDLE);
|
||||
await f.tick();
|
||||
expect(f.verdict(refusedAt)).toBe("clear");
|
||||
return { f, refusedAt };
|
||||
};
|
||||
|
||||
it("on a getter alone, past the structural window with no capture", async () => {
|
||||
const { f, refusedAt } = await clearedAfterRefusal();
|
||||
f.advance(6000);
|
||||
expect(f.verdict(refusedAt)).toBe("unknown");
|
||||
expect(f.state().needsInputEvidence).toBeNull();
|
||||
});
|
||||
|
||||
it("when the window read fails, past the structural window", async () => {
|
||||
const { f, refusedAt } = await clearedAfterRefusal();
|
||||
f.advance(6000);
|
||||
f.failWindowRead();
|
||||
await f.oracle.pollSeat(f.session);
|
||||
expect(f.verdict(refusedAt)).toBe("unknown");
|
||||
});
|
||||
|
||||
it("when the seat's inventory is declared again, without any new capture", async () => {
|
||||
const { f, refusedAt } = await clearedAfterRefusal();
|
||||
f.redeclare();
|
||||
expect(f.verdict(refusedAt)).toBe("unknown");
|
||||
});
|
||||
|
||||
it("a fresh clear stays positive while its frame is current", async () => {
|
||||
const { f, refusedAt } = await clearedAfterRefusal();
|
||||
f.advance(2000);
|
||||
expect(f.verdict(refusedAt)).toBe("clear");
|
||||
});
|
||||
});
|
||||
|
||||
describe("the frame must come from the occupant's own pane and process (#2952 R1-F2)", () => {
|
||||
it.each([
|
||||
["a same-name successor (a new process in the pane)", { id: "%5", pid: 4200 }],
|
||||
["a reused pane id held by another process", { id: "%5", pid: 9999 }],
|
||||
["another pane under the same session name", { id: "%9", pid: 4100 }],
|
||||
])("%s is refused: the prompt reads unknown, never clear", async (_name, other) => {
|
||||
const f = fixture(TRUST);
|
||||
await f.tick(); // the occupant is %5 / 4100
|
||||
const refusedAt = f.at();
|
||||
f.show(IDLE);
|
||||
f.pane(other);
|
||||
await f.tick();
|
||||
expect(f.state().needsInputEvidence).toBeNull();
|
||||
expect(f.verdict(refusedAt)).toBe("unknown");
|
||||
// Once OpenRig records that pane and process as the occupant (a managed swap's binding, or the reconciler verifying
|
||||
// the process), its frames count.
|
||||
f.occupant({ paneId: other.id, pid: other.pid });
|
||||
await f.tick();
|
||||
expect(f.verdict(refusedAt)).toBe("clear");
|
||||
});
|
||||
|
||||
it("a capture that doesn't name its pane (the per-seat fallback read) gives no prompt evidence", async () => {
|
||||
const f = fixture(IDLE);
|
||||
f.pane(undefined);
|
||||
await f.tick();
|
||||
expect(f.state().needsInputEvidence).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("the identity is the occupant's as OpenRig records it, not the first frame's (#2952 round 2)", () => {
|
||||
it("a replacement before the seat's first frame is refused, so it can't clear an earlier refusal (R2-F1)", async () => {
|
||||
const f = fixture(IDLE);
|
||||
const refusedAt = f.at();
|
||||
f.pane({ id: "%5", pid: 4200 }); // replaced behind the same name before any frame was read
|
||||
await f.tick();
|
||||
expect(f.state().needsInputEvidence).toBeNull();
|
||||
expect(f.verdict(refusedAt)).toBe("unknown");
|
||||
});
|
||||
|
||||
it("a process OpenRig hasn't verified gives no prompt evidence", async () => {
|
||||
const f = fixture(IDLE);
|
||||
f.occupant({ paneId: "%5", pid: null });
|
||||
const refusedAt = f.at();
|
||||
await f.tick();
|
||||
expect(f.state().needsInputEvidence).toBeNull();
|
||||
expect(f.verdict(refusedAt)).toBe("unknown");
|
||||
});
|
||||
|
||||
it("OpenRig's in-place restart (same pane, new process): once the reconciler verifies the process, the prompt clear works", async () => {
|
||||
const f = fixture(TRUST);
|
||||
await f.tick();
|
||||
const refusedAt = f.at();
|
||||
f.pane({ id: "%5", pid: 4300 });
|
||||
f.show(IDLE);
|
||||
await f.tick();
|
||||
// Until the identity reconciler verifies the new process, its frames aren't the occupant's.
|
||||
expect(f.verdict(refusedAt)).toBe("unknown");
|
||||
f.occupant({ paneId: "%5", pid: 4300 });
|
||||
await f.tick();
|
||||
expect(f.verdict(refusedAt)).toBe("clear");
|
||||
});
|
||||
|
||||
it("OpenRig's in-place restart keeps #2500's working frame once the reconciler verifies the process", async () => {
|
||||
const f = fixture(WORKING);
|
||||
await f.tick();
|
||||
expect(f.state()).toMatchObject({ activity: "working", decidedBy: "window-sampling" });
|
||||
f.pane({ id: "%5", pid: 4300 });
|
||||
f.occupant({ paneId: "%5", pid: 4300 });
|
||||
await f.tick();
|
||||
await f.tick();
|
||||
expect(f.state()).toMatchObject({ activity: "working", decidedBy: "window-sampling" });
|
||||
});
|
||||
});
|
||||
@@ -221,6 +221,38 @@ it("does not renew an adjacent frame using a predecessor's counter", async () =>
|
||||
expect(oracle.getSeatState(node)?.activity).toBe("idle-at-prompt");
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ name: "the same counter", predecessorAdjacent: true, step: 0 },
|
||||
{ name: "an advanced counter", predecessorAdjacent: false, step: 1 },
|
||||
])("does not credit a restarted pane process with $name from the process before it", async ({ predecessorAdjacent, step }) => {
|
||||
let time = Date.parse("2026-11-04T20:00:00.000Z"), elapsed = 32, pid = 4100;
|
||||
const session = "worker@fixture", node = "node-fixture";
|
||||
const structural = new SeatStructuralActivityService({ capturePaneContent: async () => null } as never, () => new Date(time));
|
||||
const capture = (adjacent: boolean) => {
|
||||
const at = new Date(time);
|
||||
const frame = screen.replace("32s • esc to interrupt", `${elapsed}s • esc to interrupt`);
|
||||
const text = adjacent ? frame : frame.replace("\n› Ask Codex", "\n• Edited files\n\n› Ask Codex");
|
||||
return structural.pollSeat(session, new Map([[session, { text, capturedAt: at, pane: { id: "%5", pid } }]]), "codex", null, at);
|
||||
};
|
||||
const oracle = new SeatActivityService({ tmux: { readPaneLastActivity: async () => time / 1000 - 60 },
|
||||
defaultWindowSeconds: 3, now: () => new Date(time), structuralActivity: structural } as never);
|
||||
oracle.declareRungInventory({ seatNodeId: node, sessionName: session }, CODEX_ACTIVITY_RUNG_INVENTORY);
|
||||
oracle.recordCodexOccupant(node, { paneId: "%5", pid });
|
||||
await capture(predecessorAdjacent);
|
||||
// An in-place restart: OpenRig verifies the new process in the same pane, and the inventory is unchanged.
|
||||
time += 1000; elapsed += step; pid = 4200;
|
||||
oracle.recordCodexOccupant(node, { paneId: "%5", pid });
|
||||
const replaced = await capture(false);
|
||||
await oracle.pollSeat(session);
|
||||
expect(oracle.getSeatState(node)?.activity).toBe("idle-at-prompt");
|
||||
expect(replaced?.codexWorkingFrame).toBeUndefined();
|
||||
// The new process's own advancing counter still counts.
|
||||
time += 1000; elapsed++;
|
||||
await capture(false);
|
||||
await oracle.pollSeat(session);
|
||||
expect(oracle.getSeatState(node)?.activity).toBe("working");
|
||||
});
|
||||
|
||||
it("does not treat counter readings separated by a capture outage as continuous activity", async () => {
|
||||
let time = Date.parse("2026-11-04T20:00:00.000Z"), elapsed = 32;
|
||||
const session = "worker@fixture", node = "node-fixture";
|
||||
|
||||
@@ -329,8 +329,17 @@ exec "$@"
|
||||
writePreset("free-harness.yaml", ["format: 1", "name: free-harness", `command: ${JSON.stringify(`node ${shellQuote(appScript)}`)}`, "maintainer: Example Maintainer", ""].join("\n"));
|
||||
await sleep(4_000);
|
||||
await check();
|
||||
// A relaunch doesn't start the same-named app in its place.
|
||||
expect((await rig(["down", "free-rig", "--json", "--force"])).code).toBe(0);
|
||||
// A relaunch doesn't start the same-named app in its place. The self-attached session is the person's own terminal,
|
||||
// which OpenRig didn't create, so rig down leaves it running and says so (exit 2) instead of stopping a terminal it can't
|
||||
// show is the seat's.
|
||||
const down = await rig(["down", "free-rig", "--json", "--force"]);
|
||||
expect(down.code, JSON.stringify(down)).toBe(2);
|
||||
expect(JSON.parse(down.stdout).errors).toEqual([expect.stringMatching(
|
||||
/^Kill failed for session '[^']+': '[^']+' was left running: its tmux session \$\d+ doesn't carry this seat's OpenRig marker/)]);
|
||||
// The manual path it names: the person stops their own terminal on the daemon's server, then runs rig down again.
|
||||
execFileSync("tmux", ["-S", scaffold.tmuxSocketPath, "kill-session", "-t", "=free-seat"], { env: scaffold.env as NodeJS.ProcessEnv });
|
||||
const again = await rig(["down", "free-rig", "--json", "--force"]);
|
||||
expect(JSON.parse(again.stdout).errors, JSON.stringify(again)).toEqual([]);
|
||||
// A deliberate fresh launch of the seat gets main's refusal (a self-attached seat has no startup context, checked
|
||||
// before any adapter lookup) and starts nothing.
|
||||
const seatLaunch = await rig(["seat", "launch", "work.free@free-rig", "--fresh", "--reason", "relaunch a free-text seat", "--json"], 60_000);
|
||||
|
||||
@@ -183,5 +183,10 @@ describe("who decides a runner's answered permission request", () => {
|
||||
// A chosen seat this host doesn't have can't decide: the kernel operator, when here, else nobody.
|
||||
expect(permissionDeciderFor(db([]), "runner@team", () => "ghost@team")).toBeNull();
|
||||
expect(permissionDeciderFor(both, "runner@team", () => "ghost@team")).toMatchObject({ session: KERNEL_OPERATOR_SESSION, note: "ghost@team, the receiving agent, isn't on this host" });
|
||||
// A setting that's set but ignored keeps its note when the kernel operator decides: it never reads as unset.
|
||||
const ignored = { session: null, problem: 'agents.operator_session is "alex@external", which isn\'t an agent\'s session, so it\'s ignored' };
|
||||
const kernelDecides = permissionDeciderFor(both, "runner@team", () => ignored)!;
|
||||
expect(kernelDecides).toMatchObject({ session: KERNEL_OPERATOR_SESSION, note: ignored.problem });
|
||||
expect(describeDefaultReceiver(kernelDecides)).not.toContain("unset");
|
||||
});
|
||||
});
|
||||
+65
-7
@@ -1,8 +1,10 @@
|
||||
/* Synthetic interactive provider: no provider network calls; credentials are compared only to fixture constants.
|
||||
* A real executable lets native process/binding checks run without mocking their observations. */
|
||||
#include <errno.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <sys/stat.h>
|
||||
#include <unistd.h>
|
||||
#include <signal.h>
|
||||
|
||||
@@ -15,32 +17,88 @@ static void stopped(int signalNumber) {
|
||||
}
|
||||
|
||||
static const char *flag(int argc, char **argv, const char *name, const char *fallback) {
|
||||
for (int i = 1; i + 1 < argc; i++) if (!strcmp(argv[i], name)) return argv[i + 1];
|
||||
size_t len = strlen(name);
|
||||
for (int i = 1; i < argc; i++) {
|
||||
if (!strcmp(argv[i], name) && i + 1 < argc) return argv[i + 1];
|
||||
if (!strncmp(argv[i], name, len) && argv[i][len] == '=') return argv[i] + len + 1;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
static int matches(const char *name, const char *value) {
|
||||
const char *v = getenv(name); return v && !strcmp(v, value);
|
||||
}
|
||||
static int valid_session_id(const char *s) {
|
||||
size_t len = s ? strlen(s) : 0;
|
||||
if (len == 0 || len > 64) return 0;
|
||||
for (size_t i = 0; i < len; i++) {
|
||||
unsigned char c = (unsigned char)s[i];
|
||||
if (!((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9')
|
||||
|| c == '.' || c == '_' || c == '-')) return 0;
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
static int fput_json_string(FILE *out, const char *s) {
|
||||
if (fputc('"', out) == EOF) return -1;
|
||||
for (const unsigned char *p = (const unsigned char *)s; *p; p++) {
|
||||
if (*p == '"' || *p == '\\') {
|
||||
if (fprintf(out, "\\%c", *p) < 0) return -1;
|
||||
} else if (*p < 0x20) {
|
||||
if (fprintf(out, "\\u%04x", (unsigned int)*p) < 0) return -1;
|
||||
} else if (fputc(*p, out) == EOF) {
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
return fputc('"', out) == EOF ? -1 : 0;
|
||||
}
|
||||
static int write_session_record(const char *home, pid_t pid, const char *seatName, const char *sessionId) {
|
||||
char sessionsDir[4096], tmpPath[4096], finalPath[4096];
|
||||
int n1 = snprintf(sessionsDir, sizeof(sessionsDir), "%s/sessions", home);
|
||||
int n2 = snprintf(tmpPath, sizeof(tmpPath), "%s/sessions/.%d.json.tmp", home, (int)pid);
|
||||
int n3 = snprintf(finalPath, sizeof(finalPath), "%s/sessions/%d.json", home, (int)pid);
|
||||
if (n1 < 0 || (size_t)n1 >= sizeof(sessionsDir)
|
||||
|| n2 < 0 || (size_t)n2 >= sizeof(tmpPath)
|
||||
|| n3 < 0 || (size_t)n3 >= sizeof(finalPath)) return -1;
|
||||
if (mkdir(sessionsDir, 0700) != 0 && errno != EEXIST) return -1;
|
||||
FILE *out = fopen(tmpPath, "w");
|
||||
if (!out) return -1;
|
||||
int ok = fputs("{\"name\":", out) != EOF
|
||||
&& fput_json_string(out, seatName) == 0
|
||||
&& fputs(",\"sessionId\":", out) != EOF
|
||||
&& fput_json_string(out, sessionId) == 0
|
||||
&& fputs("}\n", out) != EOF
|
||||
&& fflush(out) == 0;
|
||||
if (fclose(out) != 0 || !ok) { unlink(tmpPath); return -1; }
|
||||
if (rename(tmpPath, finalPath) != 0) { unlink(tmpPath); return -1; }
|
||||
return 0;
|
||||
}
|
||||
int main(int argc, char **argv) {
|
||||
setvbuf(stdout, NULL, _IONBF, 0);
|
||||
if (argc > 1 && !strcmp(argv[1], "--version")) { puts("2.1.139 (Claude Code)"); return 0; }
|
||||
if (argc > 1 && !strcmp(argv[1], "--help")) {
|
||||
puts("Usage: claude [options]\n--permission-mode <mode> (choices: \"acceptEdits\", \"default\", \"auto\")"); return 0;
|
||||
}
|
||||
const char *id = flag(argc, argv, "--resume", flag(argc, argv, "--session-id", "missing"));
|
||||
const char *explicitId = flag(argc, argv, "--resume", flag(argc, argv, "--session-id", NULL));
|
||||
const char *id = explicitId ? explicitId : "missing";
|
||||
const char *argName = flag(argc, argv, "--name", NULL);
|
||||
const char *seatName = (argName && argName[0]) ? argName : NULL;
|
||||
const char *model = flag(argc, argv, "--model", "default");
|
||||
const char *permission = flag(argc, argv, "--permission-mode", "default");
|
||||
const char *home = getenv("CLAUDE_CONFIG_DIR"), *rig = getenv("OPENRIG_HOME");
|
||||
if (!home || !rig || strchr(id, '/') || strlen(id) > 64) return 2;
|
||||
snprintf(removeKeyFlag, sizeof(removeKeyFlag), "%s/remove-key-on-stop-%d", rig, getpid());
|
||||
snprintf(apiKeyFile, sizeof(apiKeyFile), "%s/../api-key", rig);
|
||||
if (!home || home[0] != '/' || !rig || rig[0] != '/' || strchr(id, '/') || strlen(id) > 64) return 2;
|
||||
int r1 = snprintf(removeKeyFlag, sizeof(removeKeyFlag), "%s/remove-key-on-stop-%d", rig, getpid());
|
||||
int r2 = snprintf(apiKeyFile, sizeof(apiKeyFile), "%s/../api-key", rig);
|
||||
if (r1 < 0 || (size_t)r1 >= sizeof(removeKeyFlag) || r2 < 0 || (size_t)r2 >= sizeof(apiKeyFile)) return 2;
|
||||
signal(SIGTERM, stopped);
|
||||
signal(SIGHUP, stopped);
|
||||
const char *auth = matches("ANTHROPIC_API_KEY", "synthetic-api") ? "api"
|
||||
: matches("CLAUDE_CODE_OAUTH_TOKEN", "synthetic-plan") ? "plan" : "other";
|
||||
char journal[4096], memoryFile[4096], memory[512] = "", line[4096];
|
||||
snprintf(journal, sizeof(journal), "%s/provider-launches.jsonl", rig);
|
||||
snprintf(memoryFile, sizeof(memoryFile), "%s/fixture-conversation-%s", home, id);
|
||||
int j1 = snprintf(journal, sizeof(journal), "%s/provider-launches.jsonl", rig);
|
||||
int m1 = snprintf(memoryFile, sizeof(memoryFile), "%s/fixture-conversation-%s", home, id);
|
||||
if (j1 < 0 || (size_t)j1 >= sizeof(journal) || m1 < 0 || (size_t)m1 >= sizeof(memoryFile)) return 2;
|
||||
if (seatName && valid_session_id(explicitId)) {
|
||||
if (strlen(seatName) > 512 || write_session_record(home, getpid(), seatName, explicitId) != 0) return 7;
|
||||
}
|
||||
FILE *f = fopen(memoryFile, "r"); if (f) { fgets(memory, sizeof(memory), f); fclose(f); }
|
||||
f = fopen(journal, "a"); if (!f) return 3;
|
||||
fprintf(f, "{\"pid\":%d,\"conversation\":\"%s\",\"model\":\"%s\",\"permission\":\"%s\",\"auth\":\"%s\",\"remembered\":%s}\n",
|
||||
|
||||
+3
-2
@@ -35,9 +35,10 @@ try {
|
||||
const seats = new Map();
|
||||
for (const member of ["a", "b", "c", "sibling"]) {
|
||||
const name = `${member}@shrink-fixture`;
|
||||
await native(["new-session", "-d", "-s", name, "sleep 120"]);
|
||||
const pane = (await native(["list-panes", "-t", name, "-F", "#{pane_id}"])).trim();
|
||||
const node = rigRepo.addNode(rig.id, member, { runtime: "terminal", ...(member === "sibling" ? {} : { podId: pod.id }) });
|
||||
// As OpenRig launches a seat: its session carries the node's OPENRIG_NODE_ID, which a guarded stop requires.
|
||||
await native(["new-session", "-d", "-s", name, "-e", `OPENRIG_NODE_ID=${node.id}`, "sleep 120"]);
|
||||
const pane = (await native(["list-panes", "-t", name, "-F", "#{pane_id}"])).trim();
|
||||
const session = sessionRegistry.registerSession(node.id, name);
|
||||
sessionRegistry.updateStatus(session.id, "running");
|
||||
sessionRegistry.updateBinding(node.id, { tmuxSession: name, tmuxPane: pane });
|
||||
|
||||
@@ -4,15 +4,18 @@
|
||||
// Harness adapted from review50-r2's PR #1001 review (real hook route, SQLite store, activity service, transport,
|
||||
// parked-owner policy, queue and ladder; only terminal I/O and the final orchestrator delivery are inert).
|
||||
import { it, expect, vi, afterEach } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { Hono } from "hono";
|
||||
import type Database from "better-sqlite3";
|
||||
import { createFullTestDb } from "./helpers/test-app.js";
|
||||
import { RigRepository } from "../src/domain/rig-repository.js";
|
||||
import { SessionRegistry } from "../src/domain/session-registry.js";
|
||||
import { SeatIdentityStore } from "../src/domain/seat-identity-store.js";
|
||||
import { AgentActivityStore } from "../src/domain/agent-activity-store.js";
|
||||
import { EventBus } from "../src/domain/event-bus.js";
|
||||
import { SessionTransport } from "../src/domain/session-transport.js";
|
||||
import { SeatActivityService } from "../src/domain/seat-activity-service.js";
|
||||
import { SeatStructuralActivityService } from "../src/domain/seat-structural-activity-service.js";
|
||||
import { activityRoutes } from "../src/routes/activity.js";
|
||||
import { QueueRepository } from "../src/domain/queue-repository.js";
|
||||
import { makeParkedOwnerConsumerPolicy, makeRigAnchor, PARKED_OWNER_POLICY_NAME, REFUSED_PREFIX } from "../src/domain/policies/parked-owner-consumer.js";
|
||||
@@ -22,7 +25,8 @@ import { startWakeLadderScheduler } from "../src/index.js";
|
||||
const databases: Database.Database[] = [];
|
||||
afterEach(() => { for (const db of databases.splice(0)) db.close(); });
|
||||
|
||||
function fixture(runtime = "claude-code", permissionPromptReader?: (sessionName: string) => Promise<boolean | null>, pane = "unrecognized provider content") {
|
||||
function fixture(runtime = "claude-code", permissionPromptReader?: (sessionName: string) => Promise<boolean | null>, pane = "unrecognized provider content",
|
||||
withStructural = false) {
|
||||
const db = createFullTestDb(); databases.push(db);
|
||||
const rigRepo = new RigRepository(db), sessionRegistry = new SessionRegistry(db), bus = new EventBus(db);
|
||||
const rig = rigRepo.createRig("r1001"), node = rigRepo.addNode(rig.id, "worker.a", { role: "worker", runtime });
|
||||
@@ -36,13 +40,16 @@ function fixture(runtime = "claude-code", permissionPromptReader?: (sessionName:
|
||||
isRegisteredOccupantGeneration: (id, g) => Boolean(db.prepare("SELECT 1 FROM occupant_tenures WHERE node_id=? AND generation_uuid=?").get(id, g)),
|
||||
});
|
||||
const paste = vi.fn(async () => ({ ok: true as const })), enter = vi.fn(async () => ({ ok: true as const }));
|
||||
const screen = { value: pane };
|
||||
const tmux = { hasSession: async () => true, probeSession: async () => ({ state: "present" as const }),
|
||||
getPaneCommand: async () => runtime === "codex" ? "codex" : "claude", getPanePid: async () => null,
|
||||
listPanes: async () => [], hasSessionEnv: async () => false, capturePaneContent: async () => pane,
|
||||
listPanes: async () => [], hasSessionEnv: async () => false, capturePaneContent: async () => screen.value,
|
||||
sendText: paste, sendKeys: enter, readPaneLastActivity: async () => now().getTime() / 1000 - 120,
|
||||
};
|
||||
// The structural capture over the same pane, when a cell needs the oracle to read it (#2515).
|
||||
const structural = withStructural ? new SeatStructuralActivityService(tmux as never, now) : undefined;
|
||||
const newOracle = () => new SeatActivityService({ tmux: tmux as never, defaultWindowSeconds: 3, now,
|
||||
permissionPromptReader,
|
||||
permissionPromptReader, structuralActivity: structural,
|
||||
permissionPromptChanged: permissionPromptReader ? (sessionName, confirmed, permissionRuntime) => {
|
||||
const latest = store.getLatestForNode({ sessionName });
|
||||
store.recordHookEvent({ sessionName, runtime: permissionRuntime, generation: latest?.generation,
|
||||
@@ -70,7 +77,7 @@ function fixture(runtime = "claude-code", permissionPromptReader?: (sessionName:
|
||||
getSeatState: (nodeId) => oracle.getSeatState(nodeId),
|
||||
getLatestHook: (sessionName) => store.getLatestForNode({ sessionName }) });
|
||||
return { db, bus, node, name, store, hook, transport, paste, enter, clock, readPromptState,
|
||||
oracle: () => oracle, reconstruct: () => { oracle = newOracle(); },
|
||||
oracle: () => oracle, reconstruct: () => { oracle = newOracle(); }, structural, show: (next: string) => { screen.value = next; },
|
||||
changeGeneration: (g: string | null) => { currentGeneration = g; } };
|
||||
}
|
||||
|
||||
@@ -302,3 +309,75 @@ it("the production scheduler keeps a current Claude prompt alert when an older S
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
// #2515: a native Codex choice that the person answers on screen, with no hook after it. The structural frame observes the
|
||||
// choice and then the empty composer, so the refused wake isn't escalated, and the next wake is sent once.
|
||||
const codexScreen = (path: string) => readFileSync(new URL(path, import.meta.url), "utf8");
|
||||
it("a Codex wake refused at a native choice the person then answers: no escalation, and the next wake is sent once", async () => {
|
||||
const f = fixture("codex", undefined, codexScreen("./fixtures/codex-trust-0.156.1.txt"), true);
|
||||
// The occupant as OpenRig records it: the binding's pane, and the pane process the identity reconciler verified.
|
||||
new SessionRegistry(f.db).updateBinding(f.node.id, { tmuxPane: "%5" });
|
||||
new SeatIdentityStore(f.db).upsert({ nodeId: f.node.id, verdict: "verified", evidenceSource: null, reason: null,
|
||||
evidence: { registeredPane: "%5", observedPanePid: 4100, observedPid: 4101, observedCommand: "codex", matchedLayer: 1 },
|
||||
sessionName: f.name, observedAt: f.clock.value.toISOString() });
|
||||
// The sweep's batched capture: the screen with the pane and process tmux reported for it.
|
||||
const screen = { value: codexScreen("./fixtures/codex-trust-0.156.1.txt") };
|
||||
const observe = async () => {
|
||||
await f.structural!.pollSeat(f.name, new Map([[f.name, { text: screen.value, capturedAt: f.clock.value, pane: { id: "%5", pid: 4100 } }]]),
|
||||
"codex", null, f.clock.value);
|
||||
await f.oracle().pollAllRunningTmuxSeats(f.db);
|
||||
};
|
||||
await observe();
|
||||
expect(f.oracle().getSeatState(f.node.id)?.needsInput).toMatchObject({ count: 1, reason: "selection_prompt" });
|
||||
const repo = new QueueRepository(f.db, f.bus, { validateRig: () => true });
|
||||
const row = await repo.create({ sourceSession: "planner@r1001", destinationSession: f.name,
|
||||
body: "finish synthetic assigned work", summary: "synthetic obligation", nudge: false });
|
||||
repo.claim({ qitemId: row.qitemId, destinationSession: f.name });
|
||||
const history: Array<Record<string, unknown>> = [];
|
||||
const policy = makeParkedOwnerConsumerPolicy({
|
||||
diagnoseRig: () => ({ seats: [{ sessionName: f.name, parked: true,
|
||||
activity: { value: "idle-at-prompt", needsInput: { count: 0, reason: null } },
|
||||
obligations: { items: [{ qitemId: row.qitemId, state: "in-progress", summary: row.summary }], held: [] } }] }),
|
||||
history: { listForJob: () => history as never, countForJob: () => history.length },
|
||||
rows: { listTransitions: (id) => repo.listTransitions(id),
|
||||
appendNote: (id, note) => { repo.update({ qitemId: id, actorSession: "watchdog@system", transitionNote: note }); return { ok: true }; },
|
||||
recordNudgeResult: (id, result) => repo.recordNudgeAttempt(id, result), listOpenIds: () => [row.qitemId],
|
||||
recoveryOwnsWake: (id) => queueRecoveryOwnsWake(f.db, repo.getById(id)),
|
||||
},
|
||||
});
|
||||
const job = { jobId: "fixture-job", policy: PARKED_OWNER_POLICY_NAME, target: { session: makeRigAnchor("r1001") }, context: {} };
|
||||
const wake = await policy.evaluate(job as never);
|
||||
expect(wake.action).toBe("send");
|
||||
const sent = await f.transport.send(f.name, "synthetic assigned-work wake");
|
||||
expect(sent).toMatchObject({ ok: false, reason: "target_needs_input" });
|
||||
history.push({ historyId: "history", jobId: job.jobId, evaluatedAt: new Date().toISOString(), outcome: "sent", skipReason: null,
|
||||
deliveryTargetSession: f.name, deliveryStatus: "failed", deliveryMessage: "synthetic wake",
|
||||
evaluationNotes: { ...(wake as { notes?: Record<string, unknown> }).notes, deliveryReason: sent.error } });
|
||||
await policy.evaluate(job as never);
|
||||
const refused = repo.listTransitions(row.qitemId).find((t) => t.transitionNote?.startsWith(REFUSED_PREFIX));
|
||||
expect(refused?.transitionNote).toContain("(selection_prompt)");
|
||||
expect(f.readPromptState(f.name, refused!.ts)).toBe("blocked"); // still open: nothing is typed into it
|
||||
// The person answers the choice; no hook follows.
|
||||
f.show(codexScreen("./fixtures/input-box/codex-0.160.0/idle.txt"));
|
||||
screen.value = codexScreen("./fixtures/input-box/codex-0.160.0/idle.txt");
|
||||
f.clock.value = new Date(Date.now() + 2_000);
|
||||
await observe();
|
||||
expect(f.readPromptState(f.name, refused!.ts)).toBe("clear");
|
||||
const targets: string[] = [];
|
||||
const alerts = () => repo.list({ state: ["pending", "in-progress", "blocked"], limit: 1000 })
|
||||
.filter((r) => r.tags?.includes("wake-prompt-refusal"));
|
||||
for (const ageMs of [2_000, 3_600_000]) {
|
||||
f.clock.value = new Date(Date.now() + ageMs);
|
||||
await observe();
|
||||
await runWakeLadderTick({ db: f.db, queueRepo: repo, now: f.clock.value,
|
||||
resolveOrchestrator: () => "lead@r1001", readPromptState: f.readPromptState, retryIntervalSeconds: 1,
|
||||
attemptWake: async (_id, target) => { targets.push(target); return "verified"; }, log: () => {} });
|
||||
}
|
||||
expect(targets).not.toContain("lead@r1001");
|
||||
expect(alerts()).toHaveLength(0);
|
||||
expect(f.paste).not.toHaveBeenCalled();
|
||||
// The next wake goes through normal delivery, once.
|
||||
expect((await policy.evaluate(job as never)).action).toBe("send");
|
||||
expect((await f.transport.send(f.name, "synthetic assigned-work wake")).ok).toBe(true);
|
||||
expect(f.paste).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
@@ -65,7 +65,7 @@ describe("a hosted member with a webhook trigger is rung when work lands for it"
|
||||
expect(outcome.nudgeResult).not.toContain(url);
|
||||
});
|
||||
|
||||
it("records a failed ring for an error, a redirect it doesn't follow, a missing secret and a trigger that doesn't answer", async () => {
|
||||
it("records a failed ring for an error, a redirect it doesn't follow and a missing secret", async () => {
|
||||
reply = { status: 500 };
|
||||
expect(await ringHostedWake(ring, wake, { env: env() })).toEqual({ classified: "failed", nudgeResult: "failed:webhook (GROK_WAKE_URL) answered 500" });
|
||||
|
||||
@@ -77,9 +77,15 @@ describe("a hosted member with a webhook trigger is rung when work lands for it"
|
||||
expect((await ringHostedWake(ring, wake, { env: { GROK_WAKE_URL: url } })).nudgeResult)
|
||||
.toBe("failed:webhook (GROK_WAKE_URL) not rung: its Authorization value (GROK_WAKE_AUTH) isn't set");
|
||||
expect(received).toHaveLength(2);
|
||||
});
|
||||
|
||||
// The ring reached a trigger that then didn't answer in time, so it may have started a run: unknown, like an unconfirmed
|
||||
// send, never failed.
|
||||
it("a trigger that doesn't answer in time reads unconfirmed, not failed", async () => {
|
||||
reply = "hang";
|
||||
expect((await ringHostedWake(ring, wake, { env: env(), timeoutMs: 100 })).nudgeResult).toBe("failed:webhook (GROK_WAKE_URL) didn't answer within 100 ms");
|
||||
expect(await ringHostedWake(ring, wake, { env: env(), timeoutMs: 100 })).toEqual({ classified: "indeterminate",
|
||||
nudgeResult: "indeterminate:webhook (GROK_WAKE_URL) didn't answer within 100 ms, so it may have the ring; it isn't rung again, and pickup is the member's claim on this row" });
|
||||
expect(received).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("through the queue: work for the member rings its trigger from the roster's secrets file, never its terminal, and a claim stays a separate fact", async () => {
|
||||
@@ -126,7 +132,7 @@ describe("a hosted member with a webhook trigger is rung when work lands for it"
|
||||
// Real topology and a repository whose trigger comes from a secrets file, as on a running daemon.
|
||||
// A repository over the same database stands in for the daemon after a restart; `transport: false` for one that
|
||||
// stops before sending.
|
||||
function hostedRepo(db: Database.Database, envLines: string, declared: RosterWake["webhook"] = wake.webhook, opts: { transport?: boolean } = {}) {
|
||||
function hostedRepo(db: Database.Database, envLines: string, declared: RosterWake["webhook"] = wake.webhook, opts: { transport?: boolean; fetchImpl?: typeof fetch } = {}) {
|
||||
db.prepare("INSERT OR IGNORE INTO rigs (id, name) VALUES ('rig-lab', 'lab')").run();
|
||||
db.prepare("INSERT OR IGNORE INTO nodes (id, rig_id, logical_id, runtime, cwd) VALUES ('node-lead', 'rig-lab', 'lead', 'claude-code', '/tmp')").run();
|
||||
const secrets = join(dir, "grok.env");
|
||||
@@ -136,6 +142,7 @@ describe("a hosted member with a webhook trigger is rung when work lands for it"
|
||||
loadHumanRegistry: () => ({ ok: true, entities: [] }),
|
||||
...(opts.transport === false ? {} : { transport: { send: async () => ({ ok: true, verified: true }) } }),
|
||||
hostedWake: (destination) => destination === "grok@lab" ? { webhook: { ...declared, secrets_env_file: secrets } } : null,
|
||||
...(opts.fetchImpl ? { hostedWakeFetch: opts.fetchImpl } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -184,6 +191,56 @@ describe("a hosted member with a webhook trigger is rung when work lands for it"
|
||||
}
|
||||
});
|
||||
|
||||
// A trigger that timed out may already be running the work, so startup recovery doesn't ring it again; a refused
|
||||
// connection never left, so recovery still retries it. A timed-out ring nobody claims is still surfaced to a person.
|
||||
const ringDouble = (first: "timeout" | "refused") => {
|
||||
const rings: string[] = [];
|
||||
let next: "timeout" | "refused" | "accept" = first;
|
||||
const fetchImpl = (async () => {
|
||||
rings.push(next);
|
||||
if (next === "timeout") throw Object.assign(new Error("The operation was aborted due to timeout"), { name: "TimeoutError" });
|
||||
if (next === "refused") throw Object.assign(new TypeError("fetch failed"), { cause: { code: "ECONNREFUSED" } });
|
||||
return new Response(null, { status: 202 });
|
||||
}) as typeof fetch;
|
||||
return { rings, fetchImpl, accept: () => { next = "accept"; } };
|
||||
};
|
||||
const firstResult = (first: "timeout" | "refused") => first === "timeout"
|
||||
? /^indeterminate:webhook \(GROK_WAKE_URL\) didn't answer within 10000 ms, so it may have the ring/
|
||||
: /^failed:webhook \(GROK_WAKE_URL\) couldn't be reached \(ECONNREFUSED\)/;
|
||||
const envLines = () => `GROK_WAKE_URL=${url}\nGROK_WAKE_AUTH=Bearer test-only-value\n`;
|
||||
|
||||
it("startup recovery doesn't ring a ring that timed out again; a refused one it rings again", async () => {
|
||||
for (const first of ["timeout", "refused"] as const) {
|
||||
const { rings, fetchImpl, accept } = ringDouble(first);
|
||||
const db = new Database(":memory:");
|
||||
migrate(db, ALL_MIGRATIONS);
|
||||
const repo = hostedRepo(db, envLines(), wake.webhook, { fetchImpl });
|
||||
repo.attachOutbox(new OutboxHandler(db));
|
||||
const row = await repo.create({ sourceSession: "lead@lab", destinationSession: "grok@lab", body: "work", summary: "Work" });
|
||||
await vi.waitFor(() => expect(repo.getById(row.qitemId)!.lastNudgeWireResult).toMatch(firstResult(first)));
|
||||
accept();
|
||||
await repo.drainPendingWakeIntents();
|
||||
expect(rings).toEqual(first === "timeout" ? ["timeout"] : ["refused", "accept"]);
|
||||
db.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("a ring that timed out and was never claimed still reaches the member's person through the stuck sweep", async () => {
|
||||
const { fetchImpl } = ringDouble("timeout");
|
||||
const db = new Database(":memory:");
|
||||
migrate(db, ALL_MIGRATIONS);
|
||||
const repo = hostedRepo(db, envLines(), wake.webhook, { fetchImpl });
|
||||
const row = await repo.create({ sourceSession: "lead@lab", destinationSession: "grok@lab", body: "work", summary: "Work" });
|
||||
expect(repo.getById(row.qitemId)!.lastNudgeWireResult).toMatch(firstResult("timeout"));
|
||||
await runStuckSweep({ db, queueRepo: repo, status: createStuckSweepStatus(), resolveOrchestrator: () => null, unclaimedAgeMinutes: 60,
|
||||
now: new Date(Date.now() + 2 * 3_600_000), log: () => {}, isRegisteredHost: () => false,
|
||||
resolveOperatorSeat: () => "person@external", resolveRecoveryAgentSeat: () => null });
|
||||
const finding = repo.list({ limit: 50 }).find((item) => (item.tags ?? []).includes(STUCK_SWEEP_FINDING_TAG));
|
||||
expect(finding).toMatchObject({ destinationSession: "person@external" });
|
||||
expect(finding!.body).toContain(row.qitemId);
|
||||
db.close();
|
||||
});
|
||||
|
||||
it("a new wake for the same row rings: parked on a blocker that completes, the member is rung again, and recovery adds none", async () => {
|
||||
const db = new Database(":memory:");
|
||||
migrate(db, ALL_MIGRATIONS);
|
||||
|
||||
@@ -71,6 +71,16 @@ const SHARED_FIXTURES: Array<{ label: string; parsed: unknown; ok: boolean }> =
|
||||
{ label: "invalid: reserved hostId 'kernel'", parsed: { hosts: [{ id: "vm-a", transport: "ssh", target: "a", hostId: "kernel" }] }, ok: false },
|
||||
{ label: "invalid: reserved hostId 'host'", parsed: { hosts: [{ id: "vm-a", transport: "ssh", target: "a", hostId: "host" }] }, ok: false },
|
||||
{ label: "invalid: reserved hostId 'external'", parsed: { hosts: [{ id: "vm-a", transport: "http", url: "http://x", hostId: "external" }] }, ok: false },
|
||||
// The ssh target and the optional user are argv elements, not shell words, so whitespace
|
||||
// never splits a destination, and a target that begins with `-` is handled by the `--` the
|
||||
// callers pass before it rather than by a registry refusal. The registry refuses neither;
|
||||
// these fixtures pin that in both twins, next to the ordinary shapes that must keep working.
|
||||
{ label: "valid: ssh target with whitespace (argv keeps it whole)", parsed: { hosts: [{ id: "x", transport: "ssh", target: "two words" }] }, ok: true },
|
||||
{ label: "valid: ssh user with a space in the login name (OpenSSH accepts it)", parsed: { hosts: [{ id: "x", transport: "ssh", target: "vm-a", user: "Jane Doe" }] }, ok: true },
|
||||
{ label: "valid: option-shaped target passes validation; `--` handles it at the call site", parsed: { hosts: [{ id: "x", transport: "ssh", target: "-oProxyCommand=touch /tmp/nothing" }] }, ok: true },
|
||||
{ label: "valid: ssh target as user@host", parsed: { hosts: [{ id: "x", transport: "ssh", target: "[email protected]" }] }, ok: true },
|
||||
{ label: "valid: ssh target as an IPv6 literal", parsed: { hosts: [{ id: "x", transport: "ssh", target: "admin@[::1]" }] }, ok: true },
|
||||
{ label: "valid: ssh host alias with a user", parsed: { hosts: [{ id: "x", transport: "ssh", target: "box", user: "admin" }] }, ok: true },
|
||||
];
|
||||
|
||||
describe("hosts-registry reader — CLI/daemon validator parity (shared fixtures)", () => {
|
||||
|
||||
@@ -13,6 +13,8 @@ import { NodeLauncher } from "../src/domain/node-launcher.js";
|
||||
import { StartupOrchestrator } from "../src/domain/startup-orchestrator.js";
|
||||
import { reconcileSkillLoadout } from "../src/domain/skill-catalog.js";
|
||||
import { PodRigInstantiator } from "../src/domain/rigspec-instantiator.js";
|
||||
import { WhoamiService } from "../src/domain/whoami-service.js";
|
||||
import { TranscriptStore } from "../src/domain/transcript-store.js";
|
||||
import { BootstrapOrchestrator } from "../src/domain/bootstrap-orchestrator.js";
|
||||
import { ServiceOrchestrator } from "../src/domain/service-orchestrator.js";
|
||||
import { ComposeServicesAdapter } from "../src/adapters/compose-services-adapter.js";
|
||||
@@ -108,6 +110,34 @@ describe("PodRigInstantiator", () => {
|
||||
return { db, rigRepo, podRepo, sessionRegistry, eventBus, inst, adapter, codexAdapter, tmux };
|
||||
}
|
||||
|
||||
// #2871: a seat that asks who's on its team while `rig up` is still building it is told so; once built, the answer is today's.
|
||||
it("whoami says the team is still being built during instantiate, and answers as before once it's built", async () => {
|
||||
const files = { [`${RIG_ROOT}/agents/impl/agent.yaml`]: agentYaml("impl"), [`${RIG_ROOT}/agents/qa/agent.yaml`]: agentYaml("qa") };
|
||||
const { db, rigRepo, sessionRegistry, inst } = setup(files);
|
||||
const whoami = new WhoamiService({ db, rigRepo, sessionRegistry, transcriptStore: new TranscriptStore({ transcriptsRoot: "/tmp/transcripts", enabled: true }) });
|
||||
const spec = makeRigSpec({ pods: [{ id: "dev", label: "Dev", members: [
|
||||
{ id: "impl", agentRef: "local:agents/impl", profile: "default", runtime: "claude-code", cwd: "." },
|
||||
{ id: "qa", agentRef: "local:agents/qa", profile: "default", runtime: "claude-code", cwd: "." },
|
||||
], edges: [{ from: "impl", to: "qa", kind: "delegates_to" }] }] });
|
||||
const early: Array<ReturnType<WhoamiService["resolve"]>> = [];
|
||||
let firstNode: string | undefined;
|
||||
const result = await inst.instantiate(RigSpecCodec.serialize(spec), RIG_ROOT, { recordCreated: (_rigId, nodeId) => {
|
||||
if (!nodeId) return;
|
||||
firstNode ??= nodeId;
|
||||
early.push(whoami.resolve({ nodeId: firstNode }));
|
||||
} });
|
||||
expect(result.ok).toBe(true);
|
||||
// Mid-launch: the first seat sees only what exists so far, and is told the team isn't complete.
|
||||
expect(early[0]).toMatchObject({ rosterState: "building", peers: [], edges: { outgoing: [], incoming: [] } });
|
||||
expect(early[0]!.peersNote).toMatch(/^rosterState = building: `rig up` is still creating this team's seats and edges/);
|
||||
// Built: the full roster and edges, with no building state and today's note.
|
||||
const done = whoami.resolve({ nodeId: firstNode! });
|
||||
expect(done).not.toHaveProperty("rosterState");
|
||||
expect(done.peersNote).toBe("peers = this rig's roster excluding self (no edge filter); edges = directional relationships; use `rig ps --nodes` for node inventory including self + live state");
|
||||
expect(done.peers.map((peer) => peer.logicalId)).toEqual(["dev.qa"]);
|
||||
expect(done.edges.outgoing).toMatchObject([{ kind: "delegates_to", to: { logicalId: "dev.qa" } }]);
|
||||
});
|
||||
|
||||
// T1: valid rig instantiates pods + nodes + edges
|
||||
it("instantiates pods + nodes correctly", async () => {
|
||||
const { db, inst } = setup();
|
||||
|
||||
@@ -12,7 +12,6 @@ import type { PolicyJob } from "../src/domain/policies/types.js";
|
||||
import { WatchdogHistoryLog, type WatchdogHistoryEntry } from "../src/domain/watchdog-history-log.js";
|
||||
import { WatchdogJobsRepository } from "../src/domain/watchdog-jobs-repository.js";
|
||||
import { tmuxAttachCommand } from "../src/adapters/tmux-server.js";
|
||||
import { shellQuote } from "../src/adapters/shell-quote.js";
|
||||
import { makeKernelHookReviewReader } from "../src/domain/kernel-hook-review.js";
|
||||
import { verifyCodexPaneProcess, type NativeProcessRow } from "../src/domain/native-process-lineage.js";
|
||||
import { runWakeLadderTick, queueRecoveryOwnsWake, classifyPromptAfterRefusal } from "../src/domain/queue-wake-ladder.js";
|
||||
@@ -481,7 +480,7 @@ describe("prompt-blocked outstanding work reaches the configured route", () => {
|
||||
await hookTick(port);
|
||||
const updated = repo.getById(alert.qitemId)!;
|
||||
expect(updated.summary).toContain("Hooks need review");
|
||||
expect(updated.summary).toContain(tmuxAttachCommand(shellQuote(seat)));
|
||||
expect(updated.summary).toContain(tmuxAttachCommand(seat));
|
||||
expect(notes(alert.qitemId).filter(n => n.startsWith("hook-review observed:"))).toHaveLength(1);
|
||||
expect(notes(alert.qitemId).join("\n")).toContain("through Codex's hook review");
|
||||
expect(updated.body).toBe(before.body); // retain the original delivered content and receipt
|
||||
|
||||
@@ -0,0 +1,438 @@
|
||||
import { execFileSync, spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { once } from "node:events";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { TmuxAdapter } from "../src/adapters/tmux.js";
|
||||
import { ALL_MIGRATIONS } from "../src/db/all-migrations.js";
|
||||
import { createDb } from "../src/db/connection.js";
|
||||
import { migrate } from "../src/db/migrate.js";
|
||||
import { CheckpointStore } from "../src/domain/checkpoint-store.js";
|
||||
import { EventBus } from "../src/domain/event-bus.js";
|
||||
import { readProcessTable, type ProcessTableRow } from "../src/domain/process-table.js";
|
||||
import { ResumeMetadataRefresher } from "../src/domain/resume-metadata-refresher.js";
|
||||
import { RigRepository } from "../src/domain/rig-repository.js";
|
||||
import { RigTeardownOrchestrator } from "../src/domain/rig-teardown.js";
|
||||
import { seatRuntimeHome } from "../src/domain/seat-runtime-home.js";
|
||||
import { SessionRegistry } from "../src/domain/session-registry.js";
|
||||
import { SnapshotCapture } from "../src/domain/snapshot-capture.js";
|
||||
import { SnapshotRepository } from "../src/domain/snapshot-repository.js";
|
||||
|
||||
interface ProviderLaunchRecord {
|
||||
pid: number;
|
||||
conversation: string;
|
||||
model: string;
|
||||
permission: string;
|
||||
auth: string;
|
||||
remembered: boolean;
|
||||
}
|
||||
|
||||
describe.skipIf(process.platform === "win32")("provider-switch synthetic Claude fixture shutdown capture contract", () => {
|
||||
let binRoot: string;
|
||||
let claudeFixtureBinary: string;
|
||||
let root: string;
|
||||
let db: ReturnType<typeof createDb>;
|
||||
let rigRepo: RigRepository;
|
||||
let registry: SessionRegistry;
|
||||
let eventBus: EventBus;
|
||||
let snapshotRepo: SnapshotRepository;
|
||||
let capture: SnapshotCapture;
|
||||
const cleanupCallbacks: Array<() => Promise<void>> = [];
|
||||
|
||||
beforeAll(() => {
|
||||
binRoot = fs.mkdtempSync(path.join(os.tmpdir(), "provider-switch-claude-bin-"));
|
||||
claudeFixtureBinary = path.join(binRoot, "claude");
|
||||
const fixtureSource = fileURLToPath(new URL("fixtures/provider-switch-claude.c", import.meta.url));
|
||||
const shippedRelay = fileURLToPath(
|
||||
new URL("../assets/plugins/openrig-core/hooks/scripts/activity-relay.cjs", import.meta.url),
|
||||
);
|
||||
execFileSync("cc", [
|
||||
fixtureSource,
|
||||
"-o",
|
||||
claudeFixtureBinary,
|
||||
`-DFIXTURE_NODE=${JSON.stringify(process.execPath)}`,
|
||||
`-DFIXTURE_RELAY=${JSON.stringify(shippedRelay)}`,
|
||||
]);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
if (binRoot) fs.rmSync(binRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
root = fs.mkdtempSync(path.join(os.tmpdir(), "provider-switch-shutdown-"));
|
||||
db = createDb();
|
||||
migrate(db, ALL_MIGRATIONS);
|
||||
rigRepo = new RigRepository(db);
|
||||
registry = new SessionRegistry(db);
|
||||
eventBus = new EventBus(db);
|
||||
snapshotRepo = new SnapshotRepository(db);
|
||||
capture = new SnapshotCapture({
|
||||
db,
|
||||
rigRepo,
|
||||
sessionRegistry: registry,
|
||||
eventBus,
|
||||
snapshotRepo,
|
||||
checkpointStore: new CheckpointStore(db),
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
while (cleanupCallbacks.length > 0) {
|
||||
const stop = cleanupCallbacks.pop()!;
|
||||
await stop();
|
||||
}
|
||||
db.close();
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
async function waitForPrompt(child: ChildProcessWithoutNullStreams): Promise<void> {
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
cleanup();
|
||||
reject(new Error(`synthetic provider fixture timed out waiting for prompt (stdout=${JSON.stringify(stdout)}, stderr=${JSON.stringify(stderr)})`));
|
||||
}, 10_000);
|
||||
const onData = (chunk: Buffer) => {
|
||||
stdout += chunk.toString("utf8");
|
||||
if (stdout.includes("❯ ")) {
|
||||
cleanup();
|
||||
resolve();
|
||||
}
|
||||
};
|
||||
const onStderr = (chunk: Buffer) => {
|
||||
stderr += chunk.toString("utf8");
|
||||
};
|
||||
const onError = (err: Error) => {
|
||||
cleanup();
|
||||
reject(err);
|
||||
};
|
||||
const onExit = (code: number | null, signal: NodeJS.Signals | null) => {
|
||||
cleanup();
|
||||
reject(new Error(`synthetic provider fixture exited before prompt (code=${String(code)}, signal=${String(signal)}, stderr=${JSON.stringify(stderr)})`));
|
||||
};
|
||||
const cleanup = () => {
|
||||
clearTimeout(timer);
|
||||
child.stdout.off("data", onData);
|
||||
child.stderr.off("data", onStderr);
|
||||
child.off("error", onError);
|
||||
child.off("exit", onExit);
|
||||
};
|
||||
child.stdout.on("data", onData);
|
||||
child.stderr.on("data", onStderr);
|
||||
child.once("error", onError);
|
||||
child.once("exit", onExit);
|
||||
});
|
||||
}
|
||||
|
||||
async function launchProviderSwitchFixtureSeat(options: {
|
||||
storedToken: string;
|
||||
provenance: "operator" | "scrape" | "hook";
|
||||
args: string[];
|
||||
extraEnv: Record<string, string>;
|
||||
seatName?: string;
|
||||
}) {
|
||||
const workDir = path.join(root, "work");
|
||||
const homeDir = path.join(root, "home");
|
||||
const openrigHome = path.join(root, "openrig-home");
|
||||
const daemonConfigDir = path.join(root, "daemon-claude");
|
||||
const selectedConfigDir = path.join(root, "account");
|
||||
const scratchTmpDir = path.join(root, "tmp");
|
||||
for (const dir of [workDir, homeDir, openrigHome, daemonConfigDir, selectedConfigDir, scratchTmpDir]) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
|
||||
const rig = rigRepo.createRig("provider-switch-fixture");
|
||||
const node = rigRepo.addNode(rig.id, "work.worker", {
|
||||
runtime: "claude-code",
|
||||
cwd: workDir,
|
||||
model: "fixture-model",
|
||||
restorePolicy: "resume_if_possible",
|
||||
});
|
||||
db.prepare("UPDATE nodes SET account_home = ? WHERE id = ?").run(selectedConfigDir, node.id);
|
||||
|
||||
const seatName = options.seatName ?? "r00-provider-switch-fixture-work_worker";
|
||||
const session = registry.registerSession(node.id, seatName);
|
||||
registry.updateStatus(session.id, "running");
|
||||
registry.updateBinding(node.id, { tmuxSession: seatName, tmuxPane: "%1" });
|
||||
registry.updateResumeToken(session.id, "claude_id", options.storedToken, options.provenance);
|
||||
|
||||
const child = spawn(claudeFixtureBinary, options.args, {
|
||||
cwd: workDir,
|
||||
env: {
|
||||
PATH: "/usr/bin:/bin",
|
||||
HOME: homeDir,
|
||||
TMPDIR: scratchTmpDir,
|
||||
OPENRIG_HOME: openrigHome,
|
||||
CLAUDE_CONFIG_DIR: selectedConfigDir,
|
||||
OPENRIG_NODE_ID: node.id,
|
||||
OPENRIG_RUNTIME: "claude-code",
|
||||
...options.extraEnv,
|
||||
},
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
const stopChild = async () => {
|
||||
if (child.exitCode !== null || child.signalCode !== null) return;
|
||||
const exited = once(child, "close");
|
||||
child.kill("SIGTERM");
|
||||
const killTimer = setTimeout(() => {
|
||||
if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL");
|
||||
}, 2_000);
|
||||
try {
|
||||
await exited;
|
||||
} finally {
|
||||
clearTimeout(killTimer);
|
||||
}
|
||||
};
|
||||
cleanupCallbacks.push(stopChild);
|
||||
|
||||
await waitForPrompt(child);
|
||||
const childPid = child.pid!;
|
||||
|
||||
const panePid = process.pid;
|
||||
const readForegroundRows = async (): Promise<ProcessTableRow[]> => {
|
||||
const table = await readProcessTable();
|
||||
const selfRow = table.find((row) => row.pid === panePid);
|
||||
const liveRow = table.find((row) => row.pid === childPid);
|
||||
if (!selfRow || !liveRow) return [];
|
||||
return [
|
||||
{
|
||||
pid: selfRow.pid,
|
||||
ppid: selfRow.ppid,
|
||||
pgid: panePid,
|
||||
tpgid: liveRow.pid,
|
||||
executableName: "bash",
|
||||
startedAt: selfRow.startedAt,
|
||||
command: "bash",
|
||||
},
|
||||
{
|
||||
...liveRow,
|
||||
ppid: panePid,
|
||||
pgid: liveRow.pid,
|
||||
tpgid: liveRow.pid,
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
const initialSample = await readForegroundRows();
|
||||
const initialLiveRow = initialSample.find((row) => row.pid === childPid);
|
||||
expect(initialLiveRow, "expected live compiled provider-switch-claude OS process in process table").toBeDefined();
|
||||
expect(Number.isFinite(Date.parse(initialLiveRow!.startedAt))).toBe(true);
|
||||
|
||||
const listClaudeProcesses = vi.fn(readForegroundRows);
|
||||
const probeClaudeResume = vi.fn(async () => "inconclusive" as const);
|
||||
const tmux = {
|
||||
getPanePid: vi.fn(async () => panePid),
|
||||
killSession: vi.fn(async () => {
|
||||
await stopChild();
|
||||
return { ok: true as const };
|
||||
}),
|
||||
} as unknown as TmuxAdapter;
|
||||
|
||||
const refresher = new ResumeMetadataRefresher({
|
||||
sessionRegistry: registry,
|
||||
tmuxAdapter: tmux,
|
||||
homeDir,
|
||||
claudeConfigDir: daemonConfigDir,
|
||||
seatHome: (nodeId, runtime) => seatRuntimeHome(db, nodeId, runtime).home,
|
||||
listClaudeProcesses,
|
||||
probeClaudeResume,
|
||||
});
|
||||
const teardown = new RigTeardownOrchestrator({
|
||||
db,
|
||||
rigRepo,
|
||||
sessionRegistry: registry,
|
||||
eventBus,
|
||||
snapshotCapture: capture,
|
||||
tmuxAdapter: tmux,
|
||||
resumeMetadataRefresher: refresher,
|
||||
});
|
||||
|
||||
const readLaunches = (): ProviderLaunchRecord[] => {
|
||||
const journalPath = path.join(openrigHome, "provider-launches.jsonl");
|
||||
return fs
|
||||
.readFileSync(journalPath, "utf8")
|
||||
.trim()
|
||||
.split("\n")
|
||||
.filter(Boolean)
|
||||
.map((line) => JSON.parse(line) as ProviderLaunchRecord);
|
||||
};
|
||||
|
||||
return {
|
||||
rig,
|
||||
node,
|
||||
session,
|
||||
seatName,
|
||||
workDir,
|
||||
openrigHome,
|
||||
daemonConfigDir,
|
||||
selectedConfigDir,
|
||||
childPid,
|
||||
initialLiveRow: initialLiveRow!,
|
||||
pidSessionFile: path.join(selectedConfigDir, "sessions", `${childPid}.json`),
|
||||
probeClaudeResume,
|
||||
listClaudeProcesses,
|
||||
tmux,
|
||||
refresher,
|
||||
teardown,
|
||||
readLaunches,
|
||||
};
|
||||
}
|
||||
|
||||
it("writes current-PID session metadata from explicit --name + --resume so shutdown captures without fallback probe", async () => {
|
||||
const conversation = randomUUID();
|
||||
const seatName = "r00-provider-switch-fixture-work_worker";
|
||||
const h = await launchProviderSwitchFixtureSeat({
|
||||
storedToken: conversation,
|
||||
provenance: "operator",
|
||||
seatName,
|
||||
args: ["--resume", conversation, "--name", seatName, "--model", "fixture-model", "--permission-mode", "acceptEdits"],
|
||||
extraEnv: {
|
||||
OPENRIG_SESSION_NAME: seatName,
|
||||
ANTHROPIC_API_KEY: "synthetic-api",
|
||||
},
|
||||
});
|
||||
|
||||
expect(h.readLaunches()).toEqual([
|
||||
{
|
||||
pid: h.childPid,
|
||||
conversation,
|
||||
model: "fixture-model",
|
||||
permission: "acceptEdits",
|
||||
auth: "api",
|
||||
remembered: false,
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await h.teardown.teardown(h.rig.id);
|
||||
expect(result.errors).toEqual([]);
|
||||
expect(result.sessionsKilled).toBe(1);
|
||||
expect(h.listClaudeProcesses).toHaveBeenCalled();
|
||||
|
||||
const recordExists = fs.existsSync(h.pidSessionFile);
|
||||
expect
|
||||
.soft(recordExists, "expected synthetic fixture to write <CLAUDE_CONFIG_DIR>/sessions/<pid>.json for explicit --name + --resume")
|
||||
.toBe(true);
|
||||
if (recordExists) {
|
||||
const stat = fs.statSync(h.pidSessionFile);
|
||||
expect.soft(stat.size).toBeLessThanOrEqual(64 * 1024);
|
||||
expect.soft(stat.mtimeMs).toBeGreaterThanOrEqual(Date.parse(h.initialLiveRow.startedAt));
|
||||
expect.soft(JSON.parse(fs.readFileSync(h.pidSessionFile, "utf8"))).toMatchObject({
|
||||
name: h.seatName,
|
||||
sessionId: conversation,
|
||||
});
|
||||
}
|
||||
|
||||
expect
|
||||
.soft(h.probeClaudeResume, "expected captureClaudeSessionId hit without invoking fallback probeClaudeResume")
|
||||
.not.toHaveBeenCalled();
|
||||
const updated = registry.getSessionsForRig(h.rig.id)[0]!;
|
||||
expect.soft(updated.resumeToken).toBe(conversation);
|
||||
expect.soft(updated.resumeLastProbeStatus).toBe("resumable");
|
||||
});
|
||||
|
||||
it("writes current-PID session metadata from --name + --session-id so shutdown captures conversation without fallback probe", async () => {
|
||||
const initialConversation = randomUUID();
|
||||
const activeConversation = randomUUID();
|
||||
const seatName = "r00-provider-switch-fixture-work_worker";
|
||||
const h = await launchProviderSwitchFixtureSeat({
|
||||
storedToken: initialConversation,
|
||||
provenance: "scrape",
|
||||
seatName,
|
||||
args: ["--session-id", activeConversation, "--name", seatName, "--model", "fixture-model", "--permission-mode", "acceptEdits"],
|
||||
extraEnv: {
|
||||
CLAUDE_CODE_OAUTH_TOKEN: "synthetic-plan",
|
||||
},
|
||||
});
|
||||
|
||||
expect(h.readLaunches()).toEqual([
|
||||
{
|
||||
pid: h.childPid,
|
||||
conversation: activeConversation,
|
||||
model: "fixture-model",
|
||||
permission: "acceptEdits",
|
||||
auth: "plan",
|
||||
remembered: false,
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await h.teardown.teardown(h.rig.id);
|
||||
expect(result.errors).toEqual([]);
|
||||
expect(result.sessionsKilled).toBe(1);
|
||||
expect(h.listClaudeProcesses).toHaveBeenCalled();
|
||||
|
||||
const recordExists = fs.existsSync(h.pidSessionFile);
|
||||
expect
|
||||
.soft(recordExists, "expected synthetic fixture to write <CLAUDE_CONFIG_DIR>/sessions/<pid>.json for --name + --session-id")
|
||||
.toBe(true);
|
||||
if (recordExists) {
|
||||
const stat = fs.statSync(h.pidSessionFile);
|
||||
expect.soft(stat.size).toBeLessThanOrEqual(64 * 1024);
|
||||
expect.soft(stat.mtimeMs).toBeGreaterThanOrEqual(Date.parse(h.initialLiveRow.startedAt));
|
||||
expect.soft(JSON.parse(fs.readFileSync(h.pidSessionFile, "utf8"))).toMatchObject({
|
||||
name: h.seatName,
|
||||
sessionId: activeConversation,
|
||||
});
|
||||
}
|
||||
|
||||
expect
|
||||
.soft(h.probeClaudeResume, "expected captureClaudeSessionId hit without invoking fallback probeClaudeResume")
|
||||
.not.toHaveBeenCalled();
|
||||
const updated = registry.getSessionsForRig(h.rig.id)[0]!;
|
||||
expect.soft(updated.resumeToken).toBe(activeConversation);
|
||||
const snap = snapshotRepo.getSnapshot(result.snapshotId!)!;
|
||||
expect.soft(snap.data.sessions.find((s) => s.id === h.session.id)?.resumeToken).toBe(activeConversation);
|
||||
});
|
||||
|
||||
it("does not emit current-PID session metadata when only OPENRIG_SESSION_NAME is set without explicit --name", async () => {
|
||||
const initialConversation = randomUUID();
|
||||
const resumedConversation = randomUUID();
|
||||
const seatName = "r00-provider-switch-fixture-work_worker";
|
||||
const h = await launchProviderSwitchFixtureSeat({
|
||||
storedToken: initialConversation,
|
||||
provenance: "scrape",
|
||||
seatName,
|
||||
args: ["--resume", resumedConversation, "--model", "fixture-model", "--permission-mode", "acceptEdits"],
|
||||
extraEnv: {
|
||||
OPENRIG_SESSION_NAME: seatName,
|
||||
ANTHROPIC_API_KEY: "synthetic-api",
|
||||
},
|
||||
});
|
||||
|
||||
expect(h.readLaunches()).toEqual([
|
||||
{
|
||||
pid: h.childPid,
|
||||
conversation: resumedConversation,
|
||||
model: "fixture-model",
|
||||
permission: "acceptEdits",
|
||||
auth: "api",
|
||||
remembered: false,
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await h.teardown.teardown(h.rig.id);
|
||||
expect(result.errors).toEqual([]);
|
||||
expect(result.sessionsKilled).toBe(1);
|
||||
expect(h.listClaudeProcesses).toHaveBeenCalled();
|
||||
|
||||
expect
|
||||
.soft(fs.existsSync(h.pidSessionFile), "expected synthetic fixture not to emit <CLAUDE_CONFIG_DIR>/sessions/<pid>.json without explicit --name")
|
||||
.toBe(false);
|
||||
expect
|
||||
.soft(h.probeClaudeResume, "expected launch without explicit --name to fall through to fallback probeClaudeResume")
|
||||
.toHaveBeenCalledTimes(1);
|
||||
expect.soft(h.probeClaudeResume).toHaveBeenCalledWith(h.seatName, initialConversation, h.workDir, h.selectedConfigDir);
|
||||
|
||||
const updated = registry.getSessionsForRig(h.rig.id)[0]!;
|
||||
expect.soft(updated.resumeToken).toBe(initialConversation);
|
||||
expect.soft(updated.resumeLastProbeStatus).toBe("inconclusive");
|
||||
const snap = snapshotRepo.getSnapshot(result.snapshotId!)!;
|
||||
expect.soft(snap.data.sessions.find((s) => s.id === h.session.id)?.resumeToken).toBe(initialConversation);
|
||||
});
|
||||
|
||||
});
|
||||
@@ -0,0 +1,170 @@
|
||||
// A wake that failed at a live agent seat with no delegation parent or escalates_to got a finding addressed back to that
|
||||
// seat, whose own wake then went to the seat that had just failed to take one. That finding now goes to the row's creator,
|
||||
// or for a seat that sent itself the work, to the operator agent. A declared route keeps its routing and its fallback.
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import Database from "better-sqlite3";
|
||||
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 { EventBus } from "../src/domain/event-bus.js";
|
||||
import { QueueRepository, type QueueItem } from "../src/domain/queue-repository.js";
|
||||
import { runStuckSweep, createStuckSweepStatus, STUCK_SWEEP_FINDING_TAG } from "../src/domain/queue-stuck-sweep.js";
|
||||
|
||||
describe("stuck sweep: a failed wake at a seat with no declared route", () => {
|
||||
let db: Database.Database;
|
||||
let rigs: RigRepository;
|
||||
let queue: QueueRepository;
|
||||
let seq: number;
|
||||
beforeEach(() => {
|
||||
db = new Database(":memory:");
|
||||
migrate(db, ALL_MIGRATIONS);
|
||||
rigs = new RigRepository(db);
|
||||
queue = new QueueRepository(db, new EventBus(db), { validateRig: () => true, loadHumanRegistry: () => ({ ok: true, entities: [] }) });
|
||||
seq = 0;
|
||||
});
|
||||
afterEach(() => db.close());
|
||||
|
||||
/** An agent seat; with `running` false it has no session. */
|
||||
function seat(rigName: string, logicalId: string, session: string, running = true) {
|
||||
const rig = rigs.listRigs().find((r) => r.name === rigName) ?? rigs.createRig(rigName);
|
||||
const node = rigs.addNode(rig.id, logicalId, { runtime: "claude-code" });
|
||||
if (running) db.prepare("INSERT INTO sessions (id,node_id,session_name,status) VALUES (?,?,?,?)").run(`session-${++seq}`, node.id, session, "running");
|
||||
return { rig, node, session };
|
||||
}
|
||||
async function sweep(overrides: Record<string, unknown> = {}) {
|
||||
return runStuckSweep({ db, queueRepo: queue, status: createStuckSweepStatus(), unclaimedAgeMinutes: 60, log: () => {},
|
||||
isRegisteredHost: () => false, resolveRecoveryAgentSeat: () => null, resolveOperatorSeat: () => null, ...overrides });
|
||||
}
|
||||
/** A row for `destination` whose wake failed. */
|
||||
async function failedWake(destination: string, source = "sender@lane"): Promise<QueueItem> {
|
||||
const row = await queue.create({ sourceSession: source, destinationSession: destination, body: "Decide the scope", nudge: false });
|
||||
db.prepare("UPDATE queue_items SET last_nudge_attempt = ?, last_nudge_result = ? WHERE qitem_id = ?")
|
||||
.run(new Date().toISOString(), "failed:target_needs_input", row.qitemId);
|
||||
return row;
|
||||
}
|
||||
const findingsFor = (row: QueueItem): QueueItem[] => queue.list({ limit: 500 })
|
||||
.filter((item) => (item.tags ?? []).includes(STUCK_SWEEP_FINDING_TAG) && (item.tags ?? []).includes(`stuck-sweep:undelivered-wake:${row.qitemId}`))
|
||||
.map((item) => queue.getById(item.qitemId)!);
|
||||
|
||||
it("sends the finding to the row's creator, and says why", async () => {
|
||||
seat("front", "door.root", "root@front");
|
||||
const row = await failedWake("root@front");
|
||||
await sweep();
|
||||
const [finding, ...more] = findingsFor(row);
|
||||
expect(more).toHaveLength(0);
|
||||
expect(finding!.destinationSession).toBe("sender@lane");
|
||||
expect(finding!.body).toContain("root@front declares no delegation parent or escalates_to, so this finding goes to sender@lane, the row's creator");
|
||||
});
|
||||
|
||||
it("a seat that sent itself the work gets the operator agent instead", async () => {
|
||||
seat("front", "door.root", "root@front");
|
||||
const row = await failedWake("root@front", "root@front");
|
||||
await sweep({ resolveRecoveryAgentSeat: () => "operator-admin@kernel" });
|
||||
const [finding] = findingsFor(row);
|
||||
expect(finding!.destinationSession).toBe("operator-admin@kernel");
|
||||
expect(finding!.body).toContain("so this finding goes to operator-admin@kernel, the operator agent (agents.operator_session)");
|
||||
});
|
||||
|
||||
it("with no operator agent, a self-sent row's finding stays with the seat and says so", async () => {
|
||||
seat("front", "door.root", "root@front");
|
||||
const row = await failedWake("root@front", "root@front");
|
||||
await sweep();
|
||||
const [finding] = findingsFor(row);
|
||||
expect(finding!.destinationSession).toBe("root@front");
|
||||
expect(finding!.body).toContain("its creator and the operator agent (agents.operator_session) are each unset, missing, this seat, OpenRig's own machinery, a terminal seat or not an address this queue takes, so this finding stays addressed to it");
|
||||
});
|
||||
|
||||
// Rows that OpenRig's own machinery creates (stream intake and startup write them as daemon@kernel) have no creator that
|
||||
// can act on a finding.
|
||||
it.each(["daemon@kernel", "stuck-sweep@system"])("a row created by OpenRig's machinery (%s) gets the operator agent", async (machinery) => {
|
||||
seat("front", "door.root", "root@front");
|
||||
const row = await failedWake("root@front", machinery);
|
||||
await sweep({ resolveRecoveryAgentSeat: () => "operator-admin@kernel" });
|
||||
const [finding] = findingsFor(row);
|
||||
expect(finding!.destinationSession).toBe("operator-admin@kernel");
|
||||
expect(finding!.body).toContain("so this finding goes to operator-admin@kernel, the operator agent (agents.operator_session)");
|
||||
});
|
||||
|
||||
it("reads a seat's delegation parent again after the pass writes a finding", async () => {
|
||||
const lead = seat("lane", "work.lead", "lead@lane");
|
||||
const first = await failedWake("lead@lane");
|
||||
const second = await failedWake("lead@lane");
|
||||
// Another writer declares the seat's parent (with no session yet) while the pass awaits its first finding's write.
|
||||
const parent = seat("lane", "coord.owner", "owner@lane", false);
|
||||
const create = queue.create.bind(queue);
|
||||
let declared = false;
|
||||
vi.spyOn(queue, "create").mockImplementation(async (input) => {
|
||||
const created = await create(input);
|
||||
if (!declared && (input.tags ?? []).includes(STUCK_SWEEP_FINDING_TAG)) {
|
||||
declared = true;
|
||||
rigs.addEdge(lead.rig.id, parent.node.id, lead.node.id, "delegates_to");
|
||||
}
|
||||
return created;
|
||||
});
|
||||
await sweep();
|
||||
const routes = [first, second].map((row) => findingsFor(row)[0]!.destinationSession);
|
||||
// The first finding was routed before the parent existed; the second sees it and keeps the parent's fallback.
|
||||
expect(routes.sort()).toEqual(["lead@lane", "sender@lane"]);
|
||||
});
|
||||
|
||||
it("passes over a creator the queue doesn't take as an address", async () => {
|
||||
queue = new QueueRepository(db, new EventBus(db), {
|
||||
validateRig: (destination) => destination !== "sender@ops@elsewhere", loadHumanRegistry: () => ({ ok: true, entities: [] }),
|
||||
});
|
||||
seat("front", "door.root", "root@front");
|
||||
const row = await failedWake("root@front", "sender@ops@elsewhere");
|
||||
await sweep({ resolveRecoveryAgentSeat: () => "operator-admin@kernel" });
|
||||
expect(findingsFor(row)[0]!.destinationSession).toBe("operator-admin@kernel");
|
||||
});
|
||||
|
||||
// A bound terminal seat (a shell or a TUI) can't act on a finding: no wake is typed into it, and nothing there claims
|
||||
// work. The no-wake rule passes it over, and so must this one.
|
||||
it("passes over a creator that is a bound terminal seat", async () => {
|
||||
const { rig } = seat("front", "door.root", "root@front");
|
||||
const logger = rigs.addNode(rig.id, "logger", { runtime: "terminal" });
|
||||
db.prepare("INSERT INTO sessions (id,node_id,session_name,status) VALUES (?,?,?,?)").run(`session-${++seq}`, logger.id, "logger@front", "running");
|
||||
db.prepare("INSERT INTO bindings (id,node_id,attachment_type,tmux_session,tmux_pane) VALUES (?,?,'tmux',?,'%2')").run("bind-logger", logger.id, "logger@front");
|
||||
const row = await failedWake("root@front", "logger@front");
|
||||
await sweep({ resolveRecoveryAgentSeat: () => "operator-admin@kernel" });
|
||||
const [finding] = findingsFor(row);
|
||||
expect(finding!.destinationSession).toBe("operator-admin@kernel");
|
||||
expect(finding!.body).toContain("so this finding goes to operator-admin@kernel, the operator agent (agents.operator_session)");
|
||||
});
|
||||
|
||||
it("a hosted member whose ring failed keeps the no-wake rule's choice when that rule finds nobody", async () => {
|
||||
queue = new QueueRepository(db, new EventBus(db), {
|
||||
validateRig: () => true, loadHumanRegistry: () => ({ ok: true, entities: [] }),
|
||||
hostedWake: (destination) => destination === "grok@front" ? { webhook: { url_ref: "GROK_WAKE_URL" } } : null,
|
||||
});
|
||||
// A recorded seat with a session and no terminal: the sweep can read its topology, and it still can't be typed into.
|
||||
seat("front", "grok", "grok@front");
|
||||
expect(queue.classifyDestinationOf("grok@front").class).toBe("hosted");
|
||||
// A creator with no wake route here: the no-wake rule passes it over, and so must this one.
|
||||
const row = await failedWake("grok@front", "someone@elsewhere");
|
||||
db.prepare("UPDATE queue_items SET last_nudge_result = ? WHERE qitem_id = ?").run("failed:webhook (GROK_WAKE_URL) answered 500", row.qitemId);
|
||||
await sweep();
|
||||
const [finding] = findingsFor(row);
|
||||
expect(finding!.destinationSession).toBe("grok@front");
|
||||
expect(finding!.body).toContain("No person, creator or operator agent that can be reached is available");
|
||||
});
|
||||
|
||||
it("a declared delegation parent keeps the finding where it was, even when the parent has no session", async () => {
|
||||
const lead = seat("lane", "work.lead", "lead@lane");
|
||||
const parent = seat("lane", "coord.owner", "owner@lane", false);
|
||||
rigs.addEdge(lead.rig.id, parent.node.id, lead.node.id, "delegates_to");
|
||||
const row = await failedWake("lead@lane");
|
||||
await sweep();
|
||||
expect(findingsFor(row).map((f) => f.destinationSession)).toEqual(["lead@lane"]);
|
||||
});
|
||||
|
||||
it("a declared escalates_to that can't be used keeps its fallback", async () => {
|
||||
const door = seat("front", "door.root", "root@front");
|
||||
seat("ops", "watch.lead", "lead@ops", false);
|
||||
rigs.addEdge(door.rig.id, door.node.id, "lead@ops", "escalates_to");
|
||||
const row = await failedWake("root@front");
|
||||
await sweep();
|
||||
const [finding] = findingsFor(row);
|
||||
expect(finding!.destinationSession).toBe("root@front");
|
||||
expect(finding!.body).toContain("escalation route: escalates_to unavailable");
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,7 @@
|
||||
// Each case runs the real hook as a harness does (JSON on stdin, killed at the registration's outer bound), with a stub
|
||||
// `rig` and a stub trace script whose delays are set per case. The overlaps are hook processes overlapping; whether a
|
||||
// harness overlaps these events natively is not established here.
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { describe, expect, it, vi, type TestContext } from "vitest";
|
||||
import { spawn, spawnSync } from "node:child_process";
|
||||
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, readlinkSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
@@ -14,19 +14,17 @@ const PLUGIN = resolve(HERE, "../assets/plugins/openrig-core");
|
||||
const HOOK = join(PLUGIN, "hooks/scripts/refocus.cjs");
|
||||
const SEAT = "dev-impl@budget-rig";
|
||||
|
||||
let root: string | undefined;
|
||||
afterEach(() => {
|
||||
// A case that made the state folder read-only may have timed out before putting it back.
|
||||
if (root) try { chmodSync(join(root, "openrig", "refocus"), 0o755); } catch {}
|
||||
if (root) rmSync(root, { recursive: true, force: true });
|
||||
root = undefined;
|
||||
});
|
||||
|
||||
interface Fixture { home: string; bin: string; log: string }
|
||||
interface Fixture { root: string; home: string; bin: string; log: string }
|
||||
|
||||
/** Build isolated hook state and controllable lookup scripts for timing and overlap cases. */
|
||||
function fixture(): Fixture {
|
||||
root = mkdtempSync(join(tmpdir(), "refocus-budget-"));
|
||||
function fixture(onTestFinished: TestContext["onTestFinished"]): Fixture {
|
||||
const root = mkdtempSync(join(tmpdir(), "refocus-budget-"));
|
||||
// Bind cleanup to the owning test, including cases that construct two fixtures.
|
||||
onTestFinished(() => {
|
||||
// A case that made the state folder read-only may have timed out before putting it back.
|
||||
try { chmodSync(join(root, "openrig", "refocus"), 0o755); } catch {}
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
const home = join(root, "openrig");
|
||||
const bin = join(root, "bin");
|
||||
const log = join(root, "calls.log");
|
||||
@@ -55,20 +53,20 @@ echo "TRACE OK: work and topology"
|
||||
`);
|
||||
chmodSync(join(bin, "rig"), 0o755);
|
||||
chmodSync(join(bin, "trace"), 0o755);
|
||||
return { home, bin, log };
|
||||
return { root, home, bin, log };
|
||||
}
|
||||
|
||||
interface Run { killed: boolean; ms: number; stdout: string; stderr: string }
|
||||
|
||||
// Run the hook as the harness does, killed at `outerMs` (both registrations' bound for refocus: 60 s).
|
||||
function hook(f: Fixture, event: string, session: string, extra: Record<string, string> = {}, outerMs = 60_000, promptId?: string): Promise<Run> {
|
||||
const transcript = join(root!, `${session}.jsonl`);
|
||||
const transcript = join(f.root, `${session}.jsonl`);
|
||||
if (!existsSync(transcript)) writeFileSync(transcript, "");
|
||||
const started = Date.now();
|
||||
return new Promise((done) => {
|
||||
const child = spawn("node", [HOOK, "--runtime", "claude"], {
|
||||
env: {
|
||||
PATH: `${f.bin}:${process.env.PATH}`, HOME: root!, OPENRIG_HOME: f.home, OPENRIG_SESSION_NAME: SEAT,
|
||||
PATH: `${f.bin}:${process.env.PATH}`, HOME: f.root, OPENRIG_HOME: f.home, OPENRIG_SESSION_NAME: SEAT,
|
||||
PYTHON: join(f.bin, "trace"), OPENRIG_REFOCUS_WORK_NODE: f.home, OPENRIG_WORKSPACE_ROOT: f.home,
|
||||
OPENRIG_TOPOLOGY_ROOT: f.home, ...extra,
|
||||
},
|
||||
@@ -103,9 +101,11 @@ describe("refocus: the registrations' outer bound", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("refocus: the time budget", () => {
|
||||
it("delivers a slow but successful trace when a longer budget is selected", async () => {
|
||||
const f = fixture();
|
||||
// Each budget case owns its files and child processes. Overlap their real waits;
|
||||
// attempts within a case and the overlapping-event scenarios below stay sequential.
|
||||
describe.concurrent("refocus: the time budget", () => {
|
||||
it("delivers a slow but successful trace when a longer budget is selected", async ({ expect, onTestFinished }) => {
|
||||
const f = fixture(onTestFinished);
|
||||
await hook(f, "PostCompact", "s1");
|
||||
const r = await hook(f, "UserPromptSubmit", "s1", { OPENRIG_REFOCUS_BUDGET_MS: "10000", STUB_TRACE_MS: "2500" });
|
||||
expect(r.killed).toBe(false);
|
||||
@@ -115,8 +115,8 @@ describe("refocus: the time budget", () => {
|
||||
expect(stateOf(f, "s1")["pendingOn"]).toBeUndefined();
|
||||
}, 20_000);
|
||||
|
||||
it("ends a trace that never finishes as a gap inside the selected budget, keeping the refocus due", async () => {
|
||||
const f = fixture();
|
||||
it("ends a trace that never finishes as a gap inside the selected budget, keeping the refocus due", async ({ expect, onTestFinished }) => {
|
||||
const f = fixture(onTestFinished);
|
||||
await hook(f, "PostCompact", "s1");
|
||||
const r = await hook(f, "UserPromptSubmit", "s1", { OPENRIG_REFOCUS_BUDGET_MS: "10000", STUB_TRACE_MS: "30000" });
|
||||
expect(r.killed).toBe(false);
|
||||
@@ -125,9 +125,9 @@ describe("refocus: the time budget", () => {
|
||||
expect(stateOf(f, "s1")).toMatchObject({ pendingOn: "PostCompact" });
|
||||
}, 20_000);
|
||||
|
||||
it("limits a selected budget to 52 s, so reporting and recording the outcome still end inside the 60 s bound", async () => {
|
||||
it("limits a selected budget to 52 s, so reporting and recording the outcome still end inside the 60 s bound", async ({ expect, onTestFinished }) => {
|
||||
// At 52 s the trace's cap is 23.1 s: a 23.5 s trace ends as a gap (at 55 s its cap would have been 24.4 s).
|
||||
const f = fixture();
|
||||
const f = fixture(onTestFinished);
|
||||
await hook(f, "PostCompact", "s1");
|
||||
const r = await hook(f, "UserPromptSubmit", "s1", { OPENRIG_REFOCUS_BUDGET_MS: "120000", STUB_TRACE_MS: "23500" });
|
||||
expect(r.killed).toBe(false);
|
||||
@@ -137,8 +137,8 @@ describe("refocus: the time budget", () => {
|
||||
expect(stateOf(f, "s1")).toMatchObject({ pendingOn: "PostCompact" });
|
||||
}, 40_000);
|
||||
|
||||
it("keeps today's 2 s trace cap when no budget is selected", async () => {
|
||||
const f = fixture();
|
||||
it("keeps today's 2 s trace cap when no budget is selected", async ({ expect, onTestFinished }) => {
|
||||
const f = fixture(onTestFinished);
|
||||
await hook(f, "PostCompact", "s1");
|
||||
const r = await hook(f, "UserPromptSubmit", "s1", { STUB_TRACE_MS: "2500" }, 5_000);
|
||||
expect(r.killed).toBe(false);
|
||||
@@ -147,10 +147,10 @@ describe("refocus: the time budget", () => {
|
||||
expect(stateOf(f, "s1")).toMatchObject({ pendingOn: "PostCompact" });
|
||||
}, 20_000);
|
||||
|
||||
it("with no budget selected, gives main's result: each lookup keeps its 2 s cap, unclipped, inside the 60 s bound", async () => {
|
||||
it("with no budget selected, gives main's result: each lookup keeps its 2 s cap, unclipped, inside the 60 s bound", async ({ expect, onTestFinished }) => {
|
||||
// Lookups each under their own 2 s cap (with room for a slow runner's process start-up) that together pass the 4.5 s lookup budget: on
|
||||
// main's 5 s registration this was killed; now it completes as main's code does.
|
||||
const f = fixture();
|
||||
const f = fixture(onTestFinished);
|
||||
const slow = { STUB_WHOAMI_MS: "1550", STUB_TRACE_MS: "1550", STUB_CONTEXT_MS: "1550", OPENRIG_REFOCUS_CONTENT_REF: "refocus/team.md",
|
||||
OPENRIG_REFOCUS_WORK_NODE: "", OPENRIG_REFOCUS_TREES: "both" };
|
||||
await hook(f, "PostCompact", "s1");
|
||||
@@ -160,7 +160,7 @@ describe("refocus: the time budget", () => {
|
||||
expect(context(r)).toContain("CONFIGURED REFOCUS CONTENT");
|
||||
expect(stateOf(f, "s1")).toMatchObject({ firedOn: "UserPromptSubmit" });
|
||||
// A content-REF seat with a slow whoami gets main's full 2 s trace, not a shorter one (completes on main in ~2.9 s).
|
||||
const g = fixture();
|
||||
const g = fixture(onTestFinished);
|
||||
await hook(g, "PostCompact", "s1");
|
||||
const ref = await hook(g, "UserPromptSubmit", "s1", { STUB_WHOAMI_MS: "1000", STUB_TRACE_MS: "1500", STUB_CONTEXT_MS: "300",
|
||||
OPENRIG_REFOCUS_CONTENT_REF: "refocus/team.md", OPENRIG_REFOCUS_WORK_NODE: "", OPENRIG_REFOCUS_TREES: "both" });
|
||||
@@ -168,10 +168,10 @@ describe("refocus: the time budget", () => {
|
||||
expect(context(ref)).not.toContain("TRACE GAP");
|
||||
}, 40_000);
|
||||
|
||||
it("with no budget selected, still files the issue report after three slow failed attempts that ran past 4.5 s", async () => {
|
||||
it("with no budget selected, still files the issue report after three slow failed attempts that ran past 4.5 s", async ({ expect, onTestFinished }) => {
|
||||
// Default lookups keep their fixed 2 s caps, so a failed default attempt can end around 6 s. Its report gets its
|
||||
// own window after that (the stub answers a report after 200 ms), so the third failure is reported and confirmed.
|
||||
const f = fixture();
|
||||
const f = fixture(onTestFinished);
|
||||
const slow = { STUB_WHOAMI_MS: "1900", STUB_TRACE_MS: "2500", STUB_CONTEXT_MS: "1900", OPENRIG_REFOCUS_CONTENT_REF: "refocus/team.md",
|
||||
OPENRIG_REFOCUS_WORK_NODE: "", OPENRIG_REFOCUS_TREES: "both" };
|
||||
await hook(f, "PostCompact", "s1");
|
||||
@@ -185,9 +185,9 @@ describe("refocus: the time budget", () => {
|
||||
expect(readFileSync(f.log, "utf8").split("\n").filter((l) => l.startsWith("rig stream")).length).toBe(1);
|
||||
}, 60_000);
|
||||
|
||||
it("with a budget selected, ends lookups that together pass it as a reported gap, and records the failure", async () => {
|
||||
it("with a budget selected, ends lookups that together pass it as a reported gap, and records the failure", async ({ expect, onTestFinished }) => {
|
||||
// A 4.6 s budget gives each lookup a ~2 s cap; three of them pass the budget, so the last is clipped.
|
||||
const f = fixture();
|
||||
const f = fixture(onTestFinished);
|
||||
const slow = { OPENRIG_REFOCUS_BUDGET_MS: "4600", STUB_WHOAMI_MS: "1950", STUB_TRACE_MS: "1950", STUB_CONTEXT_MS: "1950",
|
||||
OPENRIG_REFOCUS_CONTENT_REF: "refocus/team.md", OPENRIG_REFOCUS_WORK_NODE: "", OPENRIG_REFOCUS_TREES: "both" };
|
||||
await hook(f, "PostCompact", "s1");
|
||||
@@ -204,13 +204,13 @@ describe("refocus: the time budget", () => {
|
||||
});
|
||||
|
||||
describe("refocus: overlapping events for one occupant", () => {
|
||||
it("preserves a PostCompact that lands while an earlier refocus is being delivered", async () => {
|
||||
const f = fixture();
|
||||
it("preserves a PostCompact that lands while an earlier refocus is being delivered", async ({ expect, onTestFinished }) => {
|
||||
const f = fixture(onTestFinished);
|
||||
await hook(f, "UserPromptSubmit", "s1", { OPENRIG_REFOCUS_NOW: "1" });
|
||||
const file = join(f.home, "refocus", `${SEAT}__s1.json`);
|
||||
writeFileSync(file, JSON.stringify({ ...stateOf(f, "s1"), pendingOn: "Stop", pendingAt: "2026-10-18T17:00:00.000Z" }));
|
||||
const ready = join(root!, "trace-ready");
|
||||
const release = join(root!, "trace-release");
|
||||
const ready = join(f.root, "trace-ready");
|
||||
const release = join(f.root, "trace-release");
|
||||
// Hold delivery after it has read the state; process start-up time cannot choose the interleaving.
|
||||
const delivering = hook(f, "UserPromptSubmit", "s1", {
|
||||
OPENRIG_REFOCUS_BUDGET_MS: "52000", STUB_TRACE_READY: ready, STUB_TRACE_RELEASE: release,
|
||||
@@ -230,11 +230,11 @@ describe("refocus: overlapping events for one occupant", () => {
|
||||
expect(stateOf(f, "s1")["pendingOn"]).toBeUndefined();
|
||||
}, 20_000);
|
||||
|
||||
it("delivers a pending refocus once when two handlers overlap", async () => {
|
||||
const f = fixture();
|
||||
it("delivers a pending refocus once when two handlers overlap", async ({ expect, onTestFinished }) => {
|
||||
const f = fixture(onTestFinished);
|
||||
await hook(f, "PostCompact", "s1");
|
||||
const ready = join(root!, "trace-ready");
|
||||
const release = join(root!, "trace-release");
|
||||
const ready = join(f.root, "trace-ready");
|
||||
const release = join(f.root, "trace-release");
|
||||
const first = hook(f, "UserPromptSubmit", "s1", {
|
||||
OPENRIG_REFOCUS_BUDGET_MS: "10000", STUB_TRACE_READY: ready, STUB_TRACE_RELEASE: release,
|
||||
}, 60_000, "prompt-A");
|
||||
@@ -252,11 +252,11 @@ describe("refocus: overlapping events for one occupant", () => {
|
||||
expect(context(await hook(f, "UserPromptSubmit", "s1"))).toBe("");
|
||||
}, 20_000);
|
||||
|
||||
it("retains a distinct identified on-demand prompt while an earlier prompt is rendering", async () => {
|
||||
const f = fixture();
|
||||
it("retains a distinct identified on-demand prompt while an earlier prompt is rendering", async ({ expect, onTestFinished }) => {
|
||||
const f = fixture(onTestFinished);
|
||||
await hook(f, "PostCompact", "s1");
|
||||
const ready = join(root!, "trace-ready");
|
||||
const release = join(root!, "trace-release");
|
||||
const ready = join(f.root, "trace-ready");
|
||||
const release = join(f.root, "trace-release");
|
||||
const first = hook(f, "UserPromptSubmit", "s1", {
|
||||
OPENRIG_REFOCUS_BUDGET_MS: "10000", STUB_TRACE_READY: ready, STUB_TRACE_RELEASE: release,
|
||||
}, 60_000, "prompt-A");
|
||||
@@ -273,19 +273,19 @@ describe("refocus: overlapping events for one occupant", () => {
|
||||
expect(context(await hook(f, "UserPromptSubmit", "s1", {}, 60_000, "prompt-D"))).toBe("");
|
||||
}, 20_000);
|
||||
|
||||
it("delivers once per identified prompt even if the second handler starts after completion", async () => {
|
||||
const f = fixture();
|
||||
it("delivers once per identified prompt even if the second handler starts after completion", async ({ expect, onTestFinished }) => {
|
||||
const f = fixture(onTestFinished);
|
||||
const env = { OPENRIG_REFOCUS_NOW: "1" };
|
||||
expect(context(await hook(f, "UserPromptSubmit", "s1", env, 60_000, "prompt-1"))).toContain("REFOCUS");
|
||||
expect(context(await hook(f, "UserPromptSubmit", "s1", env, 60_000, "prompt-1"))).toBe("");
|
||||
expect(context(await hook(f, "UserPromptSubmit", "s1", env, 60_000, "prompt-2"))).toContain("REFOCUS");
|
||||
});
|
||||
|
||||
it("recovers pending work after the rendering hook is killed", async () => {
|
||||
const f = fixture();
|
||||
it("recovers pending work after the rendering hook is killed", async ({ expect, onTestFinished }) => {
|
||||
const f = fixture(onTestFinished);
|
||||
await hook(f, "PostCompact", "s1");
|
||||
const ready = join(root!, "trace-ready");
|
||||
const release = join(root!, "trace-release");
|
||||
const ready = join(f.root, "trace-ready");
|
||||
const release = join(f.root, "trace-release");
|
||||
const interrupted = hook(f, "UserPromptSubmit", "s1", {
|
||||
OPENRIG_REFOCUS_BUDGET_MS: "10000", STUB_TRACE_READY: ready, STUB_TRACE_RELEASE: release,
|
||||
}, 1000);
|
||||
@@ -302,8 +302,8 @@ describe("refocus: overlapping events for one occupant", () => {
|
||||
expect(context(await hook(f, "UserPromptSubmit", "s1"))).toBe("");
|
||||
});
|
||||
|
||||
it("uses a shared successor guard after its former owner exits", async () => {
|
||||
const f = fixture();
|
||||
it("uses a shared successor guard after its former owner exits", async ({ expect, onTestFinished }) => {
|
||||
const f = fixture(onTestFinished);
|
||||
await hook(f, "PostCompact", "s1");
|
||||
const guard = join(f.home, "refocus", `${SEAT}__s1.json.guard`);
|
||||
const owner = spawnSync(process.execPath, ["-e", ""]).pid;
|
||||
@@ -316,8 +316,8 @@ describe("refocus: overlapping events for one occupant", () => {
|
||||
expect(readlinkSync(guard)).toBe(token);
|
||||
});
|
||||
|
||||
it("does not take a successor when the old guard was released before the owner exited", async () => {
|
||||
const f = fixture();
|
||||
it("does not take a successor when the old guard was released before the owner exited", async ({ expect, onTestFinished }) => {
|
||||
const f = fixture(onTestFinished);
|
||||
await hook(f, "PostCompact", "s1");
|
||||
const stateFile = join(f.home, "refocus", `${SEAT}__s1.json`);
|
||||
const guard = stateFile + ".guard";
|
||||
@@ -344,8 +344,8 @@ describe("refocus: overlapping events for one occupant", () => {
|
||||
expect(readlinkSync(guard)).toBe(liveToken);
|
||||
});
|
||||
|
||||
it("reports an unreadable guard and leaves the pending compaction available", async () => {
|
||||
const f = fixture();
|
||||
it("reports an unreadable guard and leaves the pending compaction available", async ({ expect, onTestFinished }) => {
|
||||
const f = fixture(onTestFinished);
|
||||
await hook(f, "UserPromptSubmit", "s1");
|
||||
const guard = join(f.home, "refocus", `${SEAT}__s1.json.guard`);
|
||||
mkdirSync(guard);
|
||||
@@ -359,8 +359,8 @@ describe("refocus: overlapping events for one occupant", () => {
|
||||
expect(context(await hook(f, "UserPromptSubmit", "s1"))).toBe("");
|
||||
});
|
||||
|
||||
it("still delivers to each of two occupants of one seat when they overlap", async () => {
|
||||
const f = fixture();
|
||||
it("still delivers to each of two occupants of one seat when they overlap", async ({ expect, onTestFinished }) => {
|
||||
const f = fixture(onTestFinished);
|
||||
await hook(f, "PostCompact", "s1");
|
||||
await hook(f, "PostCompact", "s2");
|
||||
const runs = await Promise.all([
|
||||
@@ -372,8 +372,8 @@ describe("refocus: overlapping events for one occupant", () => {
|
||||
});
|
||||
|
||||
describe("refocus: state left by an earlier candidate", () => {
|
||||
it("ignores a delivery claim and pendingSeq left on disk, so a resumed hook still delivers what is due", async () => {
|
||||
const f = fixture();
|
||||
it("ignores a delivery claim and pendingSeq left on disk, so a resumed hook still delivers what is due", async ({ expect, onTestFinished }) => {
|
||||
const f = fixture(onTestFinished);
|
||||
await hook(f, "PostCompact", "s1");
|
||||
const file = join(f.home, "refocus", `${SEAT}__s1.json`);
|
||||
writeFileSync(file, JSON.stringify({ ...stateOf(f, "s1"), pendingSeq: 3,
|
||||
|
||||
@@ -50,9 +50,20 @@ class FakeTmux {
|
||||
if (!found) throw new Error(`can't find session: ${target}`);
|
||||
return `${found[1].pane}|0|/tmp|80|24|1\n`;
|
||||
}
|
||||
// The stop's identity read asks for the session id with its server's pid and start time.
|
||||
if (command.startsWith("tmux display-message") && command.includes("#{pid}")) return byTarget() ? `${byTarget()![1].id}|4242|1700000000\n` : "\n";
|
||||
if (command.startsWith("tmux display-message") && command.includes("session_id")) return `${byTarget()?.[1].id ?? ""}\n`;
|
||||
if (command.startsWith("tmux display-message") && command.includes("pane_current_command")) return byTarget() ? "zsh\n" : "";
|
||||
if (command.startsWith("tmux capture-pane")) return byTarget() ? "user@host ~ % \n" : "";
|
||||
if (command.startsWith("tmux if-shell -F") && command.includes("#{==:#{session_id},")) {
|
||||
// A stop held to the session id and server it read: one branch runs, in the same command.
|
||||
const found = byTarget();
|
||||
const wantId = /#\{==:#\{session_id\},(\$\d+)\}/.exec(command)?.[1];
|
||||
const same = found && found[1].id === wantId && command.includes("#{==:#{pid},4242}") && command.includes("#{==:#{start_time},1700000000}");
|
||||
if (!same) return "openrig-stop-target-changed\n";
|
||||
if (command.includes("kill-session")) this.sessions.delete(found[0]);
|
||||
return "";
|
||||
}
|
||||
if (command.startsWith("tmux if-shell -F")) {
|
||||
// Real tmux expands the format against the target session, then runs one branch in the same command.
|
||||
const found = byTarget();
|
||||
|
||||
@@ -27,6 +27,8 @@ import { ClaudeCodeAdapter } from "../src/adapters/claude-code-adapter.js";
|
||||
import { replaceFile } from "../src/adapters/replace-file.js";
|
||||
import { shellQuote } from "../src/adapters/shell-quote.js";
|
||||
import { claudeActivityRelayPath } from "../src/domain/claude-activity-hooks.js";
|
||||
import { SeatDeliveryGuard, resolveGuardTarget } from "../src/domain/seat-delivery-guard.js";
|
||||
import { setDaemonTmuxServer } from "../src/adapters/tmux-server.js";
|
||||
|
||||
|
||||
function mockTmux(killResult?: { ok: boolean; code?: string; message?: string }): TmuxAdapter {
|
||||
@@ -1343,3 +1345,144 @@ describe("RigTeardownOrchestrator", () => {
|
||||
eventBus.persistWithinTransaction = origPersist;
|
||||
});
|
||||
});
|
||||
|
||||
// rig down stops only the seat's own terminal. On the production path (the guard installed, the server chosen by execution
|
||||
// args, every write as argv), a session is stopped only when it carries the seat's OpenRig marker, and each effect runs
|
||||
// only on the server and session id it was read from. Each case runs on its own private tmux server.
|
||||
describe.skipIf(process.platform === "win32")("rig down leaves a terminal it can't show to be the seat's", () => {
|
||||
const NAME = "dev-impl@owned";
|
||||
const exec = promisify(execFile);
|
||||
|
||||
async function harness(hook?: (argv: string[], tmux: (...a: string[]) => Promise<string>) => Promise<void>) {
|
||||
// Short socket path for macOS sockaddr_un, no inherited server, no config.
|
||||
const home = fs.mkdtempSync(path.join(os.tmpdir(), "rd-"));
|
||||
const env: NodeJS.ProcessEnv = { PATH: process.env.PATH, HOME: home, TMUX_TMPDIR: home, SHELL: "/bin/sh" };
|
||||
const tmux = async (...args: string[]) => (await exec("tmux", ["-L", "own", "-f", "/dev/null", ...args], { env, timeout: 5000 })).stdout.trim();
|
||||
const adapter = new TmuxAdapter(async () => { throw new Error("unexpected shell execution"); }, undefined, async (argv) => {
|
||||
const out = (await exec(argv[0]!, argv.slice(1), { env, timeout: 5000 })).stdout;
|
||||
await hook?.(argv, tmux);
|
||||
return out;
|
||||
}, ["-L", "own", "-f", "/dev/null"]);
|
||||
const db = createDb();
|
||||
migrate(db, ALL_MIGRATIONS);
|
||||
const rigRepo = new RigRepository(db), sessionRegistry = new SessionRegistry(db), eventBus = new EventBus(db);
|
||||
const guard = new SeatDeliveryGuard(db, (target) => resolveGuardTarget(db, target));
|
||||
guard.recoverActivation();
|
||||
adapter.deliveryGuard = guard;
|
||||
const rig = rigRepo.createRig("owned");
|
||||
const node = rigRepo.addNode(rig.id, "dev.impl", { runtime: "claude-code", cwd: home });
|
||||
const bind = async () => {
|
||||
const pane = (await tmux("list-panes", "-t", `=${NAME}:`, "-F", "#{pane_id}")).split("\n")[0]!;
|
||||
const session = sessionRegistry.registerSession(node.id, NAME);
|
||||
sessionRegistry.updateStatus(session.id, "running");
|
||||
sessionRegistry.updateBinding(node.id, { attachmentType: "tmux", tmuxSession: NAME, tmuxPane: pane });
|
||||
return session;
|
||||
};
|
||||
const teardown = () => new RigTeardownOrchestrator({ db, rigRepo, sessionRegistry, eventBus, tmuxAdapter: adapter,
|
||||
snapshotCapture: mockSnapshotCapture(db) }).teardown(rig.id, { snapshot: false });
|
||||
const alive = (name: string) => tmux("has-session", "-t", `=${name}`).then(() => true, () => false);
|
||||
const close = async () => { await tmux("kill-server").catch(() => {}); db.close(); fs.rmSync(home, { recursive: true, force: true }); };
|
||||
return { tmux, node, bind, teardown, alive, close, sessionRegistry, db };
|
||||
}
|
||||
|
||||
// What's left after a refusal: the session row still running and its binding intact, so nothing reads as stopped.
|
||||
function expectLeftRunning(h: Awaited<ReturnType<typeof harness>>, sessionId: string, result: { sessionsKilled: number; errors: string[] }, reason: RegExp) {
|
||||
expect(result.sessionsKilled).toBe(0);
|
||||
expect(result.errors).toHaveLength(1);
|
||||
expect(result.errors[0]).toMatch(new RegExp(`^Kill failed for session '${NAME}': '${NAME}' was left running: its tmux session \\$\\d+ ${reason.source}`));
|
||||
expect(h.sessionRegistry.getBindingForNode(h.node.id)?.tmuxSession).toBe(NAME);
|
||||
expect(h.db.prepare("SELECT status FROM sessions WHERE id=?").get(sessionId)).toEqual({ status: "running" });
|
||||
}
|
||||
|
||||
it("the seat's own session, marked with its node, is stopped as before", async () => {
|
||||
const h = await harness();
|
||||
try {
|
||||
await h.tmux("new-session", "-d", "-s", "keepalive", "sleep 300");
|
||||
await h.tmux("new-session", "-d", "-s", NAME, "-e", `OPENRIG_NODE_ID=${h.node.id}`, "sleep 300");
|
||||
await h.bind();
|
||||
expect(await h.teardown()).toMatchObject({ sessionsKilled: 1, errors: [] });
|
||||
expect(await h.alive(NAME)).toBe(false);
|
||||
expect(await h.alive("keepalive")).toBe(true);
|
||||
expect(h.sessionRegistry.getBindingForNode(h.node.id)).toBeNull();
|
||||
} finally { await h.close(); }
|
||||
});
|
||||
|
||||
it("after the tmux server is replaced, another terminal with the seat's name and its old ids survives", async () => {
|
||||
const h = await harness();
|
||||
try {
|
||||
await h.tmux("new-session", "-d", "-s", NAME, "-e", `OPENRIG_NODE_ID=${h.node.id}`, "sleep 300");
|
||||
const session = await h.bind();
|
||||
expect(h.sessionRegistry.getBindingForNode(h.node.id)?.tmuxPane).toBe("%0");
|
||||
await h.tmux("kill-server");
|
||||
await h.tmux("new-session", "-d", "-s", NAME, "sleep 301");
|
||||
expect(await h.tmux("list-panes", "-t", `=${NAME}:`, "-F", "#{session_id} #{pane_id}")).toBe("$0 %0");
|
||||
expectLeftRunning(h, session.id, await h.teardown(), /doesn't carry this seat's OpenRig marker \(OPENRIG_NODE_ID\)/);
|
||||
expect(await h.alive(NAME)).toBe(true);
|
||||
} finally { await h.close(); }
|
||||
});
|
||||
|
||||
it("a session with the seat's name but no marker (adopted, bound or older) is left running, not stopped", async () => {
|
||||
const h = await harness();
|
||||
// The daemon is on its own server here, so the manual path must name it: a plain `tmux` reaches another server.
|
||||
setDaemonTmuxServer(["-L", "own"]);
|
||||
try {
|
||||
await h.tmux("new-session", "-d", "-s", NAME, "sleep 300");
|
||||
const session = await h.bind();
|
||||
const result = await h.teardown();
|
||||
expectLeftRunning(h, session.id, result, /doesn't carry this seat's OpenRig marker \(OPENRIG_NODE_ID\)/);
|
||||
expect(result.errors[0]).toContain(`If it is this seat's, look first with \`tmux -L own attach -t '=${NAME}'\`, `
|
||||
+ `then stop it with \`tmux -L own kill-session -t '=${NAME}'\` and run the command again.`);
|
||||
expect(await h.alive(NAME)).toBe(true);
|
||||
} finally { setDaemonTmuxServer([]); await h.close(); }
|
||||
});
|
||||
|
||||
it("a session marked with another seat's node is left running", async () => {
|
||||
const h = await harness();
|
||||
try {
|
||||
await h.tmux("new-session", "-d", "-s", NAME, "-e", "OPENRIG_NODE_ID=another-node", "sleep 300");
|
||||
const session = await h.bind();
|
||||
expectLeftRunning(h, session.id, await h.teardown(), /carries another seat's OpenRig marker \(OPENRIG_NODE_ID=another-node\)/);
|
||||
expect(await h.alive(NAME)).toBe(true);
|
||||
} finally { await h.close(); }
|
||||
});
|
||||
|
||||
it("on the same server, a replacement with the seat's name (a different pane) survives", async () => {
|
||||
const h = await harness();
|
||||
try {
|
||||
// The keepalive comes first, so the server stays the same one throughout.
|
||||
await h.tmux("new-session", "-d", "-s", "keepalive", "sleep 300");
|
||||
await h.tmux("new-session", "-d", "-s", NAME, "-e", `OPENRIG_NODE_ID=${h.node.id}`, "sleep 300");
|
||||
const session = await h.bind();
|
||||
await h.tmux("kill-session", "-t", `=${NAME}`);
|
||||
await h.tmux("new-session", "-d", "-s", NAME, "sleep 301");
|
||||
const result = await h.teardown();
|
||||
expect(result).toMatchObject({ sessionsKilled: 0 });
|
||||
expect(result.errors[0]).toMatch(/Managed pane identity unavailable or changed; no input written/);
|
||||
expect(await h.alive(NAME)).toBe(true);
|
||||
expect(h.db.prepare("SELECT status FROM sessions WHERE id=?").get(session.id)).toEqual({ status: "running" });
|
||||
} finally { await h.close(); }
|
||||
});
|
||||
|
||||
it("a server replaced between reading the seat's marker and stopping it leaves the new terminal alone", async () => {
|
||||
let replaced = false;
|
||||
const h = await harness(async (argv, tmux) => {
|
||||
// Just after the marker is read on the seat's server, that server is replaced, and another terminal takes the name and
|
||||
// the old ids.
|
||||
if (replaced || !argv.includes("show-environment")) return;
|
||||
replaced = true;
|
||||
await tmux("kill-server");
|
||||
await tmux("new-session", "-d", "-s", NAME, "sleep 301");
|
||||
});
|
||||
try {
|
||||
await h.tmux("new-session", "-d", "-s", NAME, "-e", `OPENRIG_NODE_ID=${h.node.id}`, "sleep 300");
|
||||
const session = await h.bind();
|
||||
const result = await h.teardown();
|
||||
expect(replaced).toBe(true);
|
||||
expect(result.sessionsKilled).toBe(0);
|
||||
expect(result.errors).toEqual([`Kill failed for session '${NAME}': $0 is no longer that session on the tmux server it was read from `
|
||||
+ "(the server may have been replaced); nothing was detached or killed."]);
|
||||
expect(await h.tmux("list-panes", "-t", `=${NAME}:`, "-F", "#{session_id} #{pane_id}")).toBe("$0 %0");
|
||||
expect(h.db.prepare("SELECT status FROM sessions WHERE id=?").get(session.id)).toEqual({ status: "running" });
|
||||
} finally { await h.close(); }
|
||||
});
|
||||
});
|
||||
@@ -129,7 +129,7 @@ function fixture(options: { owner?: string | null; guest?: boolean; realGuest?:
|
||||
const grants = new ActivityGrantStore(db);
|
||||
/** OpenRig's pull-only records of the permits it decided by default. */
|
||||
const permits = () => stream.list({ hintTag: "runner-permission-permit", limit: 100 });
|
||||
return { db, runner, other, sessions, queueRepo, contract, nudges, requesters, generation, submit, read, withdraw, answer, act, decide, waitsOnPerson, rowsToSeat, grants,
|
||||
return { db, bus, runner, other, sessions, queueRepo, contract, nudges, requesters, generation, submit, read, withdraw, answer, act, decide, waitsOnPerson, rowsToSeat, grants,
|
||||
permits, relaunch: () => occupy(runner.id, SEAT, "handover") };
|
||||
}
|
||||
|
||||
@@ -717,7 +717,9 @@ describe("an answered request goes to the receiving agent: the pair's round-0 co
|
||||
it("Steering's Approve or Deny on the request is the person's yes or no: it goes to the agent, or, with none, to the request as before", async () => {
|
||||
const f = fixture({ decider: DECIDER });
|
||||
const askId = String((await f.submit()).body.askId);
|
||||
expect(await f.act(askId, "approve")).toMatchObject({ status: 200 });
|
||||
// The result says what became of the answer (the TUI prints it), never that the request closed.
|
||||
expect(await f.act(askId, "approve")).toMatchObject({ status: 200,
|
||||
body: { answer: { message: `Your answer on ${askId} went to ${DECIDER}, the receiving agent, which decides. The runner waits until then; nothing has run. A note to you says the agent's recorded state.` } } });
|
||||
expect(f.queueRepo.getById(askId)).toMatchObject({ state: "handed-off", handedOffTo: DECIDER });
|
||||
expect(await f.read(askId)).toMatchObject({ body: { state: "pending" } });
|
||||
expect(handedFrom(f, askId)!.body).toContain(`they wrote "yes"`);
|
||||
@@ -725,7 +727,8 @@ describe("an answered request goes to the receiving agent: the pair's round-0 co
|
||||
for (const verb of ["approve", "deny"] as const) {
|
||||
const g = fixture({ decider: null });
|
||||
const id = String((await g.submit()).body.askId);
|
||||
expect(await g.act(id, verb, { reason: `${verb}: only the test file` })).toMatchObject({ status: 200, body: { verb } });
|
||||
expect(await g.act(id, verb, { reason: `${verb}: only the test file` })).toMatchObject({ status: 200, body: { verb,
|
||||
answer: { message: expect.stringContaining(`Your answer is kept on ${id}, and the request is still waiting: the tool hasn't run`) } } });
|
||||
expect(await g.read(id)).toMatchObject({ body: { state: "pending" } });
|
||||
// The audit keeps what the person did: their verb, and their reason.
|
||||
expect(g.db.prepare("SELECT action_verb, reason FROM mission_control_actions WHERE qitem_id = ?").all(id))
|
||||
@@ -926,8 +929,152 @@ describe("an answered request: review-r2's round-1 questions", () => {
|
||||
await f.answer(askId, { decision: "only the test file" });
|
||||
const spy = vi.spyOn(f.queueRepo, "recordPermissionDecision").mockReturnValue(false);
|
||||
try {
|
||||
expect(await f.decide(askId, "allow")).toMatchObject({ status: 409, body: { ok: false, code: "not_recorded" } });
|
||||
expect(await f.decide(askId, "allow")).toMatchObject({ status: 409, body: { ok: false, code: "not_recorded",
|
||||
error: expect.stringContaining("Nothing was decided. Read the request again.") } });
|
||||
} finally { spy.mockRestore(); }
|
||||
expect(await f.read(askId)).toMatchObject({ body: { state: "pending" } });
|
||||
});
|
||||
});
|
||||
|
||||
describe("an answered request: #2934's follow-ups", () => {
|
||||
it("a decision on a row closed without one persists queue.updated, so a waiting runner and Steering hear it", async () => {
|
||||
const f = fixture({ decider: null });
|
||||
const askId = String((await f.submit()).body.askId);
|
||||
await f.answer(askId, { decision: "only the test file" });
|
||||
await f.queueRepo.update({ qitemId: askId, actorSession: PERSON, state: "done", closureReason: "no-follow-on" });
|
||||
const updates = () => (f.db.prepare("SELECT COUNT(*) AS n FROM events WHERE type = 'queue.updated' AND json_extract(payload, '$.qitemId') = ?").get(askId) as { n: number }).n;
|
||||
const before = updates();
|
||||
expect(await f.decide(askId, "allow")).toMatchObject({ body: { state: "answered", optionId: "allow" } });
|
||||
expect(updates()).toBe(before + 1);
|
||||
});
|
||||
|
||||
it("a handoff that fails after persisting its events still keeps the person's words (the envelope drops the rolled-back tokens)", async () => {
|
||||
const f = fixture({ decider: DECIDER });
|
||||
const askId = String((await f.submit()).body.askId);
|
||||
// The real handoff persists its two events, then fails at its last check: its savepoint rolls them back.
|
||||
const spy = vi.spyOn(f.queueRepo as unknown as { assertTerminalClosureHasIntent: () => void }, "assertTerminalClosureHasIntent")
|
||||
.mockImplementationOnce(() => { throw new Error("closure check failed"); });
|
||||
try {
|
||||
expect(await f.answer(askId, { decision: "only the test file" })).toMatchObject({ status: 200 });
|
||||
} finally { spy.mockRestore(); }
|
||||
expect(f.queueRepo.getById(askId)).toMatchObject({ state: "pending" });
|
||||
expect((f.queueRepo.getById(askId)!.humanAnswers?.permission as { text?: string })?.text).toBe("only the test file");
|
||||
expect(await f.read(askId)).toMatchObject({ body: { state: "pending" } });
|
||||
expect(f.permits()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("a handoff that throws after its own write was released rolls back whole, so the kept answer is the only outcome", async () => {
|
||||
const f = fixture({ decider: DECIDER });
|
||||
const askId = String((await f.submit()).body.askId);
|
||||
// The handoff's own transaction has released its savepoint; the throw comes after it, inside the caller's transaction.
|
||||
const spy = vi.spyOn(f.queueRepo as unknown as { deliverWakeIntentAfterCommit: () => void }, "deliverWakeIntentAfterCommit")
|
||||
.mockImplementationOnce(() => { throw new Error("failed after the handoff was written"); });
|
||||
try {
|
||||
expect(await f.answer(askId, { decision: "only the test file" })).toMatchObject({ status: 200 });
|
||||
} finally { spy.mockRestore(); }
|
||||
// Nothing of the handoff remains: the request wasn't handed on, and no row went to the receiving agent.
|
||||
expect(f.queueRepo.getById(askId)).toMatchObject({ state: "pending" });
|
||||
expect(f.db.prepare("SELECT COUNT(*) AS n FROM queue_items WHERE handed_off_from = ?").get(askId)).toEqual({ n: 0 });
|
||||
expect((f.queueRepo.getById(askId)!.humanAnswers?.permission as { text?: string })?.text).toBe("only the test file");
|
||||
expect(await f.read(askId)).toMatchObject({ body: { state: "pending" } });
|
||||
expect(f.permits()).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// The Slack fallback answers a direct runner ask with resolveDirectHumanReply itself (slack-subsystem.ts:179): no notify
|
||||
// envelope and no transaction is open around it.
|
||||
describe("a direct answer with no notify envelope (the Slack fallback)", () => {
|
||||
const watch = (f: ReturnType<typeof fixture>) => {
|
||||
const seen: Array<{ type: string; qitemId: string; to?: string; inTransaction: boolean }> = [];
|
||||
const stop = f.bus.subscribe((event) => {
|
||||
if (event.type === "queue.handed_off") seen.push({ type: event.type, qitemId: event.qitemId, to: event.toSession, inTransaction: f.db.inTransaction });
|
||||
if (event.type === "queue.created") seen.push({ type: event.type, qitemId: event.qitemId, to: event.destinationSession, inTransaction: f.db.inTransaction });
|
||||
});
|
||||
return { seen, stop };
|
||||
};
|
||||
|
||||
it("a handoff that throws after its own write delivers nothing to subscribers, and the answer is kept", async () => {
|
||||
const f = fixture({ decider: DECIDER });
|
||||
const askId = String((await f.submit()).body.askId);
|
||||
const { seen, stop } = watch(f);
|
||||
const spy = vi.spyOn(f.queueRepo as unknown as { deliverWakeIntentAfterCommit: () => void }, "deliverWakeIntentAfterCommit")
|
||||
.mockImplementationOnce(() => { throw new Error("failed after the handoff was written"); });
|
||||
try {
|
||||
f.queueRepo.resolveDirectHumanReply({ qitemId: askId, actorSession: PERSON, decision: "only the test file" });
|
||||
} finally { spy.mockRestore(); stop(); }
|
||||
expect(seen.filter((e) => (e.type === "queue.handed_off" && e.qitemId === askId) || (e.type === "queue.created" && e.to === DECIDER))).toEqual([]);
|
||||
expect(f.queueRepo.getById(askId)).toMatchObject({ state: "pending" });
|
||||
expect(f.db.prepare("SELECT COUNT(*) AS n FROM queue_items WHERE handed_off_from = ?").get(askId)).toEqual({ n: 0 });
|
||||
expect((f.queueRepo.getById(askId)!.humanAnswers?.permission as { text?: string })?.text).toBe("only the test file");
|
||||
expect(f.permits()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("a handoff that succeeds reaches subscribers once, after it commits", async () => {
|
||||
const f = fixture({ decider: DECIDER });
|
||||
const askId = String((await f.submit()).body.askId);
|
||||
const { seen, stop } = watch(f);
|
||||
try {
|
||||
expect(f.queueRepo.resolveDirectHumanReply({ qitemId: askId, actorSession: PERSON, decision: "only the test file" })).toBe(true);
|
||||
} finally { stop(); }
|
||||
const successor = f.db.prepare("SELECT qitem_id FROM queue_items WHERE handed_off_from = ?").get(askId) as { qitem_id: string };
|
||||
expect(seen.filter((e) => e.qitemId === askId || e.qitemId === successor.qitem_id)).toEqual([
|
||||
{ type: "queue.handed_off", qitemId: askId, to: DECIDER, inTransaction: false },
|
||||
{ type: "queue.created", qitemId: successor.qitem_id, to: DECIDER, inTransaction: false },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
// A person answering from the TUI answers as the host's human seat, and a person-addressed row to a human seat must
|
||||
// carry an evidence ref (convention C3). Without one, both notes to the person were refused and never written.
|
||||
describe("the person's note about a runner answer is written under the queue's own conventions", () => {
|
||||
const HUMAN_SEAT = "human@host";
|
||||
const answerAsHost = (f: ReturnType<typeof fixture>, askId: string, choice: { optionId?: string; decision?: string }) =>
|
||||
f.contract.act({ verb: "answer", qitemId: askId, actorSession: HUMAN_SEAT, authority: "primary-user",
|
||||
...(choice.optionId ? { questionId: "permission", optionId: choice.optionId } : {}), ...(choice.decision ? { decision: choice.decision } : {}) } as never);
|
||||
const notesTo = (f: ReturnType<typeof fixture>) => f.queueRepo.list({ limit: 100 })
|
||||
.filter((row) => row.destinationSession === HUMAN_SEAT && row.humanIntent === "update");
|
||||
|
||||
it("with no agent that can decide, the kept-answer note is written once, linked to the request, with the direct route", async () => {
|
||||
const f = fixture({ decider: null });
|
||||
const askId = String((await f.submit()).body.askId);
|
||||
await answerAsHost(f, askId, { optionId: "allow" });
|
||||
await vi.waitFor(() => expect(notesTo(f)).toHaveLength(1));
|
||||
const [note] = notesTo(f);
|
||||
expect(note).toMatchObject({ qitemId: `qitem-runner-permission-kept-${askId.replace(/^qitem-runner-permission-/, "")}`,
|
||||
evidenceRef: `rig queue show ${askId}`, sourceSession: SEAT });
|
||||
expect(note!.body).toContain("The tool hasn't run, and nothing was permitted or refused.");
|
||||
expect(note!.body).toContain(`rig seat decide-permission ${askId} --option <id>`);
|
||||
// The answer itself is kept on the request, and the request still waits.
|
||||
expect(f.queueRepo.getById(askId)).toMatchObject({ state: "pending", humanAnswers: { permission: "allow" } });
|
||||
// Answering again keeps the new answer and writes no second note.
|
||||
await answerAsHost(f, askId, { decision: "only the test file" });
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
expect(notesTo(f)).toHaveLength(1);
|
||||
expect(f.permits()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("when the answer goes to an agent, the who-decides note is written once, linked to the request, with the agent's state", async () => {
|
||||
const f = fixture({ receiver: "dev-other@perm-rig" });
|
||||
const askId = String((await f.submit()).body.askId);
|
||||
await answerAsHost(f, askId, { decision: "only the test file" });
|
||||
await vi.waitFor(() => expect(notesTo(f)).toHaveLength(1));
|
||||
const [note] = notesTo(f);
|
||||
expect(note).toMatchObject({ evidenceRef: `rig queue show ${askId}`, sourceSession: SEAT });
|
||||
expect(note!.body).toContain(`went to dev-other@perm-rig, which decides it while ${SEAT} waits. Nothing runs until it decides.`);
|
||||
expect(note!.body).toContain("Receiving agent: dev-other@perm-rig, chosen in agents.operator_session; its session is recorded as running.");
|
||||
expect(note!.body).toContain(`rig seat decide-permission ${askId} --option <id>`);
|
||||
expect(f.queueRepo.getById(askId)).toMatchObject({ state: "handed-off", handedOffTo: "dev-other@perm-rig" });
|
||||
});
|
||||
|
||||
it("a runner already waiting when the answer is handed to the agent reads the agent's decision at once, not at its read's end", async () => {
|
||||
const f = fixture({ decider: DECIDER });
|
||||
const askId = String((await f.submit()).body.askId);
|
||||
const started = Date.now();
|
||||
const waiting = f.read(askId, 5);
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
expect(await f.answer(askId, { optionId: "allow" })).toMatchObject({ status: 200 });
|
||||
expect(await f.decide(askId, "allow")).toMatchObject({ status: 200, body: { state: "answered" } });
|
||||
expect(await waiting).toMatchObject({ body: { state: "answered", optionId: "allow" } });
|
||||
expect(Date.now() - started).toBeLessThan(2500);
|
||||
});
|
||||
});
|
||||
@@ -25,6 +25,8 @@ function fixture(hooks: { file?: () => Promise<void>; exec?: (command: string) =
|
||||
commands.push(command);
|
||||
return hooks.exec ? hooks.exec(command)
|
||||
: command.includes("list-panes") ? "%1|0|/inert|80|24|1\n"
|
||||
: command.startsWith("tmux show-environment") ? "OPENRIG_NODE_ID=a\n"
|
||||
: command.startsWith("tmux display-message") && command.includes("#{pid}") ? "$1|4242|1700000000"
|
||||
: command.includes("#{session_id}") ? "$1" : "";
|
||||
}, { writeFile: async () => { await hooks.file?.(); }, unlink: async () => {}, tmpName: () => "/inert/buffer", bufferName: () => "inert" });
|
||||
const guard = new SeatDeliveryGuard(db, name => resolveGuardTarget(db, name)); tmux.deliveryGuard = guard;
|
||||
@@ -103,7 +105,7 @@ describe("guard-wired teardown", () => {
|
||||
expect(f.sessionRegistry.getBindingForNode("a")?.tmuxPane).toBe("%1");
|
||||
await f.guard.set("a", false, "person", "stop");
|
||||
expect(await f.teardown.teardown("r")).toMatchObject({ sessionsKilled: 1, errors: [] });
|
||||
expect(f.commands).toContain("tmux kill-session -t '$1'");
|
||||
expect(f.commands.some(c => /^tmux if-shell -F -t '\$1' .*#\{==:#\{pid\},4242\}.*kill-session -t .*\$1/.test(c))).toBe(true);
|
||||
expect(f.commands.filter(c => c.includes("list-panes"))).toHaveLength(1);
|
||||
expect(f.db.prepare("SELECT status FROM sessions WHERE id='sa'").get()).toEqual({ status: "exited" });
|
||||
expect(f.sessionRegistry.getBindingForNode("a")).toBeNull(); f.db.close();
|
||||
|
||||
@@ -472,27 +472,27 @@ it("automatic reminder records retained custody and no delivered fire or input",
|
||||
});
|
||||
it("guarded session termination binds the immutable session ID, never recycled name",async()=>{
|
||||
const {db,guard}=fixture();const commands:string[]=[];
|
||||
const tmux=new TmuxAdapter(async command=>{commands.push(command);return command.includes("display-message")?"$123\n":"";});tmux.deliveryGuard=guard;
|
||||
const tmux=new TmuxAdapter(async command=>{commands.push(command);return command.startsWith("tmux show-environment")?"OPENRIG_NODE_ID=a\n":command.startsWith("tmux display-message")?"$123|4242|1700000000\n":"";});tmux.deliveryGuard=guard;
|
||||
vi.spyOn(tmux,"listPanes").mockResolvedValue([{id:"%1"} as never]);
|
||||
expect(await guard.lifecycle(["a"],()=>tmux.killSession("a"))).toEqual({ok:true});
|
||||
expect(commands.at(-1)).toBe("tmux kill-session -t '$123'");db.close();
|
||||
expect(commands.at(-1)).toMatch(/^tmux if-shell -F -t '\$123' '#\{&&:#\{==:#\{session_id\},\$123\},#\{&&:#\{==:#\{pid\},4242\},#\{==:#\{start_time\},1700000000\}\}\}' 'kill-session -t .*\$123.*' 'display-message -p openrig-stop-target-changed'$/);db.close();
|
||||
});
|
||||
|
||||
it("failed first binding compensates only its proven fresh session under the same lifecycle lease",async()=>{
|
||||
const f=queueFixture();f.db.exec("INSERT INTO nodes(id,rig_id,logical_id) VALUES ('c','rig','fresh');");
|
||||
const commands:string[]=[];const tmux=new TmuxAdapter(async command=>{commands.push(command);return command.includes("display-message")?"$44\n":"";});tmux.deliveryGuard=f.guard;
|
||||
const commands:string[]=[];const tmux=new TmuxAdapter(async command=>{commands.push(command);return command.startsWith("tmux show-environment")?"OPENRIG_NODE_ID=c\n":command.startsWith("tmux display-message")?"$44|4242|1700000000\n":"";});tmux.deliveryGuard=f.guard;
|
||||
vi.spyOn(tmux,"listPanes").mockResolvedValue([{id:"%3"} as never]);
|
||||
vi.spyOn(f.bus,"persistWithinTransaction").mockImplementation(()=>{throw Error("injected DB failure");});
|
||||
const launcher=new NodeLauncher({db:f.db,rigRepo:f.rigRepo,sessionRegistry:f.sessionRegistry,eventBus:f.bus,tmuxAdapter:tmux});
|
||||
expect(await launcher.launchNode("rig","fresh")).toMatchObject({ok:false,code:"db_error"});
|
||||
expect(commands.at(-1)).toBe("tmux kill-session -t '$44'");expect(f.guard.target("c").pane).toBeNull();f.db.close();
|
||||
expect(commands.at(-1)).toMatch(/^tmux if-shell -F -t '\$44' .*#\{==:#\{pid\},4242\}.*kill-session -t .*\$44/);expect(f.guard.target("c").pane).toBeNull();f.db.close();
|
||||
});
|
||||
it("private probe termination uses only its positively allocated session identity",async()=>{
|
||||
const {db,guard}=fixture();const commands:string[]=[];
|
||||
const tmux=new TmuxAdapter(async command=>{commands.push(command);return command.includes("display-message")?"$55\n":"";});tmux.deliveryGuard=guard;
|
||||
const tmux=new TmuxAdapter(async command=>{commands.push(command);return command.startsWith("tmux show-environment")?"OPENRIG_NODE_ID=none\n":command.startsWith("tmux display-message")?"$55|4242|1700000000\n":"";});tmux.deliveryGuard=guard;
|
||||
vi.spyOn(tmux,"listPanes").mockResolvedValue([{id:"%55"} as never]);
|
||||
expect(await tmux.createProbeSession("probe")).toEqual({ok:true});expect(await tmux.killSession("probe")).toEqual({ok:true});
|
||||
expect(commands.at(-1)).toBe("tmux kill-session -t '$55'");const count=commands.length;
|
||||
expect(commands.at(-1)).toMatch(/^tmux if-shell -F -t '\$55' .*#\{==:#\{pid\},4242\}.*kill-session -t .*\$55/);const count=commands.length;
|
||||
expect(await tmux.sendKeys("probe",["Enter"])).toMatchObject({ok:false});expect(commands).toHaveLength(count);db.close();
|
||||
});
|
||||
|
||||
|
||||
@@ -161,6 +161,31 @@ describe("PL-slice-story-view-v0 SliceIndexer", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("filters the requested mission before deduplicating a same-named slice", () => {
|
||||
const missionsRoot = path.join(cleanup, "qualified-missions");
|
||||
for (const mission of ["mission-one", "mission-two"]) {
|
||||
writeSlice(path.join(missionsRoot, mission, "slices"), "same-name", {
|
||||
"SPEC.md": `# ${mission}\n`,
|
||||
});
|
||||
}
|
||||
const traversedMissions = fs.readdirSync(missionsRoot);
|
||||
expect(traversedMissions).toHaveLength(2);
|
||||
const requestedMission = traversedMissions[1]!;
|
||||
const expectedPath = path.join(missionsRoot, requestedMission, "slices", "same-name");
|
||||
const indexer = new SliceIndexer({
|
||||
slicesRoot: missionsRoot,
|
||||
dogfoodEvidenceRoot: null,
|
||||
db,
|
||||
missionId: requestedMission,
|
||||
});
|
||||
|
||||
expect(indexer.list().map(({ name, missionId, slicePath }) => [name, missionId, slicePath])).toEqual([
|
||||
["same-name", requestedMission, expectedPath],
|
||||
]);
|
||||
expect(indexer.get("same-name")?.slicePath).toBe(expectedPath);
|
||||
expect(fs.readFileSync(path.join(expectedPath, "SPEC.md"), "utf8")).toBe(`# ${requestedMission}\n`);
|
||||
});
|
||||
|
||||
it("can index legacy flat slices and mission-aware slices from compatibility roots", () => {
|
||||
const missionsRoot = path.join(cleanup, "missions");
|
||||
writeSlice(slicesRoot, "legacy-flat-slice", {
|
||||
@@ -523,6 +548,153 @@ describe("PL-slice-story-view-v0 SliceIndexer", () => {
|
||||
const indexer = new SliceIndexer({ slicesRoot, dogfoodEvidenceRoot: null, db });
|
||||
expect(indexer.get("x")!.proofPacket).toBeNull();
|
||||
});
|
||||
|
||||
const packetPrefixSeparators = [
|
||||
{ label: "hyphen", separator: "-" },
|
||||
{ label: "dot", separator: "." },
|
||||
{ label: "underscore", separator: "_" },
|
||||
] as const;
|
||||
|
||||
it.each(packetPrefixSeparators)(
|
||||
"qualified lookup selects an older slice packet over a newer sibling packet whose only matching token is in the mission prefix ($label separator)",
|
||||
({ separator }) => {
|
||||
const missionsRoot = path.join(cleanup, "missions");
|
||||
writeSlice(path.join(missionsRoot, "a-first", "slices"), "first", { "SPEC.md": "# a-first/first\n" });
|
||||
writeSlice(path.join(missionsRoot, "a-first", "slices"), "second", { "SPEC.md": "# a-first/second\n" });
|
||||
|
||||
const correctDir = path.join(dogfoodRoot, `a-first${separator}first`);
|
||||
fs.mkdirSync(path.join(correctDir, "screenshots"), { recursive: true });
|
||||
fs.writeFileSync(path.join(correctDir, "correct-first.md"), "# correct proof for slice first\n");
|
||||
fs.writeFileSync(path.join(correctDir, "screenshots", "first.png"), "FIRST-PNG\n");
|
||||
fs.utimesSync(correctDir, 1600000010, 1600000010);
|
||||
|
||||
const wrongSiblingDir = path.join(dogfoodRoot, `a-first${separator}second`);
|
||||
fs.mkdirSync(path.join(wrongSiblingDir, "screenshots"), { recursive: true });
|
||||
fs.writeFileSync(path.join(wrongSiblingDir, "wrong-second.md"), "# wrong sibling proof for slice second\n");
|
||||
fs.writeFileSync(path.join(wrongSiblingDir, "screenshots", "second.png"), "SECOND-PNG\n");
|
||||
fs.utimesSync(wrongSiblingDir, 1600000020, 1600000020);
|
||||
|
||||
const indexer = new SliceIndexer({
|
||||
slicesRoot: missionsRoot,
|
||||
dogfoodEvidenceRoot: dogfoodRoot,
|
||||
db,
|
||||
missionId: "a-first",
|
||||
});
|
||||
const byName = new Map(indexer.list().map((entry) => [entry.name, entry.hasProofPacket]));
|
||||
const firstRecord = indexer.get("first")!;
|
||||
const secondRecord = indexer.get("second")!;
|
||||
|
||||
expect({
|
||||
listHasProof: {
|
||||
first: byName.get("first"),
|
||||
second: byName.get("second"),
|
||||
},
|
||||
firstProofDir: firstRecord.proofPacket?.dirName,
|
||||
firstMarkdownFiles: firstRecord.proofPacket?.markdownFiles,
|
||||
firstScreenshots: firstRecord.proofPacket?.screenshots,
|
||||
firstMarkdownBody: firstRecord.proofPacket?.markdownFiles[0]
|
||||
? fs.readFileSync(path.join(firstRecord.proofPacket.absPath, firstRecord.proofPacket.markdownFiles[0]), "utf8")
|
||||
: null,
|
||||
secondProofDir: secondRecord.proofPacket?.dirName,
|
||||
}).toEqual({
|
||||
listHasProof: {
|
||||
first: true,
|
||||
second: true,
|
||||
},
|
||||
firstProofDir: `a-first${separator}first`,
|
||||
firstMarkdownFiles: ["correct-first.md"],
|
||||
firstScreenshots: ["screenshots/first.png"],
|
||||
firstMarkdownBody: "# correct proof for slice first\n",
|
||||
secondProofDir: `a-first${separator}second`,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it.each(packetPrefixSeparators)(
|
||||
"qualified lookup returns no proof packet when only a newer sibling packet shares the mission-prefix token ($label separator)",
|
||||
({ separator }) => {
|
||||
const missionsRoot = path.join(cleanup, "missions");
|
||||
writeSlice(path.join(missionsRoot, "a-first", "slices"), "first", { "SPEC.md": "# a-first/first\n" });
|
||||
writeSlice(path.join(missionsRoot, "a-first", "slices"), "second", { "SPEC.md": "# a-first/second\n" });
|
||||
|
||||
const wrongSiblingDir = path.join(dogfoodRoot, `a-first${separator}second`);
|
||||
fs.mkdirSync(path.join(wrongSiblingDir, "screenshots"), { recursive: true });
|
||||
fs.writeFileSync(path.join(wrongSiblingDir, "wrong-second.md"), "# wrong sibling proof for slice second\n");
|
||||
fs.writeFileSync(path.join(wrongSiblingDir, "screenshots", "second.png"), "SECOND-PNG\n");
|
||||
fs.utimesSync(wrongSiblingDir, 1600000020, 1600000020);
|
||||
|
||||
const indexer = new SliceIndexer({
|
||||
slicesRoot: missionsRoot,
|
||||
dogfoodEvidenceRoot: dogfoodRoot,
|
||||
db,
|
||||
missionId: "a-first",
|
||||
});
|
||||
const byName = new Map(indexer.list().map((entry) => [entry.name, entry.hasProofPacket]));
|
||||
|
||||
expect({
|
||||
listHasProof: {
|
||||
first: byName.get("first"),
|
||||
second: byName.get("second"),
|
||||
},
|
||||
firstProofPacket: indexer.get("first")!.proofPacket,
|
||||
secondProofDir: indexer.get("second")!.proofPacket?.dirName,
|
||||
}).toEqual({
|
||||
listHasProof: {
|
||||
first: false,
|
||||
second: true,
|
||||
},
|
||||
firstProofPacket: null,
|
||||
secondProofDir: `a-first${separator}second`,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("preserves qualified -vN suffix stripping, same-mission overlapping-slice latest-mtime tie-break, and unqualified whole-name matching", () => {
|
||||
const missionsRoot = path.join(cleanup, "missions");
|
||||
writeSlice(path.join(missionsRoot, "a-first", "slices"), "first-v1", { "SPEC.md": "# a-first/first-v1\n" });
|
||||
const olderQualified = path.join(dogfoodRoot, "a-first-first-20260101");
|
||||
const newerOverlappingQualified = path.join(dogfoodRoot, "a-first-first-extra-20260601");
|
||||
fs.mkdirSync(olderQualified, { recursive: true });
|
||||
fs.mkdirSync(newerOverlappingQualified, { recursive: true });
|
||||
fs.writeFileSync(path.join(olderQualified, "older.md"), "# older\n");
|
||||
fs.writeFileSync(path.join(newerOverlappingQualified, "newer.md"), "# newer\n");
|
||||
fs.utimesSync(olderQualified, 1600000010, 1600000010);
|
||||
fs.utimesSync(newerOverlappingQualified, 1600000020, 1600000020);
|
||||
|
||||
const qualifiedIndexer = new SliceIndexer({
|
||||
slicesRoot: missionsRoot,
|
||||
dogfoodEvidenceRoot: dogfoodRoot,
|
||||
db,
|
||||
missionId: "a-first",
|
||||
});
|
||||
expect({
|
||||
qualifiedDir: qualifiedIndexer.get("first-v1")!.proofPacket?.dirName,
|
||||
qualifiedDocs: qualifiedIndexer.get("first-v1")!.proofPacket?.markdownFiles,
|
||||
}).toEqual({
|
||||
qualifiedDir: "a-first-first-extra-20260601",
|
||||
qualifiedDocs: ["newer.md"],
|
||||
});
|
||||
|
||||
const legacyDogfoodRoot = path.join(cleanup, "legacy-dogfood");
|
||||
writeSlice(slicesRoot, "first", { "README.md": "---\n---\n" });
|
||||
const legacyPacket = path.join(legacyDogfoodRoot, "a-first-legacy-only");
|
||||
fs.mkdirSync(legacyPacket, { recursive: true });
|
||||
fs.writeFileSync(path.join(legacyPacket, "legacy.md"), "# legacy\n");
|
||||
fs.utimesSync(legacyPacket, 1600000030, 1600000030);
|
||||
|
||||
const unqualifiedIndexer = new SliceIndexer({
|
||||
slicesRoot,
|
||||
dogfoodEvidenceRoot: legacyDogfoodRoot,
|
||||
db,
|
||||
});
|
||||
expect({
|
||||
unqualifiedDir: unqualifiedIndexer.get("first")!.proofPacket?.dirName,
|
||||
unqualifiedDocs: unqualifiedIndexer.get("first")!.proofPacket?.markdownFiles,
|
||||
}).toEqual({
|
||||
unqualifiedDir: "a-first-legacy-only",
|
||||
unqualifiedDocs: ["legacy.md"],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("cache invalidation", () => {
|
||||
|
||||
@@ -22,9 +22,28 @@ const nodeMetadataFault = vi.hoisted(() => ({
|
||||
selectionCode: "EACCES",
|
||||
selectionHits: 0,
|
||||
}));
|
||||
const evidenceRootFault = vi.hoisted(() => ({
|
||||
targetPath: null as string | null,
|
||||
stage: null as "resolve" | "access" | "readdir" | "unexpected" | null,
|
||||
code: "EACCES",
|
||||
hits: 0,
|
||||
}));
|
||||
vi.mock("node:fs", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("node:fs")>();
|
||||
const injectedError = (code: string) => Object.assign(new Error("injected node metadata failure"), { code });
|
||||
const matchesEvidenceFaultTarget = (candidate: unknown): boolean =>
|
||||
evidenceRootFault.targetPath !== null && typeof candidate === "string" && candidate === evidenceRootFault.targetPath;
|
||||
const maybeThrowEvidenceResolveFault = (candidate: unknown): void => {
|
||||
if (!matchesEvidenceFaultTarget(candidate)) return;
|
||||
if (evidenceRootFault.stage === "resolve") {
|
||||
evidenceRootFault.hits++;
|
||||
throw injectedError(evidenceRootFault.code);
|
||||
}
|
||||
if (evidenceRootFault.stage === "unexpected") {
|
||||
evidenceRootFault.hits++;
|
||||
throw Object.assign(new TypeError("unexpected caller type error"), { code: "ERR_INVALID_ARG_TYPE" });
|
||||
}
|
||||
};
|
||||
const existsSync = ((...args: Parameters<typeof actual.existsSync>) => {
|
||||
if (String(args[0]) === nodeMetadataFault.selectionPath && nodeMetadataFault.selectionPath !== null) {
|
||||
nodeMetadataFault.selectionHits++;
|
||||
@@ -33,6 +52,14 @@ vi.mock("node:fs", async (importOriginal) => {
|
||||
}
|
||||
return actual.existsSync(...args);
|
||||
}) as typeof actual.existsSync;
|
||||
const lstatSync = ((...args: Parameters<typeof actual.lstatSync>) => {
|
||||
maybeThrowEvidenceResolveFault(args[0]);
|
||||
return actual.lstatSync(...args);
|
||||
}) as typeof actual.lstatSync;
|
||||
const realpathSync = Object.assign(((...args: Parameters<typeof actual.realpathSync>) => {
|
||||
maybeThrowEvidenceResolveFault(args[0]);
|
||||
return actual.realpathSync(...args);
|
||||
}) as typeof actual.realpathSync, { native: actual.realpathSync.native });
|
||||
const statSync = ((...args: Parameters<typeof actual.statSync>) => {
|
||||
if (String(args[0]) === nodeMetadataFault.selectionPath && nodeMetadataFault.selectionPath !== null) {
|
||||
nodeMetadataFault.selectionHits++;
|
||||
@@ -45,9 +72,20 @@ vi.mock("node:fs", async (importOriginal) => {
|
||||
nodeMetadataFault.selectionHits++;
|
||||
throw injectedError(nodeMetadataFault.selectionCode);
|
||||
}
|
||||
if (matchesEvidenceFaultTarget(args[0]) && evidenceRootFault.stage === "access") {
|
||||
evidenceRootFault.hits++;
|
||||
throw injectedError(evidenceRootFault.code);
|
||||
}
|
||||
return actual.accessSync(...args);
|
||||
}) as typeof actual.accessSync;
|
||||
const readFileSync = ((...args: Parameters<typeof actual.readFileSync>) => {
|
||||
const readdirSync = vi.fn(((...args: Parameters<typeof actual.readdirSync>) => {
|
||||
if (matchesEvidenceFaultTarget(args[0]) && evidenceRootFault.stage === "readdir") {
|
||||
evidenceRootFault.hits++;
|
||||
throw injectedError(evidenceRootFault.code);
|
||||
}
|
||||
return actual.readdirSync(...args);
|
||||
}) as typeof actual.readdirSync) as unknown as typeof actual.readdirSync;
|
||||
const readFileSync = vi.fn(((...args: Parameters<typeof actual.readFileSync>) => {
|
||||
const requestedPath = String(args[0]);
|
||||
if (catalogReadObservation.activePath !== null && requestedPath === catalogReadObservation.activePath) {
|
||||
catalogReadObservation.reads++;
|
||||
@@ -61,10 +99,18 @@ vi.mock("node:fs", async (importOriginal) => {
|
||||
throw injectedError(nodeMetadataFault.selectionCode);
|
||||
}
|
||||
return actual.readFileSync(...args);
|
||||
}) as typeof actual.readFileSync;
|
||||
}) as typeof actual.readFileSync) as unknown as typeof actual.readFileSync;
|
||||
const actualDefault = (actual as unknown as { default?: object }).default ?? actual;
|
||||
const defaultFs = Object.assign(Object.create(actualDefault), { existsSync, statSync, accessSync, readFileSync });
|
||||
return { ...actual, existsSync, statSync, accessSync, readFileSync, default: defaultFs };
|
||||
const defaultFs = Object.assign(Object.create(actualDefault), {
|
||||
existsSync,
|
||||
lstatSync,
|
||||
realpathSync,
|
||||
statSync,
|
||||
accessSync,
|
||||
readdirSync,
|
||||
readFileSync,
|
||||
});
|
||||
return { ...actual, existsSync, lstatSync, realpathSync, statSync, accessSync, readdirSync, readFileSync, default: defaultFs };
|
||||
});
|
||||
|
||||
import { Hono } from "hono";
|
||||
@@ -86,6 +132,7 @@ import { missionControlActionsSchema } from "../src/db/migrations/037_mission_co
|
||||
import { SliceIndexer } from "../src/domain/slices/slice-indexer.js";
|
||||
import { SliceDetailProjector } from "../src/domain/slices/slice-detail-projector.js";
|
||||
import { slicesRoutes } from "../src/routes/slices.js";
|
||||
import * as slicesRoutesModule from "../src/routes/slices.js";
|
||||
import { browserBoundary } from "../src/middleware/browser-boundary.js";
|
||||
import { ALL_MIGRATIONS } from "../src/db/all-migrations.js";
|
||||
import { EventBus } from "../src/domain/event-bus.js";
|
||||
@@ -169,8 +216,14 @@ describe("PL-slice-story-view-v0 slices routes", () => {
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
evidenceRootFault.targetPath = null;
|
||||
evidenceRootFault.stage = null;
|
||||
evidenceRootFault.code = "EACCES";
|
||||
evidenceRootFault.hits = 0;
|
||||
db.close();
|
||||
fs.rmSync(cleanupDir, { recursive: true, force: true });
|
||||
(fs.readdirSync as unknown as { mockClear: () => void }).mockClear();
|
||||
(fs.readFileSync as unknown as { mockClear: () => void }).mockClear();
|
||||
});
|
||||
|
||||
describe("GET /api/slices (list)", () => {
|
||||
@@ -438,6 +491,18 @@ describe("PL-slice-story-view-v0 slices routes", () => {
|
||||
"SPEC.md": `# ${project}/${mission}\n`, "notes/a b.md": `${project}/${mission} nested document\n`,
|
||||
});
|
||||
}
|
||||
const projectEvidenceRoot = path.join(projectRoot, "dogfood-evidence");
|
||||
for (const mission of ["a-first", "z-later"]) {
|
||||
const scopedProofDir = path.join(projectEvidenceRoot, `${mission}-same-slice`);
|
||||
fs.mkdirSync(path.join(scopedProofDir, "screenshots"), { recursive: true });
|
||||
fs.writeFileSync(path.join(scopedProofDir, "proof.md"), `# ${project}/${mission} proof\n`);
|
||||
fs.writeFileSync(
|
||||
path.join(scopedProofDir, "screenshots/scope.png"),
|
||||
`${project.toUpperCase()}-${mission.toUpperCase()}-PROOF\n`,
|
||||
);
|
||||
const mtime = mission === "a-first" ? 1600000003 : 1600000002;
|
||||
fs.utimesSync(scopedProofDir, mtime, mtime);
|
||||
}
|
||||
const proofDir = path.join(dogfoodRoot, `same-slice-${project}`);
|
||||
fs.mkdirSync(path.join(proofDir, "screenshots"), { recursive: true });
|
||||
fs.writeFileSync(path.join(proofDir, "screenshots/scope.png"), `${project.toUpperCase()}-PROOF\n`);
|
||||
@@ -449,17 +514,36 @@ describe("PL-slice-story-view-v0 slices routes", () => {
|
||||
app = buildApp({ indexer, projector, workspaceRoot });
|
||||
});
|
||||
|
||||
const addAlphaMissionWithProof = (mission: string, mtime: number): void => {
|
||||
const projectRoot = path.join(workspaceRoot, "alpha");
|
||||
const missionsRoot = path.join(projectRoot, "missions");
|
||||
writeSlice(missionsRoot, mission, { "SPEC.md": `# ${mission}\n` });
|
||||
writeSlice(path.join(missionsRoot, mission, "slices"), "same-slice", {
|
||||
"SPEC.md": `# alpha/${mission}\n`,
|
||||
"notes/a b.md": `alpha/${mission} nested document\n`,
|
||||
});
|
||||
const proofDir = path.join(projectRoot, "dogfood-evidence", `${mission}-same-slice`);
|
||||
fs.mkdirSync(path.join(proofDir, "screenshots"), { recursive: true });
|
||||
fs.writeFileSync(path.join(proofDir, "proof.md"), `# alpha/${mission} proof\n`);
|
||||
fs.writeFileSync(path.join(proofDir, "screenshots/scope.png"), `ALPHA-${mission.toUpperCase()}-PROOF\n`);
|
||||
fs.utimesSync(proofDir, mtime, mtime);
|
||||
};
|
||||
|
||||
it.each([
|
||||
{ project: "alpha", literal: "# alpha/a-first\n" },
|
||||
{ project: "beta", literal: "# beta/a-first\n" },
|
||||
])("detail selects $project independently and has no qualified proof discovery", async ({ project, literal }) => {
|
||||
const detail = await app.request(`/api/slices/same-slice?project=${project}&mission=a-first`);
|
||||
{ project: "alpha", mission: "a-first", literal: "# alpha/a-first\n" },
|
||||
{ project: "beta", mission: "a-first", literal: "# beta/a-first\n" },
|
||||
])("detail selects project $project/$mission and discovers project-scoped proof", async ({ project, mission, literal }) => {
|
||||
const detail = await app.request(`/api/slices/same-slice?project=${project}&mission=${mission}`);
|
||||
expect(detail.status).toBe(200);
|
||||
const body = await detail.json();
|
||||
expect(body.slicePath).toBe(path.join(workspaceRoot, project, "missions/a-first/slices/same-slice"));
|
||||
expect(body.slicePath).toBe(path.join(workspaceRoot, project, "missions", mission, "slices", "same-slice"));
|
||||
expect(fs.readFileSync(path.join(body.slicePath, "SPEC.md"), "utf8")).toBe(literal);
|
||||
expect(body.missionId).toBe("a-first");
|
||||
expect(body.tests.proofPackets).toEqual([]);
|
||||
expect(body.missionId).toBe(mission);
|
||||
expect(body.tests.proofPackets).toMatchObject([{
|
||||
dirName: `${mission}-same-slice`,
|
||||
primaryMarkdown: { relPath: "proof.md", content: `# ${project}/${mission} proof\n` },
|
||||
screenshots: ["screenshots/scope.png"],
|
||||
}]);
|
||||
expect(body.tests.aggregate).toEqual({ passCount: 0, failCount: 0 });
|
||||
});
|
||||
|
||||
@@ -473,25 +557,762 @@ describe("PL-slice-story-view-v0 slices routes", () => {
|
||||
expect(await response.json()).toEqual({ relPath: decodeURIComponent(rel), content: literal });
|
||||
});
|
||||
|
||||
it.each(["alpha", "beta"])("qualified proof for %s does not substitute the injected legacy packet", async (project) => {
|
||||
const response = await app.request(`/api/slices/same-slice/proof-asset/screenshots/scope.png?project=${project}&mission=a-first`);
|
||||
expect({ status: response.status, text: await response.text() }).toEqual({
|
||||
status: 404, text: JSON.stringify({ error: "proof_packet_not_found" }),
|
||||
it.each([
|
||||
{ project: "alpha", mission: "a-first" },
|
||||
{ project: "beta", mission: "a-first" },
|
||||
{ project: "alpha", mission: "z-later" },
|
||||
{ project: "beta", mission: "z-later" },
|
||||
])("qualified proof for $project/$mission serves its scoped packet, not the injected legacy packet", async ({ project, mission }) => {
|
||||
const response = await app.request(`/api/slices/same-slice/proof-asset/screenshots/scope.png?project=${project}&mission=${mission}`);
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get("content-type")).toBe("image/png");
|
||||
expect(await response.text()).toBe(`${project.toUpperCase()}-${mission.toUpperCase()}-PROOF\n`);
|
||||
});
|
||||
|
||||
it("qualified detail and proof assets distinguish prefix-overlapping mission names", async () => {
|
||||
addAlphaMissionWithProof("a-first-extra", 1600000004);
|
||||
const shortDetail = await app.request("/api/slices/same-slice?project=alpha&mission=a-first");
|
||||
const shortBody = await shortDetail.json();
|
||||
const shortPacket = shortBody.tests.proofPackets[0];
|
||||
const shortAsset = await app.request("/api/slices/same-slice/proof-asset/screenshots/scope.png?project=alpha&mission=a-first");
|
||||
const shortAssetBytes = await shortAsset.text();
|
||||
|
||||
const longDetail = await app.request("/api/slices/same-slice?project=alpha&mission=a-first-extra");
|
||||
const longBody = await longDetail.json();
|
||||
const longPacket = longBody.tests.proofPackets[0];
|
||||
const longAsset = await app.request("/api/slices/same-slice/proof-asset/screenshots/scope.png?project=alpha&mission=a-first-extra");
|
||||
const longAssetBytes = await longAsset.text();
|
||||
|
||||
expect({
|
||||
short: {
|
||||
detailStatus: shortDetail.status,
|
||||
missionId: shortBody.missionId,
|
||||
proofDir: shortPacket?.dirName,
|
||||
proofMarkdown: shortPacket?.primaryMarkdown?.content,
|
||||
assetStatus: shortAsset.status,
|
||||
assetBytes: shortAssetBytes,
|
||||
},
|
||||
long: {
|
||||
detailStatus: longDetail.status,
|
||||
missionId: longBody.missionId,
|
||||
proofDir: longPacket?.dirName,
|
||||
proofMarkdown: longPacket?.primaryMarkdown?.content,
|
||||
assetStatus: longAsset.status,
|
||||
assetBytes: longAssetBytes,
|
||||
},
|
||||
}).toEqual({
|
||||
short: {
|
||||
detailStatus: 200,
|
||||
missionId: "a-first",
|
||||
proofDir: "a-first-same-slice",
|
||||
proofMarkdown: "# alpha/a-first proof\n",
|
||||
assetStatus: 200,
|
||||
assetBytes: "ALPHA-A-FIRST-PROOF\n",
|
||||
},
|
||||
long: {
|
||||
detailStatus: 200,
|
||||
missionId: "a-first-extra",
|
||||
proofDir: "a-first-extra-same-slice",
|
||||
proofMarkdown: "# alpha/a-first-extra proof\n",
|
||||
assetStatus: 200,
|
||||
assetBytes: "ALPHA-A-FIRST-EXTRA-PROOF\n",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("records detail's existing later-duplicate mission absence without inventing a successful lookup", async () => {
|
||||
expect(fs.readFileSync(path.join(workspaceRoot, "alpha/missions/z-later/slices/same-slice/SPEC.md"), "utf8")).toBe("# alpha/z-later\n");
|
||||
const detail = await app.request("/api/slices/same-slice?project=alpha&mission=z-later");
|
||||
expect(detail.status).toBe(404);
|
||||
expect(await detail.json()).toEqual({ error: "slice_not_found", name: "same-slice" });
|
||||
it("does not fall back to an overlapping mission's packet when the requested mission has none", async () => {
|
||||
addAlphaMissionWithProof("a-first-extra", 1600000004);
|
||||
fs.rmSync(path.join(workspaceRoot, "alpha", "dogfood-evidence", "a-first-same-slice"), { recursive: true });
|
||||
|
||||
const detail = await app.request("/api/slices/same-slice?project=alpha&mission=a-first");
|
||||
const detailBody = await detail.json();
|
||||
const asset = await app.request("/api/slices/same-slice/proof-asset/screenshots/scope.png?project=alpha&mission=a-first");
|
||||
const assetBody = await asset.text();
|
||||
expect({
|
||||
detailStatus: detail.status,
|
||||
proofPackets: detailBody.tests.proofPackets,
|
||||
assetStatus: asset.status,
|
||||
assetNotFound: assetBody.includes("proof_packet_not_found"),
|
||||
otherMissionBytesLeaked: assetBody.includes("ALPHA-A-FIRST-EXTRA-PROOF\n"),
|
||||
}).toEqual({
|
||||
detailStatus: 200,
|
||||
proofPackets: [],
|
||||
assetStatus: 404,
|
||||
assetNotFound: true,
|
||||
otherMissionBytesLeaked: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("doc preserves the detail route's current missing later-duplicate selection", async () => {
|
||||
it.each([
|
||||
{ siblingKind: "in-project symlinked mission" },
|
||||
{ siblingKind: "plain mission without slices" },
|
||||
])("does not use an overlapping sibling packet when the sibling is a $siblingKind", async ({ siblingKind }) => {
|
||||
addAlphaMissionWithProof("a-first-extra", 1600000004);
|
||||
const projectRoot = path.join(workspaceRoot, "alpha");
|
||||
const siblingMission = path.join(projectRoot, "missions", "a-first-extra");
|
||||
if (siblingKind === "in-project symlinked mission") {
|
||||
const archiveMission = path.join(projectRoot, "archive", "a-first-extra");
|
||||
fs.mkdirSync(path.dirname(archiveMission), { recursive: true });
|
||||
fs.renameSync(siblingMission, archiveMission);
|
||||
fs.symlinkSync(path.relative(path.dirname(siblingMission), archiveMission), siblingMission, "dir");
|
||||
} else {
|
||||
fs.rmSync(path.join(siblingMission, "slices"), { recursive: true, force: true });
|
||||
}
|
||||
fs.rmSync(path.join(projectRoot, "dogfood-evidence", "a-first-same-slice"), { recursive: true, force: true });
|
||||
|
||||
const detail = await app.request("/api/slices/same-slice?project=alpha&mission=a-first");
|
||||
const detailText = await detail.text();
|
||||
const detailBody = JSON.parse(detailText) as { missionId?: string; tests?: { proofPackets?: unknown[] } };
|
||||
const asset = await app.request("/api/slices/same-slice/proof-asset/screenshots/scope.png?project=alpha&mission=a-first");
|
||||
const assetText = await asset.text();
|
||||
const returnedText = `${detailText}\n${assetText}`;
|
||||
expect({
|
||||
detailStatus: detail.status,
|
||||
missionId: detailBody.missionId,
|
||||
proofPackets: detailBody.tests?.proofPackets,
|
||||
assetStatus: asset.status,
|
||||
assetNotFound: assetText.includes("proof_packet_not_found"),
|
||||
siblingBytesLeaked: returnedText.includes("# alpha/a-first-extra proof") || returnedText.includes("ALPHA-A-FIRST-EXTRA-PROOF"),
|
||||
}).toEqual({
|
||||
detailStatus: 200,
|
||||
missionId: "a-first",
|
||||
proofPackets: [],
|
||||
assetStatus: 404,
|
||||
assetNotFound: true,
|
||||
siblingBytesLeaked: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves mission token order when proof names contain the mission id", async () => {
|
||||
addAlphaMissionWithProof("first-a", 1600000004);
|
||||
const detail = await app.request("/api/slices/same-slice?project=alpha&mission=a-first");
|
||||
const body = await detail.json();
|
||||
const packet = body.tests.proofPackets[0];
|
||||
const asset = await app.request("/api/slices/same-slice/proof-asset/screenshots/scope.png?project=alpha&mission=a-first");
|
||||
|
||||
expect({
|
||||
proofDir: packet?.dirName,
|
||||
proofMarkdown: packet?.primaryMarkdown?.content,
|
||||
assetBytes: await asset.text(),
|
||||
}).toEqual({
|
||||
proofDir: "a-first-same-slice",
|
||||
proofMarkdown: "# alpha/a-first proof\n",
|
||||
assetBytes: "ALPHA-A-FIRST-PROOF\n",
|
||||
});
|
||||
});
|
||||
|
||||
const packetPrefixSeparators = [
|
||||
{ label: "hyphen", separator: "-" },
|
||||
{ label: "dot", separator: "." },
|
||||
{ label: "underscore", separator: "_" },
|
||||
] as const;
|
||||
|
||||
it.each(packetPrefixSeparators)(
|
||||
"qualified detail and proof assets select an older slice packet over a newer same-mission packet whose only matching token is in the mission prefix ($label separator)",
|
||||
async ({ separator }) => {
|
||||
addAlphaMissionWithProof("a-first-extra", 1600000040);
|
||||
const alphaRoot = path.join(workspaceRoot, "alpha");
|
||||
const betaRoot = path.join(workspaceRoot, "beta");
|
||||
writeSlice(path.join(alphaRoot, "missions", "a-first", "slices"), "first", {
|
||||
"SPEC.md": "# alpha/a-first/first\n",
|
||||
});
|
||||
writeSlice(path.join(alphaRoot, "missions", "a-first", "slices"), "second", {
|
||||
"SPEC.md": "# alpha/a-first/second\n",
|
||||
});
|
||||
|
||||
const writeScopedPacket = (
|
||||
evidenceRoot: string,
|
||||
dirName: string,
|
||||
markdown: string,
|
||||
assetBytes: string,
|
||||
mtime: number,
|
||||
): void => {
|
||||
const packetDir = path.join(evidenceRoot, dirName);
|
||||
fs.mkdirSync(path.join(packetDir, "screenshots"), { recursive: true });
|
||||
fs.writeFileSync(path.join(packetDir, "proof.md"), markdown);
|
||||
fs.writeFileSync(path.join(packetDir, "screenshots", "scope.png"), assetBytes);
|
||||
fs.utimesSync(packetDir, mtime, mtime);
|
||||
};
|
||||
|
||||
const alphaEvidence = path.join(alphaRoot, "dogfood-evidence");
|
||||
writeScopedPacket(
|
||||
alphaEvidence,
|
||||
`a-first${separator}first`,
|
||||
"# alpha/a-first/first proof\n",
|
||||
"ALPHA-A-FIRST-SLICE-FIRST-PROOF\n",
|
||||
1600000010,
|
||||
);
|
||||
writeScopedPacket(
|
||||
alphaEvidence,
|
||||
`a-first${separator}second`,
|
||||
"# alpha/a-first/second proof\n",
|
||||
"ALPHA-A-FIRST-SLICE-SECOND-PROOF\n",
|
||||
1600000020,
|
||||
);
|
||||
writeScopedPacket(
|
||||
alphaEvidence,
|
||||
`a-first-extra${separator}first`,
|
||||
"# alpha/a-first-extra/first proof\n",
|
||||
"ALPHA-A-FIRST-EXTRA-SLICE-FIRST-PROOF\n",
|
||||
1600000040,
|
||||
);
|
||||
writeScopedPacket(
|
||||
path.join(betaRoot, "dogfood-evidence"),
|
||||
`a-first${separator}first`,
|
||||
"# beta/a-first/first proof\n",
|
||||
"BETA-A-FIRST-SLICE-FIRST-PROOF\n",
|
||||
1600000050,
|
||||
);
|
||||
|
||||
const firstDetail = await app.request("/api/slices/first?project=alpha&mission=a-first");
|
||||
const firstBody = await firstDetail.json();
|
||||
const firstPacket = firstBody.tests.proofPackets[0];
|
||||
const firstAsset = await app.request("/api/slices/first/proof-asset/screenshots/scope.png?project=alpha&mission=a-first");
|
||||
const firstAssetBytes = await firstAsset.text();
|
||||
|
||||
const secondDetail = await app.request("/api/slices/second?project=alpha&mission=a-first");
|
||||
const secondBody = await secondDetail.json();
|
||||
const secondPacket = secondBody.tests.proofPackets[0];
|
||||
const secondAsset = await app.request("/api/slices/second/proof-asset/screenshots/scope.png?project=alpha&mission=a-first");
|
||||
const secondAssetBytes = await secondAsset.text();
|
||||
|
||||
expect({
|
||||
first: {
|
||||
detailStatus: firstDetail.status,
|
||||
missionId: firstBody.missionId,
|
||||
proofDir: firstPacket?.dirName,
|
||||
proofMarkdown: firstPacket?.primaryMarkdown?.content,
|
||||
assetStatus: firstAsset.status,
|
||||
assetBytes: firstAssetBytes,
|
||||
},
|
||||
second: {
|
||||
detailStatus: secondDetail.status,
|
||||
missionId: secondBody.missionId,
|
||||
proofDir: secondPacket?.dirName,
|
||||
proofMarkdown: secondPacket?.primaryMarkdown?.content,
|
||||
assetStatus: secondAsset.status,
|
||||
assetBytes: secondAssetBytes,
|
||||
},
|
||||
}).toEqual({
|
||||
first: {
|
||||
detailStatus: 200,
|
||||
missionId: "a-first",
|
||||
proofDir: `a-first${separator}first`,
|
||||
proofMarkdown: "# alpha/a-first/first proof\n",
|
||||
assetStatus: 200,
|
||||
assetBytes: "ALPHA-A-FIRST-SLICE-FIRST-PROOF\n",
|
||||
},
|
||||
second: {
|
||||
detailStatus: 200,
|
||||
missionId: "a-first",
|
||||
proofDir: `a-first${separator}second`,
|
||||
proofMarkdown: "# alpha/a-first/second proof\n",
|
||||
assetStatus: 200,
|
||||
assetBytes: "ALPHA-A-FIRST-SLICE-SECOND-PROOF\n",
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it.each(packetPrefixSeparators)(
|
||||
"qualified detail and proof assets do not fall back to a same-mission packet whose only matching token is in the mission prefix ($label separator)",
|
||||
async ({ separator }) => {
|
||||
const alphaRoot = path.join(workspaceRoot, "alpha");
|
||||
writeSlice(path.join(alphaRoot, "missions", "a-first", "slices"), "first", {
|
||||
"SPEC.md": "# alpha/a-first/first\n",
|
||||
});
|
||||
const wrongSiblingPacket = path.join(alphaRoot, "dogfood-evidence", `a-first${separator}second`);
|
||||
fs.mkdirSync(path.join(wrongSiblingPacket, "screenshots"), { recursive: true });
|
||||
fs.writeFileSync(path.join(wrongSiblingPacket, "proof.md"), "# alpha/a-first/second proof\n");
|
||||
fs.writeFileSync(path.join(wrongSiblingPacket, "screenshots", "scope.png"), "ALPHA-A-FIRST-SLICE-SECOND-PROOF\n");
|
||||
fs.utimesSync(wrongSiblingPacket, 1600000020, 1600000020);
|
||||
|
||||
const detail = await app.request("/api/slices/first?project=alpha&mission=a-first");
|
||||
const detailText = await detail.text();
|
||||
const detailBody = JSON.parse(detailText) as { missionId?: string; tests?: { proofPackets?: unknown[] } };
|
||||
const asset = await app.request("/api/slices/first/proof-asset/screenshots/scope.png?project=alpha&mission=a-first");
|
||||
const assetText = await asset.text();
|
||||
|
||||
expect({
|
||||
detailStatus: detail.status,
|
||||
missionId: detailBody.missionId,
|
||||
proofPackets: detailBody.tests?.proofPackets,
|
||||
assetStatus: asset.status,
|
||||
assetNotFound: assetText.includes("proof_packet_not_found"),
|
||||
wrongPacketLeaked:
|
||||
detailText.includes("# alpha/a-first/second proof")
|
||||
|| assetText.includes("ALPHA-A-FIRST-SLICE-SECOND-PROOF"),
|
||||
}).toEqual({
|
||||
detailStatus: 200,
|
||||
missionId: "a-first",
|
||||
proofPackets: [],
|
||||
assetStatus: 404,
|
||||
assetNotFound: true,
|
||||
wrongPacketLeaked: false,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
const clearEvidenceReadRecord = (): void => {
|
||||
(fs.readdirSync as unknown as { mockClear: () => void }).mockClear();
|
||||
(fs.readFileSync as unknown as { mockClear: () => void }).mockClear();
|
||||
};
|
||||
|
||||
const recordedReadsUnder = (roots: string[]): string[] => {
|
||||
const prefixes = Array.from(new Set(roots.flatMap((root) => {
|
||||
const resolved = path.resolve(root);
|
||||
const parentReal = fs.existsSync(path.dirname(resolved))
|
||||
? path.join(fs.realpathSync(path.dirname(resolved)), path.basename(resolved))
|
||||
: resolved;
|
||||
const targetReal = fs.existsSync(resolved) ? fs.realpathSync(resolved) : parentReal;
|
||||
return [resolved, parentReal, targetReal];
|
||||
})));
|
||||
const matchesRoot = (target: unknown): boolean => {
|
||||
if (typeof target === "number" || target == null) return false;
|
||||
const resolved = path.resolve(String(target));
|
||||
return prefixes.some((prefix) => resolved === prefix || resolved.startsWith(`${prefix}${path.sep}`));
|
||||
};
|
||||
const readdirCalls = (fs.readdirSync as unknown as { mock: { calls: unknown[][] } }).mock.calls
|
||||
.filter((args) => matchesRoot(args[0]))
|
||||
.map((args) => `readdir:${String(args[0])}`);
|
||||
const readFileCalls = (fs.readFileSync as unknown as { mock: { calls: unknown[][] } }).mock.calls
|
||||
.filter((args) => matchesRoot(args[0]))
|
||||
.map((args) => `readFile:${String(args[0])}`);
|
||||
return [...readdirCalls, ...readFileCalls];
|
||||
};
|
||||
|
||||
const withEvidenceRootFault = async <T>(
|
||||
targetPath: string,
|
||||
stage: "resolve" | "access" | "readdir" | "unexpected",
|
||||
code: string,
|
||||
action: () => Promise<T> | T,
|
||||
): Promise<{ result: T; hits: number }> => {
|
||||
evidenceRootFault.targetPath = targetPath;
|
||||
evidenceRootFault.stage = stage;
|
||||
evidenceRootFault.code = code;
|
||||
evidenceRootFault.hits = 0;
|
||||
try {
|
||||
const result = await action();
|
||||
return { result, hits: evidenceRootFault.hits };
|
||||
} finally {
|
||||
evidenceRootFault.targetPath = null;
|
||||
evidenceRootFault.stage = null;
|
||||
evidenceRootFault.code = "EACCES";
|
||||
evidenceRootFault.hits = 0;
|
||||
}
|
||||
};
|
||||
|
||||
// Maintainer-directed PR #2960 contract change: an evidence root symlink
|
||||
// resolving outside the selected project is treated as an absent evidence
|
||||
// root (detail 200 with empty proofPackets; proof-asset 404
|
||||
// proof_packet_not_found) without reading outside the project, while an
|
||||
// escaping asset symlink inside a valid evidence root still returns 409.
|
||||
it("treats a qualified evidence root symlink outside the selected project as absent", async () => {
|
||||
const outsideRoot = path.join(cleanupDir, "outside-proof-root");
|
||||
const outsidePacket = path.join(outsideRoot, "a-first-same-slice");
|
||||
const outsideMarkdown = "OUTSIDE-ROOT-MARKDOWN";
|
||||
const outsideAsset = "OUTSIDE-ROOT-ASSET";
|
||||
fs.mkdirSync(path.join(outsidePacket, "screenshots"), { recursive: true });
|
||||
fs.writeFileSync(path.join(outsidePacket, "proof.md"), `${outsideMarkdown}\n`);
|
||||
fs.writeFileSync(path.join(outsidePacket, "screenshots/scope.png"), `${outsideAsset}\n`);
|
||||
const selectedEvidenceRoot = path.join(workspaceRoot, "alpha", "dogfood-evidence");
|
||||
fs.rmSync(selectedEvidenceRoot, { recursive: true, force: true });
|
||||
fs.symlinkSync(outsideRoot, selectedEvidenceRoot, "dir");
|
||||
|
||||
clearEvidenceReadRecord();
|
||||
const detail = await app.request("/api/slices/same-slice?project=alpha&mission=a-first");
|
||||
const detailText = await detail.text();
|
||||
const detailBody = JSON.parse(detailText) as {
|
||||
slicePath?: string;
|
||||
missionId?: string;
|
||||
tests?: { proofPackets?: unknown[] };
|
||||
};
|
||||
const asset = await app.request("/api/slices/same-slice/proof-asset/screenshots/scope.png?project=alpha&mission=a-first");
|
||||
const assetText = await asset.text();
|
||||
const assetBody = JSON.parse(assetText) as unknown;
|
||||
expect({
|
||||
detailStatus: detail.status,
|
||||
slicePath: detailBody.slicePath,
|
||||
missionId: detailBody.missionId,
|
||||
proofPackets: detailBody.tests?.proofPackets,
|
||||
detailLeaksOutsideProof: detailText.includes(outsideMarkdown),
|
||||
assetStatus: asset.status,
|
||||
assetBody,
|
||||
assetLeaksOutsideProof: assetText.includes(outsideAsset),
|
||||
evidenceReads: recordedReadsUnder([selectedEvidenceRoot, outsideRoot]),
|
||||
}).toEqual({
|
||||
detailStatus: 200,
|
||||
slicePath: path.join(workspaceRoot, "alpha", "missions", "a-first", "slices", "same-slice"),
|
||||
missionId: "a-first",
|
||||
proofPackets: [],
|
||||
detailLeaksOutsideProof: false,
|
||||
assetStatus: 404,
|
||||
assetBody: { error: "proof_packet_not_found" },
|
||||
assetLeaksOutsideProof: false,
|
||||
evidenceReads: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps a missing qualified evidence root optional", async () => {
|
||||
fs.rmSync(path.join(workspaceRoot, "alpha", "dogfood-evidence"), { recursive: true });
|
||||
|
||||
const detail = await app.request("/api/slices/same-slice?project=alpha&mission=a-first");
|
||||
const detailBody = await detail.json();
|
||||
const asset = await app.request("/api/slices/same-slice/proof-asset/screenshots/scope.png?project=alpha&mission=a-first");
|
||||
expect({
|
||||
detailStatus: detail.status,
|
||||
proofPackets: detailBody.tests.proofPackets,
|
||||
assetStatus: asset.status,
|
||||
assetError: await asset.json(),
|
||||
}).toEqual({
|
||||
detailStatus: 200,
|
||||
proofPackets: [],
|
||||
assetStatus: 404,
|
||||
assetError: { error: "proof_packet_not_found" },
|
||||
});
|
||||
});
|
||||
|
||||
it("serves a qualified slice document when the selected project's evidence root symlink escapes the project", async () => {
|
||||
const outsideRoot = path.join(cleanupDir, "outside-doc-proof-root");
|
||||
const outsidePacket = path.join(outsideRoot, "z-later-same-slice");
|
||||
const outsideMarkdown = "OUTSIDE-DOC-ROOT-MARKDOWN";
|
||||
const outsideNote = "OUTSIDE-DOC-ROOT-NOTE";
|
||||
fs.mkdirSync(path.join(outsidePacket, "notes"), { recursive: true });
|
||||
fs.writeFileSync(path.join(outsidePacket, "proof.md"), `${outsideMarkdown}\n`);
|
||||
fs.writeFileSync(path.join(outsidePacket, "notes", "a b.md"), `${outsideNote}\n`);
|
||||
const selectedEvidenceRoot = path.join(workspaceRoot, "alpha", "dogfood-evidence");
|
||||
fs.rmSync(selectedEvidenceRoot, { recursive: true, force: true });
|
||||
fs.symlinkSync(outsideRoot, selectedEvidenceRoot, "dir");
|
||||
|
||||
clearEvidenceReadRecord();
|
||||
const response = await app.request("/api/slices/same-slice/doc/notes/a%20b.md?project=alpha&mission=z-later");
|
||||
const responseText = await response.text();
|
||||
const body = JSON.parse(responseText) as unknown;
|
||||
expect({
|
||||
status: response.status,
|
||||
body,
|
||||
evidenceReads: recordedReadsUnder([selectedEvidenceRoot, outsideRoot]),
|
||||
leaksOutsideEvidence: responseText.includes(outsideMarkdown) || responseText.includes(outsideNote),
|
||||
}).toEqual({
|
||||
status: 200,
|
||||
body: { relPath: "notes/a b.md", content: "alpha/z-later nested document\n" },
|
||||
evidenceReads: [],
|
||||
leaksOutsideEvidence: false,
|
||||
});
|
||||
});
|
||||
|
||||
describe("when the selected project's evidence root symlink is dangling", () => {
|
||||
let missingTarget: string;
|
||||
let selectedEvidenceRoot: string;
|
||||
|
||||
beforeEach(() => {
|
||||
missingTarget = path.join(cleanupDir, "missing-doc-proof-root");
|
||||
selectedEvidenceRoot = path.join(workspaceRoot, "alpha", "dogfood-evidence");
|
||||
fs.rmSync(selectedEvidenceRoot, { recursive: true, force: true });
|
||||
fs.symlinkSync(missingTarget, selectedEvidenceRoot, "dir");
|
||||
expect(fs.existsSync(missingTarget)).toBe(false);
|
||||
expect(fs.lstatSync(selectedEvidenceRoot).isSymbolicLink()).toBe(true);
|
||||
});
|
||||
|
||||
it("serves a qualified slice document when the selected project's evidence root symlink is dangling", async () => {
|
||||
clearEvidenceReadRecord();
|
||||
const response = await app.request("/api/slices/same-slice/doc/notes/a%20b.md?project=alpha&mission=z-later");
|
||||
const responseText = await response.text();
|
||||
const body = JSON.parse(responseText) as unknown;
|
||||
expect({
|
||||
status: response.status,
|
||||
body,
|
||||
evidenceReads: recordedReadsUnder([selectedEvidenceRoot, missingTarget]),
|
||||
}).toEqual({
|
||||
status: 200,
|
||||
body: { relPath: "notes/a b.md", content: "alpha/z-later nested document\n" },
|
||||
evidenceReads: [],
|
||||
});
|
||||
});
|
||||
|
||||
// Maintainer-directed PR #2960 contract change: a dangling evidence root
|
||||
// symlink is treated as an absent evidence root rather than 409
|
||||
// project_source_unavailable, matching missing-root behavior on detail
|
||||
// (200 with empty proofPackets) and proof-asset (404 proof_packet_not_found).
|
||||
it("treats a dangling qualified evidence root symlink as absent on detail and proof-asset", async () => {
|
||||
clearEvidenceReadRecord();
|
||||
const detail = await app.request("/api/slices/same-slice?project=alpha&mission=z-later");
|
||||
const detailBody = await detail.json() as {
|
||||
slicePath?: string;
|
||||
missionId?: string;
|
||||
tests?: { proofPackets?: unknown[] };
|
||||
};
|
||||
const asset = await app.request("/api/slices/same-slice/proof-asset/screenshots/scope.png?project=alpha&mission=z-later");
|
||||
const assetBody = await asset.json() as unknown;
|
||||
expect({
|
||||
detailStatus: detail.status,
|
||||
slicePath: detailBody.slicePath,
|
||||
missionId: detailBody.missionId,
|
||||
proofPackets: detailBody.tests?.proofPackets,
|
||||
assetStatus: asset.status,
|
||||
assetBody,
|
||||
evidenceReads: recordedReadsUnder([selectedEvidenceRoot, missingTarget]),
|
||||
}).toEqual({
|
||||
detailStatus: 200,
|
||||
slicePath: path.join(workspaceRoot, "alpha", "missions", "z-later", "slices", "same-slice"),
|
||||
missionId: "z-later",
|
||||
proofPackets: [],
|
||||
assetStatus: 404,
|
||||
assetBody: { error: "proof_packet_not_found" },
|
||||
evidenceReads: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it.each(["resolve", "access"] as const)(
|
||||
"treats an unreadable qualified evidence root (%s EACCES) as absent on detail and proof-asset",
|
||||
async (stage) => {
|
||||
const selectedEvidenceRoot = fs.realpathSync(path.join(workspaceRoot, "alpha", "dogfood-evidence"));
|
||||
const { result, hits } = await withEvidenceRootFault(selectedEvidenceRoot, stage, "EACCES", async () => {
|
||||
const detail = await app.request("/api/slices/same-slice?project=alpha&mission=a-first");
|
||||
const detailText = await detail.text();
|
||||
const detailBody = JSON.parse(detailText) as {
|
||||
slicePath?: string;
|
||||
missionId?: string;
|
||||
tests?: { proofPackets?: unknown[] };
|
||||
};
|
||||
const asset = await app.request("/api/slices/same-slice/proof-asset/screenshots/scope.png?project=alpha&mission=a-first");
|
||||
const assetText = await asset.text();
|
||||
const assetBody = JSON.parse(assetText) as unknown;
|
||||
const doc = await app.request("/api/slices/same-slice/doc/SPEC.md?project=alpha&mission=a-first");
|
||||
const docBody = await doc.json() as unknown;
|
||||
return {
|
||||
detailStatus: detail.status,
|
||||
slicePath: detailBody.slicePath,
|
||||
missionId: detailBody.missionId,
|
||||
proofPackets: detailBody.tests?.proofPackets,
|
||||
detailLeaksProof: detailText.includes("alpha/a-first proof"),
|
||||
assetStatus: asset.status,
|
||||
assetBody,
|
||||
assetLeaksProof: assetText.includes("ALPHA-PROOF"),
|
||||
docStatus: doc.status,
|
||||
docBody,
|
||||
};
|
||||
});
|
||||
expect({ ...result, faultTriggered: hits > 0 }).toEqual({
|
||||
detailStatus: 200,
|
||||
slicePath: path.join(workspaceRoot, "alpha", "missions", "a-first", "slices", "same-slice"),
|
||||
missionId: "a-first",
|
||||
proofPackets: [],
|
||||
detailLeaksProof: false,
|
||||
assetStatus: 404,
|
||||
assetBody: { error: "proof_packet_not_found" },
|
||||
assetLeaksProof: false,
|
||||
docStatus: 200,
|
||||
docBody: { relPath: "SPEC.md", content: "# alpha/a-first\n" },
|
||||
faultTriggered: true,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("qualifiedEvidenceRoot returns null for unavailable or escaping roots without swallowing unrelated caller errors", async () => {
|
||||
const helper = (slicesRoutesModule as {
|
||||
qualifiedEvidenceRoot?: (projectRoot: string, missionsRoot: string) => string | null;
|
||||
}).qualifiedEvidenceRoot;
|
||||
expect(helper).toBeTypeOf("function");
|
||||
|
||||
const alphaRoot = fs.realpathSync(path.join(workspaceRoot, "alpha"));
|
||||
const alphaMissionsRoot = path.join(alphaRoot, "missions");
|
||||
const selectedEvidenceRoot = path.join(alphaRoot, "dogfood-evidence");
|
||||
expect(helper!(alphaRoot, alphaMissionsRoot)).toBe(selectedEvidenceRoot);
|
||||
|
||||
for (const stage of ["resolve", "access", "readdir"] as const) {
|
||||
const { result, hits } = await withEvidenceRootFault(selectedEvidenceRoot, stage, "EACCES", () =>
|
||||
helper!(alphaRoot, alphaMissionsRoot),
|
||||
);
|
||||
expect({ stage, result, faultTriggered: hits > 0 }).toEqual({
|
||||
stage,
|
||||
result: null,
|
||||
faultTriggered: true,
|
||||
});
|
||||
}
|
||||
|
||||
await expect(
|
||||
withEvidenceRootFault(selectedEvidenceRoot, "unexpected", "EACCES", () =>
|
||||
helper!(alphaRoot, alphaMissionsRoot),
|
||||
),
|
||||
).rejects.toThrow(TypeError);
|
||||
|
||||
fs.rmSync(selectedEvidenceRoot, { recursive: true, force: true });
|
||||
expect(helper!(alphaRoot, alphaMissionsRoot)).toBeNull();
|
||||
|
||||
const missingTarget = path.join(cleanupDir, "missing-helper-evidence-root");
|
||||
fs.symlinkSync(missingTarget, selectedEvidenceRoot, "dir");
|
||||
expect(helper!(alphaRoot, alphaMissionsRoot)).toBeNull();
|
||||
|
||||
fs.rmSync(selectedEvidenceRoot, { recursive: true, force: true });
|
||||
const outsideRoot = path.join(cleanupDir, "outside-helper-evidence-root");
|
||||
fs.mkdirSync(outsideRoot, { recursive: true });
|
||||
fs.symlinkSync(outsideRoot, selectedEvidenceRoot, "dir");
|
||||
expect(helper!(alphaRoot, alphaMissionsRoot)).toBeNull();
|
||||
|
||||
fs.rmSync(selectedEvidenceRoot, { recursive: true, force: true });
|
||||
fs.writeFileSync(selectedEvidenceRoot, "not-a-directory\n");
|
||||
expect(helper!(alphaRoot, alphaMissionsRoot)).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects a qualified proof asset symlink outside the selected project", async () => {
|
||||
const outsideAsset = "OUTSIDE-DIRECT-ASSET-BYTES";
|
||||
const outsidePath = path.join(cleanupDir, "outside-proof-asset.png");
|
||||
const selectedAsset = path.join(workspaceRoot, "alpha", "dogfood-evidence", "a-first-same-slice", "screenshots", "scope.png");
|
||||
fs.writeFileSync(outsidePath, `${outsideAsset}\n`);
|
||||
fs.rmSync(selectedAsset);
|
||||
fs.symlinkSync(outsidePath, selectedAsset, "file");
|
||||
|
||||
const response = await app.request("/api/slices/same-slice/proof-asset/screenshots/scope.png?project=alpha&mission=a-first");
|
||||
const responseText = await response.text();
|
||||
expect({
|
||||
status: response.status,
|
||||
projectPathEscape: responseText.includes("project_path_escape"),
|
||||
leaksOutsideAsset: responseText.includes(outsideAsset),
|
||||
}).toEqual({ status: 409, projectPathEscape: true, leaksOutsideAsset: false });
|
||||
});
|
||||
|
||||
it("uses the missions-root-relative evidence directory for custom missions.root", async () => {
|
||||
const project = "custom";
|
||||
const mission = "custom-first";
|
||||
const slice = "same-slice";
|
||||
const projectRoot = path.join(workspaceRoot, project);
|
||||
fs.writeFileSync(path.join(workspaceRoot, "workspace.yaml"),
|
||||
"projects:\n - id: alpha\n root: alpha\n - id: beta\n root: beta\n - id: custom\n root: custom\n");
|
||||
writeSlice(workspaceRoot, project, {
|
||||
"project.yaml": "metadata:\n id: custom\nmissions:\n root: work/missions\n",
|
||||
"SPEC.md": "# custom project\n",
|
||||
});
|
||||
const missionsRoot = path.join(projectRoot, "work", "missions");
|
||||
writeSlice(missionsRoot, mission, { "SPEC.md": `# ${mission}\n` });
|
||||
writeSlice(path.join(missionsRoot, mission, "slices"), slice, { "SPEC.md": `# ${project}/${mission}\n` });
|
||||
|
||||
// The CLI contract is path.dirname(project/work/missions)/dogfood-evidence,
|
||||
// i.e. project/work/dogfood-evidence; this is a literal fixture path, not a helper call.
|
||||
const conventionalRoot = path.join(projectRoot, "work", "dogfood-evidence");
|
||||
const projectRootDecoy = path.join(projectRoot, "dogfood-evidence");
|
||||
const writePacket = (root: string, marker: string, mtime: number): void => {
|
||||
const packet = path.join(root, `${mission}-${slice}`);
|
||||
fs.mkdirSync(path.join(packet, "screenshots"), { recursive: true });
|
||||
fs.writeFileSync(path.join(packet, "proof.md"), `# ${marker}\n`);
|
||||
fs.writeFileSync(path.join(packet, "screenshots/scope.png"), `${marker}\n`);
|
||||
fs.utimesSync(packet, mtime, mtime);
|
||||
};
|
||||
const expectedMarker = "CUSTOM-WORK-MISSIONS-PROOF";
|
||||
writePacket(conventionalRoot, expectedMarker, 1600000004);
|
||||
writePacket(projectRootDecoy, "CUSTOM-PROJECT-ROOT-DECOY", 1600000005);
|
||||
|
||||
const detail = await app.request(`/api/slices/${slice}?project=${project}&mission=${mission}`);
|
||||
const body = await detail.json();
|
||||
const packet = body.tests.proofPackets[0];
|
||||
const asset = await app.request(`/api/slices/${slice}/proof-asset/screenshots/scope.png?project=${project}&mission=${mission}`);
|
||||
expect({
|
||||
detailStatus: detail.status,
|
||||
proofDir: packet?.dirName,
|
||||
proofMarkdown: packet?.primaryMarkdown?.content,
|
||||
assetStatus: asset.status,
|
||||
assetBytes: await asset.text(),
|
||||
}).toEqual({
|
||||
detailStatus: 200,
|
||||
proofDir: `${mission}-${slice}`,
|
||||
proofMarkdown: `# ${expectedMarker}\n`,
|
||||
assetStatus: 200,
|
||||
assetBytes: `${expectedMarker}\n`,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps missions.root . healthy and treats its parent evidence store as optional", async () => {
|
||||
const project = "dot-root";
|
||||
const mission = "dot-first";
|
||||
const slice = "same-slice";
|
||||
const projectRoot = path.join(workspaceRoot, project);
|
||||
fs.writeFileSync(path.join(workspaceRoot, "workspace.yaml"),
|
||||
"projects:\n - id: alpha\n root: alpha\n - id: beta\n root: beta\n - id: dot-root\n root: dot-root\n");
|
||||
writeSlice(workspaceRoot, project, {
|
||||
"project.yaml": "metadata:\n id: dot-root\nmissions:\n root: .\n",
|
||||
"SPEC.md": "# dot-root project\n",
|
||||
});
|
||||
writeSlice(projectRoot, mission, { "SPEC.md": `# ${mission}\n` });
|
||||
writeSlice(path.join(projectRoot, mission, "slices"), slice, {
|
||||
"SPEC.md": "# dot-root/dot-first\n",
|
||||
"notes/a b.md": "dot-root own document\n",
|
||||
});
|
||||
|
||||
// Literal parent path for missions.root: .; this is the legacy store outside the project.
|
||||
const parentEvidenceRoot = path.join(workspaceRoot, "dogfood-evidence");
|
||||
const parentPacket = path.join(parentEvidenceRoot, `${mission}-${slice}`);
|
||||
const parentMarkdownMarker = "PARENT-LEGACY-PROOF-ROOT";
|
||||
const parentAssetMarker = "PARENT-LEGACY-PROOF-ASSET";
|
||||
fs.mkdirSync(path.join(parentPacket, "screenshots"), { recursive: true });
|
||||
fs.writeFileSync(path.join(parentPacket, "proof.md"), `# ${parentMarkdownMarker}\n`);
|
||||
fs.writeFileSync(path.join(parentPacket, "screenshots/scope.png"), `${parentAssetMarker}\n`);
|
||||
|
||||
const detail = await app.request(`/api/slices/${slice}?project=${project}&mission=${mission}`);
|
||||
const detailBody = await detail.json();
|
||||
const doc = await app.request(`/api/slices/${slice}/doc/SPEC.md?project=${project}&mission=${mission}`);
|
||||
const docBody = await doc.json();
|
||||
const asset = await app.request(`/api/slices/${slice}/proof-asset/screenshots/scope.png?project=${project}&mission=${mission}`);
|
||||
const assetText = await asset.text();
|
||||
const returnedText = `${JSON.stringify(detailBody)}\n${JSON.stringify(docBody)}\n${assetText}`;
|
||||
expect({
|
||||
detailStatus: detail.status,
|
||||
slicePath: detailBody.slicePath,
|
||||
missionId: detailBody.missionId,
|
||||
proofPackets: detailBody.tests?.proofPackets,
|
||||
docStatus: doc.status,
|
||||
docBody,
|
||||
assetStatus: asset.status,
|
||||
assetNotFound: assetText.includes("proof_packet_not_found"),
|
||||
parentProofBytesLeaked: returnedText.includes(parentMarkdownMarker) || returnedText.includes(parentAssetMarker),
|
||||
}).toEqual({
|
||||
detailStatus: 200,
|
||||
slicePath: path.join(projectRoot, mission, "slices", slice),
|
||||
missionId: mission,
|
||||
proofPackets: [],
|
||||
docStatus: 200,
|
||||
docBody: { relPath: "SPEC.md", content: "# dot-root/dot-first\n" },
|
||||
assetStatus: 404,
|
||||
assetNotFound: true,
|
||||
parentProofBytesLeaked: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("qualified detail finds the later duplicate mission and its own proof packet", async () => {
|
||||
const expectedSlicePath = path.join(workspaceRoot, "alpha", "missions", "z-later", "slices", "same-slice");
|
||||
expect(fs.readFileSync(path.join(expectedSlicePath, "SPEC.md"), "utf8")).toBe("# alpha/z-later\n");
|
||||
const detail = await app.request("/api/slices/same-slice?project=alpha&mission=z-later");
|
||||
expect(detail.status).toBe(200);
|
||||
expect(await detail.json()).toMatchObject({
|
||||
slicePath: expectedSlicePath,
|
||||
missionId: "z-later",
|
||||
tests: { proofPackets: [{
|
||||
dirName: "z-later-same-slice",
|
||||
primaryMarkdown: { relPath: "proof.md", content: "# alpha/z-later proof\n" },
|
||||
screenshots: ["screenshots/scope.png"],
|
||||
}] },
|
||||
});
|
||||
});
|
||||
|
||||
it("doc follows the detail route's later-duplicate mission selection", async () => {
|
||||
const response = await app.request("/api/slices/same-slice/doc/SPEC.md?project=alpha&mission=z-later");
|
||||
const body = await response.json();
|
||||
expect({ status: response.status, content: body.content }).toEqual({ status: 404, content: undefined });
|
||||
expect(body).toEqual({ error: "doc_not_found" });
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.json()).toEqual({ relPath: "SPEC.md", content: "# alpha/z-later\n" });
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ route: "detail", endpoint: "" },
|
||||
{ route: "doc", endpoint: "/doc/SPEC.md" },
|
||||
{ route: "proof", endpoint: "/proof-asset/screenshots/scope.png" },
|
||||
])("qualified $route preserves the source error for a nonexistent mission", async ({ endpoint }) => {
|
||||
const response = await app.request(`/api/slices/same-slice${endpoint}?project=alpha&mission=unrelated`);
|
||||
expect(response.status).toBe(409);
|
||||
expect(response.headers.get("content-type")).toContain("application/json");
|
||||
const body = await response.json() as { error: string; message: string };
|
||||
expect(body.error).toBe("project_source_unavailable");
|
||||
expect(body.message).toMatch(/ENOENT|no such file/i);
|
||||
expect(body).not.toHaveProperty("content");
|
||||
expect(body).not.toHaveProperty("proofPackets");
|
||||
});
|
||||
|
||||
it.each([
|
||||
|
||||
@@ -47,15 +47,16 @@ function respond(cmd: string, failing: string[], composer = false): string {
|
||||
case "list-sessions": return `${SESSION}\nalpha\n`;
|
||||
case "list-panes": return "%1|0|/w|80|24|1\n";
|
||||
case "display-message":
|
||||
return cmd.includes("session_id") ? "$7\n" : cmd.includes("pane_pid") ? "4242\n" : "1\n";
|
||||
case "show-environment": return "OPENRIG_NODE_ID=n1\nCLAUDECODE=1\nHERDR_PANE_ID=p1\n";
|
||||
return cmd.includes("#{pid}") ? "$7|4242|1700000000\n" : cmd.includes("session_id") ? "$7\n" : cmd.includes("pane_pid") ? "4242\n" : "1\n";
|
||||
// tmux prints just the variable asked for, or every one when none is named.
|
||||
case "show-environment": return cmd.endsWith(" OPENRIG_NODE_ID") ? "OPENRIG_NODE_ID=n1\n" : "OPENRIG_NODE_ID=n1\nCLAUDECODE=1\nHERDR_PANE_ID=p1\n";
|
||||
case "new-session": case "source-file": case "start-server": return "%1\n";
|
||||
default: return "";
|
||||
}
|
||||
}
|
||||
|
||||
/** A shell-path adapter whose every run() call records its caller line, its argv and the string exec received. */
|
||||
function recordingAdapter(calls: RunCall[], opts: { failing?: string[]; guard?: boolean; composer?: boolean } = {}): TmuxAdapter {
|
||||
function recordingAdapter(calls: RunCall[], opts: { failing?: string[]; guard?: boolean; composer?: boolean; nodeId?: string } = {}): TmuxAdapter {
|
||||
const execs: string[] = [];
|
||||
const fileOps: TmuxFileOps = {
|
||||
writeFile: async () => {},
|
||||
@@ -69,8 +70,8 @@ function recordingAdapter(calls: RunCall[], opts: { failing?: string[]; guard?:
|
||||
}, fileOps);
|
||||
if (opts.guard) {
|
||||
adapter.deliveryGuard = {
|
||||
maybeTarget: () => TARGET,
|
||||
target: () => TARGET,
|
||||
maybeTarget: () => ({ ...TARGET, nodeId: opts.nodeId ?? TARGET.nodeId }),
|
||||
target: () => ({ ...TARGET, nodeId: opts.nodeId ?? TARGET.nodeId }),
|
||||
ownsLifecycle: () => false,
|
||||
checkInput: () => {},
|
||||
input: (_name: string, fn: () => Promise<unknown>) => fn(),
|
||||
@@ -160,9 +161,10 @@ async function driveEveryMethod(): Promise<RunCall[]> {
|
||||
// A runtime switch's exchange: two session-id reads, then the guarded if-shell swap (nested quoting); and its env writes.
|
||||
await a.swapRuntimeSwitchPanes({ nodeId: "n1", stagingSession: "staging'q a", canonicalSession: SESSION, canonicalPane: "%1", stagedPane: "%1" });
|
||||
await a.setRuntimeSwitchEnvironment(SESSION, "claude-code", "gen 'q 1");
|
||||
// A failed paste deletes the loaded buffer; the guarded kill reads the session id first.
|
||||
// A failed paste deletes the loaded buffer. The guarded kill reads the session id and server, then the seat's marker
|
||||
// (the session's OPENRIG_NODE_ID, n1 here), and runs each effect held to that id and server.
|
||||
await recordingAdapter(calls, { failing: ["paste-buffer"] }).sendText(SESSION, "hello");
|
||||
await recordingAdapter(calls, { guard: true }).killSession(SESSION);
|
||||
await recordingAdapter(calls, { guard: true, nodeId: "n1" }).killSession(SESSION);
|
||||
await recordingAdapter(calls, { composer: true }).captureComposerSnapshot("%1");
|
||||
const scoped = recordingAdapter(calls);
|
||||
scoped.launchEnvironment = () => ({ inheritTmuxEnvironment: false, env: { PATH: "/a b:$HOME", PRIVATE_SYNTHETIC: "it's a\nvalue" } });
|
||||
|
||||
@@ -11,7 +11,9 @@ import { SeatStructuralActivityService } from "../src/domain/seat-structural-act
|
||||
|
||||
type Panes = Record<string, string>;
|
||||
/** Runs a tmux argv against `panes` the way tmux does: `;` separates commands, and the first failing one ends it. */
|
||||
function runTmux(argv: string[], panes: Panes, vanished = new Set<string>(), maxBuffer = Infinity): string {
|
||||
function runTmux(argv: string[], panes: Panes, vanished = new Set<string>(), maxBuffer = Infinity,
|
||||
/** What tmux expands `#{pane_id} #{pane_pid}` to, per session; left literal when absent. */
|
||||
identities: Record<string, string> = {}): string {
|
||||
if (argv[1] === "list-sessions") return Object.keys(panes).join("\n") + "\n";
|
||||
const cmds: string[][] = [[]];
|
||||
for (const a of argv.slice(1)) {
|
||||
@@ -24,7 +26,7 @@ function runTmux(argv: string[], panes: Panes, vanished = new Set<string>(), max
|
||||
const name = /^=(.*):$/.exec(target)?.[1];
|
||||
if (name === undefined) throw new Error(`expected an exact =name: target, got ${target}`);
|
||||
if (!(name in panes) || vanished.has(name)) throw Object.assign(new Error(`can't find session: ${name}`), { stdout: out });
|
||||
out += c[0] === "display-message" ? `${c[c.length - 1]}\n` : panes[name];
|
||||
out += c[0] === "display-message" ? `${c[c.length - 1]!.replace("#{pane_id} #{pane_pid}", identities[name] ?? "#{pane_id} #{pane_pid}")}\n` : panes[name];
|
||||
}
|
||||
if (out.length > maxBuffer) throw new Error("stdout maxBuffer length exceeded");
|
||||
return out;
|
||||
@@ -41,12 +43,12 @@ const pane = (n: string) => `• ${n} output\n\n❯ \n`;
|
||||
describe("TmuxAdapter.capturePanesContent (#308)", () => {
|
||||
for (const mode of ["shell", "argv"] as const) {
|
||||
/** A TmuxAdapter over the fake tmux in this test's exec mode, recording every tmux argv it runs. */
|
||||
function adapter(panes: Panes, opts: { vanished?: Set<string>; noServer?: boolean; maxBuffer?: number } = {}) {
|
||||
function adapter(panes: Panes, opts: { vanished?: Set<string>; noServer?: boolean; maxBuffer?: number; identities?: Record<string, string> } = {}) {
|
||||
const calls: string[][] = [];
|
||||
const run = (argv: string[]) => {
|
||||
calls.push(argv);
|
||||
if (opts.noServer) throw new Error("no server running on /tmp/tmux-501/default");
|
||||
return runTmux(argv, panes, opts.vanished, opts.maxBuffer);
|
||||
return runTmux(argv, panes, opts.vanished, opts.maxBuffer, opts.identities);
|
||||
};
|
||||
const exec = async (cmd: string) => run(shellToArgv(cmd));
|
||||
const a = mode === "argv" ? new TmuxAdapter(async () => { throw new Error("shell path unused"); }, undefined, async (argv: string[]) => run(argv))
|
||||
@@ -62,6 +64,17 @@ describe("TmuxAdapter.capturePanesContent (#308)", () => {
|
||||
expect(Object.fromEntries([...got!].map(([k, v]) => [k, v.text]))).toEqual({ "gone@r": null, "a@r": pane("a"), "b.x@r": pane("b.x"), "o'q@r": pane("o'q") });
|
||||
});
|
||||
|
||||
it(`${mode}: the marker line's expanded pane id and pid become the capture's pane; a malformed or literal one gives none (#2515)`, async () => {
|
||||
const { a } = adapter({ "a@r": pane("a"), "b@r": pane("b"), "c@r": pane("c") },
|
||||
{ identities: { "a@r": "%5 4100", "b@r": "%5 notapid" } });
|
||||
const got = await a.capturePanesContent(["a@r", "b@r", "c@r"], 20);
|
||||
expect(got!.get("a@r")).toMatchObject({ text: pane("a"), pane: { id: "%5", pid: 4100 } });
|
||||
for (const name of ["b@r", "c@r"]) {
|
||||
expect(got!.get(name)?.text).toBe(pane(name.slice(0, 1)));
|
||||
expect(got!.get(name)?.pane).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it(`${mode}: 50 distinct live sessions = 1 listing + 3 chained calls of at most 24`, async () => {
|
||||
const panes = Object.fromEntries(Array.from({ length: 50 }, (_, i) => [`s${i}@r`, pane(`s${i}`)]));
|
||||
const { a, calls } = adapter(panes);
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
// A daemon started from inside tmux on a non-default socket creates its sessions on that server,
|
||||
// and a person's new terminal reaches the default one. Every printed attach command must name the
|
||||
// daemon's server then, and keep today's form on the default server.
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdtempSync, mkdirSync, realpathSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { afterAll, afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { setDaemonTmuxServer, tmuxAttachCommand, tmuxServerArgs } from "../src/adapters/tmux-server.js";
|
||||
import { composeView, type ViewMemberInput } from "../src/domain/terminal/view-composer.js";
|
||||
|
||||
@@ -55,6 +56,58 @@ describe("the daemon's tmux server", () => {
|
||||
expect(tmuxAttachCommand("dev-build@starter")).toBe("tmux -S '/tmp/my sockets/team' attach -t dev-build@starter");
|
||||
});
|
||||
|
||||
it("quotes a session name that would otherwise reach the shell as code", () => {
|
||||
// An ordinary seat name keeps its exact form: shellWord only quotes what needs it.
|
||||
expect(tmuxAttachCommand("dev-build@starter")).toBe("tmux attach -t dev-build@starter");
|
||||
// Everything else reaches a shell as one word. The CMUX helper types this line into a
|
||||
// fresh terminal, and an adopted session's name comes from outside OpenRig.
|
||||
expect(tmuxAttachCommand("a;rm -rf /")).toBe("tmux attach -t 'a;rm -rf /'");
|
||||
expect(tmuxAttachCommand("$(id)")).toBe("tmux attach -t '$(id)'");
|
||||
expect(tmuxAttachCommand("it's")).toBe("tmux attach -t 'it'\"'\"'s'");
|
||||
// The server flags and the session are quoted independently: neither can borrow the
|
||||
// other's quoting.
|
||||
setDaemonTmuxServer(["-L", "person"]);
|
||||
expect(tmuxAttachCommand("a;b")).toBe("tmux -L person attach -t 'a;b'");
|
||||
});
|
||||
|
||||
it("escapes a trailing semicolon for tmux's own parser, before the shell sees the word", () => {
|
||||
const bs = "\\"; // one backslash
|
||||
// One backslash is added in front of a final separator, once, for the raw name as written.
|
||||
expect(tmuxAttachCommand("report;")).toBe(`tmux attach -t 'report${bs};'`);
|
||||
expect(tmuxAttachCommand(`report${bs};`)).toBe(`tmux attach -t 'report${bs}${bs};'`);
|
||||
expect(tmuxAttachCommand(`report${bs}${bs};`)).toBe(`tmux attach -t 'report${bs}${bs}${bs};'`);
|
||||
// The three names stay different all the way to tmux, which is the property that matters.
|
||||
expect(new Set([
|
||||
tmuxAttachCommand("report;"),
|
||||
tmuxAttachCommand(`report${bs};`),
|
||||
tmuxAttachCommand(`report${bs}${bs};`),
|
||||
]).size).toBe(3);
|
||||
// A separator that is not final is not touched, and ordinary names keep their bytes.
|
||||
expect(tmuxAttachCommand("a;b")).toBe("tmux attach -t 'a;b'");
|
||||
expect(tmuxAttachCommand("dev-build@starter")).toBe("tmux attach -t dev-build@starter");
|
||||
});
|
||||
|
||||
it("encodes tmux's target markers so the session name survives, and quotes any = for the shell", () => {
|
||||
// `=` is tmux's exact-match marker and the rest of the word is the name it looks up, so a
|
||||
// session named `=ls` needs `==ls`: sent as `=ls`, tmux looks for `ls`.
|
||||
expect(tmuxAttachCommand("=ls")).toBe("tmux attach -t '==ls'");
|
||||
expect(tmuxAttachCommand("ls")).toBe("tmux attach -t ls");
|
||||
expect(tmuxAttachCommand("==ls")).toBe("tmux attach -t '===ls'");
|
||||
// `@` and `%` open a window or pane target, which a session target cannot resolve.
|
||||
expect(tmuxAttachCommand("@work")).toBe("tmux attach -t '=@work'");
|
||||
expect(tmuxAttachCommand("%work")).toBe("tmux attach -t '=%work'");
|
||||
// `~` and `{marked}` are tmux's marked-target words.
|
||||
expect(tmuxAttachCommand("~")).toBe("tmux attach -t '=~'");
|
||||
expect(tmuxAttachCommand("{marked}")).toBe("tmux attach -t '={marked}'");
|
||||
// zsh expands `=name` to a command path, and with MAGIC_EQUAL_SUBST it can expand one inside a
|
||||
// `name=value` word, so a `=` anywhere in a session word is quoted. A shell passes it through.
|
||||
expect(tmuxAttachCommand("a=b@rig")).toBe("tmux attach -t 'a=b@rig'");
|
||||
expect(tmuxAttachCommand("work==ls")).toBe("tmux attach -t 'work==ls'");
|
||||
// Known limit, pinned so it cannot drift into looking fixed: tmux reads a leading `$` as a
|
||||
// session id before the exact-match flag, so this name is not addressable through `-t` at all.
|
||||
expect(tmuxAttachCommand("$1")).toBe("tmux attach -t '$1'");
|
||||
});
|
||||
|
||||
it("puts the server into local panes, never into an ssh pane", () => {
|
||||
const local: ViewMemberInput = { seat: "dev-review@starter", label: "reviewer", tmuxSession: "dev-review@starter", host: null, readOnly: false, alive: true };
|
||||
const remote: ViewMemberInput = { seat: "dev-build@starter", label: "builder", tmuxSession: "dev-build@starter", host: "box", readOnly: false, alive: true };
|
||||
@@ -71,3 +124,52 @@ describe("the daemon's tmux server", () => {
|
||||
]);
|
||||
});
|
||||
});
|
||||
// tmux's own parser decides which session `attach -t` reaches, so the encodings above get one
|
||||
// native check wherever tmux is installed. The suite runs on a private server (`-L`), so it never
|
||||
// touches the operator's sessions, and it skips on a machine without tmux.
|
||||
const nativeTmux = (() => {
|
||||
try {
|
||||
execFileSync("tmux", ["-V"], { stdio: "ignore" });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
})();
|
||||
|
||||
describe.skipIf(!nativeTmux)("tmux's own target parsing (native)", () => {
|
||||
const socket = `openrig-target-${process.pid}`;
|
||||
const tmux = (...args: string[]) => execFileSync("tmux", ["-L", socket, ...args], { encoding: "utf8" });
|
||||
const names = () =>
|
||||
tmux("list-sessions", "-F", "#{session_name}")
|
||||
.split("\n")
|
||||
.filter(Boolean)
|
||||
.sort();
|
||||
|
||||
afterAll(() => {
|
||||
try {
|
||||
tmux("kill-server");
|
||||
} catch {
|
||||
// the private server was already gone
|
||||
}
|
||||
});
|
||||
|
||||
it("==ls reaches the session named =ls while =ls reaches the session named ls", () => {
|
||||
tmux("new-session", "-d", "-s", "ls");
|
||||
tmux("new-session", "-d", "-s", "=ls");
|
||||
expect(names()).toEqual(["=ls", "ls"]);
|
||||
// `rename-session -t` takes a session target, so the name that changes says which session was
|
||||
// found. `=ls` is the exact-match marker plus `ls`; `==ls` is the marker plus `=ls`.
|
||||
tmux("rename-session", "-t", "=ls", "renamed-a");
|
||||
expect(names()).toEqual(["=ls", "renamed-a"]);
|
||||
tmux("rename-session", "-t", "==ls", "renamed-b");
|
||||
expect(names()).toEqual(["renamed-a", "renamed-b"]);
|
||||
});
|
||||
|
||||
it("=@work reaches the session named @work, where @work alone is a window target", () => {
|
||||
tmux("new-session", "-d", "-s", "@work");
|
||||
expect(names()).toContain("@work");
|
||||
tmux("rename-session", "-t", "=@work", "renamed-at");
|
||||
expect(names()).toContain("renamed-at");
|
||||
expect(names()).not.toContain("@work");
|
||||
});
|
||||
});
|
||||
@@ -197,6 +197,42 @@ describe("waiting face names the next action the existing ladder can actually ta
|
||||
expect(view(row.qitemId).nextBackstop.dueAt).toBe("2026-09-08T00:00:00.000Z");
|
||||
});
|
||||
|
||||
it("shows the owner, progress and backstop of a blocker with a custom queue ID", async () => {
|
||||
const blocker = await queue.create({ qitemId: "custom-blocker", sourceSession: "owner@rig", destinationSession: "healthy@rig", body: "Resolve dependency", nudge: false });
|
||||
const row = await queue.create({ sourceSession: "owner@rig", destinationSession: "worker@rig", body: "Wait for dependency", nudge: false });
|
||||
await queue.claim({ qitemId: blocker.qitemId, destinationSession: "healthy@rig" });
|
||||
await queue.update({ qitemId: row.qitemId, actorSession: "worker@rig", state: "blocked", blockedOn: blocker.qitemId });
|
||||
advance(1);
|
||||
await queue.update({ qitemId: blocker.qitemId, actorSession: "healthy@rig", transitionNote: "Dependency progress" });
|
||||
const waiting = view(row.qitemId);
|
||||
expect(waiting.blocker).toEqual({ ref: "custom-blocker", owner: "healthy@rig", state: "in-progress" });
|
||||
expect(waiting.liveness.subject).toBe("healthy@rig");
|
||||
expect(waiting.lastMeaningfulChange).toEqual(lastMeaningfulTransition(db, blocker.qitemId));
|
||||
expect(waiting.nextBackstop).toMatchObject({ owner: "healthy@rig", mechanism: "blocker-transition / queue-stuck-sweep" });
|
||||
await queue.update({ qitemId: blocker.qitemId, actorSession: "healthy@rig", state: "done", closureReason: "no-follow-on" });
|
||||
expect(queue.getByIdOrThrow(row.qitemId)).toMatchObject({ state: "pending", blockedOn: null });
|
||||
});
|
||||
|
||||
it.each(["external:vendor", "auth:vendor", "fold:vendor"])("keeps %s as a typed gate when an item has the same ID", async (gate) => {
|
||||
await queue.create({ qitemId: gate, sourceSession: "owner@rig", destinationSession: "healthy@rig", body: "Unrelated item", nudge: false });
|
||||
const row = await queue.create({ sourceSession: "owner@rig", destinationSession: "worker@rig", body: "Wait for gate", nudge: false });
|
||||
await queue.update({ qitemId: row.qitemId, actorSession: "worker@rig", state: "blocked", blockedOn: gate });
|
||||
expect(view(row.qitemId).blocker).toEqual({ ref: gate, owner: null, state: null });
|
||||
expect(view(row.qitemId).liveness.subject).toBe(gate);
|
||||
expect(view(row.qitemId).nextBackstop).toMatchObject({ mechanism: "UNVERIFIED: no timed backstop", dueAt: null });
|
||||
});
|
||||
|
||||
it("does not promise a transition wake for a custom blocker that already finished", async () => {
|
||||
const blocker = await queue.create({ qitemId: "finished-blocker", sourceSession: "owner@rig", destinationSession: "healthy@rig", body: "Finished dependency", nudge: false });
|
||||
await queue.update({ qitemId: blocker.qitemId, actorSession: "healthy@rig", state: "done", closureReason: "no-follow-on" });
|
||||
const row = await queue.create({ sourceSession: "owner@rig", destinationSession: "worker@rig", body: "Wait for dependency", nudge: false });
|
||||
await queue.update({ qitemId: row.qitemId, actorSession: "worker@rig", state: "blocked", blockedOn: blocker.qitemId });
|
||||
expect(view(row.qitemId).blocker).toEqual({ ref: blocker.qitemId, owner: "healthy@rig", state: "done" });
|
||||
expect(view(row.qitemId).nextBackstop).toMatchObject({ mechanism: "UNVERIFIED: no timed backstop", dueAt: null });
|
||||
await queue.update({ qitemId: row.qitemId, actorSession: "worker@rig", state: "blocked", blockedOn: blocker.qitemId, wakeAfterSeconds: 600 });
|
||||
expect(view(row.qitemId).nextBackstop.mechanism).toMatch(/^watchdog:/);
|
||||
});
|
||||
|
||||
it("#2739: a claimed row parked with no wake reads as having no timed backstop, and a later wake on the same blocker arms one it shows", async () => {
|
||||
const row = await queue.create({ sourceSession: "owner@rig", destinationSession: "worker@rig", body: "park-without-wake repro", nudge: false });
|
||||
await queue.claim({ qitemId: row.qitemId, destinationSession: "worker@rig" });
|
||||
|
||||
@@ -17,7 +17,8 @@ import { externalCliAttachmentSchema } from "../src/db/migrations/019_external_c
|
||||
import { RigRepository } from "../src/domain/rig-repository.js";
|
||||
import { SessionRegistry } from "../src/domain/session-registry.js";
|
||||
import { TranscriptStore } from "../src/domain/transcript-store.js";
|
||||
import { WhoamiService } from "../src/domain/whoami-service.js";
|
||||
import { WhoamiService, ROSTER_BUILDING_NOTE } from "../src/domain/whoami-service.js";
|
||||
import { markRigBuilding, unmarkRigBuilding } from "../src/domain/rigs-being-built.js";
|
||||
import { createFullTestDb } from "./helpers/test-app.js";
|
||||
|
||||
function setupDb(): Database.Database {
|
||||
@@ -143,6 +144,25 @@ describe("WhoamiService", () => {
|
||||
expect(result["podRoster"]).toBeUndefined();
|
||||
});
|
||||
|
||||
// #2871: the building state belongs to the rig being built, by id, never to another rig with the same name.
|
||||
it("only the rig being built says so; a running rig with the same name answers as before", () => {
|
||||
const { nodeA } = seedRig();
|
||||
const newRig = rigRepo.createRig("my-rig");
|
||||
const newNode = rigRepo.addNode(newRig.id, "dev.impl", { role: "worker", runtime: "claude-code" });
|
||||
markRigBuilding(db, newRig.id);
|
||||
|
||||
const running = svc.resolve({ nodeId: nodeA.id })!;
|
||||
expect(running).not.toHaveProperty("rosterState");
|
||||
expect(running.peersNote).toBe("peers = this rig's roster excluding self (no edge filter); edges = directional relationships; use `rig ps --nodes` for node inventory including self + live state");
|
||||
|
||||
const building = svc.resolve({ nodeId: newNode.id })!;
|
||||
expect(building.rosterState).toBe("building");
|
||||
expect(building.peersNote.startsWith(ROSTER_BUILDING_NOTE)).toBe(true);
|
||||
|
||||
unmarkRigBuilding(db, newRig.id);
|
||||
expect(svc.resolve({ nodeId: newNode.id })).not.toHaveProperty("rosterState");
|
||||
});
|
||||
|
||||
it("unknown nodeId returns null", () => {
|
||||
seedRig();
|
||||
const result = svc.resolve({ nodeId: "nonexistent" });
|
||||
|
||||
@@ -81,8 +81,11 @@ describe("WorkflowSpecCache (PL-004 Phase D)", () => {
|
||||
it("readThrough returns the same spec_id when content unchanged (hash hit)", () => {
|
||||
const path = join(tmp, "spec.yaml");
|
||||
writeFileSync(path, SAMPLE_SPEC);
|
||||
const first = cache.readThrough(path);
|
||||
const second = cache.readThrough(path);
|
||||
// Hold observation time fixed so this hash-hit control does not assert that newer reads never advance cached_at.
|
||||
const observedAt = new Date("2035-01-02T03:04:05.100Z");
|
||||
const stableClockCache = new WorkflowSpecCache(db, () => observedAt);
|
||||
const first = stableClockCache.readThrough(path);
|
||||
const second = stableClockCache.readThrough(path);
|
||||
expect(second.specId).toBe(first.specId);
|
||||
expect(second.cachedAt).toBe(first.cachedAt);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,538 @@
|
||||
import { beforeEach, afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const fileProbe = vi.hoisted(() => ({
|
||||
readPaths: [] as string[],
|
||||
afterRead: null as null | ((file: string, value: unknown) => void),
|
||||
statOverrides: new Map<string, { mtimeMs: number; ctimeMs: number }>(),
|
||||
}));
|
||||
|
||||
vi.mock("node:fs", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("node:fs")>();
|
||||
const readFileSync = vi.fn(((file: unknown, ...args: unknown[]) => {
|
||||
const value = actual.readFileSync(file as never, ...(args as never[]));
|
||||
const filePath = String(file);
|
||||
fileProbe.readPaths.push(filePath);
|
||||
fileProbe.afterRead?.(filePath, value);
|
||||
return value;
|
||||
}) as typeof actual.readFileSync);
|
||||
const statSync = vi.fn(((file: unknown, ...args: unknown[]) => {
|
||||
const override = fileProbe.statOverrides.get(String(file));
|
||||
if (override) return { ...override, isFile: () => true } as ReturnType<typeof actual.statSync>;
|
||||
return actual.statSync(file as never, ...(args as never[]));
|
||||
}) as typeof actual.statSync);
|
||||
return { ...actual, readFileSync, statSync };
|
||||
});
|
||||
|
||||
import * as fs from "node:fs";
|
||||
import { createHash } from "node:crypto";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { Hono } from "hono";
|
||||
import type Database from "better-sqlite3";
|
||||
import { createDb } from "../src/db/connection.js";
|
||||
import { migrate } from "../src/db/migrate.js";
|
||||
import { coreSchema } from "../src/db/migrations/001_core_schema.js";
|
||||
import { workflowSpecsSchema } from "../src/db/migrations/033_workflow_specs.js";
|
||||
import { workflowSpecsDiagnosticSchema } from "../src/db/migrations/040_workflow_specs_diagnostic.js";
|
||||
import { WorkflowSpecCache } from "../src/domain/workflow-spec-cache.js";
|
||||
import { scanWorkflowSpecFolder } from "../src/domain/spec-library-workflow-scanner.js";
|
||||
import { ActiveLensStore } from "../src/domain/active-lens-store.js";
|
||||
import { SpecLibraryService } from "../src/domain/spec-library-service.js";
|
||||
import { SpecReviewService } from "../src/domain/spec-review-service.js";
|
||||
import { SettingsStore } from "../src/domain/user-settings/settings-store.js";
|
||||
import { specLibraryRoutes } from "../src/routes/spec-library.js";
|
||||
|
||||
const VALID_YAML = (id: string) => `workflow:
|
||||
id: ${id}
|
||||
version: '1'
|
||||
objective: capture fixture
|
||||
target:
|
||||
rig: capture-fixture
|
||||
entry:
|
||||
role: producer
|
||||
roles:
|
||||
producer:
|
||||
preferred_targets:
|
||||
- producer@capture-fixture
|
||||
steps:
|
||||
- id: produce
|
||||
actor_role: producer
|
||||
objective: Draft.
|
||||
allowed_exits:
|
||||
- done
|
||||
invariants:
|
||||
allowed_exits:
|
||||
- done
|
||||
`;
|
||||
const SAME_TICK_YAML = (version: "1" | "2", revision: "one" | "two") => `workflow:
|
||||
id: same-tick-flow
|
||||
version: '${version}'
|
||||
objective: Objective ${revision}
|
||||
target:
|
||||
rig: capture-fixture
|
||||
entry:
|
||||
role: producer
|
||||
roles:
|
||||
producer:
|
||||
preferred_targets:
|
||||
- producer@capture-fixture
|
||||
steps:
|
||||
- id: produce
|
||||
actor_role: producer
|
||||
objective: Step ${revision}.
|
||||
allowed_exits:
|
||||
- done
|
||||
invariants:
|
||||
allowed_exits:
|
||||
- done
|
||||
`;
|
||||
const BROKEN_YAML = "workflow:\n id: capture-repair\n version: [unterminated\n";
|
||||
const CAPTURE_TIME = new Date("2035-01-02T03:04:05.100Z");
|
||||
const LATER_TIME = new Date("2035-01-02T03:04:06.100Z");
|
||||
const TOUCHED_STAT_TIME = new Date("2035-01-02T03:04:06.900Z").getTime();
|
||||
const P3_REFRESH_TIME = new Date("2035-01-02T03:04:07.100Z");
|
||||
const NEWER_VERIFIED_TIME = new Date("2035-01-02T03:04:08.100Z");
|
||||
const BACKWARDS_TIME = new Date("2035-01-02T03:04:07.100Z");
|
||||
const NEWER_SQLITE_TIMESTAMP = "2035-01-02 03:04:08.100Z";
|
||||
const OLDER_STAT_TIME = new Date("2035-01-02T03:00:00.000Z").getTime();
|
||||
const captureHash = (text: string) => createHash("sha256").update(text).digest("hex");
|
||||
|
||||
function statAt(file: string, time = OLDER_STAT_TIME): void {
|
||||
fileProbe.statOverrides.set(file, { mtimeMs: time, ctimeMs: time });
|
||||
}
|
||||
|
||||
function readsOf(file: string): number {
|
||||
return fileProbe.readPaths.filter((readPath) => readPath === file).length;
|
||||
}
|
||||
|
||||
function resetFileProbe(): void {
|
||||
fileProbe.readPaths.length = 0;
|
||||
fileProbe.afterRead = null;
|
||||
fileProbe.statOverrides.clear();
|
||||
}
|
||||
|
||||
function migrateSpecCache(db: Database.Database): void {
|
||||
migrate(db, [coreSchema, workflowSpecsSchema, workflowSpecsDiagnosticSchema]);
|
||||
}
|
||||
|
||||
function makeLibraryApp(opts: {
|
||||
db: Database.Database;
|
||||
cache: WorkflowSpecCache;
|
||||
folder: string;
|
||||
root: string;
|
||||
}): Hono {
|
||||
const builtinDir = join(opts.root, "builtins");
|
||||
fs.mkdirSync(builtinDir, { recursive: true });
|
||||
fs.writeFileSync(join(opts.root, "config.json"), JSON.stringify({ specs: { builtinsVisible: false } }));
|
||||
const lib = new SpecLibraryService({ roots: [], specReviewService: new SpecReviewService() });
|
||||
lib.scan();
|
||||
const app = new Hono();
|
||||
app.use("*", async (c, next) => {
|
||||
c.set("specLibraryService" as never, lib);
|
||||
c.set("specReviewService" as never, new SpecReviewService());
|
||||
c.set("activeLensStore" as never, new ActiveLensStore({ filePath: join(opts.root, "active-lens.json") }));
|
||||
c.set("rigRepo" as never, { db: opts.db });
|
||||
c.set("workflowBuiltinSpecsDir" as never, builtinDir);
|
||||
c.set("workflowSpecCache" as never, opts.cache);
|
||||
c.set("workflowsFolderDir" as never, opts.folder);
|
||||
c.set("settingsStore" as never, new SettingsStore(join(opts.root, "config.json")));
|
||||
await next();
|
||||
});
|
||||
app.route("/api/specs/library", specLibraryRoutes());
|
||||
return app;
|
||||
}
|
||||
|
||||
describe("workflow spec persisted capture and Library reads", () => {
|
||||
let db: Database.Database;
|
||||
let root: string;
|
||||
let folder: string;
|
||||
|
||||
beforeEach(() => {
|
||||
resetFileProbe();
|
||||
db = createDb();
|
||||
migrateSpecCache(db);
|
||||
root = fs.mkdtempSync(join(tmpdir(), "wf-capture-"));
|
||||
folder = join(root, "workflows");
|
||||
fs.mkdirSync(folder, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (db?.open) db.close();
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
resetFileProbe();
|
||||
});
|
||||
|
||||
it("persists the capture timestamp for a successful read", () => {
|
||||
const file = join(folder, "capture.yaml");
|
||||
fs.writeFileSync(file, VALID_YAML("capture-time"));
|
||||
let bytesRead = false;
|
||||
const cache = new WorkflowSpecCache(db, () => new Date(bytesRead ? LATER_TIME : CAPTURE_TIME));
|
||||
fileProbe.afterRead = (readPath) => {
|
||||
if (readPath === file) bytesRead = true;
|
||||
};
|
||||
|
||||
const row = cache.readThrough(file);
|
||||
const stored = db.prepare("SELECT cached_at FROM workflow_specs WHERE source_path = ?").get(file) as { cached_at: string };
|
||||
|
||||
expect(row.cachedAt).toBe(CAPTURE_TIME.toISOString());
|
||||
expect(stored.cached_at).toBe(CAPTURE_TIME.toISOString());
|
||||
});
|
||||
|
||||
it("finds a repair after a persisted diagnostic instead of skipping on its later handler timestamp", () => {
|
||||
const file = join(folder, "repair-after.yaml");
|
||||
fs.writeFileSync(file, BROKEN_YAML);
|
||||
statAt(file, CAPTURE_TIME.getTime());
|
||||
const cache = new WorkflowSpecCache(db, () => new Date(readsOf(file) === 0 ? CAPTURE_TIME : LATER_TIME));
|
||||
|
||||
const failed = scanWorkflowSpecFolder({ db, cache, folder, builtinDir: null });
|
||||
const diagnostic = db.prepare("SELECT source_hash, cached_at, status FROM workflow_specs WHERE source_path = ?").get(file) as {
|
||||
source_hash: string; cached_at: string; status: string;
|
||||
};
|
||||
expect(failed.errors).toBe(1);
|
||||
expect(diagnostic.source_hash).toBe(captureHash(BROKEN_YAML));
|
||||
|
||||
fs.writeFileSync(file, VALID_YAML("repair-after"));
|
||||
const repaired = scanWorkflowSpecFolder({ db, cache, folder, builtinDir: null });
|
||||
|
||||
expect(repaired).toEqual({ scanned: 1, valid: 1, errors: 0, removed: 0, skipped: 0 });
|
||||
expect(db.prepare("SELECT status, name, error_message FROM workflow_specs WHERE source_path = ?").get(file))
|
||||
.toEqual({ status: "valid", name: "repair-after", error_message: null });
|
||||
});
|
||||
|
||||
it("records failed bytes when the file is repaired after the read but before diagnostic handling", () => {
|
||||
const file = join(folder, "repair-race.yaml");
|
||||
fs.writeFileSync(file, BROKEN_YAML);
|
||||
statAt(file, CAPTURE_TIME.getTime());
|
||||
const cache = new WorkflowSpecCache(db, () => new Date(readsOf(file) === 0 ? CAPTURE_TIME : LATER_TIME));
|
||||
let repairedBetweenReadAndDiagnostic = false;
|
||||
fileProbe.afterRead = (readPath) => {
|
||||
if (readPath === file && !repairedBetweenReadAndDiagnostic) {
|
||||
repairedBetweenReadAndDiagnostic = true;
|
||||
fs.writeFileSync(file, VALID_YAML("repair-race"));
|
||||
}
|
||||
};
|
||||
|
||||
const failed = scanWorkflowSpecFolder({ db, cache, folder, builtinDir: null });
|
||||
const diagnostic = db.prepare("SELECT source_hash, cached_at FROM workflow_specs WHERE source_path = ?").get(file) as {
|
||||
source_hash: string; cached_at: string;
|
||||
};
|
||||
|
||||
expect(failed.errors).toBe(1);
|
||||
expect(diagnostic.source_hash).toBe(captureHash(BROKEN_YAML));
|
||||
expect(diagnostic.cached_at).toBe(CAPTURE_TIME.toISOString());
|
||||
|
||||
fileProbe.afterRead = null;
|
||||
const repaired = scanWorkflowSpecFolder({ db, cache, folder, builtinDir: null });
|
||||
|
||||
expect(repaired).toEqual({ scanned: 1, valid: 1, errors: 0, removed: 0, skipped: 0 });
|
||||
});
|
||||
|
||||
it("keeps a warm aged Library list content-free", async () => {
|
||||
const file = join(folder, "warm.yaml");
|
||||
fs.writeFileSync(file, VALID_YAML("warm-list"));
|
||||
const cache = new WorkflowSpecCache(db, () => CAPTURE_TIME);
|
||||
cache.readThrough(file);
|
||||
statAt(file);
|
||||
const app = makeLibraryApp({ db, cache, folder, root });
|
||||
fileProbe.readPaths.length = 0;
|
||||
|
||||
const response = await app.request("/api/specs/library?kind=workflow");
|
||||
const entries = await response.json() as Array<{ name: string; sourcePath: string }>;
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(entries.some((entry) => entry.name === "warm-list" && entry.sourcePath === file)).toBe(true);
|
||||
expect(readsOf(file)).toBe(0);
|
||||
});
|
||||
|
||||
it("keeps aged persisted entries content-free after reopening the database with a fresh cache", async () => {
|
||||
const dbFile = join(root, "workflow-specs.sqlite");
|
||||
db.close();
|
||||
db = createDb(dbFile);
|
||||
migrateSpecCache(db);
|
||||
const file = join(folder, "restart.yaml");
|
||||
fs.writeFileSync(file, VALID_YAML("restart-list"));
|
||||
const cache = new WorkflowSpecCache(db, () => CAPTURE_TIME);
|
||||
cache.readThrough(file);
|
||||
statAt(file);
|
||||
|
||||
db.close();
|
||||
db = createDb(dbFile);
|
||||
const freshCache = new WorkflowSpecCache(db);
|
||||
expect(freshCache.getByNameVersion("restart-list", "1")?.cachedAt).toBe(CAPTURE_TIME.toISOString());
|
||||
const app = makeLibraryApp({ db, cache: freshCache, folder, root });
|
||||
fileProbe.readPaths.length = 0;
|
||||
|
||||
const response = await app.request("/api/specs/library?kind=workflow");
|
||||
const entries = await response.json() as Array<{ name: string; sourcePath: string }>;
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(entries.some((entry) => entry.name === "restart-list" && entry.sourcePath === file)).toBe(true);
|
||||
expect(readsOf(file)).toBe(0);
|
||||
});
|
||||
|
||||
it("refreshes a touched unchanged user file once, then keeps warm and restarted Library lists content-free", async () => {
|
||||
const dbFile = join(root, "workflow-specs-touch.sqlite");
|
||||
db.close();
|
||||
db = createDb(dbFile);
|
||||
migrateSpecCache(db);
|
||||
const file = join(folder, "touched-unchanged.yaml");
|
||||
const contents = VALID_YAML("touched-unchanged");
|
||||
fs.writeFileSync(file, contents);
|
||||
let observedAt = CAPTURE_TIME;
|
||||
const cache = new WorkflowSpecCache(db, () => observedAt);
|
||||
cache.readThrough(file);
|
||||
|
||||
// Keep the bytes unchanged while advancing both stat observations into the next second.
|
||||
statAt(file, TOUCHED_STAT_TIME);
|
||||
observedAt = P3_REFRESH_TIME;
|
||||
const app = makeLibraryApp({ db, cache, folder, root });
|
||||
fileProbe.readPaths.length = 0;
|
||||
|
||||
const firstResponse = await app.request("/api/specs/library?kind=workflow");
|
||||
const firstEntries = await firstResponse.json() as Array<{ name: string; sourcePath: string; sourceType: string }>;
|
||||
const firstReads = readsOf(file);
|
||||
const firstRow = db.prepare("SELECT cached_at, source_hash, status FROM workflow_specs WHERE source_path = ?").get(file) as {
|
||||
cached_at: string; source_hash: string; status: string;
|
||||
};
|
||||
const readThroughCachedAt = cache.readThrough(file).cachedAt;
|
||||
|
||||
fileProbe.readPaths.length = 0;
|
||||
const secondResponse = await app.request("/api/specs/library?kind=workflow");
|
||||
const secondEntries = await secondResponse.json() as Array<{ name: string; sourcePath: string; sourceType: string }>;
|
||||
const secondReads = readsOf(file);
|
||||
const secondCachedAt = (db.prepare("SELECT cached_at FROM workflow_specs WHERE source_path = ?").get(file) as { cached_at: string }).cached_at;
|
||||
|
||||
db.close();
|
||||
db = createDb(dbFile);
|
||||
const freshCache = new WorkflowSpecCache(db);
|
||||
const restartedRow = db.prepare("SELECT cached_at, source_hash, status FROM workflow_specs WHERE source_path = ?").get(file) as {
|
||||
cached_at: string; source_hash: string; status: string;
|
||||
};
|
||||
const restartedApp = makeLibraryApp({ db, cache: freshCache, folder, root });
|
||||
fileProbe.readPaths.length = 0;
|
||||
const restartedResponse = await restartedApp.request("/api/specs/library?kind=workflow");
|
||||
const restartedEntries = await restartedResponse.json() as Array<{ name: string; sourcePath: string; sourceType: string }>;
|
||||
const restartedReads = readsOf(file);
|
||||
|
||||
const hasUserEntry = (entries: Array<{ name: string; sourcePath: string; sourceType: string }>) =>
|
||||
entries.some((entry) => entry.name === "touched-unchanged" && entry.sourcePath === file && entry.sourceType === "user_file");
|
||||
expect(firstResponse.status).toBe(200);
|
||||
expect(hasUserEntry(firstEntries)).toBe(true);
|
||||
expect(secondResponse.status).toBe(200);
|
||||
expect(hasUserEntry(secondEntries)).toBe(true);
|
||||
expect(restartedResponse.status).toBe(200);
|
||||
expect(hasUserEntry(restartedEntries)).toBe(true);
|
||||
expect({
|
||||
firstReads,
|
||||
firstCachedAt: firstRow.cached_at,
|
||||
firstHash: firstRow.source_hash,
|
||||
firstStatus: firstRow.status,
|
||||
readThroughCachedAt,
|
||||
secondReads,
|
||||
secondCachedAt,
|
||||
restartedReads,
|
||||
restartedCachedAt: restartedRow.cached_at,
|
||||
restartedHash: restartedRow.source_hash,
|
||||
restartedStatus: restartedRow.status,
|
||||
}).toEqual({
|
||||
firstReads: 1,
|
||||
firstCachedAt: P3_REFRESH_TIME.toISOString(),
|
||||
firstHash: captureHash(contents),
|
||||
firstStatus: "valid",
|
||||
readThroughCachedAt: P3_REFRESH_TIME.toISOString(),
|
||||
secondReads: 0,
|
||||
secondCachedAt: P3_REFRESH_TIME.toISOString(),
|
||||
restartedReads: 0,
|
||||
restartedCachedAt: P3_REFRESH_TIME.toISOString(),
|
||||
restartedHash: captureHash(contents),
|
||||
restartedStatus: "valid",
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
["immediately", 0],
|
||||
["at the first lookup five seconds later", 5_000],
|
||||
] as const)("reviews the current Library source after a same-tick rewrite %s", async (_timing, delayMs) => {
|
||||
const file = join(folder, "same-tick.yaml");
|
||||
const versionOne = SAME_TICK_YAML("1", "one");
|
||||
const versionTwo = SAME_TICK_YAML("2", "two");
|
||||
expect(Buffer.byteLength(versionOne, "utf8")).toBe(Buffer.byteLength(versionTwo, "utf8"));
|
||||
|
||||
fs.writeFileSync(file, versionOne);
|
||||
statAt(file, CAPTURE_TIME.getTime());
|
||||
const initialStat = { ...fileProbe.statOverrides.get(file)! };
|
||||
expect(initialStat).toEqual({ mtimeMs: CAPTURE_TIME.getTime(), ctimeMs: CAPTURE_TIME.getTime() });
|
||||
let observedAt = CAPTURE_TIME.getTime();
|
||||
const cache = new WorkflowSpecCache(db, () => new Date(observedAt));
|
||||
const app = makeLibraryApp({ db, cache, folder, root });
|
||||
|
||||
const initialListResponse = await app.request("/api/specs/library?kind=workflow");
|
||||
const initialEntries = await initialListResponse.json() as Array<{
|
||||
id: string; name: string; version: string; sourcePath: string; sourceType: string;
|
||||
}>;
|
||||
const initialEntry = initialEntries.find((entry) => entry.sourcePath === file
|
||||
&& entry.sourceType === "user_file"
|
||||
&& entry.name === "same-tick-flow"
|
||||
&& entry.version === "1");
|
||||
expect(initialListResponse.status).toBe(200);
|
||||
expect(initialEntry).toMatchObject({
|
||||
id: "workflow:same-tick-flow:1",
|
||||
name: "same-tick-flow",
|
||||
version: "1",
|
||||
sourcePath: file,
|
||||
sourceType: "user_file",
|
||||
});
|
||||
expect(initialEntry).toBeDefined();
|
||||
|
||||
const initialRow = db.prepare("SELECT cached_at, source_hash, status FROM workflow_specs WHERE source_path = ? AND name = ? AND version = '1'")
|
||||
.get(file, "same-tick-flow") as { cached_at: string; source_hash: string; status: string };
|
||||
expect(initialRow).toEqual({ cached_at: CAPTURE_TIME.toISOString(), source_hash: captureHash(versionOne), status: "valid" });
|
||||
|
||||
const beforeUnchangedReviewReads = readsOf(file);
|
||||
const initialReviewResponse = await app.request(`/api/specs/library/${encodeURIComponent(initialEntry!.id)}/review`);
|
||||
const initialReview = await initialReviewResponse.json() as {
|
||||
version: string; sourcePath: string; purpose: string; steps: Array<{ objective: string }>;
|
||||
};
|
||||
expect(initialReviewResponse.status).toBe(200);
|
||||
expect(initialReview).toMatchObject({
|
||||
version: "1",
|
||||
sourcePath: file,
|
||||
purpose: "Objective one",
|
||||
steps: [expect.objectContaining({ objective: "Step one." })],
|
||||
});
|
||||
expect(readsOf(file)).toBeGreaterThan(beforeUnchangedReviewReads);
|
||||
const unchangedRow = db.prepare("SELECT cached_at, source_hash FROM workflow_specs WHERE source_path = ? AND name = ? AND version = '1'")
|
||||
.get(file, "same-tick-flow") as { cached_at: string; source_hash: string };
|
||||
expect(unchangedRow).toEqual({ cached_at: CAPTURE_TIME.toISOString(), source_hash: captureHash(versionOne) });
|
||||
|
||||
const readsBeforeRewrite = readsOf(file);
|
||||
fs.writeFileSync(file, versionTwo);
|
||||
statAt(file, CAPTURE_TIME.getTime());
|
||||
expect(fileProbe.statOverrides.get(file)).toEqual(initialStat);
|
||||
observedAt = CAPTURE_TIME.getTime() + delayMs;
|
||||
const expectedLookupAt = new Date(observedAt);
|
||||
|
||||
// This ordinary Library list is the first request after the rewrite, including in the T+5s case.
|
||||
const currentListResponse = await app.request("/api/specs/library?kind=workflow");
|
||||
const currentEntries = await currentListResponse.json() as Array<{
|
||||
id: string; name: string; version: string; sourcePath: string; sourceType: string;
|
||||
}>;
|
||||
const currentEntry = currentEntries.find((entry) => entry.sourcePath === file
|
||||
&& entry.sourceType === "user_file"
|
||||
&& entry.name === "same-tick-flow"
|
||||
&& entry.version === "2");
|
||||
expect(currentListResponse.status).toBe(200);
|
||||
expect(currentEntry).toMatchObject({
|
||||
id: "workflow:same-tick-flow:2",
|
||||
name: "same-tick-flow",
|
||||
version: "2",
|
||||
sourcePath: file,
|
||||
sourceType: "user_file",
|
||||
});
|
||||
expect(currentEntry).toBeDefined();
|
||||
expect(readsOf(file)).toBeGreaterThan(readsBeforeRewrite);
|
||||
expect(fileProbe.statOverrides.get(file)).toEqual(initialStat);
|
||||
|
||||
const currentRow = db.prepare("SELECT cached_at, source_hash, status FROM workflow_specs WHERE source_path = ? AND name = ? AND version = '2'")
|
||||
.get(file, "same-tick-flow") as { cached_at: string; source_hash: string; status: string };
|
||||
expect(currentRow).toEqual({ cached_at: expectedLookupAt.toISOString(), source_hash: captureHash(versionTwo), status: "valid" });
|
||||
|
||||
const currentReviewResponse = await app.request(`/api/specs/library/${encodeURIComponent(currentEntry!.id)}/review`);
|
||||
const currentReview = await currentReviewResponse.json() as {
|
||||
version: string; sourcePath: string; purpose: string; steps: Array<{ objective: string }>;
|
||||
};
|
||||
expect(currentReviewResponse.status).toBe(200);
|
||||
expect(currentReview).toMatchObject({
|
||||
version: "2",
|
||||
sourcePath: file,
|
||||
purpose: "Objective two",
|
||||
steps: [expect.objectContaining({ objective: "Step two." })],
|
||||
});
|
||||
expect(fileProbe.statOverrides.get(file)).toEqual(initialStat);
|
||||
|
||||
const historyResponse = await app.request(`/api/specs/library/${encodeURIComponent("workflow:same-tick-flow:1")}/review`);
|
||||
const historicalReview = await historyResponse.json() as {
|
||||
version: string; sourcePath: string; purpose: string; steps: Array<{ objective: string }>;
|
||||
};
|
||||
expect(historyResponse.status).toBe(200);
|
||||
expect(historicalReview).toMatchObject({
|
||||
version: "1",
|
||||
sourcePath: file,
|
||||
purpose: "Objective one",
|
||||
steps: [expect.objectContaining({ objective: "Step one." })],
|
||||
});
|
||||
});
|
||||
|
||||
it("does not regress a newer SQLite-style cache timestamp when the injected clock moves backwards", () => {
|
||||
const file = join(folder, "backwards-clock.yaml");
|
||||
fs.writeFileSync(file, VALID_YAML("backwards-clock"));
|
||||
let observedAt = NEWER_VERIFIED_TIME;
|
||||
const cache = new WorkflowSpecCache(db, () => observedAt);
|
||||
cache.readThrough(file);
|
||||
db.prepare("UPDATE workflow_specs SET cached_at = ? WHERE source_path = ?").run(NEWER_SQLITE_TIMESTAMP, file);
|
||||
|
||||
observedAt = BACKWARDS_TIME;
|
||||
const row = cache.readThrough(file);
|
||||
const stored = db.prepare("SELECT cached_at FROM workflow_specs WHERE source_path = ?").get(file) as { cached_at: string };
|
||||
|
||||
expect(row.cachedAt).toBe(NEWER_SQLITE_TIMESTAMP);
|
||||
expect(stored.cached_at).toBe(NEWER_SQLITE_TIMESTAMP);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["without reading an identical copy", false],
|
||||
["after reading an identical copy", true],
|
||||
] as const)("re-reads the owning file's edit %s", (_scenario, readIdenticalCopy) => {
|
||||
const file = join(folder, "owner.yaml");
|
||||
const duplicateDir = join(root, "other");
|
||||
fs.mkdirSync(duplicateDir);
|
||||
const duplicate = join(duplicateDir, "copy.yaml");
|
||||
const originalContents = VALID_YAML("duplicate-flow");
|
||||
const editedContents = originalContents.replace("objective: capture fixture", "objective: edited capture fixture");
|
||||
fs.writeFileSync(file, originalContents);
|
||||
fs.writeFileSync(duplicate, originalContents);
|
||||
let observedAt = CAPTURE_TIME;
|
||||
const cache = new WorkflowSpecCache(db, () => observedAt);
|
||||
cache.readThrough(file);
|
||||
|
||||
// P's bytes really change after T0, but the stat fixture is deterministic and precedes T2.
|
||||
fileProbe.readPaths.length = 0;
|
||||
fs.writeFileSync(file, editedContents);
|
||||
statAt(file, TOUCHED_STAT_TIME);
|
||||
observedAt = P3_REFRESH_TIME;
|
||||
const copyRow = readIdenticalCopy ? cache.readThrough(duplicate) : null;
|
||||
const beforeScan = db.prepare("SELECT source_path, cached_at FROM workflow_specs WHERE name = ? AND version = '1'").get("duplicate-flow") as {
|
||||
source_path: string; cached_at: string;
|
||||
};
|
||||
const result = scanWorkflowSpecFolder({ db, cache, folder, builtinDir: null });
|
||||
const afterScan = db.prepare("SELECT source_path, source_hash, purpose, cached_at FROM workflow_specs WHERE name = ? AND version = '1'").get("duplicate-flow") as {
|
||||
source_path: string; source_hash: string; purpose: string; cached_at: string;
|
||||
};
|
||||
|
||||
expect({
|
||||
copySourcePath: copyRow?.sourcePath ?? null,
|
||||
copyCachedAt: copyRow?.cachedAt ?? null,
|
||||
copyReads: readsOf(duplicate),
|
||||
ownerReads: readsOf(file),
|
||||
beforeScanSourcePath: beforeScan.source_path,
|
||||
beforeScanCachedAt: beforeScan.cached_at,
|
||||
result,
|
||||
afterScanSourcePath: afterScan.source_path,
|
||||
afterScanHash: afterScan.source_hash,
|
||||
afterScanPurpose: afterScan.purpose,
|
||||
afterScanCachedAt: afterScan.cached_at,
|
||||
}).toEqual({
|
||||
copySourcePath: readIdenticalCopy ? file : null,
|
||||
copyCachedAt: readIdenticalCopy ? CAPTURE_TIME.toISOString() : null,
|
||||
copyReads: readIdenticalCopy ? 1 : 0,
|
||||
ownerReads: 1,
|
||||
beforeScanSourcePath: file,
|
||||
beforeScanCachedAt: CAPTURE_TIME.toISOString(),
|
||||
result: { scanned: 1, valid: 1, errors: 0, removed: 0, skipped: 0 },
|
||||
afterScanSourcePath: file,
|
||||
afterScanHash: captureHash(editedContents),
|
||||
afterScanPurpose: "edited capture fixture",
|
||||
afterScanCachedAt: P3_REFRESH_TIME.toISOString(),
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -471,7 +471,8 @@ async function run(): Promise<void> {
|
||||
const actor = actingAs(snapshot.attentionRead, action.actorSession);
|
||||
view.dispatch({ type: "notice", message: action.verb === "resolve" ? `Resolved ${action.qitemId} as ${actor}: it's unparked for its owner, with your decision.`
|
||||
: action.verb === "approve"
|
||||
? `Approved ${action.qitemId} as ${actor}: the request is closed on its own row.`
|
||||
// A runner's permission request says what became of the answer: handed to its receiving agent, or kept.
|
||||
? result.answer?.message ?? `Approved ${action.qitemId} as ${actor}: the request is closed on its own row.`
|
||||
: result.answer?.message ?? (result.answer?.outcome === "recorded" ? `Recorded on ${action.qitemId}. Still to answer: ${result.answer.unanswered.join("; ")}`
|
||||
: `Answered ${action.qitemId} as ${actor}: the answer went back to the asker, and the request is closed.`) });
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { describe, it } from "vitest";
|
||||
import { createServer } from "node:http";
|
||||
import { DaemonClient } from "../src/daemon-client.js";
|
||||
import { subscribeActivityEvents } from "../src/live-events.js";
|
||||
|
||||
function delay(ms: number): Promise<void> { return new Promise(resolve => setTimeout(resolve, ms)); }
|
||||
|
||||
describe("activity stream header deadline", () => {
|
||||
it("rejects a reachable endpoint that never returns response headers", async () => {
|
||||
describe.concurrent("activity stream header deadline", () => {
|
||||
it("rejects a reachable endpoint that never returns response headers", async ({ expect }) => {
|
||||
let closed = false;
|
||||
const server = createServer((_req, res) => { res.on("close", () => { closed = true; }); });
|
||||
await new Promise<void>(resolve => server.listen(0, "127.0.0.1", resolve));
|
||||
@@ -23,7 +23,7 @@ describe("activity stream header deadline", () => {
|
||||
} finally { if (guard) clearTimeout(guard); server.closeAllConnections(); server.close(); }
|
||||
}, 10_000);
|
||||
|
||||
it("keeps an established SSE stream alive beyond the header deadline", async () => {
|
||||
it("keeps an established SSE stream alive beyond the header deadline", async ({ expect }) => {
|
||||
let closed = false;
|
||||
let writeLater!: () => void;
|
||||
const server = createServer((_req, res) => {
|
||||
@@ -50,8 +50,8 @@ describe("activity stream header deadline", () => {
|
||||
});
|
||||
|
||||
|
||||
describe("temporary activity stream opening failures", () => {
|
||||
it("reconnects after a slow daemon exceeds the header deadline and delivers its next event", async () => {
|
||||
describe.concurrent("temporary activity stream opening failures", () => {
|
||||
it("reconnects after a slow daemon exceeds the header deadline and delivers its next event", async ({ expect }) => {
|
||||
let calls = 0;
|
||||
const late = new Set<ReturnType<typeof setTimeout>>();
|
||||
const server = createServer((_req, res) => {
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
// `rig config` and the daemon's settings store are one contract in two files: the CLI
|
||||
// writes ~/.openrig/config.json, the daemon reads it, and the daemon store's own header
|
||||
// says the two key lists are "kept in lockstep". Each list is pinned by its own package
|
||||
// test, so nothing ever compared them — and two keys had already drifted:
|
||||
// ui.terminal.max_live_terminals and retention.usage_samples_days were resolved by the
|
||||
// daemon store and served by `GET /api/config`, while `rig config get|set` rejected both
|
||||
// as "Unknown config key".
|
||||
//
|
||||
// This guard reads both lists as text, because packages/cli cannot import packages/daemon
|
||||
// (neither package depends on the other — stated in source in three places). A future
|
||||
// difference that is genuinely intended for one side only goes in
|
||||
// INTENTIONAL_DIFFERENCES with the reason beside it; an empty list means the twins agree
|
||||
// exactly.
|
||||
import { readFileSync } from "node:fs";
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const CLI_STORE = "packages/cli/src/config-store.ts";
|
||||
const DAEMON_STORE = "packages/daemon/src/domain/user-settings/settings-store.ts";
|
||||
|
||||
/** Keys a side may hold alone. Empty: the settable surfaces agree exactly. */
|
||||
const INTENTIONAL_DIFFERENCES = [];
|
||||
|
||||
function listOf(file, name) {
|
||||
const lines = readFileSync(file, "utf8").split(/\r?\n/);
|
||||
const start = lines.findIndex((line) => line.startsWith(`export const ${name} = [`));
|
||||
assert.notEqual(start, -1, `${file}: could not find "export const ${name} = ["`);
|
||||
const keys = [];
|
||||
for (let i = start + 1; i < lines.length; i += 1) {
|
||||
const line = lines[i];
|
||||
if (/^\] as const;/.test(line.trim())) return keys;
|
||||
const entry = line.match(/^\s*"([^"]+)",?\s*(?:\/\/.*)?$/);
|
||||
if (entry) keys.push(entry[1]);
|
||||
}
|
||||
throw new Error(`${file}: "${name}" was never closed with "] as const;"`);
|
||||
}
|
||||
|
||||
test("every key the daemon accepts is settable through rig config", () => {
|
||||
const cli = listOf(CLI_STORE, "VALID_KEYS");
|
||||
const daemon = listOf(DAEMON_STORE, "SETTINGS_VALID_KEYS");
|
||||
const rejectedByCli = daemon.filter((key) => !cli.includes(key) && !INTENTIONAL_DIFFERENCES.includes(key));
|
||||
assert.deepEqual(
|
||||
rejectedByCli,
|
||||
[],
|
||||
`the daemon resolves these keys and rig config rejects them: ${rejectedByCli.join(", ")}`,
|
||||
);
|
||||
});
|
||||
|
||||
test("every key rig config accepts is one the daemon resolves", () => {
|
||||
const cli = listOf(CLI_STORE, "VALID_KEYS");
|
||||
const daemon = listOf(DAEMON_STORE, "SETTINGS_VALID_KEYS");
|
||||
const unknownToDaemon = cli.filter((key) => !daemon.includes(key) && !INTENTIONAL_DIFFERENCES.includes(key));
|
||||
assert.deepEqual(
|
||||
unknownToDaemon,
|
||||
[],
|
||||
`rig config accepts these keys and the daemon does not resolve them: ${unknownToDaemon.join(", ")}`,
|
||||
);
|
||||
});
|
||||
Reference in new issue
Block a user