mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-11 03:31:38 -10:00
Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5f72f3934c | ||
|
|
16e8b7fb31 | ||
|
|
2be19e641d | ||
|
|
01b80517a9 | ||
|
|
a95065c0da | ||
|
|
81d80a1f2b | ||
|
|
8f1dbb8b05 | ||
|
|
d9292d5b04 | ||
|
|
9d096d2145 | ||
|
|
c818bfe1a5 | ||
|
|
a58db3ac33 | ||
|
|
9887add016 | ||
|
|
382b3b036a |
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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
+16
-13
@@ -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.
|
||||
|
||||
@@ -81,9 +81,12 @@ Other layouts and other herdr-app windows don't show Rig views. Each window (app
|
||||
- **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.
|
||||
- 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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -79,7 +79,7 @@ function shellWord(value: string): string {
|
||||
}
|
||||
|
||||
/** `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}` : ""}`;
|
||||
}
|
||||
|
||||
@@ -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)];
|
||||
|
||||
@@ -111,16 +111,19 @@ export class EventBus {
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a nested transactional writer (a savepoint inside the active envelope) whose failure the caller handles. If it
|
||||
* throws, its savepoint has rolled back the event rows it persisted, so their tokens leave the envelope too, and the
|
||||
* caller's transaction can still commit. Outside an envelope it just runs.
|
||||
* 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 writer();
|
||||
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);
|
||||
|
||||
@@ -2,7 +2,7 @@ import { physicalSessionName } from "./session-registry.js";
|
||||
import { resolveNodeFile } from "./scope/node-file.js";
|
||||
import { belongsToProject } from "./workspace/project-catalog.js";
|
||||
import { inspectGraph } from "./workflow-reconciliation.js";
|
||||
import { createProofPolicyRead, readMissionReadiness, readProjectReadinessAsync } from "./proof/judgments.js";
|
||||
import { createProofPolicyRead, readMissionReadiness, readProjectReadiness } from "./proof/judgments.js";
|
||||
import { lifecycleObligations, requiredLifecycleSteps } from "./lifecycle-obligations.js";
|
||||
import { QueueWakeRepository } from "./queue-wake-repository.js";
|
||||
import { isTypedGateBlocker } from "./queue-repository.js";
|
||||
@@ -1361,7 +1361,7 @@ export async function buildExecutionView(deps: ExecutionViewDeps, opts?: { missi
|
||||
...(opts?.project ? { project: opts.project, membership: "exact project:<id> queue tags and lifecycle identity; unscoped rows excluded" } : {}),
|
||||
view: "execution",
|
||||
readiness,
|
||||
project_readiness: missionsRoot ? await readProjectReadinessAsync(missionsRoot, readPolicy) : null,
|
||||
project_readiness: missionsRoot ? readProjectReadiness(missionsRoot, readPolicy) : null,
|
||||
// Authored guidance is carried verbatim in meaning, never parsed into edges or acceptance.
|
||||
planning_guidance: arrangement?.state === "valid" ? arrangement.guidance : [],
|
||||
mission,
|
||||
|
||||
@@ -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 ");
|
||||
}
|
||||
@@ -389,22 +389,6 @@ function declaredNodeId(dir: string): string | null {
|
||||
* from them); it defaults to the ordinary filesystem reader and changes nothing about the evaluation. */
|
||||
export function readProjectReadiness(missionsRoot: string, readPolicy: ProofPolicyRead = defaultPolicyRead, root?: string, io: ScopeFsDeps = proofFs, observe?: (file: string) => void) {
|
||||
const missions = fs.readdirSync(missionsRoot).filter(n => fs.statSync(path.join(missionsRoot, n)).isDirectory()).map(name => ({ name, ...readMissionReadiness(contained(missionsRoot, path.join(missionsRoot, name)), readPolicy, root, io, observe) }));
|
||||
return projectReadiness(missions);
|
||||
}
|
||||
|
||||
/** The same semantic read, yielding between missions for daemon requests and source-watch refreshes.
|
||||
* A single mission (including its evidence) is still synchronous; this is not a hard time bound or atomic snapshot. */
|
||||
export async function readProjectReadinessAsync(missionsRoot: string, readPolicy: ProofPolicyRead = defaultPolicyRead, root?: string, io: ScopeFsDeps = proofFs, observe?: (file: string) => void) {
|
||||
const names = fs.readdirSync(missionsRoot).filter(n => fs.statSync(path.join(missionsRoot, n)).isDirectory());
|
||||
const missions: Array<MissionReadiness & { name: string }> = [];
|
||||
for (const name of names) {
|
||||
await new Promise<void>(resolve => setImmediate(resolve));
|
||||
missions.push({ name, ...readMissionReadiness(contained(missionsRoot, path.join(missionsRoot, name)), readPolicy, root, io, observe) });
|
||||
}
|
||||
return projectReadiness(missions);
|
||||
}
|
||||
|
||||
function projectReadiness(missions: Array<MissionReadiness & { name: string }>) {
|
||||
const active = missions.filter(m => ["active", "release-candidate"].includes(m.historicalStatus ?? ""));
|
||||
return { revision: hash(active), state: active.length && active.every(m => m.state === "ready") ? "ready" : active.some(m => m.state === "unknown") ? "unknown" : "not-ready", missions, basis: "Active mission readiness; distinct outcome judgment and publication remain separate" };
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import * as path from "node:path";
|
||||
import { createHash } from "node:crypto";
|
||||
import type { EventBus } from "../event-bus.js";
|
||||
import type { ScopeFsDeps } from "../scope/scope-view-projection.js";
|
||||
import { createProofPolicyRead, proofFs, readProjectReadinessAsync, resolveProjectRoot } from "./judgments.js";
|
||||
import { createProofPolicyRead, proofFs, readProjectReadiness, resolveProjectRoot } from "./judgments.js";
|
||||
|
||||
export interface ProofSourceWatch {
|
||||
close(): void;
|
||||
@@ -76,15 +76,16 @@ function shallowWatches(missionsRoot: string, realRoot: string, top: string, pro
|
||||
* including absent nearer ones, and accepted evidence elsewhere in the project) is watched at its parent directory,
|
||||
* not recursively, so sibling checkouts and build trees are never subscribed to.
|
||||
*/
|
||||
export async function watchProofSources(missionsRoot: string, invalidate: () => void, bus: EventBus, scopeChanged?: () => void): Promise<ProofSourceWatch> {
|
||||
export function watchProofSources(missionsRoot: string, invalidate: () => void, bus: EventBus, scopeChanged?: () => void): ProofSourceWatch {
|
||||
missionsRoot = path.resolve(missionsRoot);
|
||||
let projectRoot = path.dirname(missionsRoot);
|
||||
try { projectRoot = resolveProjectRoot(missionsRoot) ?? projectRoot; } catch { /* Legacy roots keep the missions root's parent. */ }
|
||||
const top = path.dirname(path.resolve(projectRoot));
|
||||
// The authority boundary in real terms: what the readers' containment check accepts.
|
||||
const project = canonical(projectRoot) ?? path.resolve(projectRoot);
|
||||
// Keep the full semantic read, but let queue/send requests run between missions.
|
||||
const basis = async () => {
|
||||
// ponytail: bounded local workspaces use a full semantic read after a file burst.
|
||||
// Keep this workload measured; subtree indexing is warranted only when that bound is exceeded.
|
||||
const basis = () => {
|
||||
const read = new Set<string>();
|
||||
const note = (p: string) => { read.add(p); return p; };
|
||||
const io: ScopeFsDeps = {
|
||||
@@ -94,8 +95,7 @@ export async function watchProofSources(missionsRoot: string, invalidate: () =>
|
||||
listDir: (p) => proofFs.listDir(note(p)),
|
||||
isDirectory: (p) => proofFs.isDirectory(note(p)),
|
||||
};
|
||||
const result = await readProjectReadinessAsync(missionsRoot, createProofPolicyRead(), undefined, io, note);
|
||||
const hash = createHash("sha256").update(JSON.stringify(result.missions.map(m => [m.name, m.revision]))).digest("hex");
|
||||
const hash = createHash("sha256").update(JSON.stringify(readProjectReadiness(missionsRoot, createProofPolicyRead(), undefined, io, note).missions.map(m => [m.name, m.revision]))).digest("hex");
|
||||
// A file read through a link inside the project depends on its real location too: observe both.
|
||||
for (const file of [...read]) {
|
||||
const real = canonical(file);
|
||||
@@ -107,7 +107,6 @@ export async function watchProofSources(missionsRoot: string, invalidate: () =>
|
||||
let closed = false, failed = false, covered = false, readable = false;
|
||||
let revision = "unavailable", timer: NodeJS.Timeout | undefined;
|
||||
let scopeDirty = false;
|
||||
let reading = true, dirty = false;
|
||||
const notifyScope = () => { try { scopeChanged?.(); } catch { /* A later event or explicit refresh retries the view read. */ } };
|
||||
let wanted: Shallow = new Map();
|
||||
const shallow = new Map<string, fs.FSWatcher>();
|
||||
@@ -164,32 +163,25 @@ export async function watchProofSources(missionsRoot: string, invalidate: () =>
|
||||
/** Read the sources and keep the subscriptions in step with what was read, even when the result is unchanged.
|
||||
* Coverage is certified only by a read whose every dependency was already watched before it; if the dependencies
|
||||
* keep moving past a small budget, the sources are reported unobserved rather than read forever. */
|
||||
const evaluate = async (): Promise<{ hash: string; covered: boolean }> => {
|
||||
let read = await basis();
|
||||
const evaluate = (): string => {
|
||||
let read = basis();
|
||||
for (let i = 0; ; i++) {
|
||||
if (closed) return { hash: revision, covered: false };
|
||||
// A root whose real location is unknown or outside the project has no anchors there: never certified.
|
||||
// Coverage belongs to this candidate, just like the hash; neither escapes a discarded read.
|
||||
if (!resubscribe(read.watches)) return { hash: read.hash, covered: recursiveReal !== null && within(recursiveReal, project) };
|
||||
if (i === 2) return { hash: read.hash, covered: false };
|
||||
read = await basis();
|
||||
if (!resubscribe(read.watches)) { covered = recursiveReal !== null && within(recursiveReal, project); return read.hash; }
|
||||
if (i === 2) { covered = false; return read.hash; }
|
||||
read = basis();
|
||||
}
|
||||
};
|
||||
const observed = () => !closed && !failed && covered && readable;
|
||||
const schedule = () => {
|
||||
dirty = true;
|
||||
if (timer || reading || closed) return;
|
||||
timer = setTimeout(async () => {
|
||||
if (timer || closed) return;
|
||||
timer = setTimeout(() => {
|
||||
timer = undefined;
|
||||
if (closed) return;
|
||||
reading = true; dirty = false;
|
||||
try {
|
||||
if (scopeDirty) { scopeDirty = false; notifyScope(); }
|
||||
const before = observed();
|
||||
const candidate = await evaluate();
|
||||
if (closed || dirty) return;
|
||||
const next = candidate.hash;
|
||||
covered = candidate.covered && !failed;
|
||||
const next = evaluate();
|
||||
readable = true;
|
||||
// Deduplicate on what clients see: the revision and whether the sources are observed. Losing or regaining
|
||||
// coverage is news even when the proof result itself did not change.
|
||||
@@ -198,16 +190,12 @@ export async function watchProofSources(missionsRoot: string, invalidate: () =>
|
||||
invalidate();
|
||||
notify(observed() ? revision : "unavailable");
|
||||
} catch (e) {
|
||||
if (closed) return;
|
||||
// Never report the sources as watched after a failed read or a watch that could not be attached.
|
||||
readable = false;
|
||||
if (e instanceof WatchFailure) covered = false;
|
||||
revision = "unavailable";
|
||||
invalidate();
|
||||
notify("unavailable");
|
||||
} finally {
|
||||
reading = false;
|
||||
if (dirty && !closed) schedule();
|
||||
}
|
||||
}, 40);
|
||||
timer.unref();
|
||||
@@ -215,10 +203,7 @@ export async function watchProofSources(missionsRoot: string, invalidate: () =>
|
||||
try {
|
||||
recursiveReal = canonical(missionsRoot);
|
||||
recursive = watchDir(missionsRoot, true);
|
||||
try {
|
||||
const candidate = await evaluate();
|
||||
if (!dirty) { revision = candidate.hash; covered = candidate.covered && !failed; readable = true; }
|
||||
} catch (e) {
|
||||
try { revision = evaluate(); readable = true; } catch (e) {
|
||||
if (e instanceof WatchFailure) throw e;
|
||||
// Direct reads name unavailable inputs; watching may recover them.
|
||||
covered = true;
|
||||
@@ -229,9 +214,6 @@ export async function watchProofSources(missionsRoot: string, invalidate: () =>
|
||||
for (const watcher of shallow.values()) watcher.close();
|
||||
invalidate(); notify("unavailable"); notifyScope();
|
||||
return { close() {}, observation: () => ({ state: "unavailable", revision }) };
|
||||
} finally {
|
||||
reading = false;
|
||||
if (dirty && !closed) schedule();
|
||||
}
|
||||
return {
|
||||
observation: () => ({ state: observed() ? "watching" : "unavailable", revision }),
|
||||
|
||||
@@ -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);
|
||||
@@ -5850,7 +5838,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>>();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -3,7 +3,7 @@ import { proofSourceObservation } from "../domain/proof/source-watch.js";
|
||||
import * as path from "node:path";
|
||||
import type { SliceIndexer } from "../domain/slices/slice-indexer.js";
|
||||
import type { EventBus } from "../domain/event-bus.js";
|
||||
import { JudgmentError, evidenceAt, readSliceReadiness, readMissionReadiness, readProjectReadinessAsync, recordJudgment, resolveProofScope, resolveProjectRoot, type JudgeInput } from "../domain/proof/judgments.js";
|
||||
import { JudgmentError, evidenceAt, readSliceReadiness, readMissionReadiness, readProjectReadiness, recordJudgment, resolveProofScope, resolveProjectRoot, type JudgeInput } from "../domain/proof/judgments.js";
|
||||
import { requireSenderIdentity, resolveRecordedProvenance } from "./require-sender-identity.js";
|
||||
import { projectById, sameProjectRoot } from "../domain/workspace/project-read.js";
|
||||
import { ProjectReadError } from "../domain/workspace/project-catalog.js";
|
||||
@@ -78,15 +78,12 @@ export function proofRoutes(): Hono {
|
||||
// freshness is reported as unverified rather than borrowing the selected workspace's state.
|
||||
const observation = (c: Parameters<typeof indexer>[0], project: { id: string; root: string } | null) =>
|
||||
project ? { state: "unavailable" as const, revision: "unverified" } : proofSourceObservation(c);
|
||||
app.get("/", async c => {
|
||||
app.get("/", c => {
|
||||
const { root, evidenceRoot, scope, project } = target(c, c.req.query("scope"), c.req.query("project"), c.req.query("projectRoot"));
|
||||
const common = { sourceObservation: observation(c, project), ...(project ? { project } : {}) };
|
||||
// A catalog project's own root bounds policy, scope identity and evidence (never an ancestor's project.yaml).
|
||||
const bound = project?.root;
|
||||
if (!scope) {
|
||||
const result = await readProjectReadinessAsync(root, undefined, bound);
|
||||
return c.json({ ...result, ...common, sourceObservation: observation(c, project) });
|
||||
}
|
||||
if (!scope) return c.json({ ...readProjectReadiness(root, undefined, bound), ...common });
|
||||
const dir = resolveProofScope(root, scope);
|
||||
if (path.basename(path.dirname(dir)) !== "slices") return c.json({ ...readMissionReadiness(dir, undefined, bound), ...common });
|
||||
const refs = c.req.queries("evidence") ?? [];
|
||||
|
||||
@@ -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.
|
||||
//
|
||||
@@ -18,7 +18,7 @@ import { inferCatalogProject, ProjectReadError } from "../domain/workspace/proje
|
||||
// avoid `/proof-asset` being parsed as a slice-name.
|
||||
|
||||
import { Hono, type Context } from "hono";
|
||||
import { readSliceReadiness, readProjectReadinessAsync } from "../domain/proof/judgments.js";
|
||||
import { readSliceReadiness, readProjectReadiness } from "../domain/proof/judgments.js";
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import { SliceIndexer, SliceListEntry, SliceStatus } from "../domain/slices/slice-indexer.js";
|
||||
@@ -37,7 +37,7 @@ export function slicesRoutes(): Hono {
|
||||
|
||||
// 1) Static literal `/` BEFORE dynamic `/:name` so the list isn't
|
||||
// shadowed by a slice named "list" or similar.
|
||||
app.get("/", async (c) => {
|
||||
app.get("/", (c) => {
|
||||
const deps = getDeps(c);
|
||||
if (!deps) return c.json({ error: "slices_indexer_unavailable" }, 503);
|
||||
if (!deps.indexer.isReady()) {
|
||||
@@ -61,7 +61,7 @@ export function slicesRoutes(): Hono {
|
||||
// operation: the list rebuild, the boundToWorkflow per-slice get loop,
|
||||
// and the mission sidecar share ONE membership batch (pre-scope, each
|
||||
// uncached get built its own 2-scan batch: 2+2N total queue scans).
|
||||
const listing = deps.indexer.withMembershipBatch(() => {
|
||||
return deps.indexer.withMembershipBatch(() => {
|
||||
const all = deps.indexer.list();
|
||||
let filtered = filter === "all" ? all : all.filter((s) => s.status === filter);
|
||||
// Workflows in Spec Library v0: optional lens filter — narrow to
|
||||
@@ -102,7 +102,7 @@ export function slicesRoutes(): Hono {
|
||||
complete: projectLookup.complete,
|
||||
});
|
||||
const authored = deps.indexer.missionAuthoredStatuses();
|
||||
return {
|
||||
return c.json({
|
||||
slices: filtered.map((s) => ({
|
||||
...s,
|
||||
queueCounts: queueCountsByPath.get(s.slicePath) ?? null,
|
||||
@@ -114,17 +114,13 @@ export function slicesRoutes(): Hono {
|
||||
// VM-005 (release-0.4.7): additive authored mission-status sidecar so
|
||||
// chip surfaces can honor authored-wins precedence without a second
|
||||
// round-trip. The `slices` array itself is byte-untouched.
|
||||
missions: authored,
|
||||
};
|
||||
missions: { ...authored, ...Object.fromEntries(readProjectReadiness(deps.indexer.slicesRoot).missions.map(m => [m.name, {
|
||||
...authored[m.name],
|
||||
authoredStatus: m.historicalStatus ?? authored[m.name]?.authoredStatus ?? null,
|
||||
readiness: m,
|
||||
}])) },
|
||||
});
|
||||
});
|
||||
if (listing instanceof Response) return listing;
|
||||
// The membership batch is synchronous and request-local; finish it before yielding to other requests.
|
||||
const readiness = await readProjectReadinessAsync(deps.indexer.slicesRoot);
|
||||
return c.json({ ...listing, missions: { ...listing.missions, ...Object.fromEntries(readiness.missions.map(m => [m.name, {
|
||||
...listing.missions[m.name],
|
||||
authoredStatus: m.historicalStatus ?? listing.missions[m.name]?.authoredStatus ?? null,
|
||||
readiness: m,
|
||||
}])) } });
|
||||
});
|
||||
|
||||
// V0.3.1 slice 17 founder-walk-workspace-state-correctness (walk item 8 — Explorer auto-show): explicit cache invalidation surface.
|
||||
@@ -170,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;
|
||||
@@ -209,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 {
|
||||
@@ -222,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) }; }
|
||||
}
|
||||
|
||||
|
||||
@@ -2118,7 +2118,7 @@ export async function createDaemon(opts?: DaemonOptions): Promise<DaemonResult>
|
||||
deps.sliceIndexer = sliceIndexer;
|
||||
if (sliceIndexer.isReady()) {
|
||||
const { watchProofSources } = await import("./domain/proof/source-watch.js");
|
||||
deps.proofSourceWatch = await watchProofSources(sliceIndexer.slicesRoot, () => sliceIndexer.invalidate(), eventBus, () => {
|
||||
deps.proofSourceWatch = watchProofSources(sliceIndexer.slicesRoot, () => sliceIndexer.invalidate(), eventBus, () => {
|
||||
sliceIndexer.invalidate();
|
||||
eventBus.emit({ type: "scope.sources_changed", scope: sliceIndexer.slicesRoot });
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -24,7 +24,6 @@ import { queueItemsSchema } from "../src/db/migrations/024_queue_items.js";
|
||||
import { queueTransitionsSchema } from "../src/db/migrations/025_queue_transitions.js";
|
||||
import { queueItemSummarySchema } from "../src/db/migrations/044_queue_item_summary.js";
|
||||
import { buildExecutionView, type ExecutionViewDeps } from "../src/domain/execution-view.js";
|
||||
import { readProjectReadiness } from "../src/domain/proof/judgments.js";
|
||||
|
||||
const SHA = "a".repeat(40);
|
||||
const REGISTRY_FILES = 450;
|
||||
@@ -121,25 +120,4 @@ describe("execution view review registry", () => {
|
||||
expect(turnsAtRead[REGISTRY_FILES - 1]! - turnsAtRead[0]!).toBeGreaterThan(0);
|
||||
expect(turnsAtRead).toHaveLength(REGISTRY_FILES);
|
||||
});
|
||||
|
||||
it("also yields inside the later whole-project readiness read, preserving its projection", async () => {
|
||||
const deps = fixture("turns", 2), missions = deps.slicesRoot()!;
|
||||
for (const name of ["beta", "gamma"]) {
|
||||
fs.mkdirSync(path.join(missions, name));
|
||||
fs.writeFileSync(path.join(missions, name, "mission.yaml"), "kind: mission\ncomposition: { slices: [] }\n");
|
||||
}
|
||||
const first = fs.readdirSync(missions).slice(0, -1).find(name => name !== "turns")!;
|
||||
let started = false, yielded = false;
|
||||
hooks.onRead = file => {
|
||||
if (!started && file === path.join(missions, first, "mission.yaml")) {
|
||||
started = true;
|
||||
setImmediate(() => { yielded = true; });
|
||||
}
|
||||
};
|
||||
const doc = await buildExecutionView(deps, { mission: "turns" });
|
||||
expect(started).toBe(true);
|
||||
expect(yielded, "earlier registry yields must not hide a later synchronous project walk").toBe(true);
|
||||
hooks.onRead = null;
|
||||
expect(doc.project_readiness).toEqual(readProjectReadiness(missions));
|
||||
});
|
||||
});
|
||||
+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);
|
||||
|
||||
@@ -265,7 +265,7 @@ describe("retained authority negative controls", () => {
|
||||
const sliceRevision = readSliceReadiness(f.alpha).revision;
|
||||
const events: Array<{ type: string; revision: string }> = [];
|
||||
let invalidated = 0;
|
||||
const watch = await watchProofSources(f.missions, () => { invalidated++; }, {
|
||||
const watch = watchProofSources(f.missions, () => { invalidated++; }, {
|
||||
emit: (event: { type: string; revision: string }) => events.push(event),
|
||||
} as unknown as EventBus);
|
||||
try {
|
||||
@@ -296,11 +296,8 @@ describe("retained authority negative controls", () => {
|
||||
it("pushes changed source truth, keeps sibling basis, and ignores unchanged source bytes", async () => {
|
||||
const f = fixture(); f.judge(); const sibling = readSliceReadiness(f.beta).revision;
|
||||
const events: Array<{ type: string; revision: string }> = []; let invalidated = 0;
|
||||
const watch = await watchProofSources(f.missions, () => { invalidated++; }, { emit: (e: { type: string; revision: string }) => events.push(e) } as unknown as EventBus);
|
||||
const watch = watchProofSources(f.missions, () => { invalidated++; }, { emit: (e: { type: string; revision: string }) => events.push(e) } as unknown as EventBus);
|
||||
try {
|
||||
// Creation events may interrupt asynchronous setup; count changes only after the initial basis settles.
|
||||
await expect.poll(() => watch.observation().state, { timeout: 5000 }).toBe("watching");
|
||||
events.length = 0; invalidated = 0;
|
||||
const file = join(f.alpha, "proof/evidence.md"); fs.writeFileSync(file, fs.readFileSync(file));
|
||||
await new Promise(r => setTimeout(r, 100)); expect(events).toEqual([]);
|
||||
fs.writeFileSync(file, "Measured outcome changed.\n");
|
||||
@@ -453,7 +450,7 @@ it("notifies scope followers on a node-header change even when proof readiness i
|
||||
const f = fixture(), file = join(f.alpha, "SPEC.md");
|
||||
const original = fs.readFileSync(file, "utf8"), before = readMissionReadiness(f.mission).revision;
|
||||
let scopeChanges = 0;
|
||||
const watch = await watchProofSources(f.missions, () => {}, { emit: () => {} } as unknown as EventBus, () => { scopeChanges++; });
|
||||
const watch = watchProofSources(f.missions, () => {}, { emit: () => {} } as unknown as EventBus, () => { scopeChanges++; });
|
||||
try {
|
||||
const updated = original.replace("id: 01-alpha", "id: renamed-alias");
|
||||
fs.writeFileSync(file, updated);
|
||||
|
||||
@@ -153,15 +153,8 @@ describe("request-local proof-policy reuse", () => {
|
||||
});
|
||||
});
|
||||
|
||||
async function startWatch(...args: Parameters<typeof watchProofSources>) {
|
||||
const opening = watchProofSources(...args);
|
||||
// Startup itself now yields; settle its cooperative turns under the fixture clock.
|
||||
await vi.runAllTimersAsync();
|
||||
return opening;
|
||||
}
|
||||
|
||||
describe("watch-local proof-policy reuse", () => {
|
||||
async function observe(missions: string) {
|
||||
function observe(missions: string) {
|
||||
vi.useFakeTimers();
|
||||
// Each watched directory's listener, by path: "a change" is delivered by the missions root's own watch.
|
||||
const listeners = new Map<string, () => void>();
|
||||
@@ -170,13 +163,13 @@ describe("watch-local proof-policy reuse", () => {
|
||||
return { on: vi.fn(), close: vi.fn() };
|
||||
}) as unknown as typeof fs.watch);
|
||||
const invalidate = vi.fn(), emit = vi.fn();
|
||||
const watch = await startWatch(missions, invalidate, { emit } as unknown as EventBus);
|
||||
const watch = watchProofSources(missions, invalidate, { emit } as unknown as EventBus);
|
||||
const changed = () => listeners.get(missions)!();
|
||||
const oracle = () => createHash("sha256").update(JSON.stringify(readProjectReadiness(missions).missions.map(m => [m.name, m.revision]))).digest("hex");
|
||||
const event = async () => {
|
||||
const event = () => {
|
||||
const previous = watch.observation().revision, calls = invalidate.mock.calls.length;
|
||||
const expected = oracle();
|
||||
changed(); await vi.advanceTimersByTimeAsync(50);
|
||||
changed(); vi.advanceTimersByTime(50);
|
||||
expect(watch.observation()).toEqual({ state: "watching", revision: expected });
|
||||
expect(invalidate).toHaveBeenCalledTimes(calls + Number(previous !== expected));
|
||||
expect(emit).toHaveBeenCalledTimes(invalidate.mock.calls.length);
|
||||
@@ -184,17 +177,17 @@ describe("watch-local proof-policy reuse", () => {
|
||||
return { watch, changed, event, oracle, invalidate };
|
||||
}
|
||||
|
||||
it("reads/parses shared policy once in each real watch callback, preserving the uncached revision", async () => {
|
||||
it("reads/parses shared policy once in each real watch callback, preserving the uncached revision", () => {
|
||||
const f = fixture();
|
||||
fs.cpSync(f.mission, path.join(f.missions, "second"), { recursive: true });
|
||||
const o = await observe(f.missions), read = vi.spyOn(fs, "readFileSync"), parse = vi.spyOn(YAML, "parse");
|
||||
const o = observe(f.missions), read = vi.spyOn(fs, "readFileSync"), parse = vi.spyOn(YAML, "parse");
|
||||
const policy = fs.readFileSync(f.projectFile, "utf8");
|
||||
try {
|
||||
for (const text of ["changed outcome", "second outcome"]) {
|
||||
f.write(path.join(f.dirs[0]!, "SPEC.md"), `# Outcome\n\n## Proof contract\n- [ ] ${text}.\n`);
|
||||
const expected = o.oracle();
|
||||
read.mockClear(); parse.mockClear();
|
||||
o.changed(); await vi.advanceTimersByTimeAsync(50);
|
||||
o.changed(); vi.advanceTimersByTime(50);
|
||||
expect(o.watch.observation()).toEqual({ state: "watching", revision: expected });
|
||||
expect.soft(read.mock.calls.filter(([file]) => file === f.projectFile)).toHaveLength(1);
|
||||
expect.soft(parse.mock.calls.filter(([text]) => text === policy)).toHaveLength(1);
|
||||
@@ -203,37 +196,37 @@ describe("watch-local proof-policy reuse", () => {
|
||||
expect.soft(read.mock.calls.filter(([file]) => file === f.missionFile)).toHaveLength(2);
|
||||
}
|
||||
expect(o.invalidate).toHaveBeenCalledTimes(2);
|
||||
await o.event(); expect(o.invalidate).toHaveBeenCalledTimes(2);
|
||||
o.event(); expect(o.invalidate).toHaveBeenCalledTimes(2);
|
||||
} finally { o.watch.close(); }
|
||||
});
|
||||
|
||||
it("observes changed, missing, overridden, malformed and unreadable policy on the next event", async () => {
|
||||
it("observes changed, missing, overridden, malformed and unreadable policy on the next event", () => {
|
||||
const f = fixture(), sliceFile = path.join(f.dirs[0]!, "slice.yaml");
|
||||
const project = fs.readFileSync(f.projectFile, "utf8"), mission = fs.readFileSync(f.missionFile, "utf8"), slice = fs.readFileSync(sliceFile, "utf8");
|
||||
const o = await observe(f.missions);
|
||||
const o = observe(f.missions);
|
||||
try {
|
||||
for (const [file, base, actor] of [[f.projectFile, project, "new-project"], [f.missionFile, mission, "new-mission"], [sliceFile, slice, "new-slice"]]) {
|
||||
f.write(file!, { ...YAML.parse(base!), proofPolicy: { judges: [actor] } });
|
||||
await o.event(); expect(readSliceReadiness(f.dirs[0]!).policy!.judges).toEqual([actor]);
|
||||
o.event(); expect(readSliceReadiness(f.dirs[0]!).policy!.judges).toEqual([actor]);
|
||||
}
|
||||
f.write(sliceFile, slice); f.write(f.missionFile, mission); await o.event();
|
||||
fs.unlinkSync(f.projectFile); await o.event(); expect(readSliceReadiness(f.dirs[0]!).state).toBe("legacy");
|
||||
f.write(f.projectFile, project); await o.event();
|
||||
f.write(sliceFile, slice); f.write(f.missionFile, mission); o.event();
|
||||
fs.unlinkSync(f.projectFile); o.event(); expect(readSliceReadiness(f.dirs[0]!).state).toBe("legacy");
|
||||
f.write(f.projectFile, project); o.event();
|
||||
for (const bad of ["proofPolicy: [unclosed", { proofPolicy: { judges: [] } }]) {
|
||||
f.write(f.projectFile, bad); await o.event(); expect(readSliceReadiness(f.dirs[0]!).state).toBe("unknown");
|
||||
f.write(f.projectFile, project); await o.event(); expect(readSliceReadiness(f.dirs[0]!).policy!.judges).toEqual(["judge@trial"]);
|
||||
f.write(f.projectFile, bad); o.event(); expect(readSliceReadiness(f.dirs[0]!).state).toBe("unknown");
|
||||
f.write(f.projectFile, project); o.event(); expect(readSliceReadiness(f.dirs[0]!).policy!.judges).toEqual(["judge@trial"]);
|
||||
}
|
||||
const original = fs.readFileSync, read = vi.spyOn(fs, "readFileSync");
|
||||
read.mockImplementation(((file: fs.PathOrFileDescriptor, ...args: any[]) => {
|
||||
if (file === f.projectFile) throw Object.assign(new Error("policy denied"), { code: "EACCES" });
|
||||
return (original as any)(file, ...args);
|
||||
}) as typeof fs.readFileSync);
|
||||
await o.event(); expect(readSliceReadiness(f.dirs[0]!).state).toBe("unknown");
|
||||
read.mockImplementation(original); await o.event(); expect(readSliceReadiness(f.dirs[0]!).policy!.judges).toEqual(["judge@trial"]);
|
||||
o.event(); expect(readSliceReadiness(f.dirs[0]!).state).toBe("unknown");
|
||||
read.mockImplementation(original); o.event(); expect(readSliceReadiness(f.dirs[0]!).policy!.judges).toEqual(["judge@trial"]);
|
||||
} finally { o.watch.close(); }
|
||||
});
|
||||
|
||||
it("observes accepted evidence outside the missions root at its parent, never recursively, through edit and atomic replacement", async () => {
|
||||
it("observes accepted evidence outside the missions root at its parent, never recursively, through edit and atomic replacement", () => {
|
||||
const f = fixture(), dir = f.dirs[0]!, outside = path.join(f.root, "build", "out"), evidence = path.join(outside, "report.md");
|
||||
f.write(evidence, "accepted observation");
|
||||
const item = readSliceReadiness(dir).items[0]!;
|
||||
@@ -245,7 +238,7 @@ describe("watch-local proof-policy reuse", () => {
|
||||
return { on: vi.fn(), close: vi.fn() };
|
||||
}) as unknown as typeof fs.watch);
|
||||
const invalidate = vi.fn();
|
||||
const watch = await startWatch(f.missions, invalidate, { emit: vi.fn() } as unknown as EventBus);
|
||||
const watch = watchProofSources(f.missions, invalidate, { emit: vi.fn() } as unknown as EventBus);
|
||||
const oracle = () => createHash("sha256").update(JSON.stringify(readProjectReadiness(f.missions).missions.map(m => [m.name, m.revision]))).digest("hex");
|
||||
try {
|
||||
expect(watch.observation()).toEqual({ state: "watching", revision: oracle() });
|
||||
@@ -253,34 +246,34 @@ describe("watch-local proof-policy reuse", () => {
|
||||
expect(recursive.get(outside)).toBe(false);
|
||||
expect([...recursive].filter(([root, deep]) => deep && root !== f.missions)).toEqual([]);
|
||||
f.write(evidence, "changed observation");
|
||||
listeners.get(outside)!("change", "report.md"); await vi.advanceTimersByTimeAsync(50);
|
||||
listeners.get(outside)!("change", "report.md"); vi.advanceTimersByTime(50);
|
||||
expect(readSliceReadiness(dir).state).toBe("unknown");
|
||||
expect(watch.observation()).toEqual({ state: "watching", revision: oracle() });
|
||||
fs.writeFileSync(`${evidence}.tmp`, "accepted observation"); fs.renameSync(`${evidence}.tmp`, evidence);
|
||||
listeners.get(outside)!("rename", "report.md"); await vi.advanceTimersByTimeAsync(50);
|
||||
listeners.get(outside)!("rename", "report.md"); vi.advanceTimersByTime(50);
|
||||
expect(readSliceReadiness(dir).state).toBe("ready");
|
||||
expect(watch.observation()).toEqual({ state: "watching", revision: oracle() });
|
||||
expect(invalidate).toHaveBeenCalledTimes(2);
|
||||
} finally { watch.close(); }
|
||||
});
|
||||
|
||||
it("rechecks accepted evidence between events without reusing judgment inputs", async () => {
|
||||
it("rechecks accepted evidence between events without reusing judgment inputs", () => {
|
||||
const f = fixture(), dir = f.dirs[0]!, evidence = path.join(dir, "proof/evidence.md");
|
||||
f.write(evidence, "accepted observation");
|
||||
const item = readSliceReadiness(dir).items[0]!;
|
||||
recordJudgment(f.missions, { scope: "trial/slices/slice-0", item: item.id, verdict: "accept", reason: "observed", evidence: ["proof/evidence.md"], expectedRevision: item.revision, expectedPrevious: null }, "judge@trial", "fixture");
|
||||
const o = await observe(f.missions);
|
||||
const o = observe(f.missions);
|
||||
try {
|
||||
expect(readSliceReadiness(dir).state).toBe("ready");
|
||||
f.write(evidence, "changed observation"); await o.event(); expect(readSliceReadiness(dir).state).toBe("unknown");
|
||||
f.write(evidence, "accepted observation"); await o.event(); expect(readSliceReadiness(dir).state).toBe("ready");
|
||||
f.write(evidence, "changed observation"); o.event(); expect(readSliceReadiness(dir).state).toBe("unknown");
|
||||
f.write(evidence, "accepted observation"); o.event(); expect(readSliceReadiness(dir).state).toBe("ready");
|
||||
expect(o.invalidate).toHaveBeenCalledTimes(2);
|
||||
} finally { o.watch.close(); }
|
||||
});
|
||||
});
|
||||
|
||||
describe("QA bounded adverse coverage at 8b236e06", () => {
|
||||
async function adverseFixture() {
|
||||
function adverseFixture() {
|
||||
const f = fixture(), dir = f.dirs[0]!, outside = path.join(f.root, "build", "out"), evidence = path.join(outside, "report.md");
|
||||
f.write(evidence, "accepted observation");
|
||||
const item = readSliceReadiness(dir).items[0]!;
|
||||
@@ -291,46 +284,46 @@ describe("QA bounded adverse coverage at 8b236e06", () => {
|
||||
const h = { event: listener, error: undefined as (() => void) | undefined }; handles.set(root, h);
|
||||
return { on: (_kind: string, cb: () => void) => { h.error = cb; }, close: vi.fn() };
|
||||
}) as unknown as typeof fs.watch);
|
||||
const invalidate = vi.fn(), emit = vi.fn(), watch = await startWatch(f.missions, invalidate, { emit } as unknown as EventBus);
|
||||
const invalidate = vi.fn(), emit = vi.fn(), watch = watchProofSources(f.missions, invalidate, { emit } as unknown as EventBus);
|
||||
const oracle = () => createHash("sha256").update(JSON.stringify(readProjectReadiness(f.missions).missions.map(m => [m.name, m.revision]))).digest("hex");
|
||||
return { f, dir, outside, evidence, handles, watch, oracle, invalidate, emit };
|
||||
}
|
||||
it("QA retains unavailable after an async required-handle error and another valid event unless coverage is replaced", async () => {
|
||||
const t = await adverseFixture();
|
||||
it("QA retains unavailable after an async required-handle error and another valid event unless coverage is replaced", () => {
|
||||
const t = adverseFixture();
|
||||
try {
|
||||
expect(t.watch.observation()).toEqual({ state: "watching", revision: t.oracle() });
|
||||
const failed = t.handles.get(t.outside)!; failed.error!();
|
||||
expect(t.watch.observation().state).toBe("unavailable");
|
||||
t.f.write(t.evidence, "changed observation");
|
||||
t.handles.get(t.f.missions)!.event("change", "trial/slices/slice-0/SPEC.md"); await vi.advanceTimersByTimeAsync(50);
|
||||
t.handles.get(t.f.missions)!.event("change", "trial/slices/slice-0/SPEC.md"); vi.advanceTimersByTime(50);
|
||||
expect(readSliceReadiness(t.dir).state).toBe("unknown");
|
||||
expect(t.watch.observation().revision).toBe(t.oracle());
|
||||
// A fresh semantic read is not repaired notification coverage for the errored, unreplaced handle.
|
||||
expect(t.watch.observation().state).toBe("unavailable");
|
||||
} finally { t.watch.close(); }
|
||||
});
|
||||
it("rebinds a replaced evidence parent when its structural parent reports a change without a filename", async () => {
|
||||
const t = await adverseFixture();
|
||||
it("rebinds a replaced evidence parent when its structural parent reports a change without a filename", () => {
|
||||
const t = adverseFixture();
|
||||
try {
|
||||
const opened = () => vi.mocked(fs.watch).mock.calls.filter(([root]) => root === t.outside).length;
|
||||
expect(opened()).toBe(1);
|
||||
fs.renameSync(t.outside, `${t.outside}.old`); t.f.write(t.evidence, "changed observation");
|
||||
// The platform names nothing: only the structural parent of the replaced directory reports a change.
|
||||
t.handles.get(path.dirname(t.outside))!.event("rename", null); await vi.advanceTimersByTimeAsync(50);
|
||||
t.handles.get(path.dirname(t.outside))!.event("rename", null); vi.advanceTimersByTime(50);
|
||||
expect(readSliceReadiness(t.dir).state).toBe("unknown");
|
||||
expect(t.watch.observation().revision).toBe(t.oracle());
|
||||
// The old same-path handle cannot certify coverage: it is replaced, or the sources are reported unobserved.
|
||||
expect(opened() === 2 || t.watch.observation().state === "unavailable").toBe(true);
|
||||
} finally { t.watch.close(); }
|
||||
});
|
||||
it("watches the real location of evidence reached through a link inside the project", async () => {
|
||||
const t = await adverseFixture();
|
||||
it("watches the real location of evidence reached through a link inside the project", () => {
|
||||
const t = adverseFixture();
|
||||
try {
|
||||
// The evidence directory becomes a link to another directory inside the project, with the same accepted bytes.
|
||||
const real = path.join(t.f.root, "real"), live = path.join(real, "live");
|
||||
t.f.write(path.join(live, "report.md"), "accepted observation");
|
||||
fs.renameSync(t.outside, `${t.outside}.old`); fs.symlinkSync(live, t.outside);
|
||||
t.handles.get(path.dirname(t.outside))!.event("rename", path.basename(t.outside)); await vi.advanceTimersByTimeAsync(50);
|
||||
t.handles.get(path.dirname(t.outside))!.event("rename", path.basename(t.outside)); vi.advanceTimersByTime(50);
|
||||
expect(readSliceReadiness(t.dir).state).toBe("ready");
|
||||
// Replacing the real directory is seen at its own parent, or the sources are reported unobserved.
|
||||
const watched = vi.mocked(fs.watch).mock.calls.map(([root]) => root);
|
||||
@@ -338,7 +331,7 @@ describe("QA bounded adverse coverage at 8b236e06", () => {
|
||||
expect(watched.some((root) => !String(root).startsWith(t.f.root) && root !== path.dirname(t.f.root))).toBe(false);
|
||||
} finally { t.watch.close(); }
|
||||
});
|
||||
for (const name of ["missions", null]) it(`rebinds a linked missions root when its real directory is replaced (${name ?? "no filename"})`, async () => {
|
||||
for (const name of ["missions", null]) it(`rebinds a linked missions root when its real directory is replaced (${name ?? "no filename"})`, () => {
|
||||
const f = fixture(), real = path.join(f.root, "real"), linked = path.join(f.root, "linked-missions");
|
||||
fs.mkdirSync(real); fs.renameSync(f.missions, path.join(real, "missions")); fs.symlinkSync(path.join(real, "missions"), linked);
|
||||
vi.useFakeTimers();
|
||||
@@ -349,22 +342,22 @@ describe("QA bounded adverse coverage at 8b236e06", () => {
|
||||
return { on: vi.fn(), close: vi.fn() };
|
||||
}) as unknown as typeof fs.watch);
|
||||
const oracle = () => createHash("sha256").update(JSON.stringify(readProjectReadiness(linked).missions.map(m => [m.name, m.revision]))).digest("hex");
|
||||
const watch = await startWatch(linked, vi.fn(), { emit: vi.fn() } as unknown as EventBus);
|
||||
const watch = watchProofSources(linked, vi.fn(), { emit: vi.fn() } as unknown as EventBus);
|
||||
try {
|
||||
expect(watch.observation()).toEqual({ state: "watching", revision: oracle() });
|
||||
fs.renameSync(path.join(real, "missions"), path.join(real, "missions.old"));
|
||||
fs.cpSync(path.join(real, "missions.old"), path.join(real, "missions"), { recursive: true });
|
||||
const before = opened.length;
|
||||
opened.filter((h) => h.root === real).at(-1)!.event("rename", name); await vi.advanceTimersByTimeAsync(50);
|
||||
opened.filter((h) => h.root === real).at(-1)!.event("rename", name); vi.advanceTimersByTime(50);
|
||||
// A new mission in the replacement tree reaches clients through the live watches only.
|
||||
fs.cpSync(path.join(real, "missions", "trial"), path.join(real, "missions", "new-mission"), { recursive: true });
|
||||
for (const h of opened.slice(before)) h.event("rename", h.recursive ? "new-mission/mission.yaml" : "new-mission");
|
||||
await vi.advanceTimersByTimeAsync(50);
|
||||
vi.advanceTimersByTime(50);
|
||||
expect(watch.observation().state === "unavailable" || watch.observation().revision === oracle()).toBe(true);
|
||||
expect(opened.some((h) => !h.root.startsWith(f.root) && h.root !== path.dirname(f.root))).toBe(false);
|
||||
} finally { watch.close(); }
|
||||
});
|
||||
for (const start of ["outside", "inside"] as const) it(`reports unavailable while a linked missions root's real location is outside the project (starts ${start})`, async () => {
|
||||
for (const start of ["outside", "inside"] as const) it(`reports unavailable while a linked missions root's real location is outside the project (starts ${start})`, () => {
|
||||
const f = fixture(), linked = path.join(f.root, "linked-missions"), inside = path.join(f.root, "real", "missions");
|
||||
const other = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "policy-reuse-other-"))); roots.push(other);
|
||||
const outside = path.join(other, "missions");
|
||||
@@ -377,21 +370,21 @@ describe("QA bounded adverse coverage at 8b236e06", () => {
|
||||
opened.push({ root, event: listener });
|
||||
return { on: vi.fn(), close: vi.fn() };
|
||||
}) as unknown as typeof fs.watch);
|
||||
const emit = vi.fn(), watch = await startWatch(linked, vi.fn(), { emit } as unknown as EventBus);
|
||||
const emit = vi.fn(), watch = watchProofSources(linked, vi.fn(), { emit } as unknown as EventBus);
|
||||
try {
|
||||
if (start === "inside") {
|
||||
expect(watch.observation().state).toBe("watching");
|
||||
// The supported alias is re-pointed outside the project; the link's own parent sees it.
|
||||
fs.unlinkSync(linked); fs.symlinkSync(outside, linked);
|
||||
opened.filter((h) => h.root === f.root).at(-1)!.event("rename", "linked-missions"); await vi.advanceTimersByTimeAsync(50);
|
||||
opened.filter((h) => h.root === f.root).at(-1)!.event("rename", "linked-missions"); vi.advanceTimersByTime(50);
|
||||
expect(emit.mock.calls.map(([e]) => e.revision)).toContain("unavailable");
|
||||
}
|
||||
expect(watch.observation().state).toBe("unavailable");
|
||||
expect(opened.some((h) => h.root.startsWith(other))).toBe(false);
|
||||
} finally { watch.close(); }
|
||||
});
|
||||
it("QA observes replacement of an external evidence parent or honestly reports unavailable", async () => {
|
||||
const t = await adverseFixture();
|
||||
it("QA observes replacement of an external evidence parent or honestly reports unavailable", () => {
|
||||
const t = adverseFixture();
|
||||
try {
|
||||
const previous = t.watch.observation().revision;
|
||||
expect(readSliceReadiness(t.dir).state).toBe("ready");
|
||||
@@ -401,7 +394,7 @@ describe("QA bounded adverse coverage at 8b236e06", () => {
|
||||
if (root === path.dirname(t.outside)) h.event("rename", path.basename(t.outside));
|
||||
if (root === t.f.root) h.event("rename", "build");
|
||||
}
|
||||
await vi.advanceTimersByTimeAsync(50);
|
||||
vi.advanceTimersByTime(50);
|
||||
expect(readSliceReadiness(t.dir).state).toBe("unknown");
|
||||
expect(t.oracle()).not.toBe(previous);
|
||||
expect(t.watch.observation().state === "unavailable" || t.watch.observation().revision === t.oracle()).toBe(true);
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
import { afterEach, expect, it } from "vitest";
|
||||
import * as fs from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { Hono } from "hono";
|
||||
import { proofRoutes } from "../src/routes/proof.js";
|
||||
import { slicesRoutes } from "../src/routes/slices.js";
|
||||
import { readProjectReadiness } from "../src/domain/proof/judgments.js";
|
||||
|
||||
const roots: string[] = [];
|
||||
afterEach(() => { for (const root of roots.splice(0)) fs.rmSync(root, { recursive: true, force: true }); });
|
||||
|
||||
it.each(["proof", "slices"])("serves another event-loop turn during the %s project read without changing the result", async target => {
|
||||
const root = fs.mkdtempSync(join(tmpdir(), "proof-project-yield-")); roots.push(root);
|
||||
const missions = join(root, "missions");
|
||||
fs.mkdirSync(missions);
|
||||
fs.writeFileSync(join(root, "project.yaml"), "kind: project\nmetadata: { id: trial }\n");
|
||||
for (const name of ["alpha", "beta", "gamma"]) {
|
||||
fs.mkdirSync(join(missions, name));
|
||||
fs.writeFileSync(join(missions, name, "mission.yaml"), "kind: mission\nmetadata: { status: active }\ncomposition: { slices: [] }\n");
|
||||
}
|
||||
const readiness = readProjectReadiness(missions);
|
||||
const expected = target === "proof" ? readiness : {
|
||||
slices: [], totalCount: 0, filter: "all", boundToWorkflow: null,
|
||||
missions: Object.fromEntries(readiness.missions.map(m => [m.name, { authoredStatus: m.historicalStatus, readiness: m }])),
|
||||
};
|
||||
let sourceReady = true;
|
||||
const app = new Hono();
|
||||
app.use("*", async (c, next) => {
|
||||
c.set("sliceIndexer" as never, { isReady: () => true, slicesRoot: missions,
|
||||
withMembershipBatch: (fn: () => unknown) => fn(), list: () => [],
|
||||
observedQueueCounts: () => new Map(), missionAuthoredStatuses: () => ({}),
|
||||
} as never);
|
||||
c.set("sliceDetailProjector" as never, {} as never);
|
||||
c.set("proofSourceWatch" as never, { observation: () => ({ state: sourceReady ? "watching" : "unavailable", revision: "fixture" }) } as never);
|
||||
await next();
|
||||
});
|
||||
app.route("/api/proof", proofRoutes());
|
||||
app.route("/api/slices", slicesRoutes());
|
||||
let completed = false;
|
||||
const read = app.request(`/api/${target}`).then(response => { completed = true; return response; });
|
||||
await new Promise<void>(resolve => setImmediate(resolve));
|
||||
if (completed) {
|
||||
const early = await read; expect(early.status).toBe(200);
|
||||
expect(await early.clone().json()).toMatchObject(expected);
|
||||
}
|
||||
expect(completed, "the proof read must yield before finishing the whole tree").toBe(false);
|
||||
sourceReady = false;
|
||||
const response = await read;
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.json()).toMatchObject(target === "proof" ? { ...expected, sourceObservation: { state: "unavailable" } } : expected);
|
||||
});
|
||||
@@ -6,7 +6,7 @@ const { watch, createProofPolicyRead, readProjectReadiness, resolveProjectRoot,
|
||||
}));
|
||||
// Real-location checks see every fixture path as itself (no links in these mocked trees).
|
||||
vi.mock("node:fs", () => ({ watch, existsSync: () => true, realpathSync: (p: string) => p }));
|
||||
vi.mock("../src/domain/proof/judgments.js", () => ({ createProofPolicyRead, readProjectReadiness, readProjectReadinessAsync: async (...args: unknown[]) => readProjectReadiness(...args), resolveProjectRoot, proofFs }));
|
||||
vi.mock("../src/domain/proof/judgments.js", () => ({ createProofPolicyRead, readProjectReadiness, resolveProjectRoot, proofFs }));
|
||||
import { watchProofSources } from "../src/domain/proof/source-watch.js";
|
||||
import type { EventBus } from "../src/domain/event-bus.js";
|
||||
afterEach(() => { vi.useRealTimers(); vi.resetAllMocks(); proofFs.exists.mockImplementation(() => true); });
|
||||
@@ -21,14 +21,14 @@ function reads(paths: (missions: string) => Array<[keyof Io, string]>) {
|
||||
}
|
||||
const handle = () => { const close = vi.fn(); watch.mockImplementation(() => ({ on: vi.fn(), close })); return close; };
|
||||
|
||||
it("watches only the missions root recursively; the project and evidence outside it get shallow, filtered watches", async () => {
|
||||
it("watches only the missions root recursively; the project and evidence outside it get shallow, filtered watches", () => {
|
||||
vi.useFakeTimers();
|
||||
const project = path.resolve("fixture/project");
|
||||
const missions = path.join(project, "missions");
|
||||
resolveProjectRoot.mockReturnValue(project);
|
||||
reads(() => [["exists", path.join(project, "project.yaml")], ["readBytes", path.join(project, "build", "out", "report.md")]]);
|
||||
const close = handle();
|
||||
const instance = await watchProofSources(missions, vi.fn(), { emit: vi.fn() } as unknown as EventBus);
|
||||
const instance = watchProofSources(missions, vi.fn(), { emit: vi.fn() } as unknown as EventBus);
|
||||
// Inputs at their parents, plus shallow structural anchors up to the project's parent (for replaced directories).
|
||||
expect(watch.mock.calls.map(([root, options]) => [root, options.recursive])).toEqual([
|
||||
[missions, true], [project, false], [path.join(project, "build", "out"), false],
|
||||
@@ -43,23 +43,23 @@ it("watches only the missions root recursively; the project and evidence outside
|
||||
const notifyProject = watch.mock.calls[1]![2], notifyEvidence = watch.mock.calls[2]![2];
|
||||
notifyProject("change", "unrelated.txt");
|
||||
notifyEvidence("change", Buffer.from("other.md"));
|
||||
await vi.advanceTimersByTimeAsync(50);
|
||||
vi.advanceTimersByTime(50);
|
||||
expect(readProjectReadiness).toHaveBeenCalledTimes(2);
|
||||
notifyProject("rename", Buffer.from("project.yaml"));
|
||||
await vi.advanceTimersByTimeAsync(50);
|
||||
vi.advanceTimersByTime(50);
|
||||
expect(readProjectReadiness).toHaveBeenCalledTimes(3);
|
||||
notifyEvidence("rename", "report.md");
|
||||
await vi.advanceTimersByTimeAsync(50);
|
||||
vi.advanceTimersByTime(50);
|
||||
expect(readProjectReadiness).toHaveBeenCalledTimes(4);
|
||||
// No filename: that directory's watch is rebound (closed, reattached by path) and the read verified again.
|
||||
notifyEvidence("change", null);
|
||||
await vi.advanceTimersByTimeAsync(50);
|
||||
vi.advanceTimersByTime(50);
|
||||
expect(readProjectReadiness).toHaveBeenCalledTimes(6);
|
||||
instance.close();
|
||||
expect(close).toHaveBeenCalledTimes(6);
|
||||
});
|
||||
|
||||
it("watches the nearest existing ancestor of a missing input, filtered to the next path segment", async () => {
|
||||
it("watches the nearest existing ancestor of a missing input, filtered to the next path segment", () => {
|
||||
vi.useFakeTimers();
|
||||
const project = path.resolve("fixture/project");
|
||||
const missions = path.join(project, "missions");
|
||||
@@ -67,26 +67,26 @@ it("watches the nearest existing ancestor of a missing input, filtered to the ne
|
||||
proofFs.exists.mockImplementation((p: string) => !p.startsWith(path.join(project, "build")));
|
||||
reads(() => [["readBytes", path.join(project, "build", "out", "report.md")]]);
|
||||
handle();
|
||||
const instance = await watchProofSources(missions, vi.fn(), { emit: vi.fn() } as unknown as EventBus);
|
||||
const instance = watchProofSources(missions, vi.fn(), { emit: vi.fn() } as unknown as EventBus);
|
||||
expect(watch.mock.calls.map(([root, options]) => [root, options.recursive])).toEqual([[missions, true], [project, false], [path.dirname(project), false]]);
|
||||
const notify = watch.mock.calls[1]![2];
|
||||
notify("rename", "unrelated");
|
||||
await vi.advanceTimersByTimeAsync(50);
|
||||
vi.advanceTimersByTime(50);
|
||||
expect(readProjectReadiness).toHaveBeenCalledTimes(2);
|
||||
notify("rename", "build");
|
||||
await vi.advanceTimersByTimeAsync(50);
|
||||
vi.advanceTimersByTime(50);
|
||||
expect(readProjectReadiness).toHaveBeenCalledTimes(3);
|
||||
instance.close();
|
||||
});
|
||||
|
||||
it("reports unavailable and closes what it opened when a required watch fails, without a wider fallback", async () => {
|
||||
it("reports unavailable and closes what it opened when a required watch fails, without a wider fallback", () => {
|
||||
const project = path.resolve("fixture/project");
|
||||
const missions = path.join(project, "missions");
|
||||
resolveProjectRoot.mockReturnValue(project);
|
||||
reads(() => [["exists", path.join(project, "project.yaml")]]);
|
||||
const close = vi.fn();
|
||||
watch.mockReturnValueOnce({ on: vi.fn(), close }).mockImplementationOnce(() => { throw new Error("ENOSPC"); });
|
||||
const instance = await watchProofSources(missions, vi.fn(), { emit: vi.fn() } as unknown as EventBus);
|
||||
const instance = watchProofSources(missions, vi.fn(), { emit: vi.fn() } as unknown as EventBus);
|
||||
expect(instance.observation().state).toBe("unavailable");
|
||||
expect(close).toHaveBeenCalledOnce();
|
||||
expect(watch).toHaveBeenCalledTimes(2);
|
||||
@@ -94,12 +94,12 @@ it("reports unavailable and closes what it opened when a required watch fails, w
|
||||
expect(close).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("watches nothing else recursively when the evaluation reads nothing outside the missions root", async () => {
|
||||
it("watches nothing else recursively when the evaluation reads nothing outside the missions root", () => {
|
||||
const missions = path.resolve("fixture/missions");
|
||||
resolveProjectRoot.mockReturnValue(path.dirname(missions));
|
||||
reads((root) => [["exists", path.join(root, "m1", "mission.yaml")]]);
|
||||
const close = handle();
|
||||
const instance = await watchProofSources(missions, vi.fn(), { emit: vi.fn() } as unknown as EventBus);
|
||||
const instance = watchProofSources(missions, vi.fn(), { emit: vi.fn() } as unknown as EventBus);
|
||||
// Only the missions root's structural anchors are added, shallow.
|
||||
expect(watch.mock.calls.map(([root, options]) => [root, options.recursive])).toEqual([
|
||||
[missions, true], [path.dirname(missions), false], [path.dirname(path.dirname(missions)), false],
|
||||
@@ -108,7 +108,7 @@ it("watches nothing else recursively when the evaluation reads nothing outside t
|
||||
expect(close).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it("does not report watching when the inputs keep changing past the reconciliation budget", async () => {
|
||||
it("does not report watching when the inputs keep changing past the reconciliation budget", () => {
|
||||
const project = path.resolve("fixture/project");
|
||||
const missions = path.join(project, "missions");
|
||||
resolveProjectRoot.mockReturnValue(project);
|
||||
@@ -120,7 +120,7 @@ it("does not report watching when the inputs keep changing past the reconciliati
|
||||
return { missions: [] };
|
||||
});
|
||||
handle();
|
||||
const instance = await watchProofSources(missions, vi.fn(), { emit: vi.fn() } as unknown as EventBus);
|
||||
const instance = watchProofSources(missions, vi.fn(), { emit: vi.fn() } as unknown as EventBus);
|
||||
const attached = new Set(watch.mock.calls.map(([root]) => root));
|
||||
// Either the final dependency set is actually watched and verified, or the monitor says it is not watching.
|
||||
const finalDir = path.dirname(sets[2]!);
|
||||
@@ -129,7 +129,7 @@ it("does not report watching when the inputs keep changing past the reconciliati
|
||||
instance.close();
|
||||
});
|
||||
|
||||
it("tells clients when coverage is lost or regained even though the proof result is unchanged", async () => {
|
||||
it("tells clients when coverage is lost or regained even though the proof result is unchanged", () => {
|
||||
vi.useFakeTimers();
|
||||
const project = path.resolve("fixture/project");
|
||||
const missions = path.join(project, "missions");
|
||||
@@ -146,19 +146,19 @@ it("tells clients when coverage is lost or regained even though the proof result
|
||||
});
|
||||
handle();
|
||||
const emit = vi.fn();
|
||||
const instance = await watchProofSources(missions, vi.fn(), { emit } as unknown as EventBus);
|
||||
const instance = watchProofSources(missions, vi.fn(), { emit } as unknown as EventBus);
|
||||
expect(instance.observation().state).toBe("watching");
|
||||
const notifyProject = watch.mock.calls[1]![2];
|
||||
notifyProject("change", "project.yaml"); await vi.advanceTimersByTimeAsync(50);
|
||||
notifyProject("change", "project.yaml"); vi.advanceTimersByTime(50);
|
||||
expect(instance.observation().state).toBe("unavailable");
|
||||
expect(emit.mock.calls.map(([e]) => e.revision)).toEqual(["unavailable"]);
|
||||
notifyProject("change", "project.yaml"); await vi.advanceTimersByTimeAsync(50);
|
||||
notifyProject("change", "project.yaml"); vi.advanceTimersByTime(50);
|
||||
expect(instance.observation().state).toBe("watching");
|
||||
expect(emit.mock.calls.map(([e]) => e.revision)).toEqual(["unavailable", instance.observation().revision]);
|
||||
instance.close();
|
||||
});
|
||||
|
||||
it("after close, a queued callback neither re-reads, reopens nor emits", async () => {
|
||||
it("after close, a queued callback neither re-reads, reopens nor emits", () => {
|
||||
vi.useFakeTimers();
|
||||
const project = path.resolve("fixture/project");
|
||||
const missions = path.join(project, "missions");
|
||||
@@ -166,141 +166,14 @@ it("after close, a queued callback neither re-reads, reopens nor emits", async (
|
||||
reads(() => [["exists", path.join(project, "project.yaml")]]);
|
||||
handle();
|
||||
const emit = vi.fn();
|
||||
const instance = await watchProofSources(missions, vi.fn(), { emit } as unknown as EventBus);
|
||||
const instance = watchProofSources(missions, vi.fn(), { emit } as unknown as EventBus);
|
||||
const notifyMissions = watch.mock.calls[0]![2];
|
||||
notifyMissions("change", "x");
|
||||
instance.close();
|
||||
notifyMissions("change", "y");
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
vi.advanceTimersByTime(100);
|
||||
expect(readProjectReadiness).toHaveBeenCalledTimes(2);
|
||||
expect(watch).toHaveBeenCalledTimes(3);
|
||||
expect(emit).not.toHaveBeenCalled();
|
||||
expect(instance.observation().state).toBe("unavailable");
|
||||
});
|
||||
|
||||
it.each(["changed", "unchanged"])("coalesces mid-read events and publishes only a current %s basis", async mode => {
|
||||
vi.useFakeTimers();
|
||||
const project = path.resolve("fixture/project"), missions = path.join(project, "missions");
|
||||
resolveProjectRoot.mockReturnValue(project);
|
||||
reads(() => [["exists", path.join(project, "project.yaml")]]);
|
||||
handle();
|
||||
const emit = vi.fn();
|
||||
const instance = await watchProofSources(missions, vi.fn(), { emit } as unknown as EventBus);
|
||||
let finish!: (value: { missions: Array<{ name: string; revision: string }> }) => void;
|
||||
readProjectReadiness.mockReturnValueOnce(new Promise(resolve => { finish = resolve; }));
|
||||
const notify = watch.mock.calls[0]![2];
|
||||
notify("change", "SPEC.md");
|
||||
await vi.advanceTimersByTimeAsync(50);
|
||||
const settledRevision = instance.observation().revision;
|
||||
const calls = readProjectReadiness.mock.calls.length;
|
||||
notify("change", "SPEC.md"); notify("change", "SPEC.md");
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
expect(readProjectReadiness).toHaveBeenCalledTimes(calls);
|
||||
finish({ missions: [{ name: "trial", revision: "superseded" }] });
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(instance.observation().revision).toBe(settledRevision);
|
||||
expect(emit).not.toHaveBeenCalled();
|
||||
readProjectReadiness.mockReturnValue({ missions: mode === "changed" ? [{ name: "trial", revision: "current" }] : [] });
|
||||
await vi.advanceTimersByTimeAsync(50);
|
||||
expect(instance.observation().state).toBe("watching");
|
||||
expect(emit).toHaveBeenCalledTimes(mode === "changed" ? 1 : 0);
|
||||
instance.close();
|
||||
});
|
||||
|
||||
it("publishes unchanged-revision coverage recovery only after an interrupted read settles cleanly", async () => {
|
||||
vi.useFakeTimers();
|
||||
const project = path.resolve("fixture/project"), missions = path.join(project, "missions");
|
||||
resolveProjectRoot.mockReturnValue(project);
|
||||
let call = 0;
|
||||
readProjectReadiness.mockImplementation((_m: string, _r: unknown, _root: unknown, io?: Io) => {
|
||||
io?.exists(path.join(project, "project.yaml"));
|
||||
io?.readBytes(path.join(project, `input-${Math.min(call++, 2)}`, "report.md"));
|
||||
return { missions: [] };
|
||||
});
|
||||
handle();
|
||||
const emit = vi.fn();
|
||||
const instance = await watchProofSources(missions, vi.fn(), { emit } as unknown as EventBus);
|
||||
const settled = instance.observation();
|
||||
expect(settled.state).toBe("unavailable"); // all three setup passes attached a previously unwatched dependency
|
||||
const read = readProjectReadiness.getMockImplementation()!;
|
||||
let finish!: (value: { missions: [] }) => void;
|
||||
readProjectReadiness.mockImplementationOnce((...args: unknown[]) => {
|
||||
read(...args);
|
||||
return new Promise(resolve => { finish = resolve; });
|
||||
});
|
||||
const notify = watch.mock.calls[0]![2];
|
||||
notify("change", "report.md"); await vi.advanceTimersByTimeAsync(50);
|
||||
notify("change", "report.md");
|
||||
finish({ missions: [] }); await vi.advanceTimersByTimeAsync(0);
|
||||
expect.soft(instance.observation()).toEqual(settled);
|
||||
expect(emit).not.toHaveBeenCalled();
|
||||
await vi.advanceTimersByTimeAsync(50);
|
||||
expect(instance.observation()).toEqual({ state: "watching", revision: settled.revision });
|
||||
expect.soft(emit.mock.calls.map(([event]) => event.revision)).toEqual([settled.revision]);
|
||||
instance.close();
|
||||
});
|
||||
|
||||
it("reports watch errors immediately while a refresh is still awaiting its read", async () => {
|
||||
vi.useFakeTimers();
|
||||
const project = path.resolve("fixture/project"), missions = path.join(project, "missions");
|
||||
resolveProjectRoot.mockReturnValue(project);
|
||||
reads(() => [["exists", path.join(project, "project.yaml")]]);
|
||||
let error!: () => void;
|
||||
watch.mockImplementation(() => ({ on: (_event: string, callback: () => void) => { error = callback; }, close: vi.fn() }));
|
||||
const emit = vi.fn();
|
||||
const instance = await watchProofSources(missions, vi.fn(), { emit } as unknown as EventBus);
|
||||
let finish!: (value: { missions: [] }) => void;
|
||||
readProjectReadiness.mockReturnValueOnce(new Promise(resolve => { finish = resolve; }));
|
||||
watch.mock.calls[0]; await vi.advanceTimersByTimeAsync(50);
|
||||
error();
|
||||
expect(instance.observation()).toEqual({ state: "unavailable", revision: "unavailable" });
|
||||
expect(emit.mock.calls.at(-1)![0].revision).toBe("unavailable");
|
||||
finish({ missions: [] }); await vi.advanceTimersByTimeAsync(0);
|
||||
expect(instance.observation().state).toBe("unavailable");
|
||||
expect(emit.mock.calls.every(([event]) => event.revision === "unavailable")).toBe(true);
|
||||
instance.close();
|
||||
});
|
||||
|
||||
it("does not publish an interrupted setup candidate before a clean refresh", async () => {
|
||||
vi.useFakeTimers();
|
||||
const project = path.resolve("fixture/project"), missions = path.join(project, "missions");
|
||||
resolveProjectRoot.mockReturnValue(project);
|
||||
reads(() => [["exists", path.join(project, "project.yaml")]]);
|
||||
const read = readProjectReadiness.getMockImplementation()!;
|
||||
let finish!: (value: { missions: [] }) => void;
|
||||
readProjectReadiness.mockImplementationOnce((...args: unknown[]) => {
|
||||
read(...args);
|
||||
return new Promise(resolve => { finish = resolve; });
|
||||
});
|
||||
handle();
|
||||
const emit = vi.fn(), opening = watchProofSources(missions, vi.fn(), { emit } as unknown as EventBus);
|
||||
watch.mock.calls[0];
|
||||
finish({ missions: [] });
|
||||
const instance = await opening;
|
||||
expect(instance.observation()).toEqual({ state: "unavailable", revision: "unavailable" });
|
||||
expect(emit).not.toHaveBeenCalled();
|
||||
await vi.advanceTimersByTimeAsync(50);
|
||||
expect(instance.observation().state).toBe("watching");
|
||||
expect(emit).toHaveBeenCalledTimes(1);
|
||||
instance.close();
|
||||
});
|
||||
|
||||
it("does not reattach watches or publish when an in-flight read finishes after close", async () => {
|
||||
vi.useFakeTimers();
|
||||
const project = path.resolve("fixture/project"), missions = path.join(project, "missions");
|
||||
resolveProjectRoot.mockReturnValue(project);
|
||||
reads(() => [["exists", path.join(project, "project.yaml")]]);
|
||||
handle();
|
||||
const emit = vi.fn();
|
||||
const instance = await watchProofSources(missions, vi.fn(), { emit } as unknown as EventBus);
|
||||
let finish!: (value: { missions: [] }) => void;
|
||||
readProjectReadiness.mockReturnValueOnce(new Promise(resolve => { finish = resolve; }));
|
||||
watch.mock.calls[0];
|
||||
await vi.advanceTimersByTimeAsync(50);
|
||||
const watches = watch.mock.calls.length;
|
||||
instance.close(); finish({ missions: [] });
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
expect(watch).toHaveBeenCalledTimes(watches);
|
||||
expect(emit).not.toHaveBeenCalled();
|
||||
expect(instance.observation().state).toBe("unavailable");
|
||||
});
|
||||
@@ -25,13 +25,10 @@ it("watches the owning project policy above a nested missions root", async () =>
|
||||
expect(readSliceReadiness(slice).state).toBe("ready");
|
||||
const events: unknown[] = [];
|
||||
let invalidated = 0;
|
||||
const watch = await watchProofSources(missions, () => invalidated++, {
|
||||
const watch = watchProofSources(missions, () => invalidated++, {
|
||||
emit: (event: unknown) => events.push(event),
|
||||
} as unknown as EventBus);
|
||||
try {
|
||||
// A creation event can dirty setup before it returns; start assertions at the settled observed basis.
|
||||
await expect.poll(() => watch.observation().state, { timeout: 5000 }).toBe("watching");
|
||||
events.length = 0; invalidated = 0;
|
||||
await new Promise(resolve => setTimeout(resolve, 250));
|
||||
expect(events).toHaveLength(0);
|
||||
const before = watch.observation().revision;
|
||||
@@ -46,11 +43,11 @@ it("watches the owning project policy above a nested missions root", async () =>
|
||||
} finally { watch.close(); fs.rmSync(root, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
it("retains the existing watch boundary for roots without project.yaml", async () => {
|
||||
it("retains the existing watch boundary for roots without project.yaml", () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "proof-legacy-root-"));
|
||||
const missions = path.join(root, "missions");
|
||||
fs.mkdirSync(missions);
|
||||
const watch = await watchProofSources(missions, () => {}, { emit() {} } as unknown as EventBus);
|
||||
const watch = watchProofSources(missions, () => {}, { emit() {} } as unknown as EventBus);
|
||||
try { expect(watch.observation().state).toBe("watching"); }
|
||||
finally { watch.close(); fs.rmSync(root, { recursive: true, force: true }); }
|
||||
});
|
||||
@@ -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") };
|
||||
}
|
||||
|
||||
@@ -962,4 +962,64 @@ describe("an answered request: #2934's follow-ups", () => {
|
||||
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 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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" });
|
||||
|
||||
@@ -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