mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-11 11:41:38 -10:00
Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8ac23f19e6 | ||
|
|
99c6ff5a08 | ||
|
|
d7d29b9d39 | ||
|
|
98260ad3aa | ||
|
|
4c8813b4b6 | ||
|
|
4aa38abf22 | ||
|
|
03850f2b6f | ||
|
|
04980d9f45 | ||
|
|
ceae26774e | ||
|
|
adb5555f08 | ||
|
|
aa75c28f58 | ||
|
|
86c7af6a76 | ||
|
|
898a342bd2 | ||
|
|
5f72f3934c |
No files matched your search
+569
@@ -8,6 +8,575 @@ deprecations, and behavioral changes. Breaking changes are called out explicitly
|
||||
|
||||
---
|
||||
|
||||
## [0.7.12]
|
||||
|
||||
OpenRig 0.7.12 is a patch for people running their own agent teams, cut from
|
||||
main `16e8b7fb`. When you answer your team, by typing, clicking or qualifying
|
||||
an answer, the answer now goes to an agent that reads it with the question and
|
||||
decides. Answers go to the configured receiving agent or kernel operator when
|
||||
available; otherwise they wait. Setup offers to register you as the team's
|
||||
person. `rig down` no longer stops a terminal it
|
||||
can't show belongs to the seat. There are fixes across delivery, Codex,
|
||||
restore, the queue, Slack and speed, many contributed by people using
|
||||
OpenRig. Two repairs aren't in this release: see Known limits.
|
||||
|
||||
### Before you upgrade
|
||||
|
||||
- **`rig down` is more careful.** It stops only a tmux session that carries the
|
||||
seat's own `OPENRIG_NODE_ID`, on the server and session it read. A terminal
|
||||
OpenRig didn't create, a mismarked one or a replaced one is left running, and
|
||||
`rig down` exits 2 naming it and how to stop it yourself. A seat joined with
|
||||
`rig attach --self` is in that group. ([#2962](https://github.com/mvschwarz/openrig/pull/2962))
|
||||
- **openrig-core is 0.1.28.** A Codex session kept running across the upgrade
|
||||
can ask for hook approval until it's relaunched.
|
||||
|
||||
### Answering your team
|
||||
|
||||
- Answers nothing else reads, such as a host pairing request or a mode's
|
||||
scheduled end, use the configured receiving agent or the kernel operator.
|
||||
Answers wait when no receiver is available. ([#2923](https://github.com/mvschwarz/openrig/pull/2923))
|
||||
- Pairing answers go to the receiving agent, and you can decide directly in the
|
||||
TUI's pairing settings. A pairing request says who it went to and who reads
|
||||
the answer. ([#2931](https://github.com/mvschwarz/openrig/pull/2931), [#2935](https://github.com/mvschwarz/openrig/pull/2935))
|
||||
- A runner's permission answer goes to the receiving agent, which decides while
|
||||
the runner waits, so a qualified answer such as "only if it's the test file"
|
||||
is read before anything acts. ([#2934](https://github.com/mvschwarz/openrig/pull/2934), [#2938](https://github.com/mvschwarz/openrig/pull/2938), [#2950](https://github.com/mvschwarz/openrig/pull/2950), [#2966](https://github.com/mvschwarz/openrig/pull/2966))
|
||||
- Setup offers to register you as the team's person, and the empty-registry
|
||||
hint is right. A team you start from your own assistant fits your own logins,
|
||||
with a review before anything is committed. ([#2924](https://github.com/mvschwarz/openrig/pull/2924), [#2905](https://github.com/mvschwarz/openrig/pull/2905))
|
||||
|
||||
### Harnesses and connections
|
||||
|
||||
- A community harness can declare `protocol: pi-rpc`, presets can carry launch
|
||||
wrappers, and a seat can switch between Pi and another runtime. The
|
||||
community-maintained nodecode runner is listed in the reference.
|
||||
([#2870](https://github.com/mvschwarz/openrig/pull/2870), [#2759](https://github.com/mvschwarz/openrig/pull/2759), [#2669](https://github.com/mvschwarz/openrig/pull/2669), [#2859](https://github.com/mvschwarz/openrig/pull/2859))
|
||||
- A relay connector that can't accept incoming connections can pull pending
|
||||
messages for a person instead of being pushed to: set
|
||||
`OPENRIG_RELAY_MODE=pull` in the relay secrets file. Push stays the default.
|
||||
A person's gateway binding can be unset. ([#2844](https://github.com/mvschwarz/openrig/pull/2844), [#2812](https://github.com/mvschwarz/openrig/pull/2812))
|
||||
- `rig terminal open <view> --provider herdr-app` opens a Rig view inside the
|
||||
herdr-app desktop window. ([#2744](https://github.com/mvschwarz/openrig/pull/2744), [#2956](https://github.com/mvschwarz/openrig/pull/2956), [#2981](https://github.com/mvschwarz/openrig/pull/2981))
|
||||
|
||||
### Queue, scope and seats
|
||||
|
||||
- Queue content can be erased for closed-work retention and account deletion,
|
||||
and a retried create can't bring erased work back. ([#2837](https://github.com/mvschwarz/openrig/pull/2837), [#2843](https://github.com/mvschwarz/openrig/pull/2843))
|
||||
- Each send records what's known about its delivery in one
|
||||
`transport.delivery_evidence` event: what was admitted, what reached the
|
||||
input, what the screen check showed, and for which occupant. ([#2937](https://github.com/mvschwarz/openrig/pull/2937), [#2943](https://github.com/mvschwarz/openrig/pull/2943))
|
||||
- `rig queue create` advises on an unresolved scope, slice lists show observed
|
||||
queue counts, a proof policy can require every listed judge, the execution
|
||||
ladder shows each slice's recorded status, `rig queue show` names a handed-off
|
||||
row's successor, and a custom blocker ID explains the wait.
|
||||
([#2901](https://github.com/mvschwarz/openrig/pull/2901), [#2902](https://github.com/mvschwarz/openrig/pull/2902), [#2867](https://github.com/mvschwarz/openrig/pull/2867), [#2872](https://github.com/mvschwarz/openrig/pull/2872), [#2874](https://github.com/mvschwarz/openrig/pull/2874), [#2983](https://github.com/mvschwarz/openrig/pull/2983))
|
||||
- Every seat's managed guidance points to its skills, help and project context.
|
||||
`rig seat status` shows when guidance was written relative to the session's
|
||||
recorded moments, and what
|
||||
the seat's refocus hook recorded. A rebuilt seat's briefing lists older recaps
|
||||
as optional history. ([#2918](https://github.com/mvschwarz/openrig/pull/2918), [#2920](https://github.com/mvschwarz/openrig/pull/2920), [#2916](https://github.com/mvschwarz/openrig/pull/2916), [#2876](https://github.com/mvschwarz/openrig/pull/2876))
|
||||
|
||||
### Fixes
|
||||
|
||||
- **Delivery and drafts:** Claude seats with a custom status line read idle and
|
||||
verify sends; a fresh idle hook doesn't type into a current Claude dialog;
|
||||
Claude's context hint and drawn cursor are read correctly; a deliberate
|
||||
override is delivered when the input can't be read; update notices beside the
|
||||
paste hint are recognized. ([#2807](https://github.com/mvschwarz/openrig/pull/2807), [#2813](https://github.com/mvschwarz/openrig/pull/2813), [#2947](https://github.com/mvschwarz/openrig/pull/2947), [#2942](https://github.com/mvschwarz/openrig/pull/2942), [#2933](https://github.com/mvschwarz/openrig/pull/2933), [#2800](https://github.com/mvschwarz/openrig/pull/2800), [#2786](https://github.com/mvschwarz/openrig/pull/2786))
|
||||
- **Codex:** when you answer a Codex choice prompt, a message waiting on it is
|
||||
delivered instead of escalated; a working counter is compared only within one pane process; hook
|
||||
trust records are written under smol-toml 1.9, so launches no longer stop at
|
||||
"Hooks need review". ([#2952](https://github.com/mvschwarz/openrig/pull/2952), [#2980](https://github.com/mvschwarz/openrig/pull/2980), [#2804](https://github.com/mvschwarz/openrig/pull/2804))
|
||||
- **Restore, start and handover:** owed restore input is delivered after a
|
||||
bounded wait on unknown readiness; compaction uses the draft defaults and
|
||||
reports repeated unsent preparation; `rig daemon start` archives a start
|
||||
reservation left from an earlier boot; the kernel restart guidance describes
|
||||
the existing recovery; an earlier occupant can't replace a Claude seat's
|
||||
resume target; token switches keep the Claude launch; rebuild continuity and
|
||||
Codex launch choices are explained.
|
||||
([#2818](https://github.com/mvschwarz/openrig/pull/2818), [#2799](https://github.com/mvschwarz/openrig/pull/2799), [#2797](https://github.com/mvschwarz/openrig/pull/2797), [#2915](https://github.com/mvschwarz/openrig/pull/2915), [#2965](https://github.com/mvschwarz/openrig/pull/2965), [#2796](https://github.com/mvschwarz/openrig/pull/2796), [#2853](https://github.com/mvschwarz/openrig/pull/2853), [#2906](https://github.com/mvschwarz/openrig/pull/2906))
|
||||
- **Queue and recovery:** a polling hosted member (an outside assistant on the
|
||||
team; experimental and off by default) gets one standing notice; a hosted
|
||||
member's ring that times out reads unknown, and durable recovery doesn't
|
||||
repeat that recorded wake (other recovery paths can still ring it again);
|
||||
unroutable items wake as soon as the seat is reachable; the stuck sweep counts
|
||||
a finding once; an escalation keeps its owner through a handoff; findings route to the
|
||||
seat's own fallback; a park on an outside condition with no wake says so;
|
||||
`rig workflow validate` is read-only.
|
||||
([#2864](https://github.com/mvschwarz/openrig/pull/2864), [#2957](https://github.com/mvschwarz/openrig/pull/2957), [#2790](https://github.com/mvschwarz/openrig/pull/2790), [#2824](https://github.com/mvschwarz/openrig/pull/2824), [#2946](https://github.com/mvschwarz/openrig/pull/2946), [#2865](https://github.com/mvschwarz/openrig/pull/2865), [#2851](https://github.com/mvschwarz/openrig/pull/2851), [#2852](https://github.com/mvschwarz/openrig/pull/2852), [#2817](https://github.com/mvschwarz/openrig/pull/2817), [#2763](https://github.com/mvschwarz/openrig/pull/2763))
|
||||
- **Slack and people:** a Slack reply that can't be read says the message "may
|
||||
have posted"; threads are recovered when needed; an ask settles after its
|
||||
original message is deleted; eligible asks post when quiet hours can't be
|
||||
read, marked when OpenRig can't fully check them; a held ask whose person has
|
||||
no connector closes as no-connector; update delivery is described without
|
||||
assuming Slack. ([#2909](https://github.com/mvschwarz/openrig/pull/2909), [#2788](https://github.com/mvschwarz/openrig/pull/2788), [#2792](https://github.com/mvschwarz/openrig/pull/2792), [#2814](https://github.com/mvschwarz/openrig/pull/2814), [#2805](https://github.com/mvschwarz/openrig/pull/2805), [#2815](https://github.com/mvschwarz/openrig/pull/2815), [#2927](https://github.com/mvschwarz/openrig/pull/2927))
|
||||
- **CLI and config:** `rig context recap-write` writes where every reader looks;
|
||||
config accepts two keys the daemon already resolves; `rig whoami` says the
|
||||
team is still being built during `rig up`; rigs with `@` in the name find
|
||||
attention; a seat waiting on a person names its question; the installer
|
||||
reports PATH gaps with shell hints; daemon credentials stay out of the tmux
|
||||
server's environment. ([#2914](https://github.com/mvschwarz/openrig/pull/2914), [#2969](https://github.com/mvschwarz/openrig/pull/2969), [#2955](https://github.com/mvschwarz/openrig/pull/2955), [#2949](https://github.com/mvschwarz/openrig/pull/2949), [#2891](https://github.com/mvschwarz/openrig/pull/2891), [#2888](https://github.com/mvschwarz/openrig/pull/2888), [#2883](https://github.com/mvschwarz/openrig/pull/2883), [#2850](https://github.com/mvschwarz/openrig/pull/2850))
|
||||
- **Pi and OMP:** typed edits submit in order on the affected Node releases
|
||||
(22.21.x to 22.22.0, 24.9.0 to 24.11.1, 25.0.0 to 25.1.0) instead of appending an
|
||||
insertion at the end. ([#2847](https://github.com/mvschwarz/openrig/pull/2847))
|
||||
- **Terminals:** a Herdr grid whose blank filler cells have no label is confirmed,
|
||||
instead of reading every seat unconfirmed. ([#2849](https://github.com/mvschwarz/openrig/pull/2849))
|
||||
- **Speed:** queue listings, `rig queue show` and `rig parked` stop re-reading
|
||||
each item;
|
||||
the stuck sweep yields by elapsed time; Markdown section ends are found in one
|
||||
pass. ([#2825](https://github.com/mvschwarz/openrig/pull/2825), [#2926](https://github.com/mvschwarz/openrig/pull/2926), [#2833](https://github.com/mvschwarz/openrig/pull/2833))
|
||||
|
||||
### Behaviour that changed
|
||||
|
||||
- **Your answer goes to an agent.** Through 0.7.11 some answers acted on the
|
||||
request directly, and some had no receiver when no operator was set. Now these
|
||||
answers use the configured receiving agent or the kernel operator, with the
|
||||
question included. Answers wait when no receiver is available.
|
||||
([#2923](https://github.com/mvschwarz/openrig/pull/2923), [#2931](https://github.com/mvschwarz/openrig/pull/2931), [#2934](https://github.com/mvschwarz/openrig/pull/2934))
|
||||
- **`rig down`** leaves terminals it can't show belong to the seat; see Before
|
||||
you upgrade. ([#2962](https://github.com/mvschwarz/openrig/pull/2962))
|
||||
- **Codex "Hooks need review" with smol-toml 1.9** no longer needs a manual
|
||||
workaround. ([#2804](https://github.com/mvschwarz/openrig/pull/2804))
|
||||
- **A daemon start reservation left after a reboot** is archived by
|
||||
`rig daemon start`; don't clear it by hand. ([#2915](https://github.com/mvschwarz/openrig/pull/2915))
|
||||
|
||||
### Known limits
|
||||
|
||||
- **Not in this release:** the daemon can pause for up to about a second while
|
||||
reading proof on large mission trees ([#2970](https://github.com/mvschwarz/openrig/pull/2970)), and delivery to a Claude seat
|
||||
showing Claude's auto-update failure footer can stall because that footer
|
||||
isn't recognized ([#2948](https://github.com/mvschwarz/openrig/pull/2948)).
|
||||
- **`rig down`:** a terminal it leaves running stays running until you stop it.
|
||||
It doesn't keep a stored host or boot identity, doesn't guard against another
|
||||
program running as you, and a daemon restart between losing the tmux server
|
||||
and the stop wasn't exercised. For an adopted session, such as a seat joined
|
||||
with `rig attach --self`, `rig seat stop` still suggests `rig down`, which
|
||||
leaves that terminal running too. Release it with `rig unclaim <session>`
|
||||
(that leaves its process running), then close it in its own terminal. ([#2962](https://github.com/mvschwarz/openrig/pull/2962))
|
||||
- **Codex working counter:** a capture that doesn't name its pane keeps the old
|
||||
comparison, a restart that keeps both the pane and its process id isn't seen,
|
||||
and a leftover live frame can count once until it expires. ([#2980](https://github.com/mvschwarz/openrig/pull/2980))
|
||||
- **Custom blocker IDs** explain the wait, but their progress gives no event
|
||||
wake; the timer remains. ([#2983](https://github.com/mvschwarz/openrig/pull/2983))
|
||||
- **Delivery evidence** records what was known, not that the agent read the
|
||||
message; where the input ended up can read unknown. ([#2937](https://github.com/mvschwarz/openrig/pull/2937), [#2943](https://github.com/mvschwarz/openrig/pull/2943))
|
||||
- **Runner permission answers:** when a person answers a runner's permission
|
||||
request, the answer is kept, and an explicit decision works. In 0.7.12 the
|
||||
runner's confirmation updates fail to record, so no receipt or Feed
|
||||
confirmation appears, and the decision can reach the runner on its next read
|
||||
rather than at once (up to about 21 seconds in our test). To settle a request
|
||||
directly, run `rig seat decide-permission <request> --option <id>`. Pairing
|
||||
answers are separate, and their receipts are kept. Tested on one native daemon
|
||||
with the TUI and a runner driven by a stand-in agent. ([#2934](https://github.com/mvschwarz/openrig/pull/2934))
|
||||
- **herdr-app views** are one-shot; following isn't claimed. **Pull relay**
|
||||
needs a connector that implements pull. ([#2744](https://github.com/mvschwarz/openrig/pull/2744), [#2844](https://github.com/mvschwarz/openrig/pull/2844))
|
||||
- **From 0.7.11:** its known limits below still apply, except Codex hook setup
|
||||
with smol-toml 1.9.x ([#2720](https://github.com/mvschwarz/openrig/issues/2720)), fixed by [#2804](https://github.com/mvschwarz/openrig/pull/2804). 0.7.12 changes openrig-core
|
||||
again, so the Codex hook note there applies to this upgrade too. They weren't
|
||||
re-checked for this release unless named here.
|
||||
|
||||
### Credits
|
||||
|
||||
Thanks to @billbenetti, @oodadoudou, @DeryFerd, @Aummadour, @seanmcn, @w4rc0n,
|
||||
@ziadeh, @vanducng, @smota, @sn1thy, @dajiaohuang and @Vaishnavi220506 for
|
||||
merged contributions, and to @KM199, co-author of [#2947](https://github.com/mvschwarz/openrig/pull/2947) and [#2952](https://github.com/mvschwarz/openrig/pull/2952), which finish
|
||||
their [#2513](https://github.com/mvschwarz/openrig/pull/2513) and [#2515](https://github.com/mvschwarz/openrig/pull/2515).
|
||||
Thanks also for the reports behind these fixes: @nassersala ([#2955](https://github.com/mvschwarz/openrig/pull/2955)), @djuntgen
|
||||
([#2804](https://github.com/mvschwarz/openrig/pull/2804)), @todd-dhango ([#2851](https://github.com/mvschwarz/openrig/pull/2851)), @Kayhusk ([#2796](https://github.com/mvschwarz/openrig/pull/2796)), @pr0xc3nt4ur1 ([#2849](https://github.com/mvschwarz/openrig/pull/2849) and [#2850](https://github.com/mvschwarz/openrig/pull/2850)), @amsayfi
|
||||
([#2776](https://github.com/mvschwarz/openrig/pull/2776)), @oodadoudou ([#2847](https://github.com/mvschwarz/openrig/pull/2847)) and @CaptObvious ([#2933](https://github.com/mvschwarz/openrig/pull/2933) and [#2942](https://github.com/mvschwarz/openrig/pull/2942)).
|
||||
|
||||
## [0.7.11]
|
||||
|
||||
OpenRig 0.7.11 is a patch for people running their own agent teams, cut from
|
||||
main `b40bc71c`. Its biggest change: an ordinary send or work notice now goes
|
||||
into a person's typed draft by default instead of being refused (see Behaviour
|
||||
that changed). A person can now work with their team from their own coding
|
||||
agent through the new `openrig` plugin or from an ACP app such as Zed or T3
|
||||
Code, and, as an experiment that's off by default, answer the team's questions
|
||||
from an assistant connected through a grant service. A prepared managed compaction waits for an idle moment instead
|
||||
of asking again, and a restore stopped at a prompt clears itself. On Linux, a
|
||||
daemon run as a systemd user service no longer takes its seats down when its
|
||||
unit stops or restarts, and writable terminal tiles reflow text when the screen resizes. There
|
||||
are fixes across the queue, workflows, launch and speed under load, many
|
||||
contributed or reported by people using OpenRig. The OpenShell worker's
|
||||
maintained cleanup now works on OpenShell 0.1.6, but the worker still isn't end
|
||||
to end: see Known limits.
|
||||
|
||||
### Before you upgrade
|
||||
|
||||
- **Back up your database first.** Five new migrations run at the daemon's
|
||||
first start: 150 (node launch wrapper), 151 (Pi account switches), 152
|
||||
(workflow instance spec), 153 (events retention marks) and 154 (workflow
|
||||
instance root objective). 0.7.10 had none. With the daemon stopped, copy the
|
||||
file `$OPENRIG_DB` names. Going back to 0.7.10 after these migrations isn't
|
||||
established.
|
||||
- **Restart the daemon after installing:**
|
||||
```bash
|
||||
npm install -g @openrig/cli@0.7.11
|
||||
rig daemon stop && rig daemon start
|
||||
```
|
||||
- **Linux, with the daemon run as a systemd user service:** the seats move into
|
||||
their own scope only when their tmux server next starts fresh. Until then the
|
||||
server stays in the daemon's unit, so stopping or restarting that unit still
|
||||
takes the seats down, as before. ([#2749](https://github.com/mvschwarz/openrig/pull/2749))
|
||||
- **The openrig-core plugin moves from 0.1.26 to 0.1.27.**
|
||||
Its activity relay and refocus hooks changed, as did the refocusing,
|
||||
openrig-user and openrig-skills skills. Replaced files are kept under
|
||||
`$OPENRIG_HOME/backups/managed-refresh/<id>/`; reapply local edits you still
|
||||
need from there. ([#2756](https://github.com/mvschwarz/openrig/pull/2756), [#2743](https://github.com/mvschwarz/openrig/pull/2743), [#2717](https://github.com/mvschwarz/openrig/pull/2717), [#2735](https://github.com/mvschwarz/openrig/pull/2735), [#2657](https://github.com/mvschwarz/openrig/pull/2657), [#2647](https://github.com/mvschwarz/openrig/pull/2647), [#2619](https://github.com/mvschwarz/openrig/pull/2619))
|
||||
- **Sends now join a person's draft** (`transport.draft_delivery` = `deliver`).
|
||||
To keep the old refusal, run `rig config set transport.draft_delivery refuse`. ([#2645](https://github.com/mvschwarz/openrig/pull/2645))
|
||||
- **A Pi seat that declares an absolute `PI_CODING_AGENT_DIR`** in its `env:`
|
||||
now uses it as its account home and needs a readable `auth.json` there, or
|
||||
the launch stops. Prepare one, or remove the declaration to keep the managed
|
||||
seat login. ([#2630](https://github.com/mvschwarz/openrig/pull/2630))
|
||||
|
||||
### Sends and drafts
|
||||
|
||||
- **A send now goes into a person's typed draft.** Under the new
|
||||
`transport.draft_delivery` setting's default, `deliver`, an ordinary
|
||||
`rig send` or work notice to a seat whose prompt holds a person's typed draft
|
||||
pastes the message at the cursor and submits the combined input as one
|
||||
prompt, and the send's result says whether submission was confirmed.
|
||||
`refuse` (also `OPENRIG_TRANSPORT_DRAFT_DELIVERY` in the daemon's
|
||||
environment) restores the old refusal where OpenRig detects the draft, from
|
||||
the next send with no restart. Menus, permission prompts, a seat's typing
|
||||
guard, managed compaction and `--wait-for-idle` still refuse or wait under
|
||||
either value. ([#2645](https://github.com/mvschwarz/openrig/pull/2645))
|
||||
- **A draft only the input box shows is caught:** an ordinary Claude Code or
|
||||
Codex send reads the box once before pasting (three tmux calls, within
|
||||
500 ms). Under `refuse` the send is refused before any input; under `deliver`
|
||||
the result says it joined the draft. ([#2653](https://github.com/mvschwarz/openrig/pull/2653))
|
||||
- **Claude seats showing a statusLine or the background task panel** read as a
|
||||
normal composer under the draft-aware typing guard, so an empty box gets the
|
||||
delivery instead of `draft_input_unknown`. ([#2703](https://github.com/mvschwarz/openrig/pull/2703))
|
||||
- **The bare `✔ Update installed · Restart to update` row** of Claude Code
|
||||
2.1.319 in a narrow pane is recognised, so an idle seat's empty prompt reads
|
||||
empty and wakes go through under the draft-aware guard. Checked against the
|
||||
row as reported, not a live pane. ([#2662](https://github.com/mvschwarz/openrig/pull/2662))
|
||||
- **A full held-message quota no longer drops a sender's later wakes:** the
|
||||
sender's newest held message retires its earlier ones, listed as `coalesced`
|
||||
in `rig seat held-messages <seat>` and readable with `--id`. ([#2701](https://github.com/mvschwarz/openrig/pull/2701))
|
||||
- **Seats stopped while they owe work** don't get a burst of undeliverable
|
||||
wakes at each daemon start; the start-up log says `waiting=N (not replayed: …)`. ([#2620](https://github.com/mvschwarz/openrig/pull/2620))
|
||||
|
||||
### Compaction, restore and handover
|
||||
|
||||
- **A prepared managed compaction waits for its window.** When the restore map
|
||||
is complete but `/compact` couldn't go in (a draft, a tmux mode, no idle
|
||||
moment), the attempt is held as `prepared` and sends `/compact` at the next
|
||||
idle moment instead of asking the seat to prepare again about every 11
|
||||
minutes. `rig compact --state` shows the hold and `--cancel` disarms it. A
|
||||
busy seat isn't asked again after an ended attempt, across a restart too. ([#2698](https://github.com/mvschwarz/openrig/pull/2698), [#2716](https://github.com/mvschwarz/openrig/pull/2716))
|
||||
- **The final `/compact` waits out the preparation's own fresh activity:** its
|
||||
guarded send now allows the 15-second hook freshness window plus the 8-second
|
||||
capture budget (23 seconds, against 9), so a seat that's already idle, such
|
||||
as one out of context, gets `/compact` instead of a refusal. A busy seat can
|
||||
wait up to about 14 seconds longer; the attempt's deadline still limits it. ([#2764](https://github.com/mvschwarz/openrig/pull/2764))
|
||||
- **A restore stopped at a prompt clears itself** (such as Claude's resume
|
||||
selection) once the seat runs the conversation it was restoring, using the
|
||||
check `rig seat clear-attention` runs. The docs cover the manual route. ([#2711](https://github.com/mvschwarz/openrig/pull/2711), [#2674](https://github.com/mvschwarz/openrig/pull/2674))
|
||||
- **A queue wake refused at a prompt no longer goes in right after "Compacted"**
|
||||
while the compaction still owes its turn boundary and restore request. It
|
||||
goes in at the seat's next idle moment after that step clears, as a recovered
|
||||
notification. ([#2666](https://github.com/mvschwarz/openrig/pull/2666))
|
||||
- **A Codex helper inside a Claude seat** (such as `codex exec` from its Bash
|
||||
tool) no longer becomes the seat's resume token, so a restart resumes the
|
||||
Claude conversation instead of failing with `no_conversation_found`. ([#2740](https://github.com/mvschwarz/openrig/pull/2740))
|
||||
- **A Claude rebuild handover checks its priming packet:** if it's still
|
||||
staged, the handover reports the submission unconfirmed and says to submit
|
||||
the existing packet once, never to resend or rerun. ([#2650](https://github.com/mvschwarz/openrig/pull/2650))
|
||||
- **Restoring a Claude seat's activity hooks** finds the agent's own source from
|
||||
its saved launch recipe, not a same-name agent in the rig's folder. ([#2617](https://github.com/mvschwarz/openrig/pull/2617))
|
||||
- **`on_restore: follow_source`** on a spec's `resources.skills[]` entry
|
||||
refreshes that skill from its source when a Claude or Codex seat is restored,
|
||||
keeping its conversation; `keep_saved` stays the default. ([#2626](https://github.com/mvschwarz/openrig/pull/2626))
|
||||
|
||||
### Your own coding agent
|
||||
|
||||
- **The `openrig` plugin,** shipped beside openrig-core, lets a person work with
|
||||
their local team from their own Claude Code or Codex: hand it work, follow
|
||||
it, answer the team's questions about it, and read the result. Claude Code:
|
||||
`claude plugin marketplace add "$(npm root -g)/@openrig/cli/daemon/assets/plugins/openrig"`,
|
||||
then `claude plugin install openrig@openrig-app`. Codex: the same
|
||||
`codex plugin marketplace add` path, `codex plugin add openrig@openrig-app`, then
|
||||
`codex mcp add openrig --env OPENRIG_SESSION_NAME=<address> -- rig mcp serve --front-door`.
|
||||
The plugin's README has the steps. ([#2647](https://github.com/mvschwarz/openrig/pull/2647))
|
||||
- **VS Code, Copilot CLI and Cursor** install it too, with no seat; the README
|
||||
and `rig_whoami` say to add your own `rig mcp serve --front-door` server with
|
||||
`OPENRIG_SESSION_NAME` set and turn the plugin's off. A seatless app can still
|
||||
create a task, send and post, as `unknown-mcp-<8 hex>`. ([#2683](https://github.com/mvschwarz/openrig/pull/2683), [#2709](https://github.com/mvschwarz/openrig/pull/2709), [#2718](https://github.com/mvschwarz/openrig/pull/2718), [#2657](https://github.com/mvschwarz/openrig/pull/2657))
|
||||
- **Answers are recorded exactly as the person typed them.** ([#2733](https://github.com/mvschwarz/openrig/pull/2733))
|
||||
- **Marketplace files at the repository root** let Claude Code's
|
||||
`/plugin marketplace add mvschwarz/openrig`, and directories that take a public
|
||||
Git repo, install it from GitHub (marketplace `openrig-app`). ([#2719](https://github.com/mvschwarz/openrig/pull/2719))
|
||||
|
||||
### ACP apps: `rig acp serve`
|
||||
|
||||
- **OpenRig as an agent in ACP apps:** `rig acp serve --to <member@rig>` runs
|
||||
on stdio for an app that launches a custom agent (Zed's `agent_servers`, T3
|
||||
Code). A prompt becomes a queue row, each row the work reaches shows as a
|
||||
step, and the closing resolution is the reply. Nothing runs until an app
|
||||
starts it, and it talks to the daemon `rig` uses. ([#2648](https://github.com/mvschwarz/openrig/pull/2648), [#2714](https://github.com/mvschwarz/openrig/pull/2714))
|
||||
- **The team's questions reach the app** while a turn is open, as a permission
|
||||
request or a one-choice form, and the choice is the person's answer. Sessions
|
||||
can be listed and reopened, and a question left unanswered there is named as
|
||||
still open in the TUI and Slack. ([#2651](https://github.com/mvschwarz/openrig/pull/2651), [#2708](https://github.com/mvschwarz/openrig/pull/2708), [#2686](https://github.com/mvschwarz/openrig/pull/2686), [#2712](https://github.com/mvschwarz/openrig/pull/2712))
|
||||
- **The `@openrig/acp` launcher** (`openrig-acp`) runs the matching
|
||||
`@openrig/cli`'s `rig acp serve` for the ACP registry's `npx` launch, with a
|
||||
new `acp.to` setting for the receiver. `npm run pack:release` packs it beside
|
||||
the CLI at the same version. ([#2655](https://github.com/mvschwarz/openrig/pull/2655), [#2715](https://github.com/mvschwarz/openrig/pull/2715))
|
||||
|
||||
### People and connected assistants
|
||||
|
||||
- Denying or dropping a host pairing request in Steering now gives the requesting machine no access; in 0.7.9 and 0.7.10, that action could return the daemon access token. ([#2884](https://github.com/mvschwarz/openrig/pull/2884))
|
||||
- **Pairing answers now reach the target's kernel operator.** On a 0.7.11 target with a receiving kernel operator, typed words and Approve or Deny choices in the TUI, Steering or Slack are replies for that agent to read. Receiving a reply neither grants nor denies access. The operator acts on clear intent with `rig host approve <code>` or `rig host deny <code>` and asks back when the answer is unclear. The commands also work from a plain terminal on the target; they use its operator bearer (`OPENRIG_AUTH_BEARER_TOKEN`, or `--bearer <token>`). ([#2900](https://github.com/mvschwarz/openrig/pull/2900))
|
||||
- **An Approve click on an operator-owned request waits for the operator to act.** A stopped operator keeps ownership; the answer waits in its queue. A stopped or unknown running state is named with the terminal route, without claiming the answer was read. If there is no operator, it cannot receive answers, or OpenRig cannot determine one, the request names that case and uses the agent-free path: Approve and Deny decide directly, and replies lead to another ask rather than denial. File-only replies and the literal `[file reply]` have no words stored on the agent-free ask; they still do not deny it.
|
||||
- **TUI answers use the daemon's operator bearer when configured.** Ordinary terminal calls keep their separate terminal credential. A missing or wrong action credential leaves the answer unrecorded and shows what to set; this does not weaken the daemon's authentication.
|
||||
- **Receipt, clarification and outcome are separate steps.** Receipts, agent-free re-asks and outcomes inferred from clicks or closes need the requesting machine to keep polling. An explicit `rig host approve` or `rig host deny` attempts its outcome update directly. An expiry timer closes the ask even if polling stopped; an unanswered expired ask gets no further update. Requests and decisions live in daemon memory, so a restart drops an open request. Approval still must be collected before expiry. This target-side change does not change older targets.
|
||||
|
||||
- **Assistants connected through a grant service** (experimental, unsupported,
|
||||
off by default: you serve `rig mcp serve --http --grant-service <url>` behind
|
||||
your own TLS tunnel, and an outside service grants each connection):
|
||||
`rig_question_list` and `rig_question_answer` let a registered person answer
|
||||
the team's questions as their own; `--task-destination <member@rig>` names
|
||||
the receiver for `rig_queue_create`, and a bare seat name resolves. ([#2735](https://github.com/mvschwarz/openrig/pull/2735), [#2638](https://github.com/mvschwarz/openrig/pull/2638), [#2750](https://github.com/mvschwarz/openrig/pull/2750))
|
||||
- **The `relay` gateway connector** sends questions, updates and notices to an
|
||||
operator-owned bridge over loopback HTTP and takes replies back. It's off
|
||||
unless you add a relay binding, and the bridge, with its reach to a messaging
|
||||
platform, is yours (`docs/reference/gateway-relay.md`). ([#2624](https://github.com/mvschwarz/openrig/pull/2624))
|
||||
- **The web UI's For You feed** answers a local ask's questions on its card. ([#2612](https://github.com/mvschwarz/openrig/pull/2612))
|
||||
|
||||
### Queue, workflows and status
|
||||
|
||||
- **One `unclaimed-backlog` notice** replaces a finding per row for a seat that
|
||||
reads without claiming and has no escalation route or delegation parent. ([#2752](https://github.com/mvschwarz/openrig/pull/2752))
|
||||
- **Claims read `working`, not stalled,** for up to 60 minutes while a Claude
|
||||
owner has a live background shell, and 20 while a managed restore is owed. ([#2675](https://github.com/mvschwarz/openrig/pull/2675))
|
||||
- **`rig queue show`, `list`, `transitions`, `update` and `block` take
|
||||
`--host <id>`,** as `create` and `handoff` did; `claim` doesn't. ([#2678](https://github.com/mvschwarz/openrig/pull/2678))
|
||||
- **Cross-host handoffs** that override a claim wake the claimer, and a close
|
||||
to an unverifiable successor names `rig queue handoff`. ([#2734](https://github.com/mvschwarz/openrig/pull/2734), [#2621](https://github.com/mvschwarz/openrig/pull/2621))
|
||||
- **Code results:** an `openrig.code-result/v1` manifest as `evidenceRef` gives
|
||||
`rig_artifact_read` and the new `rig queue artifact` the commit pair to diff. ([#2659](https://github.com/mvschwarz/openrig/pull/2659))
|
||||
- **Workflows:** a run keeps its own spec when another pod's copy reuses the id
|
||||
and version; the root objective is kept and printed as `objective:`;
|
||||
`rig workflow unregister` removes versions; `trace --full` returns the whole
|
||||
trail. ([#2696](https://github.com/mvschwarz/openrig/pull/2696), [#2738](https://github.com/mvschwarz/openrig/pull/2738), [#2676](https://github.com/mvschwarz/openrig/pull/2676), [#2667](https://github.com/mvschwarz/openrig/pull/2667))
|
||||
- **Activity:** a helper harness inside a seat no longer reports its turns as
|
||||
the seat's, and a resumed seat no longer reads a false model divergence. ([#2743](https://github.com/mvschwarz/openrig/pull/2743), [#2664](https://github.com/mvschwarz/openrig/pull/2664))
|
||||
- **Also:** refocus traces projects outside `workspace.root`; a timed-out
|
||||
`rig stream emit` names a retry that can't duplicate; `rig context
|
||||
reconstruction inspect` and `view` read an exact revision from local stores.
|
||||
([#2717](https://github.com/mvschwarz/openrig/pull/2717), [#2619](https://github.com/mvschwarz/openrig/pull/2619), [#2207](https://github.com/mvschwarz/openrig/pull/2207))
|
||||
|
||||
### Launch, install and setup
|
||||
|
||||
- **On Linux, a daemon run as a systemd user service starts the seats' tmux
|
||||
server in its own user scope** (`openrig-tmux-<id>.scope`, with
|
||||
`OOMPolicy=continue` from systemd 253), so stopping or restarting the
|
||||
daemon's unit no longer ends the seats, and their memory no longer counts
|
||||
against it. A daemon started from a login session, or a failed `systemd-run`,
|
||||
starts the server as before with one log line giving the reason; macOS and
|
||||
Windows are unchanged. ([#2749](https://github.com/mvschwarz/openrig/pull/2749))
|
||||
- **A Claude seat whose launch passes `--settings` JSON written with spaces**
|
||||
(such as `{"env": {…}}` from a proxy alias) can be verified again: OpenRig
|
||||
reads the value whole, so it finds the seat's conversation. ([#2665](https://github.com/mvschwarz/openrig/pull/2665))
|
||||
- **A Claude launcher script that execs the native installer's symlink**
|
||||
becomes ready after a launch or rebuild, and `--no-chrome` is recognised. ([#2751](https://github.com/mvschwarz/openrig/pull/2751))
|
||||
- **`launch_wrapper`,** an argv prefix whose launcher prepares the environment
|
||||
and execs the agent, works for local Claude Code, Pi and Codex seats; a
|
||||
wrapped seat never starts unwrapped. ([#2746](https://github.com/mvschwarz/openrig/pull/2746), [#2609](https://github.com/mvschwarz/openrig/pull/2609))
|
||||
- **`rig provider switch --seat <pi seat> --to-account <name> --account-home <dir> --reason "<why>"`**
|
||||
moves a Pi seat to another account through a confirmed successor, keeping its
|
||||
model, effort and wrapper; `--dry-run` shows the plan. A Pi seat whose spec
|
||||
declares its account home starts, resumes and switches accounts again: on the
|
||||
first 0.7.11 candidate it didn't start, because the staged command began with
|
||||
shell setup that `exec` can't run. ([#2630](https://github.com/mvschwarz/openrig/pull/2630), [#2782](https://github.com/mvschwarz/openrig/pull/2782))
|
||||
- **Claude seats that load openrig-core themselves** stop loading its skills
|
||||
twice: the next launch removes OpenRig's unchanged copies. ([#2660](https://github.com/mvschwarz/openrig/pull/2660))
|
||||
- **Also:** a short permission-hook policy name; `rig attach --self` outside
|
||||
tmux adds the rig; `rig host pair --timeout` holds the request that long;
|
||||
`rig config set` names a masking variable; daemon start and stop treat a
|
||||
probe the OS blocked as unknown; a bare piped `rig` skips the compile cache.
|
||||
([#2640](https://github.com/mvschwarz/openrig/pull/2640), [#2641](https://github.com/mvschwarz/openrig/pull/2641), [#2692](https://github.com/mvschwarz/openrig/pull/2692), [#2547](https://github.com/mvschwarz/openrig/pull/2547), [#2502](https://github.com/mvschwarz/openrig/pull/2502), [#2643](https://github.com/mvschwarz/openrig/pull/2643))
|
||||
|
||||
### Terminals and views
|
||||
|
||||
- **Writable tiles reflow with the screen.** They attach as in 0.7.10: the screen
|
||||
in use sizes the seat, and text reflows on resize. They stay attached until
|
||||
detached or closed. Read-only tiles keep showing the seat without attaching,
|
||||
typing into it or resizing it. Fixed web geometry is opt-in through
|
||||
`terminal.fixed_geometry` (off by default).
|
||||
([#2868](https://github.com/mvschwarz/openrig/pull/2868), [#2757](https://github.com/mvschwarz/openrig/pull/2757), [#2821](https://github.com/mvschwarz/openrig/pull/2821))
|
||||
- **Herdr follow views** work on macOS too (protocol 22), open a labelled
|
||||
snapshot where live following isn't supported, and show a cleaner strip. ([#2663](https://github.com/mvschwarz/openrig/pull/2663), [#2706](https://github.com/mvschwarz/openrig/pull/2706))
|
||||
- **Closing the herdr-core terminal that started OpenRig** no longer ends the
|
||||
daemon or its seats (per herdr-core's documented behaviour; a real close
|
||||
wasn't exercised). ([#2737](https://github.com/mvschwarz/openrig/pull/2737))
|
||||
- **Also:** terminal snapshots keep colours; a reattach during a broker
|
||||
teardown works; a home under a symlinked parent records its real path;
|
||||
`rig doctor` says why tmux control mode is off. ([#2654](https://github.com/mvschwarz/openrig/pull/2654), [#2671](https://github.com/mvschwarz/openrig/pull/2671), [#2633](https://github.com/mvschwarz/openrig/pull/2633), [#2707](https://github.com/mvschwarz/openrig/pull/2707))
|
||||
|
||||
### Speed under load
|
||||
|
||||
- **The stuck sweep** batches its reads and yields every 50 rows: on a copy of
|
||||
a busy 340 MB database, a pass went from 10.2–11.4 s to 5.1–5.5 s and the
|
||||
longest stall from about 10 s to 0.57 s. ([#2649](https://github.com/mvschwarz/openrig/pull/2649))
|
||||
- **`view.changed` events are no longer stored,** so the `events` table grows
|
||||
about a third as fast on the busy host measured, and opt-in
|
||||
`retention.events_days` prunes eight history-only event types. ([#2695](https://github.com/mvschwarz/openrig/pull/2695), [#2699](https://github.com/mvschwarz/openrig/pull/2699))
|
||||
- **The web UI** loads the newest 100 events instead of the whole log. ([#2704](https://github.com/mvschwarz/openrig/pull/2704))
|
||||
- **`daemon.seat_poll_interval_ms`** (1000 to 2000) trades activity freshness
|
||||
for daemon CPU on large fleets. ([#2688](https://github.com/mvschwarz/openrig/pull/2688))
|
||||
- **Also:** with `OPENRIG_TMUX_CONTROL=1`, short sends and `set-option` use the
|
||||
control client; the idle-shell check reads the plain process table; skill
|
||||
catalog Git reads time out after five seconds. ([#2636](https://github.com/mvschwarz/openrig/pull/2636), [#2690](https://github.com/mvschwarz/openrig/pull/2690), [#2713](https://github.com/mvschwarz/openrig/pull/2713))
|
||||
|
||||
### Docs and tests
|
||||
|
||||
Daemon HTTP clients and bearer tokens ([#2652](https://github.com/mvschwarz/openrig/pull/2652)), the 0.7.10 capability map on
|
||||
main ([#2736](https://github.com/mvschwarz/openrig/pull/2736)), a neutral TUI demo snapshot ([#2724](https://github.com/mvschwarz/openrig/pull/2724)), a web UI editor timer
|
||||
([#2689](https://github.com/mvschwarz/openrig/pull/2689)), and test and CI fixes ([#2753](https://github.com/mvschwarz/openrig/pull/2753), [#2681](https://github.com/mvschwarz/openrig/pull/2681), [#2685](https://github.com/mvschwarz/openrig/pull/2685), [#2673](https://github.com/mvschwarz/openrig/pull/2673), [#2628](https://github.com/mvschwarz/openrig/pull/2628)).
|
||||
|
||||
### Behaviour that changed
|
||||
|
||||
- **Sends and work notices that were refused with `prompt_draft` now go
|
||||
through by default, joined to the person's draft** (above).
|
||||
`rig config set transport.draft_delivery refuse` restores the old refusal
|
||||
where OpenRig detects the draft, from the next send with no restart; under
|
||||
`refuse`, a draft only the input box shows now refuses too. ([#2645](https://github.com/mvschwarz/openrig/pull/2645), [#2653](https://github.com/mvschwarz/openrig/pull/2653))
|
||||
- **A Pi seat with an absolute `PI_CODING_AGENT_DIR`** uses it as its account
|
||||
home on launch, restart, resume and restore; no `auth.json`, no launch. ([#2630](https://github.com/mvschwarz/openrig/pull/2630))
|
||||
- **`/api/events` no longer emits `view.changed`,** live or replayed, so new
|
||||
ones leave the web UI's log, activity feed and For You and the historical
|
||||
telemetry export; the view streams still carry them. ([#2695](https://github.com/mvschwarz/openrig/pull/2695))
|
||||
- **The stuck sweep** files one backlog notice where it filed one per row,
|
||||
skips `stalled-after-claim` inside the two windows above, re-checks each
|
||||
rule just before filing, and skips overlapping ticks. ([#2752](https://github.com/mvschwarz/openrig/pull/2752), [#2675](https://github.com/mvschwarz/openrig/pull/2675), [#2649](https://github.com/mvschwarz/openrig/pull/2649))
|
||||
- **Managed compaction:** a prepared attempt stays `waiting`, and a manual
|
||||
`rig compact` over it reads `already_in_progress`. ([#2698](https://github.com/mvschwarz/openrig/pull/2698), [#2716](https://github.com/mvschwarz/openrig/pull/2716))
|
||||
- **The daemon and seats no longer inherit `HC_SCOPE`, `HC_TERMINAL_ID` or
|
||||
`HC_TERMINAL_SOCKET`;** one already running keeps them until restarted. ([#2737](https://github.com/mvschwarz/openrig/pull/2737))
|
||||
- **On Linux, the seats' tmux server runs in its own systemd user scope** when
|
||||
the daemon is a user service (above): the daemon unit's stop, restart and
|
||||
memory accounting no longer cover the seats. ([#2749](https://github.com/mvschwarz/openrig/pull/2749))
|
||||
- **Read-only terminal tiles** show the seat without attaching and end with
|
||||
tmux's own error if they cannot read it. Writable tiles use the ordinary
|
||||
sizing attach; detaching ends that viewer, while the agent keeps running.
|
||||
([#2757](https://github.com/mvschwarz/openrig/pull/2757), [#2868](https://github.com/mvschwarz/openrig/pull/2868))
|
||||
- **Also:** each sender keeps one live held message per seat; daemon start
|
||||
doesn't re-send a failed wake to a seat with no receiver; restores record
|
||||
`restore.outcome_reconciled` themselves; `rig terminal open --follow` returns
|
||||
`follow_unsupported` with a snapshot. ([#2701](https://github.com/mvschwarz/openrig/pull/2701), [#2620](https://github.com/mvschwarz/openrig/pull/2620), [#2711](https://github.com/mvschwarz/openrig/pull/2711), [#2663](https://github.com/mvschwarz/openrig/pull/2663))
|
||||
- **MCP and ACP:** an empty or placeholder seat value means no seat; the
|
||||
seatless `rig_whoami` text changed; `--front-door` is `front-door/v2`;
|
||||
grant-service callers' `destination` is optional; `rig acp serve` gives
|
||||
form-taking apps a form, leads step titles with the latest note, and asks
|
||||
nothing after a cancel. ([#2657](https://github.com/mvschwarz/openrig/pull/2657), [#2709](https://github.com/mvschwarz/openrig/pull/2709), [#2718](https://github.com/mvschwarz/openrig/pull/2718), [#2733](https://github.com/mvschwarz/openrig/pull/2733), [#2638](https://github.com/mvschwarz/openrig/pull/2638), [#2750](https://github.com/mvschwarz/openrig/pull/2750), [#2708](https://github.com/mvschwarz/openrig/pull/2708), [#2686](https://github.com/mvschwarz/openrig/pull/2686), [#2655](https://github.com/mvschwarz/openrig/pull/2655))
|
||||
- **CLI:** `rig attach --self` outside tmux records `<name>@<rig>`;
|
||||
`rig host pair` keeps a request for its `--timeout`; `rig config set` and
|
||||
`reset` name environment overrides; `rig daemon start` and `stop` refuse when
|
||||
a blocked probe leaves identity or liveness unknown; `--host` receipts print
|
||||
a runnable command. ([#2641](https://github.com/mvschwarz/openrig/pull/2641), [#2692](https://github.com/mvschwarz/openrig/pull/2692), [#2547](https://github.com/mvschwarz/openrig/pull/2547), [#2502](https://github.com/mvschwarz/openrig/pull/2502), [#2678](https://github.com/mvschwarz/openrig/pull/2678))
|
||||
- **Seat launches:** unchanged OpenRig copies of openrig-core's skills are
|
||||
removed, with warnings; the permission hook reads the installed policy lists
|
||||
on each call; model divergence reads pending until the new occupant's record.
|
||||
([#2660](https://github.com/mvschwarz/openrig/pull/2660), [#2640](https://github.com/mvschwarz/openrig/pull/2640), [#2664](https://github.com/mvschwarz/openrig/pull/2664))
|
||||
|
||||
### Known limits
|
||||
|
||||
- **OpenShell:** on OpenShell 0.1.6 the maintained cleanup (`delete-owned`)
|
||||
now works for a custom image, and recreate and an exact resume passed in our
|
||||
native run. A restored guest still receives its startup text again, so strict
|
||||
recall after a restore isn't met, and the worker isn't end to end. The CA fix
|
||||
holds on 0.1.6. Never give the guest the daemon's main API port (normally
|
||||
7433). After a same-generation reconciled launch, run
|
||||
`rig environment connect` yourself. ([#2755](https://github.com/mvschwarz/openrig/pull/2755))
|
||||
- **Drafts:** detection isn't complete. Under `refuse`, an unreadable input box
|
||||
lets the send go ahead, so an undetected draft goes with the message; under
|
||||
`deliver`, a result may not mention a draft it joined. ([#2645](https://github.com/mvschwarz/openrig/pull/2645), [#2653](https://github.com/mvschwarz/openrig/pull/2653))
|
||||
- **A post-compaction step can still hold** when the box reads as typed, under
|
||||
draft-aware delivery or the typing guard, or after a lost `/compact`. Run
|
||||
`rig compact <seat> --cancel`, then send a restore request naming the newest
|
||||
restore map, marker and packet. A queue wake refused at a prompt now waits for
|
||||
that step, but recovery at daemon start and a new handoff's wake don't check
|
||||
for it yet. ([#2575](https://github.com/mvschwarz/openrig/issues/2575), [#2666](https://github.com/mvschwarz/openrig/pull/2666))
|
||||
- **Draft-aware delivery to Claude:** a message held as `draft_input_unknown`
|
||||
still isn't retried. Whether managed compaction now gets past the banner
|
||||
(`target_activity_unknown`) wasn't retested. ([#2605](https://github.com/mvschwarz/openrig/issues/2605))
|
||||
- **Claude provider switch:** the failure reported in [#2341](https://github.com/mvschwarz/openrig/issues/2341) ("The probe pane
|
||||
returned to a shell instead of staying inside the runtime") isn't shown fixed.
|
||||
- **`rig exec`:** checked with Claude Code and Codex through their ACP
|
||||
adapters. A cancel is acknowledged, not settled, and says nothing about
|
||||
further spend. Cancel with `rig exec cancel`, not `rig acp cancel`.
|
||||
- **ACP apps:** checked in T3 Code (Beta) 0.1.7 and with a scripted client; the
|
||||
unanswered-question notice hasn't run in a live app, nor the note-first step
|
||||
title in Zed. A cancel stops waiting; the work carries on. ([#2648](https://github.com/mvschwarz/openrig/pull/2648), [#2686](https://github.com/mvschwarz/openrig/pull/2686), [#2712](https://github.com/mvschwarz/openrig/pull/2712))
|
||||
- **The `openrig` plugin** hasn't run in Claude Desktop, the Codex app or
|
||||
Copilot CLI. A seatless app can't claim, update, hand off or answer
|
||||
questions, and everyone using the app on a machine acts as its seat. ([#2647](https://github.com/mvschwarz/openrig/pull/2647), [#2657](https://github.com/mvschwarz/openrig/pull/2657), [#2683](https://github.com/mvschwarz/openrig/pull/2683))
|
||||
- **Grant-service assistants** still aren't woken: an item stays queued until
|
||||
its receiver claims it. **Relay:** "Accepted by bridge" means the bridge has
|
||||
it, not the person, and a failed send replays only at a restart. ([#2735](https://github.com/mvschwarz/openrig/pull/2735), [#2624](https://github.com/mvschwarz/openrig/pull/2624))
|
||||
- **Event retention** can't be undone, and the database file doesn't shrink. ([#2699](https://github.com/mvschwarz/openrig/pull/2699))
|
||||
- **`keep_saved` skills:** a skill whose source changes between `rig down` and
|
||||
the restore comes back missing, named in the restore's warnings; an edited seat
|
||||
copy is kept. ([#2626](https://github.com/mvschwarz/openrig/pull/2626))
|
||||
- **Read-only terminal tiles** each run one tmux command a second while
|
||||
showing. The earlier eight-tile showing measurement used about 6–8% of one
|
||||
core on an Apple Silicon Mac mini, against about 0.2% for attached 0.7.10
|
||||
tiles. This polling cost no longer applies to writable tiles. ([#2757](https://github.com/mvschwarz/openrig/pull/2757))
|
||||
- **Early writable-tile input:** as in 0.7.10, Enter or Ctrl-C typed while a
|
||||
new tile is still attaching can close that viewer. The agent keeps running;
|
||||
reopen the view. Once attached, tmux handles input and paste directly.
|
||||
([#2868](https://github.com/mvschwarz/openrig/pull/2868))
|
||||
- **Kept writable tiles from 0.7.8, 0.7.9 or pre-reflow 0.7.11** keep their old
|
||||
client. Manually refresh them: close only their owned viewer and reopen with
|
||||
`rig terminal open`. For a `--follow` view, use `--no-follow` first, then open
|
||||
a fresh view. This does not touch the agent or its conversation; it is not an
|
||||
automatic migration. Existing 0.7.10 plain and read-only tiles stay as they are.
|
||||
([#2868](https://github.com/mvschwarz/openrig/pull/2868))
|
||||
- **Claude `launch_wrapper`** hasn't run with zsh, on macOS arm64 or with a real
|
||||
installed Claude; an alias or shell function named `claude` fails closed. ([#2746](https://github.com/mvschwarz/openrig/pull/2746))
|
||||
- **Going back to 0.7.10:** besides the migrations, an older permission hook
|
||||
gives named-policy Claude sessions no decision until relaunched. ([#2640](https://github.com/mvschwarz/openrig/pull/2640))
|
||||
- **Codex hooks:** a session kept running across an upgrade can show "Hook
|
||||
failed — plugin contents changed; hook approval required" until relaunched,
|
||||
and 0.7.11 changes openrig-core again. `rig seat restart --keep-conversation`
|
||||
refuses a Codex seat launched with developer instructions (`settings_drift`).
|
||||
- **Codex hook setup:** with smol-toml 1.9.x and an existing `hooks.json`,
|
||||
OpenRig can leave its activity hooks and trust records unchanged. The range
|
||||
(`^1.8.0`) still permits 1.9.x; the repository lockfile uses 1.8.0. ([#2720](https://github.com/mvschwarz/openrig/issues/2720))
|
||||
- **Claude Code 2.1.319 starts in auto mode by default,** so a new Claude seat
|
||||
may act without asking; manual mode restores asking.
|
||||
- **Also:** permission requests in seat status show only the latest, in memory
|
||||
for five minutes, and suggested rules can contain sensitive values; Slack
|
||||
private channels need the documented, untested route, direct messages aren't
|
||||
supported and people reads haven't run live; a Codex snapshot restore writes
|
||||
OpenRig's guidance blocks into a tracked `AGENTS.md` (check `git status`); a
|
||||
scheduled reminder that fails before input isn't retried; SSH host status
|
||||
reads `unknown` behind ProxyJump or ProxyCommand; advisories don't show on a
|
||||
native resume; seats handed over before 0.7.9 refuse stop, restart and
|
||||
clean; after a project-local install, plain `rig` isn't found in the TUI's
|
||||
own shell; with Claude Code 2.1.319, `rig up` can wrongly say "Startup prompt
|
||||
still staged … press Enter"; two npm installs into one folder at once once
|
||||
left a partial install, cause unknown.
|
||||
- **Fixed or changed from 0.7.10's known issues:** the bare update banner
|
||||
([#2662](https://github.com/mvschwarz/openrig/pull/2662)) and the Claude launcher-script readiness timeout with `--no-chrome`
|
||||
([#2751](https://github.com/mvschwarz/openrig/pull/2751)). 0.7.10's OpenShell limit is superseded by the one above.
|
||||
- **The earlier limits below that 0.7.11 doesn't fix or change still apply.**
|
||||
|
||||
### Credits
|
||||
|
||||
Thanks to @billbenetti, @seanmcn, @smota, @Aummadour, @dajiaohuang, @w4rc0n,
|
||||
@Mohsintl, @twotnguyen, @ziadeh and @Nikhi00718 for merged contributions.
|
||||
Thanks to @billbenetti for the reports behind [#2752](https://github.com/mvschwarz/openrig/pull/2752), [#2660](https://github.com/mvschwarz/openrig/pull/2660), [#2738](https://github.com/mvschwarz/openrig/pull/2738), [#2749](https://github.com/mvschwarz/openrig/pull/2749), [#2666](https://github.com/mvschwarz/openrig/pull/2666) and [#2665](https://github.com/mvschwarz/openrig/pull/2665), to
|
||||
@pr0xc3nt4ur1 for the report behind [#2740](https://github.com/mvschwarz/openrig/pull/2740) and [#2743](https://github.com/mvschwarz/openrig/pull/2743), to @oneandonlydean for
|
||||
the process chain behind [#2751](https://github.com/mvschwarz/openrig/pull/2751), and to @niklasbaldi for the report and cause
|
||||
behind [#2654](https://github.com/mvschwarz/openrig/pull/2654).
|
||||
Thanks also to @ziadeh for the reports behind [#2622](https://github.com/mvschwarz/openrig/issues/2622) and [#2486](https://github.com/mvschwarz/openrig/issues/2486), and to @mlc-factory-bot for the reports behind [#2605](https://github.com/mvschwarz/openrig/issues/2605) and [#2607](https://github.com/mvschwarz/openrig/issues/2607).
|
||||
|
||||
## [0.7.10]
|
||||
|
||||
Published 2026-11-08 as `@openrig/[email protected]`, built from the commit tagged
|
||||
|
||||
@@ -175,8 +175,14 @@ The standing detector in `queue-stuck-sweep.ts` creates findings through the
|
||||
queue repository, with `evidenceRef: rig queue show <source-qitem-id>` pointing
|
||||
to the underlying durable work row. Unclaimed and undelivered findings go to
|
||||
the destination's orchestrator when one is derivable, otherwise to the
|
||||
destination itself, and that seat may be dead. Overdue claims go to their
|
||||
destination. Repeated detections reuse the existing finding without changing
|
||||
destination itself, and that seat may be dead. A wake that failed (`failed:…`)
|
||||
at a recorded agent seat with no `delegates_to` parent or `escalates_to` route is
|
||||
the exception: its finding tries the row's creator, then the operator agent
|
||||
(`agents.operator_session`), skipping an unset, confirmed-missing, self or
|
||||
unaccepted address, a bound terminal seat, and OpenRig's own machinery
|
||||
(`daemon@kernel` or a `@system` actor), so the finding's own wake isn't sent to the seat that just failed to
|
||||
take one. If neither can be used, it stays with the original seat and says why.
|
||||
Overdue claims go to their destination. Repeated detections reuse the existing finding without changing
|
||||
its route; when the source condition resolves, the sweep closes its own finding.
|
||||
Previously, falling out of a reporting query could itself close a delivery or
|
||||
unclaimed finding. An unconfirmed retry, a fresh human-notification post window,
|
||||
|
||||
@@ -866,6 +866,9 @@ What it costs:
|
||||
## Stop your teams
|
||||
|
||||
`rig down <rig-name>` ends that rig's agent sessions and work in progress.
|
||||
A terminal OpenRig didn't start, such as a seat joined with `rig attach --self`,
|
||||
is left running: `rig down` names it with the commands to stop it by hand, and
|
||||
exits 2.
|
||||
Read `rig ps --nodes --rig <rig-name>` first so you can see who will stop;
|
||||
`rig down` also prints the rig's recorded agent sessions and their activity
|
||||
before stopping them.
|
||||
|
||||
@@ -79,8 +79,9 @@ Other layouts and other herdr-app windows don't show Rig views. Each window (app
|
||||
- Entering a view again (a click in the strip, or herdr-app restarting with it shown) returns the keyboard to the cell
|
||||
you were last in there, else the first live cell.
|
||||
- **Your tabs** gives the keyboard back to your own pane.
|
||||
- OpenRig's own rewrites (another view opening or closing, a cell ending) never move the keyboard, whether you are in
|
||||
a view or in your tabs.
|
||||
- Apart from an open, OpenRig's own rewrites (another view closing, a cell ending) leave the keyboard where it is,
|
||||
whether you are in a view or in your tabs, with one exception: when the cell you were last in ends while its view is
|
||||
shown, the keyboard goes to that view's first live cell, if one is left.
|
||||
- Once the view is shown, an interactive cell for a local seat attaches its tile to the seat with plain tmux, as a
|
||||
writable tile does ([terminal sessions](terminal-sessions.md#replacing-a-seats-session)). Until then it only shows
|
||||
the seat, and anything typed in it is dropped. A cell for a seat on another host is its ssh attachment from the
|
||||
|
||||
@@ -78,14 +78,17 @@ entry. The entry holds secret names, never values:
|
||||
- It's a pointer, not the row's body: the member reads the row under its grant.
|
||||
- A receiver should tolerate a repeated `qitem_id`. A ring accepted just before the daemon stops, before it's recorded,
|
||||
is rung again on recovery, the same at-least-once window a terminal wake has.
|
||||
- It waits up to 10 seconds and doesn't follow redirects.
|
||||
- It waits up to 10 seconds and doesn't follow redirects. A ring with no answer by then may still have reached the
|
||||
trigger, so it reads unknown, not failed, and durable recovery doesn't repeat that recorded wake (other recovery paths
|
||||
can still ring it again). The member sees the work when it next checks its rows.
|
||||
- **What the trigger should do:** start a run that lists the member's rows (`rig_queue_list` with `owned`) and claims its work.
|
||||
|
||||
## What the record says, and doesn't
|
||||
|
||||
These facts stay separate on the row:
|
||||
- **Wake:** with a trigger, the row's wake result is `wake-accepted:webhook (<url_ref>) answered <status>` when the trigger took the
|
||||
ring, or `failed:webhook …`. Accepted means the tool's trigger answered, not that the member read the row.
|
||||
ring, `indeterminate:webhook …` when it didn't answer in time, or `failed:webhook …`. Accepted means the tool's trigger
|
||||
answered, not that the member read the row.
|
||||
- With no trigger, the result is `unroutable`. The member finds work by reading its own inbox and picks a row up by claiming
|
||||
it, so a row gets no finding of its own. Work it leaves unclaimed past the stuck sweep's unclaimed age
|
||||
(`queue.stuck_sweep_unclaimed_age_minutes`, 60 by default) is listed in one standing notice per member. The notice goes to
|
||||
|
||||
@@ -1088,6 +1088,20 @@ and returns `edge_not_found` (HTTP 404, exit 1), so a repeated removal reports n
|
||||
Rig names resolve only among unarchived rigs, and an ambiguous name requires an
|
||||
explicit ID. These commands target the local daemon, as `rig add` does.
|
||||
|
||||
Managed Claude compaction also uses the delegation parent for two diagnostic notices: repeated
|
||||
unsent preparation attempts and repeated restore-delivery failures. If the seat has no recorded
|
||||
parent session (including when it is outside the recorded topology), these notices go to the
|
||||
configured registered human operator, or the default registered person when no operator is set.
|
||||
If no registered person can be selected, OpenRig creates no notice rather than inventing a
|
||||
destination: `rig compact <seat> --state` reports a restore-delivery notice failure, while a
|
||||
preparation notice is tried again on later polls. A parent is the source of a `delegates_to` edge
|
||||
to the seat; declare one when an agent should receive these notices. With multiple parents, the
|
||||
parent session with the latest creation time is selected; `escalates_to` is not used for these
|
||||
notices. The restore-delivery notice to a person omits local paths and
|
||||
points to `rig compact <seat> --state` on the host; the preparation notice includes its state-file
|
||||
evidence reference and attempt details. Treat these notices and any chat relay accordingly. Existing
|
||||
notices retain their destination when the topology changes.
|
||||
|
||||
A `delegates_to` edge also decides routing. A seat's stuck-row findings and recovery go to its most
|
||||
recently started parent. A person's top-level Slack message in the rig's mapped channel goes to the rig's
|
||||
representative, its one delegation root, unless that channel names an `inbound` seat; with no single root it
|
||||
|
||||
@@ -109,7 +109,10 @@ To move a team that runs on an older server:
|
||||
shares your default server, which runs the new version only after every session
|
||||
on it, yours included, has ended.
|
||||
3. Stop each rig deliberately with `rig down <rig>`. Its seats' sessions end, and a
|
||||
turn in progress stops. Queue rows, rigs and seats stay as they are.
|
||||
turn in progress stops. Queue rows, rigs and seats stay as they are. A session
|
||||
OpenRig didn't start, such as a seat joined with `rig attach --self`, is left
|
||||
running and `rig down` exits 2; stop it with the commands it prints, or it stays on
|
||||
the old server.
|
||||
4. Restart the daemon with `OPENRIG_TMUX_CONTROL=1` in its environment.
|
||||
`rig terminal status --json` should show `migrationPending: false`. If it's still
|
||||
`true`, `blockingSessions` names the sessions still on the old server.
|
||||
|
||||
@@ -172,7 +172,9 @@ function defaultDoctorDeps(): DoctorDeps {
|
||||
* Null when ssh can't resolve the target or a ProxyJump/ProxyCommand sits in between, since no
|
||||
* direct dial stands for that transport. */
|
||||
export function resolveSshEndpoint(host: SshHostEntry, timeoutMs = 1500): Promise<{ hostname: string; port: number } | null> {
|
||||
const args = [...(host.user ? ["-l", host.user] : []), "-G", host.target];
|
||||
// `--` before the target: a destination that begins with `-` is fed to ssh as the host, not
|
||||
// read as an option. ssh answers an invalid hostname on its own.
|
||||
const args = [...(host.user ? ["-l", host.user] : []), "-G", "--", host.target];
|
||||
return new Promise((resolve) => {
|
||||
execFile("ssh", args, { timeout: timeoutMs }, (error, stdout) => {
|
||||
if (error) return resolve(null);
|
||||
|
||||
@@ -101,7 +101,12 @@ export async function runCrossHostCommand(
|
||||
? `OPENRIG_SESSION_NAME=${shellQuote(opts.originTriple)} ${quotedArgv}`
|
||||
: quotedArgv;
|
||||
const remoteCommandLine = `sh -lc ${shellQuote(innerCommand)}`;
|
||||
const fullArgs = [...sshOpts, sshHost.target, remoteCommandLine];
|
||||
// SEP-1: `--` ends ssh's own option parsing, so a target that begins with `-` reaches ssh as the
|
||||
// destination instead of being read as one more option. That misreading is the harm: `-V`, for
|
||||
// instance, is ssh's own version flag, parsed and exited 0 before any connection, which this
|
||||
// executor would count as a success with no remote command run. With the separator, ssh rejects
|
||||
// the name itself.
|
||||
const fullArgs = [...sshOpts, "--", sshHost.target, remoteCommandLine];
|
||||
|
||||
const spawn = opts.spawn ?? (nodeSpawn as unknown as SpawnFn);
|
||||
const child = spawn("ssh", fullArgs);
|
||||
|
||||
@@ -156,8 +156,10 @@ describe("runCrossHostCommand", () => {
|
||||
expect(capture.command).toBe("ssh");
|
||||
// D13 supersession: the remote command now runs under `sh -lc` (login-shell PATH
|
||||
// resolution) — the quoted argv rides inside one further quoting layer.
|
||||
// SEP-1: `--` ends ssh's own option parsing before the target.
|
||||
expect(capture.args).toEqual([
|
||||
"-o", "ConnectTimeout=10",
|
||||
"--",
|
||||
"vm-test.local",
|
||||
"sh -lc ''\\''rig'\\'' '\\''send'\\'' '\\''dev-impl@rig'\\'' '\\''hello world'\\'' '\\''--verify'\\'''",
|
||||
]);
|
||||
@@ -202,6 +204,29 @@ describe("runCrossHostCommand", () => {
|
||||
expect(capture.args?.[1]).toBe("ops");
|
||||
});
|
||||
|
||||
it("SEP-1 — `--` separates the options from the target, whatever shape the target has", async () => {
|
||||
const capture: { command?: string; args?: readonly string[] } = {};
|
||||
const spawn = mockSpawnFor({ exitCode: 0, capture });
|
||||
await runCrossHostCommand({ id: "vm-test", transport: "ssh", target: "-oProxyCommand=touch /tmp/nothing" }, ["rig", "ps"], { spawn });
|
||||
const args = capture.args ?? [];
|
||||
const separator = args.indexOf("--");
|
||||
expect(separator).toBeGreaterThan(0);
|
||||
// The whole target reaches ssh as one argument after the separator, so an option-shaped
|
||||
// destination is a host ssh rejects rather than an option it obeys.
|
||||
expect(args[separator + 1]).toBe("-oProxyCommand=touch /tmp/nothing");
|
||||
// Everything we pass as an option stays before it.
|
||||
expect(separator).toBeGreaterThan(args.indexOf("-o"));
|
||||
expect(separator).toBeLessThan(args.length - 1);
|
||||
});
|
||||
|
||||
it("SEP-1 — the separator sits after -l <user> too", async () => {
|
||||
const capture: { command?: string; args?: readonly string[] } = {};
|
||||
const spawn = mockSpawnFor({ exitCode: 0, capture });
|
||||
await runCrossHostCommand(HOST_WITH_USER, ["rig", "ps"], { spawn });
|
||||
const args = capture.args ?? [];
|
||||
expect(args.slice(0, args.indexOf("--"))).toEqual(["-l", "ops", "-o", "ConnectTimeout=10"]);
|
||||
});
|
||||
|
||||
it("happy path returns stdout/stderr verbatim", async () => {
|
||||
const spawn = mockSpawnFor({
|
||||
exitCode: 0,
|
||||
|
||||
@@ -76,7 +76,7 @@ describe("SystemPreflight", () => {
|
||||
it("Node version below 22 → ready: false with guidance", async () => {
|
||||
Object.defineProperty(process, "version", { value: "v18.19.0", writable: true });
|
||||
const pf = createPreflight();
|
||||
const result = await pf.run();
|
||||
const result = await pf.run({ port: 0 });
|
||||
expect(result.ready).toBe(false);
|
||||
const nodeCheck = result.checks.find((c) => c.name === "node_version");
|
||||
expect(nodeCheck!.ok).toBe(false);
|
||||
@@ -104,7 +104,7 @@ describe("SystemPreflight", () => {
|
||||
// Test 3
|
||||
it("tmux missing → ready: false with guidance", async () => {
|
||||
const pf = createPreflight({ tmuxMode: "missing" });
|
||||
const result = await pf.run();
|
||||
const result = await pf.run({ port: 0 });
|
||||
expect(result.ready).toBe(false);
|
||||
const tmuxCheck = result.checks.find((c) => c.name === "tmux");
|
||||
expect(tmuxCheck!.ok).toBe(false);
|
||||
@@ -114,7 +114,7 @@ describe("SystemPreflight", () => {
|
||||
|
||||
it("tmux installed but default control socket unhealthy → ready: false with guidance", async () => {
|
||||
const pf = createPreflight({ tmuxMode: "unhealthy" });
|
||||
const result = await pf.run();
|
||||
const result = await pf.run({ port: 0 });
|
||||
|
||||
expect(result.ready).toBe(false);
|
||||
const tmuxCheck = result.checks.find((c) => c.name === "tmux");
|
||||
@@ -135,7 +135,7 @@ describe("SystemPreflight", () => {
|
||||
getDaemonStatus: makeStatus(),
|
||||
riggedHome: join(readonlyDir, "nested"),
|
||||
});
|
||||
const result = await pf.run();
|
||||
const result = await pf.run({ port: 0 });
|
||||
expect(result.ready).toBe(false);
|
||||
const homeCheck = result.checks.find((c) => c.name === "writable_home");
|
||||
expect(homeCheck!.ok).toBe(false);
|
||||
@@ -187,9 +187,18 @@ describe("SystemPreflight", () => {
|
||||
const pf = createPreflight({
|
||||
daemonStatus: { state: "running", port: 7433, host: "127.0.0.1", healthy: true },
|
||||
});
|
||||
const result = await pf.run({ port: 7433, host: "127.0.0.1" });
|
||||
const portCheck = result.checks.find((c) => c.name === "port_available");
|
||||
expect(portCheck!.ok).toBe(true);
|
||||
// A regressed skip would fail at construction before it could reach any socket.
|
||||
const constructor = vi.spyOn(net, "Socket").mockImplementation(() => {
|
||||
throw new Error("same-endpoint preflight must not construct a probe socket");
|
||||
});
|
||||
try {
|
||||
const result = await pf.run({ port: 7433, host: "127.0.0.1" });
|
||||
const portCheck = result.checks.find((c) => c.name === "port_available");
|
||||
expect(portCheck).toMatchObject({ ok: true });
|
||||
expect(constructor).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
constructor.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
// Test 7
|
||||
@@ -198,18 +207,33 @@ describe("SystemPreflight", () => {
|
||||
const pf = createPreflight({
|
||||
daemonStatus: { state: "running", port: 7433, host: "127.0.0.1", healthy: true },
|
||||
});
|
||||
// Use a port that's actually available, but the skip rule should not apply
|
||||
const result = await pf.run({ port: 7433, host: "10.0.0.5" });
|
||||
const portCheck = result.checks.find((c) => c.name === "port_available");
|
||||
// Should have attempted the check (not skipped)
|
||||
expect(portCheck).toBeDefined();
|
||||
// A deterministic refusal proves the requested endpoint without dialing it.
|
||||
const socket = Object.assign(new EventEmitter(), {
|
||||
setTimeout: vi.fn(),
|
||||
destroy: vi.fn(),
|
||||
connect: vi.fn((_port: number, _host: string) => {
|
||||
queueMicrotask(() => socket.emit("error", Object.assign(new Error("connection refused"), { code: "ECONNREFUSED" })));
|
||||
}),
|
||||
});
|
||||
const constructor = vi.spyOn(net, "Socket").mockImplementation(() => socket as unknown as net.Socket);
|
||||
try {
|
||||
const result = await pf.run({ port: 7433, host: "10.0.0.5" });
|
||||
const portCheck = result.checks.find((c) => c.name === "port_available");
|
||||
expect(portCheck).toMatchObject({ ok: true });
|
||||
expect(constructor).toHaveBeenCalledTimes(1);
|
||||
expect(socket.connect).toHaveBeenCalledTimes(1);
|
||||
expect(socket.connect).toHaveBeenCalledWith(7433, "10.0.0.5");
|
||||
expect(socket.destroy).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
constructor.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
// Test 8
|
||||
it("multiple failures reported together (additive)", async () => {
|
||||
Object.defineProperty(process, "version", { value: "v18.19.0", writable: true });
|
||||
const pf = createPreflight({ tmuxMode: "missing" });
|
||||
const result = await pf.run();
|
||||
const result = await pf.run({ port: 0 });
|
||||
expect(result.ready).toBe(false);
|
||||
const failedChecks = result.checks.filter((c) => !c.ok);
|
||||
expect(failedChecks.length).toBeGreaterThanOrEqual(2);
|
||||
@@ -343,9 +367,12 @@ describe("Preflight CLI", () => {
|
||||
it("rig preflight with failure prints three-part error and exits 1", async () => {
|
||||
const homeDir = join(tmpDir, ".openrig");
|
||||
mkdirSync(homeDir, { recursive: true });
|
||||
const configPath = join(tmpDir, "config.json");
|
||||
const { writeFileSync } = await import("node:fs");
|
||||
writeFileSync(configPath, JSON.stringify({ daemon: { port: 0 } }));
|
||||
const cmd = preflightCommand({
|
||||
exec: makeExec("missing"),
|
||||
configPath: join(tmpDir, "config.json"),
|
||||
configPath,
|
||||
riggedHome: homeDir,
|
||||
getDaemonStatus: makeStatus(),
|
||||
});
|
||||
|
||||
@@ -11,7 +11,7 @@ not know exists.** So the point of what follows is not skill. It is recognition:
|
||||
lands, something rhymes, and you go check instead of building.
|
||||
|
||||
Ninety-two top-level verbs ship (capability canon refreshed through
|
||||
`capability-delta-v0.7.11`). This marker describes the pack's teaching, not publication or live
|
||||
`capability-delta-v0.7.12`). This marker describes the pack's teaching, not publication or live
|
||||
adoption. Read this once for shape, and let it make you suspicious that a thing already exists.
|
||||
What changed in the installed version, and what to do about it, is one read away:
|
||||
`rig context get reference/whats-new.md`.
|
||||
@@ -62,6 +62,8 @@ one command away. Your memory of the fleet is a claim about the past.
|
||||
arguments) and Claude's bounded suggested rules, which can contain paths, patterns or sensitive
|
||||
values. It's evidence, not an applied permission: it applies and approves nothing, and it's held
|
||||
in memory for five minutes and lost on a daemon restart (`docs/reference/seat-delivery.md`).
|
||||
It also shows when guidance was written relative to the session's recorded moments, and what
|
||||
the seat's refocus hook recorded; every seat's managed guidance points to its skills, help and project context.
|
||||
- **`rig view list` / `rig view show <lens>`** — named lenses over coordination state: use
|
||||
`view show escalations` for owner attention, `view show pickup` for claimed-row state, and
|
||||
`view show execution` for done/now/next and current authored planning guidance with its
|
||||
@@ -104,6 +106,10 @@ one command away. Your memory of the fleet is a claim about the past.
|
||||
before querying the database yourself. If `retention.events_days` is set (default `0` keeps
|
||||
everything), the history-only event types (views, snapshots, watchdog firings, queue events) older
|
||||
than that are pruned, and an older window reports them as a `retained_sequence_gap`.
|
||||
Closed work's content and metadata can also be erased on a schedule
|
||||
(`retention.closed_content_days`, `retention.closed_metadata_days`; `0`, the default, keeps them),
|
||||
with an optional `retention.erasure_ledger`, for a retention policy or an account deletion. It's
|
||||
opt-in, and a retried create can't bring erased work back.
|
||||
- **Check the exit code.** Many commands used to print nothing and exit 0 when a daemon read
|
||||
failed; now they exit 1 or 2. Empty output from a failed read is not empty state.
|
||||
|
||||
@@ -142,6 +148,9 @@ unseen — which makes messages the one delivery channel that never gets skipped
|
||||
A seat's typing guard and `--wait-for-idle` retain their checks. Managed compaction follows the
|
||||
ordinary draft default and explicit operator protections; it separately reconciles its own
|
||||
uncertain `/compact` write before sending restore input. Draft detection isn't complete.
|
||||
**Each send records one `transport.delivery_evidence` event**: what was admitted, what reached
|
||||
the input, what the screen check showed, and for which occupant. Read it as what was known about
|
||||
the delivery, not proof the agent read it; where the input landed can read unknown.
|
||||
- **`rig send --raw`** — send exact text or keystrokes without the From/To envelope;
|
||||
the interactive-prompt guard still applies. To deliberately drive an interactive prompt,
|
||||
**`--dangerously-interact --reason "<why>"`** is the explicit override and implies raw text.
|
||||
@@ -307,6 +316,8 @@ unseen — which makes messages the one delivery channel that never gets skipped
|
||||
**Open terminals ▸** opens the inspected plan through the same desktop launcher, herdr or plain
|
||||
tmux, so the TUI must run on the daemon's desktop; when no window was attempted it shows the
|
||||
preview and attach commands. Help or a side trip returns to the same preview.
|
||||
`--provider herdr-app` opens the view inside the herdr-app desktop window as an equal grid with a
|
||||
title per cell; it's a one-shot view (following isn't claimed), so check the cells yourself.
|
||||
- **`rig walk <seat> --through <files> --pace <n>`** — deliver context pieces through
|
||||
file-backed terminal paste. When the current native generation record resolves, the complete
|
||||
piece and its matching Claude/Codex turn closure must arrive before pacing onward, including
|
||||
@@ -374,6 +385,9 @@ your context, your compaction and your replacement — and it is the only thing
|
||||
another host, as `create` and `handoff` do; `claim` doesn't, since only the destination seat
|
||||
claims, on its own host. `rig queue artifact <qitemId>` (and MCP `rig_artifact_read`) returns the
|
||||
exact commit pair from an `openrig.code-result/v1` manifest set as the row's `evidenceRef`.
|
||||
It names a handed-off row's successor. `rig queue create` advises when a row's scope doesn't
|
||||
resolve, and a custom blocker ID explains the wait in the waiting views (its progress gives no
|
||||
event wake; the timer remains).
|
||||
- **`rig queue claim` / `unclaim`** — is this mine and running, or still pending where two agents
|
||||
might double-work it. Put it down honestly when it is not yours.
|
||||
- **`rig queue update`** — record what happened in a form the rest of the system can act on. A
|
||||
@@ -655,6 +669,8 @@ not have to tear one down to change it, and you rarely have to start from nothin
|
||||
so capture the pane first; for an unattended seat use the Gateway preset, within what it declares.
|
||||
A Hermes gateway runs per Hermes home, so one per seat with the example launcher; its preset
|
||||
doesn't declare `resume`.
|
||||
A harness can declare `protocol: pi-rpc`, a preset can carry a launch wrapper, and a seat can
|
||||
switch between Pi and another runtime.
|
||||
- **`rig down`** — stop a rig's seats and take it out of the running set. It ends the agents'
|
||||
sessions and any work in progress; don't run it on a live team unless the person asked. In
|
||||
human mode it first prints one line with each agent's last known activity, or “unknown”: a
|
||||
@@ -666,6 +682,11 @@ not have to tear one down to change it, and you rarely have to start from nothin
|
||||
edited skills-line block, edited team skill files, and settings or a collector that don't match
|
||||
what OpenRig wrote or that another seat shares by a link. Any other guidance block goes even if
|
||||
someone edited it.
|
||||
**Since 0.7.12 it stops only a tmux session that carries the seat's own `OPENRIG_NODE_ID`**, on the
|
||||
server and session it read. A terminal OpenRig didn't create, a mismarked or replaced one, or a seat
|
||||
joined with `rig attach --self` is left running, and `rig down` exits 2 naming it and the manual way
|
||||
to stop it. Teardown is incomplete for those terminals: if one is yours, look at it and stop it
|
||||
yourself, then run `rig down` again.
|
||||
- **`rig launch <rig> [seat]`** — one seat is down; start just that one, without disturbing the
|
||||
rest. It takes the rig's name or ID; a name shared by more than one active rig stops and lists their IDs. Add `--plan` to preview it first, locally or with `--host`; a plan is never sent to a
|
||||
daemon older than 0.5.9, which would ignore it and launch. A seat whose agent exited to a shell in
|
||||
@@ -767,6 +788,8 @@ version, from checking the environment to a useful report to the OpenRig team.
|
||||
database another live daemon already holds (the default, `warn`, only warns), and
|
||||
`rig daemon start --allow-db-conflict` allows one deliberate start. A start still in progress reads
|
||||
`Daemon starting: launch reserved by pid N since T; not listening yet` in `rig daemon status`, not stopped.
|
||||
A start reservation left from an earlier boot is archived by the next start; don't clear it by
|
||||
hand.
|
||||
- **A busy daemon isn't a down one.** The CLI refuses only when the daemon is confirmed down
|
||||
(stopped, stale, a refused probe), its probe is blocked, or it's a sibling home; a busy daemon
|
||||
gets the real request. A write that then times out says its outcome is **unknown**: check its
|
||||
@@ -1239,6 +1262,14 @@ reaching them is ordinary work rather than an escalation.
|
||||
person can settle an ask that was asked of someone else, recorded as themselves beside the person
|
||||
asked. The first committed answer wins; a later one gets 409 `qitem_already_terminal` and records
|
||||
nothing. A park addressed to a registered person can be resolved.
|
||||
- **A person's answer goes to an agent.** Answers nothing else reads (a host pairing request, a
|
||||
mode's scheduled end) use the configured receiving agent or the kernel operator. Pairing and
|
||||
runner permission answers go to the receiving agent with the question for it to decide;
|
||||
qualified words ("only if it's the test file") need that judgment. Answers wait when no receiver
|
||||
is available: don't describe them as read or decided. A pairing request names its receiver, and
|
||||
a person can decide directly in the TUI's pairing settings. A relay connector that can't accept
|
||||
incoming connections can pull instead (`OPENRIG_RELAY_MODE=pull` in the relay secrets file); push
|
||||
stays the default, and a person's connector binding can be unset.
|
||||
- **`rig gateway channel add <id> --display-name <name> --binding
|
||||
slack:<connectorRef>:<secretsRef>:<channelId>`** — a Slack shared channel as one participant,
|
||||
`<id>@external` (Slack only). Anyone writing there reaches the team, registered or not. Each row
|
||||
@@ -1435,6 +1466,8 @@ your circumstances is configuration, and the ones that are not, another agent ca
|
||||
The choice is saved as `provider.selected`, and the kernel starts seats only for those providers
|
||||
(empty, the default, means every signed-in one). `--router-line yes|no` decides whether OpenRig
|
||||
adds its one line to shared guidance files (`skills.router_line.enabled`).
|
||||
Setup offers to register the person when none is registered, and a team started from the
|
||||
person's own assistant fits their own logins, with a review before anything is committed.
|
||||
- **`rig usage series`** — what a seat's token curve has looked like over time: climbing steadily,
|
||||
reset, or stopped reporting entirely. The last one is a signal, not a gap.
|
||||
- **`rig usage top`** — which of this machine's seats used the most tokens over a window (`--window
|
||||
|
||||
@@ -74,8 +74,8 @@ export function daemonTmuxControlStatus(): { requested: boolean } & TmuxControlS
|
||||
return { requested: true, ...(daemonControl.status?.() ?? { state: "off" }) };
|
||||
}
|
||||
|
||||
function shellWord(value: string): string {
|
||||
return /^[A-Za-z0-9_@%+=:,./-]+$/.test(value) ? value : "'" + value.replace(/'/g, "'\"'\"'") + "'";
|
||||
function shellWord(value: string, force = false): string {
|
||||
return !force && /^[A-Za-z0-9_@%+=:,./-]+$/.test(value) ? value : "'" + value.replace(/'/g, "'\"'\"'") + "'";
|
||||
}
|
||||
|
||||
/** `tmux`, with the daemon's server flags when it isn't on the default server. */
|
||||
@@ -84,7 +84,39 @@ export function tmuxOnDaemonServer(): string {
|
||||
return `tmux${server ? ` ${server}` : ""}`;
|
||||
}
|
||||
|
||||
/** `tmux attach -t <session>`, with the daemon's server flags when it isn't on the default server. */
|
||||
/** A session name as tmux must receive it.
|
||||
*
|
||||
* Callers hand this a raw session name, not an argument tmux has already escaped, so the name is
|
||||
* encoded exactly once here, for the tmux rules that apply at this layer.
|
||||
*
|
||||
* `cmd_parse_from_arguments` drops a final `;` as a command separator and consumes a backslash
|
||||
* sitting immediately in front of it. One more backslash goes before that separator, whether or
|
||||
* not one is already there: a session named `report;` goes as `report\;` and one named `report\;`
|
||||
* goes as `report\\;`, so the two stay different all the way to tmux.
|
||||
*
|
||||
* `cmd_find_parse_target` reads several spellings as target markers rather than as part of a name:
|
||||
* a leading `=` is the exact-match prefix, a leading `@` a window target, a leading `%` a pane
|
||||
* target, and the words `~` and `{marked}` mean the marked target. One more `=` in front turns
|
||||
* each of those back into a session name, because the marker is consumed and the rest is looked up
|
||||
* as the name. A name starting with `$` is the one that cannot be rescued this way:
|
||||
* `cmd_find_get_session` reads it as a session id before the exact-match flag is consulted. */
|
||||
function tmuxSessionWord(session: string): string {
|
||||
const markedTarget = session === "~" || session === "{marked}";
|
||||
let word = /^[=@%]/.test(session) || markedTarget ? `=${session}` : session;
|
||||
if (word.endsWith(";")) word = `${word.slice(0, -1)}\\;`;
|
||||
return word;
|
||||
}
|
||||
|
||||
/** `tmux attach -t <session>`, with the daemon's server flags when it isn't on the default server.
|
||||
*
|
||||
* The session is encoded for tmux first and then quoted like any other shell word: callers type
|
||||
* this line into a fresh terminal (the CMUX panes) or hand it to a person to run, and a session
|
||||
* name is not guaranteed to be shell-safe — an adopted session's name comes from outside
|
||||
* OpenRig. `shellWord` leaves the ordinary `name@rig` form byte-identical. `=` is the one
|
||||
* character it cannot leave bare: zsh expands a word beginning with `=` to the path of that
|
||||
* command, and with MAGIC_EQUAL_SUBST it can expand one inside a `name=value` word, so any
|
||||
* session word carrying `=` is forced into quotes. A shell passes those quotes through. */
|
||||
export function tmuxAttachCommand(session: string): string {
|
||||
return `${tmuxOnDaemonServer()} attach -t ${session}`;
|
||||
const word = tmuxSessionWord(session);
|
||||
return `${tmuxOnDaemonServer()} attach -t ${shellWord(word, word.includes("="))}`;
|
||||
}
|
||||
@@ -58,6 +58,12 @@ const NO_WAKE_ROUTE_RULES = [
|
||||
"the operator agent (agents.operator_session)",
|
||||
] as const;
|
||||
|
||||
/** How a failed wake's finding at a seat with no declared route names the rule that chose its recipient, in the order tried. */
|
||||
const FAILED_WAKE_ROUTE_RULES = [
|
||||
"the row's creator",
|
||||
"the operator agent (agents.operator_session)",
|
||||
] as const;
|
||||
|
||||
// S01 ladder marker vocabulary (the seam contract). S01 writes these transition-note
|
||||
// prefixes; the sweep derives "live ladder" from the latest marker. An attempt/rung is
|
||||
// LIVE, an exhausted marker hands the row back, and a later attempt starts a live cycle
|
||||
@@ -320,6 +326,12 @@ function defaultIsRegisteredHost(log: (line: string) => void): (hostId: string)
|
||||
* Use the closing transition, including archived history; later same-state notes
|
||||
* and delivery receipts do not transfer responsibility. A newer cancellation
|
||||
* without closure metadata still supersedes an older closing actor. */
|
||||
/** OpenRig's own machinery (the daemon, or a `@system` actor such as this sweep): it creates and closes rows, but it isn't
|
||||
* a seat that can act on a finding. */
|
||||
function isMachinerySession(session: string): boolean {
|
||||
return session === "daemon@kernel" || session.endsWith("@system");
|
||||
}
|
||||
|
||||
function custodyFindingRoute(db: Database.Database, queueRepo: QueueRepository, row: QueueItem): string {
|
||||
// Unknown identity cannot establish a new owner. The caller retains this row's
|
||||
// established destination if the registry needed for classification is unavailable.
|
||||
@@ -338,7 +350,7 @@ function custodyFindingRoute(db: Database.Database, queueRepo: QueueRepository,
|
||||
ORDER BY transition_id DESC LIMIT 1`,
|
||||
).get(row.qitemId, row.qitemId) as { actor_session: string } | undefined;
|
||||
for (const session of [closure?.actor_session, row.sourceSession]) {
|
||||
if (!session || session === "daemon@kernel" || session.endsWith("@system")) continue;
|
||||
if (!session || isMachinerySession(session)) continue;
|
||||
if (!queueRepo.isHumanDestination(session) && queueRepo.acceptsDestination(session)) {
|
||||
return session;
|
||||
}
|
||||
@@ -684,7 +696,10 @@ export async function runStuckSweep(deps: StuckSweepDeps): Promise<StuckSweepRes
|
||||
// Every yield and every awaited write lets other code run: indexes read before it are dropped, and `othersRan`
|
||||
// tells a list read before it that its rows need a fresh read.
|
||||
let othersRan = 0;
|
||||
const othersMayHaveRun = (): void => { othersRan++; recoveries.invalidate(); successors?.invalidate(); };
|
||||
/** Each seat's agent-seat reading (agentSeatNode, below), dropped with the indexes: another writer can declare a
|
||||
* seat's delegation parent while the pass yields or awaits a write. */
|
||||
const agentSeats = new Map<string, { logicalId: string; rig: string } | null>();
|
||||
const othersMayHaveRun = (): void => { othersRan++; recoveries.invalidate(); successors?.invalidate(); agentSeats.clear(); };
|
||||
// A successor index whose build took at least half of a stretch that yielded by elapsed time is not rebuilt for
|
||||
// the rest of the pass: the yield drops it, the next stretch would pay the build again and end the same way after a
|
||||
// few rows, so the rows would pay about one build each instead of one per STUCK_SWEEP_YIELD_EVERY_ROWS. Those rows
|
||||
@@ -1274,8 +1289,6 @@ export async function runStuckSweep(deps: StuckSweepDeps): Promise<StuckSweepRes
|
||||
c.why += `. This finding goes to ${recipient.seat}, ${NO_WAKE_ROUTE_RULES[recipient.chosen]}`;
|
||||
c.route = recipient.seat;
|
||||
};
|
||||
/** The two re-routes, in order, for one candidate. */
|
||||
const routeCandidate = (c: Candidate): void => { routeAroundMissing(c); routeAroundNoWake(c); };
|
||||
for (const c of candidates) {
|
||||
if (dueToYield()) await yieldLoop();
|
||||
routeAroundMissing(c);
|
||||
@@ -1299,10 +1312,9 @@ export async function runStuckSweep(deps: StuckSweepDeps): Promise<StuckSweepRes
|
||||
// (below). A finding per row stays for a person's address or a terminal seat, for a declared route that failed (its
|
||||
// finding names why, and a declared delegation parent with no session is one), and for a row that already has a
|
||||
// recovery.
|
||||
const agentSeats = new Map<string, { logicalId: string; rig: string } | null>();
|
||||
/** An agent seat's node: one whose runtime isn't a terminal, with no declared delegation parent, at an address that
|
||||
* isn't a person's. An address the human registry can't classify counts as a person's. null for anything else. Read
|
||||
* once per seat per pass. */
|
||||
* once per seat until other code may have run (agentSeats). */
|
||||
const agentSeatNode = (session: string): { logicalId: string; rig: string } | null => {
|
||||
if (agentSeats.has(session)) return agentSeats.get(session)!;
|
||||
const nodeId = resolveSessionNodeId(deps.db, session);
|
||||
@@ -1319,6 +1331,30 @@ export async function runStuckSweep(deps: StuckSweepDeps): Promise<StuckSweepRes
|
||||
agentSeats.set(session, agent);
|
||||
return agent;
|
||||
};
|
||||
// A wake that failed at a live agent seat with no delegation parent or escalates_to made its finding that same seat's,
|
||||
// so the finding's own wake went to the seat that had just failed to take one, onto whatever was in its input box.
|
||||
// The row's creator can act on it (resend, reroute or ask), as the waiting and parked-owner findings already route;
|
||||
// for a seat that sent itself the work, a row OpenRig's own machinery created, or a creator that is a bound terminal
|
||||
// seat (nothing there can claim it, as for the no-wake rule), the operator agent can. A declared
|
||||
// route that failed keeps its fallback, and a finding the two re-routes above moved keeps its recipient. A wake that
|
||||
// found no route never reached the seat, so a seat launched since keeps its own finding, whose wake is the one it missed.
|
||||
const routeAroundFailedWake = (c: Candidate): void => {
|
||||
if (c.kind !== "undelivered-wake" || !startsWithCI(c.row.lastNudgeResult, "failed:") || c.noWake
|
||||
|| c.route !== c.row.destinationSession
|
||||
|| routeNotes.has(c.row.qitemId) || agentSeatNode(c.route) === null) return;
|
||||
const choices = [c.row.sourceSession, (deps.resolveRecoveryAgentSeat ?? resolveRecoveryAgentSeat)()];
|
||||
const chosen = choices.findIndex((seat) => !!seat && seat !== c.route && !isMachinerySession(seat)
|
||||
&& !sessionMissing(seat) && deps.queueRepo.acceptsDestination(seat) && !isBoundTerminalSeat(deps.db, seat));
|
||||
if (chosen < 0) {
|
||||
c.why += `; ${c.route} declares no delegation parent or escalates_to, and its creator and the operator agent (agents.operator_session) are each unset, missing, this seat, OpenRig's own machinery, a terminal seat or not an address this queue takes, so this finding stays addressed to it`;
|
||||
return;
|
||||
}
|
||||
c.why += `; ${c.route} declares no delegation parent or escalates_to, so this finding goes to ${choices[chosen]}, ${FAILED_WAKE_ROUTE_RULES[chosen]}`;
|
||||
c.route = choices[chosen]!;
|
||||
};
|
||||
/** The three re-routes, in order, for one candidate. The third reads no earlier pass: a failed wake's finding is
|
||||
* routed only here, on the row as it is right before the finding is created. */
|
||||
const routeCandidate = (c: Candidate): void => { routeAroundMissing(c); routeAroundNoWake(c); routeAroundFailedWake(c); };
|
||||
const selfRoutedUnclaimed = (c: Candidate): boolean => c.kind === "unclaimed-obligation"
|
||||
&& c.route === c.row.destinationSession && !routeNotes.has(c.row.qitemId) && agentSeatNode(c.route) !== null;
|
||||
// A hosted member that polls for its work (no wake trigger) can't hear a finding, so each unclaimed row went to a
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { resolveEscalationOwner, ESCALATION_HOP_TAG, hasConsumedEscalationHop } from "./escalation-owner.js";
|
||||
import { tmuxAttachCommand } from "../adapters/tmux-server.js";
|
||||
import { shellQuote } from "../adapters/shell-quote.js";
|
||||
import type { HookReviewObservation } from "./kernel-hook-review.js";
|
||||
import type { ArbitratedSeatState } from "./activity-taxonomy.js";
|
||||
import { seatRuntimeHomeBySession } from "./seat-runtime-home.js";
|
||||
@@ -1447,7 +1446,7 @@ async function advancePromptRefusals(
|
||||
const route = !deps.resolveOrchestrator
|
||||
? resolveEscalationOwner(deps.db, dest, target => deps.queueRepo.acceptsDestination(target)) : null;
|
||||
const reason = (group.hookReview
|
||||
? `Seat ${dest} is stopped at Codex's "Hooks need review" (hook_trust_gate). Reach that pane on this host with ${tmuxAttachCommand(shellQuote(dest))}. Review the hooks there through Codex's hook review; approve them there only if you choose to. OpenRig does not approve or bypass this review. No input was sent.`
|
||||
? `Seat ${dest} is stopped at Codex's "Hooks need review" (hook_trust_gate). Reach that pane on this host with ${tmuxAttachCommand(dest)}. Review the hooks there through Codex's hook review; approve them there only if you choose to. OpenRig does not approve or bypass this review. No input was sent.`
|
||||
: "interactive prompt blocks outstanding work; no input was sent")
|
||||
+ (route && route.outcome !== "none" && route.outcome !== "parent" ? `; ${route.reason}` : "");
|
||||
// Every refusal at a choice prompt: the alert follows queue.wake_human_rung and holds for other choice alerts. Any other
|
||||
|
||||
@@ -72,32 +72,25 @@ import { resolveSeatKind, seatProcessOwner } from "./seat-kinds.js";
|
||||
const SEAT_LOOKUP_GUIDANCE = "List seats with: rig ps --nodes";
|
||||
|
||||
/**
|
||||
* The supported routes for a session registered as adopted (origin=claimed). `rig unclaim` releases
|
||||
* The supported route for a session registered as adopted (origin=claimed): `rig unclaim` releases
|
||||
* it and leaves its process running, so ending that process in its own terminal ends only this seat.
|
||||
* `rig down` ends it with the rest of its rig, so it is the route only when the rig has one seat;
|
||||
* otherwise it is named for what it ends. Only the guidance names them; the refusal stands.
|
||||
* `seats` is the rig's node count when known. A seat handed over before 0.7.9 gets
|
||||
* earlierHandoverRefusal instead. A seat joined with `rig attach` as an external CLI has no tmux
|
||||
* session that `rig down` stops.
|
||||
* `rig down` stops only a session carrying the seat's OpenRig marker, which OpenRig sets on the sessions
|
||||
* it starts and an adopted, bound or self-attached session lacks, so it isn't a route here and the
|
||||
* guidance says why. Only the guidance names the route; the refusal stands. A seat handed over before
|
||||
* 0.7.9 gets earlierHandoverRefusal instead. A seat joined with `rig attach` as an external CLI has no
|
||||
* tmux session that `rig down` stops.
|
||||
*/
|
||||
export function claimedSessionRoutes(rigName: string, sessionName: string, seats: number | null, externalCli = false): string {
|
||||
export function claimedSessionRoutes(rigName: string, sessionName: string, externalCli = false): string {
|
||||
const session = shellQuote(sessionName);
|
||||
const release = `To release it from OpenRig and leave its process running, run rig unclaim ${session}.`;
|
||||
if (externalCli) return `This seat was joined with rig attach as an external CLI, so rig down leaves it running. ${release} End the process in its own terminal.`;
|
||||
if (seats === 1) return `To end the session, run ${rigDownRoute(rigName)}. ${release}`;
|
||||
return `To end only this session, run rig unclaim ${session} (it releases the session from OpenRig and leaves its process running), `
|
||||
+ `then end that process in its own terminal. ${rigDownScope(rigName, seats)}`;
|
||||
return `To end this session, run rig unclaim ${session} (it releases the session from OpenRig and leaves its process running), `
|
||||
+ `then end that process in its own terminal. ${rigDownLeavesIt(rigName)}`;
|
||||
}
|
||||
|
||||
/** `rig down <rig>` as the route for a one-seat rig, and how the rig comes back. */
|
||||
function rigDownRoute(rigName: string): string {
|
||||
const rig = shellQuote(rigName);
|
||||
return `rig down ${rig} (this rig has one seat, so it stops only this session; rig up ${rig} --existing brings the rig back)`;
|
||||
}
|
||||
|
||||
/** What `rig down <rig>` ends in a rig with more than one seat, or an unknown count. */
|
||||
function rigDownScope(rigName: string, seats: number | null): string {
|
||||
return `rig down ${shellQuote(rigName)} ends ${seats !== null && seats > 1 ? `all ${seats} seats` : "every seat"} in this rig.`;
|
||||
/** Why `rig down <rig>` isn't the route for an adopted session. */
|
||||
function rigDownLeavesIt(rigName: string): string {
|
||||
return `rig down ${shellQuote(rigName)} leaves a session OpenRig didn't start running.`;
|
||||
}
|
||||
|
||||
/** Terminal session statuses — rows the clean verb must NOT touch (they already
|
||||
@@ -1427,13 +1420,12 @@ export class SeatLifecycleService {
|
||||
const rows = this.nonTerminalSessions(nodeId);
|
||||
const claimed = rows.find(row => row.origin === "claimed");
|
||||
if (claimed) {
|
||||
const seats = this.rigRepo.getRig(seat.rigId)?.nodes.length ?? null;
|
||||
const externalCli = seatProcessOwner(resolveSeatKind(this.sessionRegistry.getBindingForNode(nodeId)?.attachmentType)) === "external";
|
||||
return {
|
||||
ok: false, code: "claimed_session",
|
||||
message: `Seat "${seatRef}" has an adopted/operator-owned occupant; fresh launch refuses even with --stop.`,
|
||||
guidance: `Release it with rig unclaim ${shellQuote(claimed.session_name)} (its process keeps running), end that process yourself, then launch fresh.`
|
||||
+ (externalCli ? "" : seats === 1 ? ` Or end it with ${rigDownRoute(seat.rigName)}.` : ` ${rigDownScope(seat.rigName, seats)}`),
|
||||
+ (externalCli ? "" : ` ${rigDownLeavesIt(seat.rigName)}`),
|
||||
};
|
||||
}
|
||||
const binding = this.sessionRegistry.getBindingForNode(nodeId);
|
||||
@@ -2292,7 +2284,7 @@ export class SeatLifecycleService {
|
||||
|
||||
/** The claimed-session routes for this seat's rig (see claimedSessionRoutes). */
|
||||
private claimedRoutes(seat: SeatDescriptor, sessionName: string): string {
|
||||
return claimedSessionRoutes(seat.rigName, sessionName, this.rigRepo.getRig(seat.rigId)?.nodes.length ?? null,
|
||||
return claimedSessionRoutes(seat.rigName, sessionName,
|
||||
seatProcessOwner(resolveSeatKind(this.sessionRegistry.getBindingForNode(seat.nodeId)?.attachmentType)) === "external");
|
||||
}
|
||||
|
||||
|
||||
@@ -811,19 +811,22 @@ export class SliceIndexer {
|
||||
// --- internals ---
|
||||
|
||||
private readSliceLocations(): SliceLocation[] {
|
||||
return this.walkSliceLocations().filter((location) => !this.missionId || location.missionId === this.missionId);
|
||||
return this.walkSliceLocations(false, [], undefined, this.missionId || undefined);
|
||||
}
|
||||
|
||||
/** Every slice location under the roots, whatever mission this indexer is scoped to.
|
||||
* Directory names are deduplicated (first wins) unless `physical` asks for every folder. */
|
||||
/** Every slice location under the roots. A requested mission is filtered before
|
||||
* directory-name deduplication; otherwise the first matching name wins unless
|
||||
* `physical` asks for every folder. */
|
||||
private walkSliceLocations(
|
||||
physical = false,
|
||||
additionalRoots: readonly string[] = [],
|
||||
onReadError?: () => void,
|
||||
missionId?: string,
|
||||
): SliceLocation[] {
|
||||
const locations: SliceLocation[] = [];
|
||||
const seen = new Set<string>();
|
||||
const addLocation = (location: SliceLocation) => {
|
||||
if (missionId !== undefined && location.missionId !== missionId) return;
|
||||
if (!physical) {
|
||||
if (seen.has(location.name)) return;
|
||||
seen.add(location.name);
|
||||
@@ -1085,7 +1088,7 @@ export class SliceIndexer {
|
||||
const status = this.mapStatus(frontmatter["status"] as string | undefined);
|
||||
const railItem = this.extractRailItem(frontmatter, missionId);
|
||||
const qitemIds = this.matchQitems(name, served);
|
||||
const proofPacket = this.findProofPacket(name);
|
||||
const proofPacket = this.findProofPacket(name, this.missionId ? missionId : null);
|
||||
const lastActivityAt = this.computeLastActivity(slicePath, qitemIds, proofPacket);
|
||||
|
||||
return {
|
||||
@@ -1115,7 +1118,7 @@ export class SliceIndexer {
|
||||
const status = this.mapStatus(frontmatter["status"] as string | undefined);
|
||||
const railItem = this.extractRailItem(frontmatter, missionId);
|
||||
const qitemIds = this.matchQitems(name, served);
|
||||
const proofPacket = this.findProofPacket(name);
|
||||
const proofPacket = this.findProofPacket(name, this.missionId ? missionId : null);
|
||||
const lastActivityAt = this.computeLastActivity(slicePath, qitemIds, proofPacket);
|
||||
const commitRefs = this.extractCommitRefs(frontmatter);
|
||||
const files = this.listSliceFiles(slicePath);
|
||||
@@ -1502,7 +1505,34 @@ export class SliceIndexer {
|
||||
return this.membershipIndex;
|
||||
}
|
||||
|
||||
private findProofPacket(sliceName: string): SliceProofPacket | null {
|
||||
private discoverMissionIds(): Set<string> {
|
||||
const missionIds = new Set<string>();
|
||||
for (const root of this.sliceRoots()) {
|
||||
let entries: fs.Dirent[];
|
||||
try { entries = fs.readdirSync(root, { withFileTypes: true }); }
|
||||
catch { continue; }
|
||||
for (const entry of entries) {
|
||||
if ((!entry.isDirectory() && !entry.isSymbolicLink()) || entry.name.startsWith(".")) continue;
|
||||
// Project readers treat every non-dot directory or symlink entry as a mission;
|
||||
// ownership needs only its name and never follows a sibling link.
|
||||
missionIds.add(entry.name);
|
||||
}
|
||||
}
|
||||
return missionIds;
|
||||
}
|
||||
|
||||
private proofDirectoryMissionOwner(dirName: string, missionIds: ReadonlySet<string>): string | null {
|
||||
let owner: string | null = null;
|
||||
for (const candidate of missionIds) {
|
||||
if (!dirName.startsWith(candidate)) continue;
|
||||
const separator = dirName[candidate.length];
|
||||
if (separator !== "-" && separator !== "." && separator !== "_") continue;
|
||||
if (!owner || candidate.length > owner.length) owner = candidate;
|
||||
}
|
||||
return owner;
|
||||
}
|
||||
|
||||
private findProofPacket(sliceName: string, missionId: string | null): SliceProofPacket | null {
|
||||
if (!this.dogfoodEvidenceRoot) return null;
|
||||
let entries: fs.Dirent[];
|
||||
try {
|
||||
@@ -1511,19 +1541,26 @@ export class SliceIndexer {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Match strategy: token-based. Tokenize slice name on `-`, drop
|
||||
// trailing `vN` version tokens (real proof dirs typically don't
|
||||
// include the version suffix), then a dir matches if every remaining
|
||||
// token appears as a hyphen-bounded substring of the dir name. This
|
||||
// handles real-world dir-naming where phase indicators land at the
|
||||
// front (e.g. `pl005-phase-a-mission-control-queue-observability-...`)
|
||||
// even though the slice folder uses suffix form
|
||||
// (`mission-control-queue-observability-phase-a`). Latest mtime wins.
|
||||
// Qualified proof directories use the observed `<mission>-<slice>` form:
|
||||
// the literal mission ID must lead the name at a separator boundary. If
|
||||
// known mission IDs overlap, the longest prefix owns the directory. This
|
||||
// is a fixture-supported rule, not a historically documented contract.
|
||||
// Unqualified reads retain their legacy slice-only token match. Once a
|
||||
// qualified directory is attributed, slice tokens match against the
|
||||
// remainder after that mission prefix and separator (`vN` handling unchanged);
|
||||
// latest mtime remains the tie-break among same-mission matches.
|
||||
let knownMissionIds: Set<string> | null = null;
|
||||
if (missionId) {
|
||||
knownMissionIds = this.discoverMissionIds();
|
||||
knownMissionIds.add(missionId);
|
||||
}
|
||||
const sliceTokens = sliceName.split("-").filter((t) => t.length > 0 && !/^v\d+$/.test(t));
|
||||
const matches: { dirent: fs.Dirent; mtime: number }[] = [];
|
||||
for (const dirent of entries) {
|
||||
if (!dirent.isDirectory()) continue;
|
||||
const dirTokenSet = new Set(dirent.name.split(/[-._]/).filter((t) => t.length > 0));
|
||||
if (knownMissionIds && this.proofDirectoryMissionOwner(dirent.name, knownMissionIds) !== missionId) continue;
|
||||
const tokenSource = knownMissionIds && missionId ? dirent.name.slice(missionId.length + 1) : dirent.name;
|
||||
const dirTokenSet = new Set(tokenSource.split(/[-._]/).filter((t) => t.length > 0));
|
||||
const allTokensPresent = sliceTokens.every((t) => dirTokenSet.has(t));
|
||||
if (!allTokensPresent) continue;
|
||||
try {
|
||||
|
||||
@@ -24,7 +24,7 @@ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
||||
import { createHash } from "node:crypto";
|
||||
import type Database from "better-sqlite3";
|
||||
import type { WorkflowSpec, WorkflowExitKind, WorkflowAgentHarness, WorkflowGateSpec } from "./workflow-types.js";
|
||||
import { RETAINED_STATUS, type WorkflowSpecCache } from "./workflow-spec-cache.js";
|
||||
import { getFailedSpecRead, RETAINED_STATUS, type WorkflowSpecCache } from "./workflow-spec-cache.js";
|
||||
import type { EventBus } from "./event-bus.js";
|
||||
|
||||
export interface SpecLibraryWorkflowEntry {
|
||||
@@ -377,12 +377,13 @@ function isUnderDir(childPath: string, parentDir: string): boolean {
|
||||
// disappear since the last scan get removed via removeBySourcePath
|
||||
// (OQ-4 decision; deletion + audit log).
|
||||
//
|
||||
// OQ-3 timestamp check: a file is skipped when both its mtime/ctime are <=
|
||||
// the cache row's cached_at. A file can be restored or copied with an older
|
||||
// mtime, so ctime keeps the stat-only fast path honest. Files in the same
|
||||
// timestamp second still use the content hash, and other newer files are
|
||||
// re-parsed via the cache (which itself hashes the content and returns the
|
||||
// prior row when the hash matches).
|
||||
// OQ-3 timestamp check: a file is skipped when its mtime/ctime precede the
|
||||
// persisted cache observation. This stat-only hit assumes filesystem timestamps
|
||||
// and the daemon clock are comparable and that edits advance mtime or ctime. A
|
||||
// restored/copied file can have an older mtime, so ctime is included. Files in
|
||||
// the same timestamp second still use the content hash; other newer files are
|
||||
// re-parsed via the cache (which itself hashes the content and returns the prior
|
||||
// row when the hash matches).
|
||||
|
||||
export interface ScanWorkflowSpecFolderOpts {
|
||||
/** SQLite handle (used for direct lookups). */
|
||||
@@ -403,6 +404,8 @@ export interface ScanWorkflowSpecFolderOpts {
|
||||
eventBus?: EventBus;
|
||||
/** A user file with the same bytes and identity as an existing built-in row. */
|
||||
onBuiltinAlias?: (name: string, version: string, sourcePath: string, sourceHash: string) => void;
|
||||
/** A valid source whose hash was just read or whose persisted stat observation is safely aged. */
|
||||
onVerifiedSource?: (sourcePath: string, sourceHash: string) => void;
|
||||
}
|
||||
|
||||
export interface ScanWorkflowSpecFolderResult {
|
||||
@@ -468,14 +471,15 @@ export function scanWorkflowSpecFolder(
|
||||
// A path can hold several cached versions plus a diagnostic row (#503); the most
|
||||
// recently written row is the one that reflects the file's last scan.
|
||||
const cachedAt = opts.db
|
||||
.prepare(`SELECT cached_at, source_hash FROM workflow_specs WHERE source_path = ? AND status != '${RETAINED_STATUS}' ORDER BY cached_at DESC, rowid DESC LIMIT 1`)
|
||||
.get(filePath) as { cached_at: string; source_hash: string } | undefined;
|
||||
.prepare(`SELECT cached_at, source_hash, status FROM workflow_specs WHERE source_path = ? AND status != '${RETAINED_STATUS}' ORDER BY cached_at DESC, rowid DESC LIMIT 1`)
|
||||
.get(filePath) as { cached_at: string; source_hash: string; status: string } | undefined;
|
||||
if (cachedAt) {
|
||||
const cachedAtMs = Date.parse(cachedAt.cached_at);
|
||||
const cachedAtSec = Math.floor(cachedAtMs / 1000);
|
||||
const changeSec = Math.floor(changeMs / 1000);
|
||||
if (Number.isFinite(cachedAtMs) && Number.isFinite(changeMs)) {
|
||||
if (cachedAtSec > changeSec) {
|
||||
if (cachedAt.status === "valid") opts.onVerifiedSource?.(filePath, cachedAt.source_hash);
|
||||
result.skipped += 1;
|
||||
continue;
|
||||
}
|
||||
@@ -489,6 +493,7 @@ export function scanWorkflowSpecFolder(
|
||||
unchanged = createHash("sha256").update(readFileSync(filePath, "utf-8")).digest("hex") === cachedAt.source_hash;
|
||||
} catch { /* The existing readThrough path records the diagnostic. */ }
|
||||
if (unchanged) {
|
||||
if (cachedAt.status === "valid") opts.onVerifiedSource?.(filePath, cachedAt.source_hash);
|
||||
result.skipped += 1;
|
||||
continue;
|
||||
}
|
||||
@@ -505,25 +510,29 @@ export function scanWorkflowSpecFolder(
|
||||
&& isUnderDir(cached.sourcePath, opts.builtinDir)) {
|
||||
opts.onBuiltinAlias(cached.name, cached.version, filePath, cached.sourceHash);
|
||||
}
|
||||
if (cached.sourcePath === filePath) opts.onVerifiedSource?.(filePath, cached.sourceHash);
|
||||
result.valid += 1;
|
||||
} catch (err) {
|
||||
// Diagnostic: hash the raw content best-effort so we can detect
|
||||
// edits that fix the error (mtime alone is fine; this is for
|
||||
// bookkeeping). Use the empty string when content is unreadable.
|
||||
// Prefer the digest and timestamp captured by the failed read itself. A second path read here can observe a
|
||||
// repair that raced parse handling and falsely describe those replacement bytes as the bytes that failed.
|
||||
// Errors without captured text (missing/unreadable files) retain the previous best-effort path read.
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
let sourceHash = "";
|
||||
try {
|
||||
sourceHash = createHash("sha256").update(readFileSync(filePath, "utf-8")).digest("hex");
|
||||
} catch {
|
||||
// unreadable / disappeared between stat and read; treat as
|
||||
// empty hash so the next scan re-evaluates
|
||||
sourceHash = "";
|
||||
const failedRead = getFailedSpecRead(err);
|
||||
let sourceHash = failedRead?.sourceHash ?? "";
|
||||
if (!failedRead) {
|
||||
try {
|
||||
sourceHash = createHash("sha256").update(readFileSync(filePath, "utf-8")).digest("hex");
|
||||
} catch {
|
||||
// unreadable / disappeared between stat and read; treat as empty so the next scan re-evaluates
|
||||
sourceHash = "";
|
||||
}
|
||||
}
|
||||
try {
|
||||
opts.cache.writeDiagnostic({
|
||||
sourcePath: filePath,
|
||||
sourceHash,
|
||||
errorMessage: message,
|
||||
...(failedRead ? { cachedAt: failedRead.cachedAt } : {}),
|
||||
});
|
||||
} catch (writeErr) {
|
||||
// One file's diagnostic must not end the scan of the rest of the folder (#511).
|
||||
|
||||
@@ -76,6 +76,20 @@ export class WorkflowSpecError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
interface FailedSpecRead {
|
||||
sourceHash: string;
|
||||
cachedAt: string;
|
||||
}
|
||||
|
||||
// Keep capture evidence out of the original exception and out of process-global path state. The scanner can retrieve it
|
||||
// while handling this exact parse error, then persist it in the diagnostic row; WeakMap lifetime follows the exception.
|
||||
const failedSpecReads = new WeakMap<object, FailedSpecRead>();
|
||||
|
||||
export function getFailedSpecRead(error: unknown): FailedSpecRead | null {
|
||||
if ((typeof error !== "object" || error === null) && typeof error !== "function") return null;
|
||||
return failedSpecReads.get(error as object) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* OPR.0.4.6.WF1 FR-7 — THE closed keysets, EXPORTED as named
|
||||
* constants: parseWorkflowSpec is the ONLY seam that sees raw keys
|
||||
@@ -643,7 +657,10 @@ export class WorkflowSpecCache {
|
||||
* not previously cached).
|
||||
*/
|
||||
readThrough(sourcePath: string): WorkflowSpecRow {
|
||||
const { spec, sourceHash } = readSpecFile(sourcePath);
|
||||
// Stamp before opening the file. If an edit races the read, a later stat must not be hidden by a timestamp
|
||||
// assigned after that edit and after parse-error handling.
|
||||
const capturedAt = this.now().toISOString();
|
||||
const { spec, sourceHash } = readSpecFile(sourcePath, capturedAt);
|
||||
const named = this.db
|
||||
.prepare(
|
||||
`SELECT * FROM workflow_specs WHERE name = ? AND version = ?`,
|
||||
@@ -671,11 +688,18 @@ export class WorkflowSpecCache {
|
||||
.run(sourcePath, existing?.spec_id ?? "").changes
|
||||
: 0;
|
||||
if (existing && existing.status !== "error" && existing.status !== RETAINED_STATUS && !moved && existing.source_hash === sourceHash) {
|
||||
// A repair back to the cached bytes records this scan, so the next scan skips the file.
|
||||
if (cleared > 0) {
|
||||
const repairedAt = this.now().toISOString();
|
||||
this.db.prepare(`UPDATE workflow_specs SET cached_at = ? WHERE spec_id = ?`).run(repairedAt, existing.spec_id);
|
||||
existing.cached_at = repairedAt;
|
||||
// A read verifies the timestamp only for its own source path, even when another copy has matching bytes. Advance only forward:
|
||||
// this pre-read stamp is safe for stat-only skips, while a backwards clock must not replace a newer observation.
|
||||
const existingCachedAtMs = Date.parse(existing.cached_at);
|
||||
const capturedAtMs = Date.parse(capturedAt);
|
||||
const shouldAdvanceObservation = existing.source_path === sourcePath && Number.isFinite(capturedAtMs) && (
|
||||
Number.isFinite(existingCachedAtMs)
|
||||
? existingCachedAtMs < capturedAtMs
|
||||
: cleared > 0
|
||||
);
|
||||
if (shouldAdvanceObservation) {
|
||||
this.db.prepare(`UPDATE workflow_specs SET cached_at = ? WHERE spec_id = ?`).run(capturedAt, existing.spec_id);
|
||||
existing.cached_at = capturedAt;
|
||||
}
|
||||
// readThrough is file-authoritative: return the freshly parsed
|
||||
// file spec so validation sees non-column metadata such as
|
||||
@@ -691,7 +715,7 @@ export class WorkflowSpecCache {
|
||||
}
|
||||
return rowToWorkflowSpec(existing, spec);
|
||||
}
|
||||
const cachedAt = this.now().toISOString();
|
||||
const cachedAt = capturedAt;
|
||||
const purpose = spec.objective ?? null;
|
||||
const targetRig = spec.target?.rig ?? null;
|
||||
const rolesJson = JSON.stringify(spec.roles);
|
||||
@@ -940,8 +964,10 @@ export class WorkflowSpecCache {
|
||||
sourcePath: string;
|
||||
sourceHash: string;
|
||||
errorMessage: string;
|
||||
/** Capture time from the read that failed, when available. */
|
||||
cachedAt?: string;
|
||||
}): void {
|
||||
const cachedAt = this.now().toISOString();
|
||||
const cachedAt = opts.cachedAt ?? this.now().toISOString();
|
||||
const existing = this.db
|
||||
.prepare(`SELECT spec_id FROM workflow_specs WHERE source_path = ? AND status = 'error'`)
|
||||
.get(opts.sourcePath) as { spec_id: string } | undefined;
|
||||
@@ -1116,7 +1142,7 @@ function warnLegacyRehydrationOnce(name: string, version: string): void {
|
||||
);
|
||||
}
|
||||
|
||||
function readSpecFile(sourcePath: string): { spec: WorkflowSpec; sourceHash: string } {
|
||||
function readSpecFile(sourcePath: string, cachedAt?: string): { spec: WorkflowSpec; sourceHash: string } {
|
||||
if (!existsSync(sourcePath)) {
|
||||
throw new WorkflowSpecError(
|
||||
"spec_file_missing",
|
||||
@@ -1126,7 +1152,14 @@ function readSpecFile(sourcePath: string): { spec: WorkflowSpec; sourceHash: str
|
||||
}
|
||||
const raw = readFileSync(sourcePath, "utf-8");
|
||||
const sourceHash = createHash("sha256").update(raw).digest("hex");
|
||||
return { spec: parseWorkflowSpec(raw, sourcePath), sourceHash };
|
||||
try {
|
||||
return { spec: parseWorkflowSpec(raw, sourcePath), sourceHash };
|
||||
} catch (error) {
|
||||
if (cachedAt && ((typeof error === "object" && error !== null) || typeof error === "function")) {
|
||||
failedSpecReads.set(error as object, { sourceHash, cachedAt });
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function rowToWorkflowSpec(row: SpecRow, parsedSpec?: WorkflowSpec): WorkflowSpecRow {
|
||||
|
||||
@@ -335,7 +335,9 @@ export function hostsRoutes(opts?: { bearerToken?: string | null; humanRegistry?
|
||||
// Where ssh would connect for this host, from `ssh -G` (reads ssh config, no connection); null when
|
||||
// ssh can't resolve it or a ProxyJump/ProxyCommand sits in between. Twin of the CLI's resolveSshEndpoint.
|
||||
function resolveSshEndpoint(host: SshHostEntry, timeoutMs: number): Promise<{ hostname: string; port: number } | null> {
|
||||
const args = [...(host.user ? ["-l", host.user] : []), "-G", host.target];
|
||||
// `--` before the target, the same separator the CLI twin and the executor pass: a
|
||||
// destination that begins with `-` is read as the host, not as an option.
|
||||
const args = [...(host.user ? ["-l", host.user] : []), "-G", "--", host.target];
|
||||
return new Promise((resolve) => {
|
||||
execFile("ssh", args, { timeout: timeoutMs }, (error, stdout) => {
|
||||
if (error) return resolve(null);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { EventBus } from "../domain/event-bus.js";
|
||||
import { listProjects, selectedProject, projectMission, workSource, projectReadResponse } from "../domain/workspace/project-read.js";
|
||||
import { listProjects, selectedProject, projectMission, workSource, insideProject, projectReadResponse } from "../domain/workspace/project-read.js";
|
||||
import { inferCatalogProject, ProjectReadError } from "../domain/workspace/project-catalog.js";
|
||||
// Slice Story View v0 — HTTP routes.
|
||||
//
|
||||
@@ -166,15 +166,20 @@ export function slicesRoutes(): Hono {
|
||||
const abs = deps.projector.resolveProofAssetPath(slice.proofPacket, relPath);
|
||||
if (!abs) return c.json({ error: "proof_asset_not_found" }, 404);
|
||||
|
||||
let servedPath = abs;
|
||||
if (selected.qualified) {
|
||||
try { servedPath = insideProject(selected.projectRoot, abs); }
|
||||
catch (err) { return projectReadResponse(err); }
|
||||
}
|
||||
const contentType = inferContentType(abs);
|
||||
return fileAssetResponse(abs, contentType, c.req.header("Range"));
|
||||
return fileAssetResponse(servedPath, contentType, c.req.header("Range"));
|
||||
});
|
||||
|
||||
// 3) Doc serving for the Docs tab — markdown content of a single file
|
||||
// inside the slice folder. Path-traversal guarded by the projector.
|
||||
app.get("/:name/doc/*", (c) => {
|
||||
const name = c.req.param("name");
|
||||
const selected = selectSliceDeps(c, name);
|
||||
const selected = selectSliceDeps(c, name, { includeEvidenceRoot: false });
|
||||
if ("response" in selected) return selected.response;
|
||||
const { deps } = selected;
|
||||
const fullPath = c.req.path;
|
||||
@@ -205,10 +210,39 @@ export function slicesRoutes(): Hono {
|
||||
return app;
|
||||
}
|
||||
|
||||
const UNAVAILABLE_EVIDENCE_ROOT_CODES = new Set([
|
||||
"ENOENT",
|
||||
"EACCES",
|
||||
"EPERM",
|
||||
"ENOTDIR",
|
||||
"ELOOP",
|
||||
"EIO",
|
||||
"ENAMETOOLONG",
|
||||
"ESTALE",
|
||||
]);
|
||||
|
||||
export function qualifiedEvidenceRoot(projectRoot: string, missionsRoot: string): string | null {
|
||||
const candidate = path.join(path.dirname(missionsRoot), "dogfood-evidence");
|
||||
const relative = path.relative(path.resolve(projectRoot), path.resolve(candidate));
|
||||
if (relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) return null;
|
||||
try {
|
||||
const resolved = insideProject(projectRoot, candidate);
|
||||
fs.accessSync(resolved, fs.constants.R_OK | fs.constants.X_OK);
|
||||
fs.readdirSync(resolved);
|
||||
return resolved;
|
||||
} catch (err) {
|
||||
if (err instanceof ProjectReadError && err.code === "project_path_escape") return null;
|
||||
const code = (err as NodeJS.ErrnoException | null | undefined)?.code;
|
||||
if (typeof code === "string" && UNAVAILABLE_EVIDENCE_ROOT_CODES.has(code)) return null;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
function selectSliceDeps(
|
||||
c: Context,
|
||||
name: string,
|
||||
): { deps: SlicesRoutesDeps; qualified: boolean } | { response: Response } {
|
||||
opts: { includeEvidenceRoot?: boolean } = {},
|
||||
): { deps: SlicesRoutesDeps; qualified: false } | { deps: SlicesRoutesDeps; qualified: true; projectRoot: string } | { response: Response } {
|
||||
let deps = getDeps(c);
|
||||
if (!deps) return { response: c.json({ error: "slices_indexer_unavailable" }, 503) };
|
||||
try {
|
||||
@@ -218,9 +252,15 @@ function selectSliceDeps(
|
||||
if (!mission || !/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(name)) return { response: c.json({ error: "exact_mission_and_slice_required" }, 400) };
|
||||
const dir = projectMission(project, mission);
|
||||
workSource(project.root, path.join(dir, "slices", name));
|
||||
const indexer = new SliceIndexer({ db: deps.indexer.db, slicesRoot: project.missionsRoot, dogfoodEvidenceRoot: null, projectId: project.id, missionId: mission });
|
||||
const indexer = new SliceIndexer({
|
||||
db: deps.indexer.db,
|
||||
slicesRoot: project.missionsRoot,
|
||||
dogfoodEvidenceRoot: (opts.includeEvidenceRoot ?? true) ? qualifiedEvidenceRoot(project.root, project.missionsRoot) : null,
|
||||
projectId: project.id,
|
||||
missionId: mission,
|
||||
});
|
||||
deps = { indexer, projector: deps.projector.withIndexer(indexer) };
|
||||
return { deps, qualified: true };
|
||||
return { deps, qualified: true, projectRoot: project.root };
|
||||
} catch (err) { return { response: projectReadResponse(err) }; }
|
||||
}
|
||||
|
||||
|
||||
@@ -46,10 +46,12 @@ export function specLibraryRoutes(): Hono {
|
||||
const folder = c.get("workflowsFolderDir" as never) as string | undefined;
|
||||
const eventBus = c.get("eventBus" as never) as EventBus | undefined;
|
||||
const aliases: Array<{ name: string; version: string; sourcePath: string; sourceHash: string }> = [];
|
||||
const verifiedSourceHashes = new Map<string, string>();
|
||||
if (cache && folder) {
|
||||
try {
|
||||
scanWorkflowSpecFolder({ db, cache, folder, builtinDir: builtinDir ?? null, eventBus,
|
||||
onBuiltinAlias: (name, version, sourcePath, sourceHash) => aliases.push({ name, version, sourcePath, sourceHash }) });
|
||||
onBuiltinAlias: (name, version, sourcePath, sourceHash) => aliases.push({ name, version, sourcePath, sourceHash }),
|
||||
onVerifiedSource: (sourcePath, sourceHash) => verifiedSourceHashes.set(sourcePath, sourceHash) });
|
||||
} catch { /* best-effort — folder scan failure must not break Library list */ }
|
||||
}
|
||||
// Re-scan workflow specs on each list request — cheap (single
|
||||
@@ -70,9 +72,11 @@ export function specLibraryRoutes(): Hono {
|
||||
// Keep a source-specific link valid when an identical copy is edited and
|
||||
// becomes the cache's own user-file row. The normal row is listed once.
|
||||
if (folder && cache) {
|
||||
const sourceHashes = new Map<string, string>();
|
||||
// Reuse the scanner's persisted/stat-validated hash for ordinary user files. Alias copies intentionally keep
|
||||
// their separate two-file byte check above because they have no persisted row of their own.
|
||||
const sourceHashes = verifiedSourceHashes;
|
||||
for (const entry of [...workflowEntries]) {
|
||||
if (entry.sourceType !== "user_file" || entry.status === "error" || dirname(entry.sourcePath) !== folder) continue;
|
||||
if (entry.id.startsWith("workflow-source:") || entry.sourceType !== "user_file" || entry.status === "error" || dirname(entry.sourcePath) !== folder) continue;
|
||||
try {
|
||||
let sourceHash = sourceHashes.get(entry.sourcePath);
|
||||
if (sourceHash === undefined) {
|
||||
|
||||
@@ -221,9 +221,17 @@ describe("auth-bearer-token middleware (PL-005 Phase B)", () => {
|
||||
|
||||
// Scenario 7: DNS-fails hostname, no bearer → THROW
|
||||
it("(7) DNS-unresolvable hostname with no bearer → THROW (HG-4)", async () => {
|
||||
await expect(
|
||||
assertBindAuthInvariant({ host: "this-host-does-not-exist.invalid", bearerToken: null }),
|
||||
).rejects.toThrow(AuthBearerTokenStartupError);
|
||||
const lookup = vi.spyOn(dns.promises, "lookup").mockRejectedValue(
|
||||
Object.assign(new Error("getaddrinfo ENOTFOUND this-host-does-not-exist.invalid"), { code: "ENOTFOUND" }),
|
||||
);
|
||||
try {
|
||||
await expect(
|
||||
assertBindAuthInvariant({ host: "this-host-does-not-exist.invalid", bearerToken: null }),
|
||||
).rejects.toThrow(AuthBearerTokenStartupError);
|
||||
expect(lookup).toHaveBeenCalledWith("this-host-does-not-exist.invalid", { all: false });
|
||||
} finally {
|
||||
lookup.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
// Existing-behavior preservation
|
||||
|
||||
+65
-7
@@ -1,8 +1,10 @@
|
||||
/* Synthetic interactive provider: no provider network calls; credentials are compared only to fixture constants.
|
||||
* A real executable lets native process/binding checks run without mocking their observations. */
|
||||
#include <errno.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <sys/stat.h>
|
||||
#include <unistd.h>
|
||||
#include <signal.h>
|
||||
|
||||
@@ -15,32 +17,88 @@ static void stopped(int signalNumber) {
|
||||
}
|
||||
|
||||
static const char *flag(int argc, char **argv, const char *name, const char *fallback) {
|
||||
for (int i = 1; i + 1 < argc; i++) if (!strcmp(argv[i], name)) return argv[i + 1];
|
||||
size_t len = strlen(name);
|
||||
for (int i = 1; i < argc; i++) {
|
||||
if (!strcmp(argv[i], name) && i + 1 < argc) return argv[i + 1];
|
||||
if (!strncmp(argv[i], name, len) && argv[i][len] == '=') return argv[i] + len + 1;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
static int matches(const char *name, const char *value) {
|
||||
const char *v = getenv(name); return v && !strcmp(v, value);
|
||||
}
|
||||
static int valid_session_id(const char *s) {
|
||||
size_t len = s ? strlen(s) : 0;
|
||||
if (len == 0 || len > 64) return 0;
|
||||
for (size_t i = 0; i < len; i++) {
|
||||
unsigned char c = (unsigned char)s[i];
|
||||
if (!((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9')
|
||||
|| c == '.' || c == '_' || c == '-')) return 0;
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
static int fput_json_string(FILE *out, const char *s) {
|
||||
if (fputc('"', out) == EOF) return -1;
|
||||
for (const unsigned char *p = (const unsigned char *)s; *p; p++) {
|
||||
if (*p == '"' || *p == '\\') {
|
||||
if (fprintf(out, "\\%c", *p) < 0) return -1;
|
||||
} else if (*p < 0x20) {
|
||||
if (fprintf(out, "\\u%04x", (unsigned int)*p) < 0) return -1;
|
||||
} else if (fputc(*p, out) == EOF) {
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
return fputc('"', out) == EOF ? -1 : 0;
|
||||
}
|
||||
static int write_session_record(const char *home, pid_t pid, const char *seatName, const char *sessionId) {
|
||||
char sessionsDir[4096], tmpPath[4096], finalPath[4096];
|
||||
int n1 = snprintf(sessionsDir, sizeof(sessionsDir), "%s/sessions", home);
|
||||
int n2 = snprintf(tmpPath, sizeof(tmpPath), "%s/sessions/.%d.json.tmp", home, (int)pid);
|
||||
int n3 = snprintf(finalPath, sizeof(finalPath), "%s/sessions/%d.json", home, (int)pid);
|
||||
if (n1 < 0 || (size_t)n1 >= sizeof(sessionsDir)
|
||||
|| n2 < 0 || (size_t)n2 >= sizeof(tmpPath)
|
||||
|| n3 < 0 || (size_t)n3 >= sizeof(finalPath)) return -1;
|
||||
if (mkdir(sessionsDir, 0700) != 0 && errno != EEXIST) return -1;
|
||||
FILE *out = fopen(tmpPath, "w");
|
||||
if (!out) return -1;
|
||||
int ok = fputs("{\"name\":", out) != EOF
|
||||
&& fput_json_string(out, seatName) == 0
|
||||
&& fputs(",\"sessionId\":", out) != EOF
|
||||
&& fput_json_string(out, sessionId) == 0
|
||||
&& fputs("}\n", out) != EOF
|
||||
&& fflush(out) == 0;
|
||||
if (fclose(out) != 0 || !ok) { unlink(tmpPath); return -1; }
|
||||
if (rename(tmpPath, finalPath) != 0) { unlink(tmpPath); return -1; }
|
||||
return 0;
|
||||
}
|
||||
int main(int argc, char **argv) {
|
||||
setvbuf(stdout, NULL, _IONBF, 0);
|
||||
if (argc > 1 && !strcmp(argv[1], "--version")) { puts("2.1.139 (Claude Code)"); return 0; }
|
||||
if (argc > 1 && !strcmp(argv[1], "--help")) {
|
||||
puts("Usage: claude [options]\n--permission-mode <mode> (choices: \"acceptEdits\", \"default\", \"auto\")"); return 0;
|
||||
}
|
||||
const char *id = flag(argc, argv, "--resume", flag(argc, argv, "--session-id", "missing"));
|
||||
const char *explicitId = flag(argc, argv, "--resume", flag(argc, argv, "--session-id", NULL));
|
||||
const char *id = explicitId ? explicitId : "missing";
|
||||
const char *argName = flag(argc, argv, "--name", NULL);
|
||||
const char *seatName = (argName && argName[0]) ? argName : NULL;
|
||||
const char *model = flag(argc, argv, "--model", "default");
|
||||
const char *permission = flag(argc, argv, "--permission-mode", "default");
|
||||
const char *home = getenv("CLAUDE_CONFIG_DIR"), *rig = getenv("OPENRIG_HOME");
|
||||
if (!home || !rig || strchr(id, '/') || strlen(id) > 64) return 2;
|
||||
snprintf(removeKeyFlag, sizeof(removeKeyFlag), "%s/remove-key-on-stop-%d", rig, getpid());
|
||||
snprintf(apiKeyFile, sizeof(apiKeyFile), "%s/../api-key", rig);
|
||||
if (!home || home[0] != '/' || !rig || rig[0] != '/' || strchr(id, '/') || strlen(id) > 64) return 2;
|
||||
int r1 = snprintf(removeKeyFlag, sizeof(removeKeyFlag), "%s/remove-key-on-stop-%d", rig, getpid());
|
||||
int r2 = snprintf(apiKeyFile, sizeof(apiKeyFile), "%s/../api-key", rig);
|
||||
if (r1 < 0 || (size_t)r1 >= sizeof(removeKeyFlag) || r2 < 0 || (size_t)r2 >= sizeof(apiKeyFile)) return 2;
|
||||
signal(SIGTERM, stopped);
|
||||
signal(SIGHUP, stopped);
|
||||
const char *auth = matches("ANTHROPIC_API_KEY", "synthetic-api") ? "api"
|
||||
: matches("CLAUDE_CODE_OAUTH_TOKEN", "synthetic-plan") ? "plan" : "other";
|
||||
char journal[4096], memoryFile[4096], memory[512] = "", line[4096];
|
||||
snprintf(journal, sizeof(journal), "%s/provider-launches.jsonl", rig);
|
||||
snprintf(memoryFile, sizeof(memoryFile), "%s/fixture-conversation-%s", home, id);
|
||||
int j1 = snprintf(journal, sizeof(journal), "%s/provider-launches.jsonl", rig);
|
||||
int m1 = snprintf(memoryFile, sizeof(memoryFile), "%s/fixture-conversation-%s", home, id);
|
||||
if (j1 < 0 || (size_t)j1 >= sizeof(journal) || m1 < 0 || (size_t)m1 >= sizeof(memoryFile)) return 2;
|
||||
if (seatName && valid_session_id(explicitId)) {
|
||||
if (strlen(seatName) > 512 || write_session_record(home, getpid(), seatName, explicitId) != 0) return 7;
|
||||
}
|
||||
FILE *f = fopen(memoryFile, "r"); if (f) { fgets(memory, sizeof(memory), f); fclose(f); }
|
||||
f = fopen(journal, "a"); if (!f) return 3;
|
||||
fprintf(f, "{\"pid\":%d,\"conversation\":\"%s\",\"model\":\"%s\",\"permission\":\"%s\",\"auth\":\"%s\",\"remembered\":%s}\n",
|
||||
|
||||
@@ -71,6 +71,16 @@ const SHARED_FIXTURES: Array<{ label: string; parsed: unknown; ok: boolean }> =
|
||||
{ label: "invalid: reserved hostId 'kernel'", parsed: { hosts: [{ id: "vm-a", transport: "ssh", target: "a", hostId: "kernel" }] }, ok: false },
|
||||
{ label: "invalid: reserved hostId 'host'", parsed: { hosts: [{ id: "vm-a", transport: "ssh", target: "a", hostId: "host" }] }, ok: false },
|
||||
{ label: "invalid: reserved hostId 'external'", parsed: { hosts: [{ id: "vm-a", transport: "http", url: "http://x", hostId: "external" }] }, ok: false },
|
||||
// The ssh target and the optional user are argv elements, not shell words, so whitespace
|
||||
// never splits a destination, and a target that begins with `-` is handled by the `--` the
|
||||
// callers pass before it rather than by a registry refusal. The registry refuses neither;
|
||||
// these fixtures pin that in both twins, next to the ordinary shapes that must keep working.
|
||||
{ label: "valid: ssh target with whitespace (argv keeps it whole)", parsed: { hosts: [{ id: "x", transport: "ssh", target: "two words" }] }, ok: true },
|
||||
{ label: "valid: ssh user with a space in the login name (OpenSSH accepts it)", parsed: { hosts: [{ id: "x", transport: "ssh", target: "vm-a", user: "Jane Doe" }] }, ok: true },
|
||||
{ label: "valid: option-shaped target passes validation; `--` handles it at the call site", parsed: { hosts: [{ id: "x", transport: "ssh", target: "-oProxyCommand=touch /tmp/nothing" }] }, ok: true },
|
||||
{ label: "valid: ssh target as user@host", parsed: { hosts: [{ id: "x", transport: "ssh", target: "[email protected]" }] }, ok: true },
|
||||
{ label: "valid: ssh target as an IPv6 literal", parsed: { hosts: [{ id: "x", transport: "ssh", target: "admin@[::1]" }] }, ok: true },
|
||||
{ label: "valid: ssh host alias with a user", parsed: { hosts: [{ id: "x", transport: "ssh", target: "box", user: "admin" }] }, ok: true },
|
||||
];
|
||||
|
||||
describe("hosts-registry reader — CLI/daemon validator parity (shared fixtures)", () => {
|
||||
|
||||
@@ -12,7 +12,6 @@ import type { PolicyJob } from "../src/domain/policies/types.js";
|
||||
import { WatchdogHistoryLog, type WatchdogHistoryEntry } from "../src/domain/watchdog-history-log.js";
|
||||
import { WatchdogJobsRepository } from "../src/domain/watchdog-jobs-repository.js";
|
||||
import { tmuxAttachCommand } from "../src/adapters/tmux-server.js";
|
||||
import { shellQuote } from "../src/adapters/shell-quote.js";
|
||||
import { makeKernelHookReviewReader } from "../src/domain/kernel-hook-review.js";
|
||||
import { verifyCodexPaneProcess, type NativeProcessRow } from "../src/domain/native-process-lineage.js";
|
||||
import { runWakeLadderTick, queueRecoveryOwnsWake, classifyPromptAfterRefusal } from "../src/domain/queue-wake-ladder.js";
|
||||
@@ -481,7 +480,7 @@ describe("prompt-blocked outstanding work reaches the configured route", () => {
|
||||
await hookTick(port);
|
||||
const updated = repo.getById(alert.qitemId)!;
|
||||
expect(updated.summary).toContain("Hooks need review");
|
||||
expect(updated.summary).toContain(tmuxAttachCommand(shellQuote(seat)));
|
||||
expect(updated.summary).toContain(tmuxAttachCommand(seat));
|
||||
expect(notes(alert.qitemId).filter(n => n.startsWith("hook-review observed:"))).toHaveLength(1);
|
||||
expect(notes(alert.qitemId).join("\n")).toContain("through Codex's hook review");
|
||||
expect(updated.body).toBe(before.body); // retain the original delivered content and receipt
|
||||
|
||||
@@ -0,0 +1,438 @@
|
||||
import { execFileSync, spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { once } from "node:events";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { TmuxAdapter } from "../src/adapters/tmux.js";
|
||||
import { ALL_MIGRATIONS } from "../src/db/all-migrations.js";
|
||||
import { createDb } from "../src/db/connection.js";
|
||||
import { migrate } from "../src/db/migrate.js";
|
||||
import { CheckpointStore } from "../src/domain/checkpoint-store.js";
|
||||
import { EventBus } from "../src/domain/event-bus.js";
|
||||
import { readProcessTable, type ProcessTableRow } from "../src/domain/process-table.js";
|
||||
import { ResumeMetadataRefresher } from "../src/domain/resume-metadata-refresher.js";
|
||||
import { RigRepository } from "../src/domain/rig-repository.js";
|
||||
import { RigTeardownOrchestrator } from "../src/domain/rig-teardown.js";
|
||||
import { seatRuntimeHome } from "../src/domain/seat-runtime-home.js";
|
||||
import { SessionRegistry } from "../src/domain/session-registry.js";
|
||||
import { SnapshotCapture } from "../src/domain/snapshot-capture.js";
|
||||
import { SnapshotRepository } from "../src/domain/snapshot-repository.js";
|
||||
|
||||
interface ProviderLaunchRecord {
|
||||
pid: number;
|
||||
conversation: string;
|
||||
model: string;
|
||||
permission: string;
|
||||
auth: string;
|
||||
remembered: boolean;
|
||||
}
|
||||
|
||||
describe.skipIf(process.platform === "win32")("provider-switch synthetic Claude fixture shutdown capture contract", () => {
|
||||
let binRoot: string;
|
||||
let claudeFixtureBinary: string;
|
||||
let root: string;
|
||||
let db: ReturnType<typeof createDb>;
|
||||
let rigRepo: RigRepository;
|
||||
let registry: SessionRegistry;
|
||||
let eventBus: EventBus;
|
||||
let snapshotRepo: SnapshotRepository;
|
||||
let capture: SnapshotCapture;
|
||||
const cleanupCallbacks: Array<() => Promise<void>> = [];
|
||||
|
||||
beforeAll(() => {
|
||||
binRoot = fs.mkdtempSync(path.join(os.tmpdir(), "provider-switch-claude-bin-"));
|
||||
claudeFixtureBinary = path.join(binRoot, "claude");
|
||||
const fixtureSource = fileURLToPath(new URL("fixtures/provider-switch-claude.c", import.meta.url));
|
||||
const shippedRelay = fileURLToPath(
|
||||
new URL("../assets/plugins/openrig-core/hooks/scripts/activity-relay.cjs", import.meta.url),
|
||||
);
|
||||
execFileSync("cc", [
|
||||
fixtureSource,
|
||||
"-o",
|
||||
claudeFixtureBinary,
|
||||
`-DFIXTURE_NODE=${JSON.stringify(process.execPath)}`,
|
||||
`-DFIXTURE_RELAY=${JSON.stringify(shippedRelay)}`,
|
||||
]);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
if (binRoot) fs.rmSync(binRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
root = fs.mkdtempSync(path.join(os.tmpdir(), "provider-switch-shutdown-"));
|
||||
db = createDb();
|
||||
migrate(db, ALL_MIGRATIONS);
|
||||
rigRepo = new RigRepository(db);
|
||||
registry = new SessionRegistry(db);
|
||||
eventBus = new EventBus(db);
|
||||
snapshotRepo = new SnapshotRepository(db);
|
||||
capture = new SnapshotCapture({
|
||||
db,
|
||||
rigRepo,
|
||||
sessionRegistry: registry,
|
||||
eventBus,
|
||||
snapshotRepo,
|
||||
checkpointStore: new CheckpointStore(db),
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
while (cleanupCallbacks.length > 0) {
|
||||
const stop = cleanupCallbacks.pop()!;
|
||||
await stop();
|
||||
}
|
||||
db.close();
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
async function waitForPrompt(child: ChildProcessWithoutNullStreams): Promise<void> {
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
cleanup();
|
||||
reject(new Error(`synthetic provider fixture timed out waiting for prompt (stdout=${JSON.stringify(stdout)}, stderr=${JSON.stringify(stderr)})`));
|
||||
}, 10_000);
|
||||
const onData = (chunk: Buffer) => {
|
||||
stdout += chunk.toString("utf8");
|
||||
if (stdout.includes("❯ ")) {
|
||||
cleanup();
|
||||
resolve();
|
||||
}
|
||||
};
|
||||
const onStderr = (chunk: Buffer) => {
|
||||
stderr += chunk.toString("utf8");
|
||||
};
|
||||
const onError = (err: Error) => {
|
||||
cleanup();
|
||||
reject(err);
|
||||
};
|
||||
const onExit = (code: number | null, signal: NodeJS.Signals | null) => {
|
||||
cleanup();
|
||||
reject(new Error(`synthetic provider fixture exited before prompt (code=${String(code)}, signal=${String(signal)}, stderr=${JSON.stringify(stderr)})`));
|
||||
};
|
||||
const cleanup = () => {
|
||||
clearTimeout(timer);
|
||||
child.stdout.off("data", onData);
|
||||
child.stderr.off("data", onStderr);
|
||||
child.off("error", onError);
|
||||
child.off("exit", onExit);
|
||||
};
|
||||
child.stdout.on("data", onData);
|
||||
child.stderr.on("data", onStderr);
|
||||
child.once("error", onError);
|
||||
child.once("exit", onExit);
|
||||
});
|
||||
}
|
||||
|
||||
async function launchProviderSwitchFixtureSeat(options: {
|
||||
storedToken: string;
|
||||
provenance: "operator" | "scrape" | "hook";
|
||||
args: string[];
|
||||
extraEnv: Record<string, string>;
|
||||
seatName?: string;
|
||||
}) {
|
||||
const workDir = path.join(root, "work");
|
||||
const homeDir = path.join(root, "home");
|
||||
const openrigHome = path.join(root, "openrig-home");
|
||||
const daemonConfigDir = path.join(root, "daemon-claude");
|
||||
const selectedConfigDir = path.join(root, "account");
|
||||
const scratchTmpDir = path.join(root, "tmp");
|
||||
for (const dir of [workDir, homeDir, openrigHome, daemonConfigDir, selectedConfigDir, scratchTmpDir]) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
|
||||
const rig = rigRepo.createRig("provider-switch-fixture");
|
||||
const node = rigRepo.addNode(rig.id, "work.worker", {
|
||||
runtime: "claude-code",
|
||||
cwd: workDir,
|
||||
model: "fixture-model",
|
||||
restorePolicy: "resume_if_possible",
|
||||
});
|
||||
db.prepare("UPDATE nodes SET account_home = ? WHERE id = ?").run(selectedConfigDir, node.id);
|
||||
|
||||
const seatName = options.seatName ?? "r00-provider-switch-fixture-work_worker";
|
||||
const session = registry.registerSession(node.id, seatName);
|
||||
registry.updateStatus(session.id, "running");
|
||||
registry.updateBinding(node.id, { tmuxSession: seatName, tmuxPane: "%1" });
|
||||
registry.updateResumeToken(session.id, "claude_id", options.storedToken, options.provenance);
|
||||
|
||||
const child = spawn(claudeFixtureBinary, options.args, {
|
||||
cwd: workDir,
|
||||
env: {
|
||||
PATH: "/usr/bin:/bin",
|
||||
HOME: homeDir,
|
||||
TMPDIR: scratchTmpDir,
|
||||
OPENRIG_HOME: openrigHome,
|
||||
CLAUDE_CONFIG_DIR: selectedConfigDir,
|
||||
OPENRIG_NODE_ID: node.id,
|
||||
OPENRIG_RUNTIME: "claude-code",
|
||||
...options.extraEnv,
|
||||
},
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
const stopChild = async () => {
|
||||
if (child.exitCode !== null || child.signalCode !== null) return;
|
||||
const exited = once(child, "close");
|
||||
child.kill("SIGTERM");
|
||||
const killTimer = setTimeout(() => {
|
||||
if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL");
|
||||
}, 2_000);
|
||||
try {
|
||||
await exited;
|
||||
} finally {
|
||||
clearTimeout(killTimer);
|
||||
}
|
||||
};
|
||||
cleanupCallbacks.push(stopChild);
|
||||
|
||||
await waitForPrompt(child);
|
||||
const childPid = child.pid!;
|
||||
|
||||
const panePid = process.pid;
|
||||
const readForegroundRows = async (): Promise<ProcessTableRow[]> => {
|
||||
const table = await readProcessTable();
|
||||
const selfRow = table.find((row) => row.pid === panePid);
|
||||
const liveRow = table.find((row) => row.pid === childPid);
|
||||
if (!selfRow || !liveRow) return [];
|
||||
return [
|
||||
{
|
||||
pid: selfRow.pid,
|
||||
ppid: selfRow.ppid,
|
||||
pgid: panePid,
|
||||
tpgid: liveRow.pid,
|
||||
executableName: "bash",
|
||||
startedAt: selfRow.startedAt,
|
||||
command: "bash",
|
||||
},
|
||||
{
|
||||
...liveRow,
|
||||
ppid: panePid,
|
||||
pgid: liveRow.pid,
|
||||
tpgid: liveRow.pid,
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
const initialSample = await readForegroundRows();
|
||||
const initialLiveRow = initialSample.find((row) => row.pid === childPid);
|
||||
expect(initialLiveRow, "expected live compiled provider-switch-claude OS process in process table").toBeDefined();
|
||||
expect(Number.isFinite(Date.parse(initialLiveRow!.startedAt))).toBe(true);
|
||||
|
||||
const listClaudeProcesses = vi.fn(readForegroundRows);
|
||||
const probeClaudeResume = vi.fn(async () => "inconclusive" as const);
|
||||
const tmux = {
|
||||
getPanePid: vi.fn(async () => panePid),
|
||||
killSession: vi.fn(async () => {
|
||||
await stopChild();
|
||||
return { ok: true as const };
|
||||
}),
|
||||
} as unknown as TmuxAdapter;
|
||||
|
||||
const refresher = new ResumeMetadataRefresher({
|
||||
sessionRegistry: registry,
|
||||
tmuxAdapter: tmux,
|
||||
homeDir,
|
||||
claudeConfigDir: daemonConfigDir,
|
||||
seatHome: (nodeId, runtime) => seatRuntimeHome(db, nodeId, runtime).home,
|
||||
listClaudeProcesses,
|
||||
probeClaudeResume,
|
||||
});
|
||||
const teardown = new RigTeardownOrchestrator({
|
||||
db,
|
||||
rigRepo,
|
||||
sessionRegistry: registry,
|
||||
eventBus,
|
||||
snapshotCapture: capture,
|
||||
tmuxAdapter: tmux,
|
||||
resumeMetadataRefresher: refresher,
|
||||
});
|
||||
|
||||
const readLaunches = (): ProviderLaunchRecord[] => {
|
||||
const journalPath = path.join(openrigHome, "provider-launches.jsonl");
|
||||
return fs
|
||||
.readFileSync(journalPath, "utf8")
|
||||
.trim()
|
||||
.split("\n")
|
||||
.filter(Boolean)
|
||||
.map((line) => JSON.parse(line) as ProviderLaunchRecord);
|
||||
};
|
||||
|
||||
return {
|
||||
rig,
|
||||
node,
|
||||
session,
|
||||
seatName,
|
||||
workDir,
|
||||
openrigHome,
|
||||
daemonConfigDir,
|
||||
selectedConfigDir,
|
||||
childPid,
|
||||
initialLiveRow: initialLiveRow!,
|
||||
pidSessionFile: path.join(selectedConfigDir, "sessions", `${childPid}.json`),
|
||||
probeClaudeResume,
|
||||
listClaudeProcesses,
|
||||
tmux,
|
||||
refresher,
|
||||
teardown,
|
||||
readLaunches,
|
||||
};
|
||||
}
|
||||
|
||||
it("writes current-PID session metadata from explicit --name + --resume so shutdown captures without fallback probe", async () => {
|
||||
const conversation = randomUUID();
|
||||
const seatName = "r00-provider-switch-fixture-work_worker";
|
||||
const h = await launchProviderSwitchFixtureSeat({
|
||||
storedToken: conversation,
|
||||
provenance: "operator",
|
||||
seatName,
|
||||
args: ["--resume", conversation, "--name", seatName, "--model", "fixture-model", "--permission-mode", "acceptEdits"],
|
||||
extraEnv: {
|
||||
OPENRIG_SESSION_NAME: seatName,
|
||||
ANTHROPIC_API_KEY: "synthetic-api",
|
||||
},
|
||||
});
|
||||
|
||||
expect(h.readLaunches()).toEqual([
|
||||
{
|
||||
pid: h.childPid,
|
||||
conversation,
|
||||
model: "fixture-model",
|
||||
permission: "acceptEdits",
|
||||
auth: "api",
|
||||
remembered: false,
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await h.teardown.teardown(h.rig.id);
|
||||
expect(result.errors).toEqual([]);
|
||||
expect(result.sessionsKilled).toBe(1);
|
||||
expect(h.listClaudeProcesses).toHaveBeenCalled();
|
||||
|
||||
const recordExists = fs.existsSync(h.pidSessionFile);
|
||||
expect
|
||||
.soft(recordExists, "expected synthetic fixture to write <CLAUDE_CONFIG_DIR>/sessions/<pid>.json for explicit --name + --resume")
|
||||
.toBe(true);
|
||||
if (recordExists) {
|
||||
const stat = fs.statSync(h.pidSessionFile);
|
||||
expect.soft(stat.size).toBeLessThanOrEqual(64 * 1024);
|
||||
expect.soft(stat.mtimeMs).toBeGreaterThanOrEqual(Date.parse(h.initialLiveRow.startedAt));
|
||||
expect.soft(JSON.parse(fs.readFileSync(h.pidSessionFile, "utf8"))).toMatchObject({
|
||||
name: h.seatName,
|
||||
sessionId: conversation,
|
||||
});
|
||||
}
|
||||
|
||||
expect
|
||||
.soft(h.probeClaudeResume, "expected captureClaudeSessionId hit without invoking fallback probeClaudeResume")
|
||||
.not.toHaveBeenCalled();
|
||||
const updated = registry.getSessionsForRig(h.rig.id)[0]!;
|
||||
expect.soft(updated.resumeToken).toBe(conversation);
|
||||
expect.soft(updated.resumeLastProbeStatus).toBe("resumable");
|
||||
});
|
||||
|
||||
it("writes current-PID session metadata from --name + --session-id so shutdown captures conversation without fallback probe", async () => {
|
||||
const initialConversation = randomUUID();
|
||||
const activeConversation = randomUUID();
|
||||
const seatName = "r00-provider-switch-fixture-work_worker";
|
||||
const h = await launchProviderSwitchFixtureSeat({
|
||||
storedToken: initialConversation,
|
||||
provenance: "scrape",
|
||||
seatName,
|
||||
args: ["--session-id", activeConversation, "--name", seatName, "--model", "fixture-model", "--permission-mode", "acceptEdits"],
|
||||
extraEnv: {
|
||||
CLAUDE_CODE_OAUTH_TOKEN: "synthetic-plan",
|
||||
},
|
||||
});
|
||||
|
||||
expect(h.readLaunches()).toEqual([
|
||||
{
|
||||
pid: h.childPid,
|
||||
conversation: activeConversation,
|
||||
model: "fixture-model",
|
||||
permission: "acceptEdits",
|
||||
auth: "plan",
|
||||
remembered: false,
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await h.teardown.teardown(h.rig.id);
|
||||
expect(result.errors).toEqual([]);
|
||||
expect(result.sessionsKilled).toBe(1);
|
||||
expect(h.listClaudeProcesses).toHaveBeenCalled();
|
||||
|
||||
const recordExists = fs.existsSync(h.pidSessionFile);
|
||||
expect
|
||||
.soft(recordExists, "expected synthetic fixture to write <CLAUDE_CONFIG_DIR>/sessions/<pid>.json for --name + --session-id")
|
||||
.toBe(true);
|
||||
if (recordExists) {
|
||||
const stat = fs.statSync(h.pidSessionFile);
|
||||
expect.soft(stat.size).toBeLessThanOrEqual(64 * 1024);
|
||||
expect.soft(stat.mtimeMs).toBeGreaterThanOrEqual(Date.parse(h.initialLiveRow.startedAt));
|
||||
expect.soft(JSON.parse(fs.readFileSync(h.pidSessionFile, "utf8"))).toMatchObject({
|
||||
name: h.seatName,
|
||||
sessionId: activeConversation,
|
||||
});
|
||||
}
|
||||
|
||||
expect
|
||||
.soft(h.probeClaudeResume, "expected captureClaudeSessionId hit without invoking fallback probeClaudeResume")
|
||||
.not.toHaveBeenCalled();
|
||||
const updated = registry.getSessionsForRig(h.rig.id)[0]!;
|
||||
expect.soft(updated.resumeToken).toBe(activeConversation);
|
||||
const snap = snapshotRepo.getSnapshot(result.snapshotId!)!;
|
||||
expect.soft(snap.data.sessions.find((s) => s.id === h.session.id)?.resumeToken).toBe(activeConversation);
|
||||
});
|
||||
|
||||
it("does not emit current-PID session metadata when only OPENRIG_SESSION_NAME is set without explicit --name", async () => {
|
||||
const initialConversation = randomUUID();
|
||||
const resumedConversation = randomUUID();
|
||||
const seatName = "r00-provider-switch-fixture-work_worker";
|
||||
const h = await launchProviderSwitchFixtureSeat({
|
||||
storedToken: initialConversation,
|
||||
provenance: "scrape",
|
||||
seatName,
|
||||
args: ["--resume", resumedConversation, "--model", "fixture-model", "--permission-mode", "acceptEdits"],
|
||||
extraEnv: {
|
||||
OPENRIG_SESSION_NAME: seatName,
|
||||
ANTHROPIC_API_KEY: "synthetic-api",
|
||||
},
|
||||
});
|
||||
|
||||
expect(h.readLaunches()).toEqual([
|
||||
{
|
||||
pid: h.childPid,
|
||||
conversation: resumedConversation,
|
||||
model: "fixture-model",
|
||||
permission: "acceptEdits",
|
||||
auth: "api",
|
||||
remembered: false,
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await h.teardown.teardown(h.rig.id);
|
||||
expect(result.errors).toEqual([]);
|
||||
expect(result.sessionsKilled).toBe(1);
|
||||
expect(h.listClaudeProcesses).toHaveBeenCalled();
|
||||
|
||||
expect
|
||||
.soft(fs.existsSync(h.pidSessionFile), "expected synthetic fixture not to emit <CLAUDE_CONFIG_DIR>/sessions/<pid>.json without explicit --name")
|
||||
.toBe(false);
|
||||
expect
|
||||
.soft(h.probeClaudeResume, "expected launch without explicit --name to fall through to fallback probeClaudeResume")
|
||||
.toHaveBeenCalledTimes(1);
|
||||
expect.soft(h.probeClaudeResume).toHaveBeenCalledWith(h.seatName, initialConversation, h.workDir, h.selectedConfigDir);
|
||||
|
||||
const updated = registry.getSessionsForRig(h.rig.id)[0]!;
|
||||
expect.soft(updated.resumeToken).toBe(initialConversation);
|
||||
expect.soft(updated.resumeLastProbeStatus).toBe("inconclusive");
|
||||
const snap = snapshotRepo.getSnapshot(result.snapshotId!)!;
|
||||
expect.soft(snap.data.sessions.find((s) => s.id === h.session.id)?.resumeToken).toBe(initialConversation);
|
||||
});
|
||||
|
||||
});
|
||||
@@ -0,0 +1,170 @@
|
||||
// A wake that failed at a live agent seat with no delegation parent or escalates_to got a finding addressed back to that
|
||||
// seat, whose own wake then went to the seat that had just failed to take one. That finding now goes to the row's creator,
|
||||
// or for a seat that sent itself the work, to the operator agent. A declared route keeps its routing and its fallback.
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import Database from "better-sqlite3";
|
||||
import { ALL_MIGRATIONS } from "../src/db/all-migrations.js";
|
||||
import { migrate } from "../src/db/migrate.js";
|
||||
import { RigRepository } from "../src/domain/rig-repository.js";
|
||||
import { EventBus } from "../src/domain/event-bus.js";
|
||||
import { QueueRepository, type QueueItem } from "../src/domain/queue-repository.js";
|
||||
import { runStuckSweep, createStuckSweepStatus, STUCK_SWEEP_FINDING_TAG } from "../src/domain/queue-stuck-sweep.js";
|
||||
|
||||
describe("stuck sweep: a failed wake at a seat with no declared route", () => {
|
||||
let db: Database.Database;
|
||||
let rigs: RigRepository;
|
||||
let queue: QueueRepository;
|
||||
let seq: number;
|
||||
beforeEach(() => {
|
||||
db = new Database(":memory:");
|
||||
migrate(db, ALL_MIGRATIONS);
|
||||
rigs = new RigRepository(db);
|
||||
queue = new QueueRepository(db, new EventBus(db), { validateRig: () => true, loadHumanRegistry: () => ({ ok: true, entities: [] }) });
|
||||
seq = 0;
|
||||
});
|
||||
afterEach(() => db.close());
|
||||
|
||||
/** An agent seat; with `running` false it has no session. */
|
||||
function seat(rigName: string, logicalId: string, session: string, running = true) {
|
||||
const rig = rigs.listRigs().find((r) => r.name === rigName) ?? rigs.createRig(rigName);
|
||||
const node = rigs.addNode(rig.id, logicalId, { runtime: "claude-code" });
|
||||
if (running) db.prepare("INSERT INTO sessions (id,node_id,session_name,status) VALUES (?,?,?,?)").run(`session-${++seq}`, node.id, session, "running");
|
||||
return { rig, node, session };
|
||||
}
|
||||
async function sweep(overrides: Record<string, unknown> = {}) {
|
||||
return runStuckSweep({ db, queueRepo: queue, status: createStuckSweepStatus(), unclaimedAgeMinutes: 60, log: () => {},
|
||||
isRegisteredHost: () => false, resolveRecoveryAgentSeat: () => null, resolveOperatorSeat: () => null, ...overrides });
|
||||
}
|
||||
/** A row for `destination` whose wake failed. */
|
||||
async function failedWake(destination: string, source = "sender@lane"): Promise<QueueItem> {
|
||||
const row = await queue.create({ sourceSession: source, destinationSession: destination, body: "Decide the scope", nudge: false });
|
||||
db.prepare("UPDATE queue_items SET last_nudge_attempt = ?, last_nudge_result = ? WHERE qitem_id = ?")
|
||||
.run(new Date().toISOString(), "failed:target_needs_input", row.qitemId);
|
||||
return row;
|
||||
}
|
||||
const findingsFor = (row: QueueItem): QueueItem[] => queue.list({ limit: 500 })
|
||||
.filter((item) => (item.tags ?? []).includes(STUCK_SWEEP_FINDING_TAG) && (item.tags ?? []).includes(`stuck-sweep:undelivered-wake:${row.qitemId}`))
|
||||
.map((item) => queue.getById(item.qitemId)!);
|
||||
|
||||
it("sends the finding to the row's creator, and says why", async () => {
|
||||
seat("front", "door.root", "root@front");
|
||||
const row = await failedWake("root@front");
|
||||
await sweep();
|
||||
const [finding, ...more] = findingsFor(row);
|
||||
expect(more).toHaveLength(0);
|
||||
expect(finding!.destinationSession).toBe("sender@lane");
|
||||
expect(finding!.body).toContain("root@front declares no delegation parent or escalates_to, so this finding goes to sender@lane, the row's creator");
|
||||
});
|
||||
|
||||
it("a seat that sent itself the work gets the operator agent instead", async () => {
|
||||
seat("front", "door.root", "root@front");
|
||||
const row = await failedWake("root@front", "root@front");
|
||||
await sweep({ resolveRecoveryAgentSeat: () => "operator-admin@kernel" });
|
||||
const [finding] = findingsFor(row);
|
||||
expect(finding!.destinationSession).toBe("operator-admin@kernel");
|
||||
expect(finding!.body).toContain("so this finding goes to operator-admin@kernel, the operator agent (agents.operator_session)");
|
||||
});
|
||||
|
||||
it("with no operator agent, a self-sent row's finding stays with the seat and says so", async () => {
|
||||
seat("front", "door.root", "root@front");
|
||||
const row = await failedWake("root@front", "root@front");
|
||||
await sweep();
|
||||
const [finding] = findingsFor(row);
|
||||
expect(finding!.destinationSession).toBe("root@front");
|
||||
expect(finding!.body).toContain("its creator and the operator agent (agents.operator_session) are each unset, missing, this seat, OpenRig's own machinery, a terminal seat or not an address this queue takes, so this finding stays addressed to it");
|
||||
});
|
||||
|
||||
// Rows that OpenRig's own machinery creates (stream intake and startup write them as daemon@kernel) have no creator that
|
||||
// can act on a finding.
|
||||
it.each(["daemon@kernel", "stuck-sweep@system"])("a row created by OpenRig's machinery (%s) gets the operator agent", async (machinery) => {
|
||||
seat("front", "door.root", "root@front");
|
||||
const row = await failedWake("root@front", machinery);
|
||||
await sweep({ resolveRecoveryAgentSeat: () => "operator-admin@kernel" });
|
||||
const [finding] = findingsFor(row);
|
||||
expect(finding!.destinationSession).toBe("operator-admin@kernel");
|
||||
expect(finding!.body).toContain("so this finding goes to operator-admin@kernel, the operator agent (agents.operator_session)");
|
||||
});
|
||||
|
||||
it("reads a seat's delegation parent again after the pass writes a finding", async () => {
|
||||
const lead = seat("lane", "work.lead", "lead@lane");
|
||||
const first = await failedWake("lead@lane");
|
||||
const second = await failedWake("lead@lane");
|
||||
// Another writer declares the seat's parent (with no session yet) while the pass awaits its first finding's write.
|
||||
const parent = seat("lane", "coord.owner", "owner@lane", false);
|
||||
const create = queue.create.bind(queue);
|
||||
let declared = false;
|
||||
vi.spyOn(queue, "create").mockImplementation(async (input) => {
|
||||
const created = await create(input);
|
||||
if (!declared && (input.tags ?? []).includes(STUCK_SWEEP_FINDING_TAG)) {
|
||||
declared = true;
|
||||
rigs.addEdge(lead.rig.id, parent.node.id, lead.node.id, "delegates_to");
|
||||
}
|
||||
return created;
|
||||
});
|
||||
await sweep();
|
||||
const routes = [first, second].map((row) => findingsFor(row)[0]!.destinationSession);
|
||||
// The first finding was routed before the parent existed; the second sees it and keeps the parent's fallback.
|
||||
expect(routes.sort()).toEqual(["lead@lane", "sender@lane"]);
|
||||
});
|
||||
|
||||
it("passes over a creator the queue doesn't take as an address", async () => {
|
||||
queue = new QueueRepository(db, new EventBus(db), {
|
||||
validateRig: (destination) => destination !== "sender@ops@elsewhere", loadHumanRegistry: () => ({ ok: true, entities: [] }),
|
||||
});
|
||||
seat("front", "door.root", "root@front");
|
||||
const row = await failedWake("root@front", "sender@ops@elsewhere");
|
||||
await sweep({ resolveRecoveryAgentSeat: () => "operator-admin@kernel" });
|
||||
expect(findingsFor(row)[0]!.destinationSession).toBe("operator-admin@kernel");
|
||||
});
|
||||
|
||||
// A bound terminal seat (a shell or a TUI) can't act on a finding: no wake is typed into it, and nothing there claims
|
||||
// work. The no-wake rule passes it over, and so must this one.
|
||||
it("passes over a creator that is a bound terminal seat", async () => {
|
||||
const { rig } = seat("front", "door.root", "root@front");
|
||||
const logger = rigs.addNode(rig.id, "logger", { runtime: "terminal" });
|
||||
db.prepare("INSERT INTO sessions (id,node_id,session_name,status) VALUES (?,?,?,?)").run(`session-${++seq}`, logger.id, "logger@front", "running");
|
||||
db.prepare("INSERT INTO bindings (id,node_id,attachment_type,tmux_session,tmux_pane) VALUES (?,?,'tmux',?,'%2')").run("bind-logger", logger.id, "logger@front");
|
||||
const row = await failedWake("root@front", "logger@front");
|
||||
await sweep({ resolveRecoveryAgentSeat: () => "operator-admin@kernel" });
|
||||
const [finding] = findingsFor(row);
|
||||
expect(finding!.destinationSession).toBe("operator-admin@kernel");
|
||||
expect(finding!.body).toContain("so this finding goes to operator-admin@kernel, the operator agent (agents.operator_session)");
|
||||
});
|
||||
|
||||
it("a hosted member whose ring failed keeps the no-wake rule's choice when that rule finds nobody", async () => {
|
||||
queue = new QueueRepository(db, new EventBus(db), {
|
||||
validateRig: () => true, loadHumanRegistry: () => ({ ok: true, entities: [] }),
|
||||
hostedWake: (destination) => destination === "grok@front" ? { webhook: { url_ref: "GROK_WAKE_URL" } } : null,
|
||||
});
|
||||
// A recorded seat with a session and no terminal: the sweep can read its topology, and it still can't be typed into.
|
||||
seat("front", "grok", "grok@front");
|
||||
expect(queue.classifyDestinationOf("grok@front").class).toBe("hosted");
|
||||
// A creator with no wake route here: the no-wake rule passes it over, and so must this one.
|
||||
const row = await failedWake("grok@front", "someone@elsewhere");
|
||||
db.prepare("UPDATE queue_items SET last_nudge_result = ? WHERE qitem_id = ?").run("failed:webhook (GROK_WAKE_URL) answered 500", row.qitemId);
|
||||
await sweep();
|
||||
const [finding] = findingsFor(row);
|
||||
expect(finding!.destinationSession).toBe("grok@front");
|
||||
expect(finding!.body).toContain("No person, creator or operator agent that can be reached is available");
|
||||
});
|
||||
|
||||
it("a declared delegation parent keeps the finding where it was, even when the parent has no session", async () => {
|
||||
const lead = seat("lane", "work.lead", "lead@lane");
|
||||
const parent = seat("lane", "coord.owner", "owner@lane", false);
|
||||
rigs.addEdge(lead.rig.id, parent.node.id, lead.node.id, "delegates_to");
|
||||
const row = await failedWake("lead@lane");
|
||||
await sweep();
|
||||
expect(findingsFor(row).map((f) => f.destinationSession)).toEqual(["lead@lane"]);
|
||||
});
|
||||
|
||||
it("a declared escalates_to that can't be used keeps its fallback", async () => {
|
||||
const door = seat("front", "door.root", "root@front");
|
||||
seat("ops", "watch.lead", "lead@ops", false);
|
||||
rigs.addEdge(door.rig.id, door.node.id, "lead@ops", "escalates_to");
|
||||
const row = await failedWake("root@front");
|
||||
await sweep();
|
||||
const [finding] = findingsFor(row);
|
||||
expect(finding!.destinationSession).toBe("root@front");
|
||||
expect(finding!.body).toContain("escalation route: escalates_to unavailable");
|
||||
});
|
||||
});
|
||||
@@ -493,14 +493,14 @@ describe("SeatLifecycleService.launchFresh", () => {
|
||||
expect(tmux.startServer).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("preserves the adopted seat name and names the release and rig down routes in plans and execution", async () => {
|
||||
it("preserves the adopted seat name and names the release route in plans and execution", async () => {
|
||||
const seat = seedSeat({ adopted: true });
|
||||
const expected = {
|
||||
code: "claimed_session",
|
||||
message: `Seat "${seat.sessionName}" has an adopted/operator-owned occupant; fresh launch refuses even with --stop.`,
|
||||
// B5: rig unclaim releases it (the process keeps running); rig down ends it with its rig.
|
||||
// B5: rig unclaim releases it (the process keeps running); rig down leaves a session OpenRig didn't start.
|
||||
guidance: "Release it with rig unclaim 'dev-impl@fresh-rig' (its process keeps running), end that process yourself, then launch fresh. "
|
||||
+ "Or end it with rig down 'fresh-rig' (this rig has one seat, so it stops only this session; rig up 'fresh-rig' --existing brings the rig back).",
|
||||
+ "rig down 'fresh-rig' leaves a session OpenRig didn't start running.",
|
||||
};
|
||||
const before = db.serialize();
|
||||
expect(await service.planFresh({ seatRef: seat.sessionName, fresh: true, stop: true }))
|
||||
|
||||
@@ -444,9 +444,10 @@ describe("SeatLifecycleService", () => {
|
||||
expect(claimedResult.ok).toBe(false);
|
||||
if (claimedResult.ok) throw new Error("expected refusal");
|
||||
expect(claimedResult.code).toBe("claimed_session");
|
||||
// the refusal names the supported routes — rig down for the (here one-seat) rig, and rig unclaim.
|
||||
expect(claimedResult.guidance).toBe("To end the session, run rig down 's5-rig' (this rig has one seat, so it stops only this session; "
|
||||
+ "rig up 's5-rig' --existing brings the rig back). To release it from OpenRig and leave its process running, run rig unclaim 'dev-adopted@s5-rig'.");
|
||||
// the refusal names the supported route, rig unclaim, and says rig down doesn't stop a session OpenRig didn't start,
|
||||
// even in a one-seat rig.
|
||||
expect(claimedResult.guidance).toBe("To end this session, run rig unclaim 'dev-adopted@s5-rig' (it releases the session from OpenRig "
|
||||
+ "and leaves its process running), then end that process in its own terminal. rig down 's5-rig' leaves a session OpenRig didn't start running.");
|
||||
|
||||
const probed = seatFixture("s5-rig", "dev.flaky");
|
||||
tmux.failProbeFor(probed.sessionName);
|
||||
@@ -464,14 +465,13 @@ describe("SeatLifecycleService", () => {
|
||||
expect(bare.code).toBe("no_session");
|
||||
});
|
||||
|
||||
it("the claimed-session guidance counts the rig's seats and says rig down leaves an external CLI running", async () => {
|
||||
it("the claimed-session guidance is the same in a rig of several seats, and says rig down leaves an external CLI running", async () => {
|
||||
const claimed = seatFixture("adopted-rig", "dev.adopted", { origin: "claimed" });
|
||||
seatFixture("adopted-rig", "dev.peer");
|
||||
const shared = await service.stopSeat({ seatRef: claimed.sessionName, reason: "x" });
|
||||
if (shared.ok) throw new Error("expected refusal");
|
||||
// more than one seat: the route that ends only this seat comes first, and rig down is named for what it ends.
|
||||
expect(shared.guidance).toBe("To end only this session, run rig unclaim 'dev-adopted@adopted-rig' (it releases the session from OpenRig "
|
||||
+ "and leaves its process running), then end that process in its own terminal. rig down 'adopted-rig' ends all 2 seats in this rig.");
|
||||
expect(shared.guidance).toBe("To end this session, run rig unclaim 'dev-adopted@adopted-rig' (it releases the session from OpenRig "
|
||||
+ "and leaves its process running), then end that process in its own terminal. rig down 'adopted-rig' leaves a session OpenRig didn't start running.");
|
||||
|
||||
sessionRegistry.updateBinding(claimed.node.id, { attachmentType: "external_cli", externalSessionName: claimed.sessionName });
|
||||
const external = await service.stopSeat({ seatRef: claimed.sessionName, reason: "x" });
|
||||
@@ -483,9 +483,9 @@ describe("SeatLifecycleService", () => {
|
||||
expect(tmux.killed).toEqual([]);
|
||||
});
|
||||
|
||||
it("claimedSessionRoutes without a seat count stays general", () => {
|
||||
expect(claimedSessionRoutes("r", "a@r", null)).toBe("To end only this session, run rig unclaim 'a@r' (it releases the session from OpenRig "
|
||||
+ "and leaves its process running), then end that process in its own terminal. rig down 'r' ends every seat in this rig.");
|
||||
it("claimedSessionRoutes routes a tmux session through rig unclaim, never rig down", () => {
|
||||
expect(claimedSessionRoutes("r", "a@r")).toBe("To end this session, run rig unclaim 'a@r' (it releases the session from OpenRig "
|
||||
+ "and leaves its process running), then end that process in its own terminal. rig down 'r' leaves a session OpenRig didn't start running.");
|
||||
});
|
||||
|
||||
// ---- P5 / P6 — clean ----
|
||||
|
||||
@@ -636,8 +636,12 @@ describe("rig seat restart --keep-conversation: gates before any signal", () =>
|
||||
w.db.prepare("UPDATE sessions SET origin = 'claimed' WHERE id = ?").run(w.session.id);
|
||||
const claimed = await w.service.restartInPlace({ seatRef: SEAT, plan: true });
|
||||
expect(claimed).toMatchObject({ ok: false, code: "claimed_session" });
|
||||
// B5: the refusal names rig down and rig unclaim, not only rig unclaim.
|
||||
expect((claimed as { guidance?: string }).guidance).toMatch(/run rig down '[^']+' \(.*\)\. To release it from OpenRig and leave its process running, run rig unclaim '/);
|
||||
// B5: the refusal's route is rig unclaim, then ending the process in its own terminal; rig down leaves a session
|
||||
// OpenRig didn't start running, so it isn't offered as the way to end this one.
|
||||
const guidance = (claimed as { guidance?: string }).guidance ?? "";
|
||||
expect(guidance).toMatch(/^To end this session, run rig unclaim '[^']+' \(it releases the session from OpenRig and leaves its process running\), then end that process in its own terminal\. /);
|
||||
expect(guidance).toMatch(/rig down '[^']+' leaves a session OpenRig didn't start running\.$/);
|
||||
expect(guidance).not.toMatch(/run rig down/);
|
||||
});
|
||||
|
||||
it("names a seat handed over before 0.7.9 instead of pointing at rig unclaim", async () => {
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
// A daemon started from inside tmux on a non-default socket creates its sessions on that server,
|
||||
// and a person's new terminal reaches the default one. Every printed attach command must name the
|
||||
// daemon's server then, and keep today's form on the default server.
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdtempSync, mkdirSync, realpathSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { afterAll, afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { setDaemonTmuxServer, tmuxAttachCommand, tmuxServerArgs } from "../src/adapters/tmux-server.js";
|
||||
import { composeView, type ViewMemberInput } from "../src/domain/terminal/view-composer.js";
|
||||
|
||||
@@ -55,6 +56,58 @@ describe("the daemon's tmux server", () => {
|
||||
expect(tmuxAttachCommand("dev-build@starter")).toBe("tmux -S '/tmp/my sockets/team' attach -t dev-build@starter");
|
||||
});
|
||||
|
||||
it("quotes a session name that would otherwise reach the shell as code", () => {
|
||||
// An ordinary seat name keeps its exact form: shellWord only quotes what needs it.
|
||||
expect(tmuxAttachCommand("dev-build@starter")).toBe("tmux attach -t dev-build@starter");
|
||||
// Everything else reaches a shell as one word. The CMUX helper types this line into a
|
||||
// fresh terminal, and an adopted session's name comes from outside OpenRig.
|
||||
expect(tmuxAttachCommand("a;rm -rf /")).toBe("tmux attach -t 'a;rm -rf /'");
|
||||
expect(tmuxAttachCommand("$(id)")).toBe("tmux attach -t '$(id)'");
|
||||
expect(tmuxAttachCommand("it's")).toBe("tmux attach -t 'it'\"'\"'s'");
|
||||
// The server flags and the session are quoted independently: neither can borrow the
|
||||
// other's quoting.
|
||||
setDaemonTmuxServer(["-L", "person"]);
|
||||
expect(tmuxAttachCommand("a;b")).toBe("tmux -L person attach -t 'a;b'");
|
||||
});
|
||||
|
||||
it("escapes a trailing semicolon for tmux's own parser, before the shell sees the word", () => {
|
||||
const bs = "\\"; // one backslash
|
||||
// One backslash is added in front of a final separator, once, for the raw name as written.
|
||||
expect(tmuxAttachCommand("report;")).toBe(`tmux attach -t 'report${bs};'`);
|
||||
expect(tmuxAttachCommand(`report${bs};`)).toBe(`tmux attach -t 'report${bs}${bs};'`);
|
||||
expect(tmuxAttachCommand(`report${bs}${bs};`)).toBe(`tmux attach -t 'report${bs}${bs}${bs};'`);
|
||||
// The three names stay different all the way to tmux, which is the property that matters.
|
||||
expect(new Set([
|
||||
tmuxAttachCommand("report;"),
|
||||
tmuxAttachCommand(`report${bs};`),
|
||||
tmuxAttachCommand(`report${bs}${bs};`),
|
||||
]).size).toBe(3);
|
||||
// A separator that is not final is not touched, and ordinary names keep their bytes.
|
||||
expect(tmuxAttachCommand("a;b")).toBe("tmux attach -t 'a;b'");
|
||||
expect(tmuxAttachCommand("dev-build@starter")).toBe("tmux attach -t dev-build@starter");
|
||||
});
|
||||
|
||||
it("encodes tmux's target markers so the session name survives, and quotes any = for the shell", () => {
|
||||
// `=` is tmux's exact-match marker and the rest of the word is the name it looks up, so a
|
||||
// session named `=ls` needs `==ls`: sent as `=ls`, tmux looks for `ls`.
|
||||
expect(tmuxAttachCommand("=ls")).toBe("tmux attach -t '==ls'");
|
||||
expect(tmuxAttachCommand("ls")).toBe("tmux attach -t ls");
|
||||
expect(tmuxAttachCommand("==ls")).toBe("tmux attach -t '===ls'");
|
||||
// `@` and `%` open a window or pane target, which a session target cannot resolve.
|
||||
expect(tmuxAttachCommand("@work")).toBe("tmux attach -t '=@work'");
|
||||
expect(tmuxAttachCommand("%work")).toBe("tmux attach -t '=%work'");
|
||||
// `~` and `{marked}` are tmux's marked-target words.
|
||||
expect(tmuxAttachCommand("~")).toBe("tmux attach -t '=~'");
|
||||
expect(tmuxAttachCommand("{marked}")).toBe("tmux attach -t '={marked}'");
|
||||
// zsh expands `=name` to a command path, and with MAGIC_EQUAL_SUBST it can expand one inside a
|
||||
// `name=value` word, so a `=` anywhere in a session word is quoted. A shell passes it through.
|
||||
expect(tmuxAttachCommand("a=b@rig")).toBe("tmux attach -t 'a=b@rig'");
|
||||
expect(tmuxAttachCommand("work==ls")).toBe("tmux attach -t 'work==ls'");
|
||||
// Known limit, pinned so it cannot drift into looking fixed: tmux reads a leading `$` as a
|
||||
// session id before the exact-match flag, so this name is not addressable through `-t` at all.
|
||||
expect(tmuxAttachCommand("$1")).toBe("tmux attach -t '$1'");
|
||||
});
|
||||
|
||||
it("puts the server into local panes, never into an ssh pane", () => {
|
||||
const local: ViewMemberInput = { seat: "dev-review@starter", label: "reviewer", tmuxSession: "dev-review@starter", host: null, readOnly: false, alive: true };
|
||||
const remote: ViewMemberInput = { seat: "dev-build@starter", label: "builder", tmuxSession: "dev-build@starter", host: "box", readOnly: false, alive: true };
|
||||
@@ -71,3 +124,52 @@ describe("the daemon's tmux server", () => {
|
||||
]);
|
||||
});
|
||||
});
|
||||
// tmux's own parser decides which session `attach -t` reaches, so the encodings above get one
|
||||
// native check wherever tmux is installed. The suite runs on a private server (`-L`), so it never
|
||||
// touches the operator's sessions, and it skips on a machine without tmux.
|
||||
const nativeTmux = (() => {
|
||||
try {
|
||||
execFileSync("tmux", ["-V"], { stdio: "ignore" });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
})();
|
||||
|
||||
describe.skipIf(!nativeTmux)("tmux's own target parsing (native)", () => {
|
||||
const socket = `openrig-target-${process.pid}`;
|
||||
const tmux = (...args: string[]) => execFileSync("tmux", ["-L", socket, ...args], { encoding: "utf8" });
|
||||
const names = () =>
|
||||
tmux("list-sessions", "-F", "#{session_name}")
|
||||
.split("\n")
|
||||
.filter(Boolean)
|
||||
.sort();
|
||||
|
||||
afterAll(() => {
|
||||
try {
|
||||
tmux("kill-server");
|
||||
} catch {
|
||||
// the private server was already gone
|
||||
}
|
||||
});
|
||||
|
||||
it("==ls reaches the session named =ls while =ls reaches the session named ls", () => {
|
||||
tmux("new-session", "-d", "-s", "ls");
|
||||
tmux("new-session", "-d", "-s", "=ls");
|
||||
expect(names()).toEqual(["=ls", "ls"]);
|
||||
// `rename-session -t` takes a session target, so the name that changes says which session was
|
||||
// found. `=ls` is the exact-match marker plus `ls`; `==ls` is the marker plus `=ls`.
|
||||
tmux("rename-session", "-t", "=ls", "renamed-a");
|
||||
expect(names()).toEqual(["=ls", "renamed-a"]);
|
||||
tmux("rename-session", "-t", "==ls", "renamed-b");
|
||||
expect(names()).toEqual(["renamed-a", "renamed-b"]);
|
||||
});
|
||||
|
||||
it("=@work reaches the session named @work, where @work alone is a window target", () => {
|
||||
tmux("new-session", "-d", "-s", "@work");
|
||||
expect(names()).toContain("@work");
|
||||
tmux("rename-session", "-t", "=@work", "renamed-at");
|
||||
expect(names()).toContain("renamed-at");
|
||||
expect(names()).not.toContain("@work");
|
||||
});
|
||||
});
|
||||
@@ -81,8 +81,11 @@ describe("WorkflowSpecCache (PL-004 Phase D)", () => {
|
||||
it("readThrough returns the same spec_id when content unchanged (hash hit)", () => {
|
||||
const path = join(tmp, "spec.yaml");
|
||||
writeFileSync(path, SAMPLE_SPEC);
|
||||
const first = cache.readThrough(path);
|
||||
const second = cache.readThrough(path);
|
||||
// Hold observation time fixed so this hash-hit control does not assert that newer reads never advance cached_at.
|
||||
const observedAt = new Date("2035-01-02T03:04:05.100Z");
|
||||
const stableClockCache = new WorkflowSpecCache(db, () => observedAt);
|
||||
const first = stableClockCache.readThrough(path);
|
||||
const second = stableClockCache.readThrough(path);
|
||||
expect(second.specId).toBe(first.specId);
|
||||
expect(second.cachedAt).toBe(first.cachedAt);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,714 @@
|
||||
import { beforeEach, afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const fileProbe = vi.hoisted(() => ({
|
||||
readPaths: [] as string[],
|
||||
afterRead: null as null | ((file: string, value: unknown) => void),
|
||||
statOverrides: new Map<string, { mtimeMs: number; ctimeMs: number }>(),
|
||||
}));
|
||||
|
||||
vi.mock("node:fs", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("node:fs")>();
|
||||
const readFileSync = vi.fn(((file: unknown, ...args: unknown[]) => {
|
||||
const value = actual.readFileSync(file as never, ...(args as never[]));
|
||||
const filePath = String(file);
|
||||
fileProbe.readPaths.push(filePath);
|
||||
fileProbe.afterRead?.(filePath, value);
|
||||
return value;
|
||||
}) as typeof actual.readFileSync);
|
||||
const statSync = vi.fn(((file: unknown, ...args: unknown[]) => {
|
||||
const override = fileProbe.statOverrides.get(String(file));
|
||||
if (override) return { ...override, isFile: () => true } as ReturnType<typeof actual.statSync>;
|
||||
return actual.statSync(file as never, ...(args as never[]));
|
||||
}) as typeof actual.statSync);
|
||||
return { ...actual, readFileSync, statSync };
|
||||
});
|
||||
|
||||
import * as fs from "node:fs";
|
||||
import { createHash } from "node:crypto";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { Hono } from "hono";
|
||||
import type Database from "better-sqlite3";
|
||||
import { createDb } from "../src/db/connection.js";
|
||||
import { migrate } from "../src/db/migrate.js";
|
||||
import { coreSchema } from "../src/db/migrations/001_core_schema.js";
|
||||
import { workflowSpecsSchema } from "../src/db/migrations/033_workflow_specs.js";
|
||||
import { workflowSpecsDiagnosticSchema } from "../src/db/migrations/040_workflow_specs_diagnostic.js";
|
||||
import { WorkflowSpecCache } from "../src/domain/workflow-spec-cache.js";
|
||||
import { scanWorkflowSpecFolder } from "../src/domain/spec-library-workflow-scanner.js";
|
||||
import { ActiveLensStore } from "../src/domain/active-lens-store.js";
|
||||
import { SpecLibraryService } from "../src/domain/spec-library-service.js";
|
||||
import { SpecReviewService } from "../src/domain/spec-review-service.js";
|
||||
import { SettingsStore } from "../src/domain/user-settings/settings-store.js";
|
||||
import { specLibraryRoutes } from "../src/routes/spec-library.js";
|
||||
|
||||
const VALID_YAML = (id: string) => `workflow:
|
||||
id: ${id}
|
||||
version: '1'
|
||||
objective: capture fixture
|
||||
target:
|
||||
rig: capture-fixture
|
||||
entry:
|
||||
role: producer
|
||||
roles:
|
||||
producer:
|
||||
preferred_targets:
|
||||
- producer@capture-fixture
|
||||
steps:
|
||||
- id: produce
|
||||
actor_role: producer
|
||||
objective: Draft.
|
||||
allowed_exits:
|
||||
- done
|
||||
invariants:
|
||||
allowed_exits:
|
||||
- done
|
||||
`;
|
||||
const SAME_TICK_YAML = (version: "1" | "2", revision: "one" | "two") => `workflow:
|
||||
id: same-tick-flow
|
||||
version: '${version}'
|
||||
objective: Objective ${revision}
|
||||
target:
|
||||
rig: capture-fixture
|
||||
entry:
|
||||
role: producer
|
||||
roles:
|
||||
producer:
|
||||
preferred_targets:
|
||||
- producer@capture-fixture
|
||||
steps:
|
||||
- id: produce
|
||||
actor_role: producer
|
||||
objective: Step ${revision}.
|
||||
allowed_exits:
|
||||
- done
|
||||
invariants:
|
||||
allowed_exits:
|
||||
- done
|
||||
`;
|
||||
const BROKEN_YAML = "workflow:\n id: capture-repair\n version: [unterminated\n";
|
||||
const CAPTURE_TIME = new Date("2035-01-02T03:04:05.100Z");
|
||||
const LATER_TIME = new Date("2035-01-02T03:04:06.100Z");
|
||||
const TOUCHED_STAT_TIME = new Date("2035-01-02T03:04:06.900Z").getTime();
|
||||
const P3_REFRESH_TIME = new Date("2035-01-02T03:04:07.100Z");
|
||||
const NEWER_VERIFIED_TIME = new Date("2035-01-02T03:04:08.100Z");
|
||||
const BACKWARDS_TIME = new Date("2035-01-02T03:04:07.100Z");
|
||||
const NEWER_SQLITE_TIMESTAMP = "2035-01-02 03:04:08.100Z";
|
||||
const OLDER_STAT_TIME = new Date("2035-01-02T03:00:00.000Z").getTime();
|
||||
const captureHash = (text: string) => createHash("sha256").update(text).digest("hex");
|
||||
|
||||
function statAt(file: string, time = OLDER_STAT_TIME): void {
|
||||
fileProbe.statOverrides.set(file, { mtimeMs: time, ctimeMs: time });
|
||||
}
|
||||
|
||||
function readsOf(file: string): number {
|
||||
return fileProbe.readPaths.filter((readPath) => readPath === file).length;
|
||||
}
|
||||
|
||||
function resetFileProbe(): void {
|
||||
fileProbe.readPaths.length = 0;
|
||||
fileProbe.afterRead = null;
|
||||
fileProbe.statOverrides.clear();
|
||||
}
|
||||
|
||||
function migrateSpecCache(db: Database.Database): void {
|
||||
migrate(db, [coreSchema, workflowSpecsSchema, workflowSpecsDiagnosticSchema]);
|
||||
}
|
||||
|
||||
function makeLibraryApp(opts: {
|
||||
db: Database.Database;
|
||||
cache: WorkflowSpecCache;
|
||||
folder: string;
|
||||
root: string;
|
||||
}): Hono {
|
||||
const builtinDir = join(opts.root, "builtins");
|
||||
fs.mkdirSync(builtinDir, { recursive: true });
|
||||
fs.writeFileSync(join(opts.root, "config.json"), JSON.stringify({ specs: { builtinsVisible: false } }));
|
||||
const lib = new SpecLibraryService({ roots: [], specReviewService: new SpecReviewService() });
|
||||
lib.scan();
|
||||
const app = new Hono();
|
||||
app.use("*", async (c, next) => {
|
||||
c.set("specLibraryService" as never, lib);
|
||||
c.set("specReviewService" as never, new SpecReviewService());
|
||||
c.set("activeLensStore" as never, new ActiveLensStore({ filePath: join(opts.root, "active-lens.json") }));
|
||||
c.set("rigRepo" as never, { db: opts.db });
|
||||
c.set("workflowBuiltinSpecsDir" as never, builtinDir);
|
||||
c.set("workflowSpecCache" as never, opts.cache);
|
||||
c.set("workflowsFolderDir" as never, opts.folder);
|
||||
c.set("settingsStore" as never, new SettingsStore(join(opts.root, "config.json")));
|
||||
await next();
|
||||
});
|
||||
app.route("/api/specs/library", specLibraryRoutes());
|
||||
return app;
|
||||
}
|
||||
|
||||
describe("workflow spec persisted capture and Library reads", () => {
|
||||
let db: Database.Database;
|
||||
let root: string;
|
||||
let folder: string;
|
||||
|
||||
beforeEach(() => {
|
||||
resetFileProbe();
|
||||
db = createDb();
|
||||
migrateSpecCache(db);
|
||||
root = fs.mkdtempSync(join(tmpdir(), "wf-capture-"));
|
||||
folder = join(root, "workflows");
|
||||
fs.mkdirSync(folder, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (db?.open) db.close();
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
resetFileProbe();
|
||||
});
|
||||
|
||||
it("persists the capture timestamp for a successful read", () => {
|
||||
const file = join(folder, "capture.yaml");
|
||||
fs.writeFileSync(file, VALID_YAML("capture-time"));
|
||||
let bytesRead = false;
|
||||
const cache = new WorkflowSpecCache(db, () => new Date(bytesRead ? LATER_TIME : CAPTURE_TIME));
|
||||
fileProbe.afterRead = (readPath) => {
|
||||
if (readPath === file) bytesRead = true;
|
||||
};
|
||||
|
||||
const row = cache.readThrough(file);
|
||||
const stored = db.prepare("SELECT cached_at FROM workflow_specs WHERE source_path = ?").get(file) as { cached_at: string };
|
||||
|
||||
expect(row.cachedAt).toBe(CAPTURE_TIME.toISOString());
|
||||
expect(stored.cached_at).toBe(CAPTURE_TIME.toISOString());
|
||||
});
|
||||
|
||||
it("finds a repair after a persisted diagnostic instead of skipping on its later handler timestamp", () => {
|
||||
const file = join(folder, "repair-after.yaml");
|
||||
fs.writeFileSync(file, BROKEN_YAML);
|
||||
statAt(file, CAPTURE_TIME.getTime());
|
||||
const cache = new WorkflowSpecCache(db, () => new Date(readsOf(file) === 0 ? CAPTURE_TIME : LATER_TIME));
|
||||
|
||||
const failed = scanWorkflowSpecFolder({ db, cache, folder, builtinDir: null });
|
||||
const diagnostic = db.prepare("SELECT source_hash, cached_at, status FROM workflow_specs WHERE source_path = ?").get(file) as {
|
||||
source_hash: string; cached_at: string; status: string;
|
||||
};
|
||||
expect(failed.errors).toBe(1);
|
||||
expect(diagnostic.source_hash).toBe(captureHash(BROKEN_YAML));
|
||||
|
||||
fs.writeFileSync(file, VALID_YAML("repair-after"));
|
||||
const repaired = scanWorkflowSpecFolder({ db, cache, folder, builtinDir: null });
|
||||
|
||||
expect(repaired).toEqual({ scanned: 1, valid: 1, errors: 0, removed: 0, skipped: 0 });
|
||||
expect(db.prepare("SELECT status, name, error_message FROM workflow_specs WHERE source_path = ?").get(file))
|
||||
.toEqual({ status: "valid", name: "repair-after", error_message: null });
|
||||
});
|
||||
|
||||
it("records failed bytes when the file is repaired after the read but before diagnostic handling", () => {
|
||||
const file = join(folder, "repair-race.yaml");
|
||||
fs.writeFileSync(file, BROKEN_YAML);
|
||||
statAt(file, CAPTURE_TIME.getTime());
|
||||
const cache = new WorkflowSpecCache(db, () => new Date(readsOf(file) === 0 ? CAPTURE_TIME : LATER_TIME));
|
||||
let repairedBetweenReadAndDiagnostic = false;
|
||||
fileProbe.afterRead = (readPath) => {
|
||||
if (readPath === file && !repairedBetweenReadAndDiagnostic) {
|
||||
repairedBetweenReadAndDiagnostic = true;
|
||||
fs.writeFileSync(file, VALID_YAML("repair-race"));
|
||||
}
|
||||
};
|
||||
|
||||
const failed = scanWorkflowSpecFolder({ db, cache, folder, builtinDir: null });
|
||||
const diagnostic = db.prepare("SELECT source_hash, cached_at FROM workflow_specs WHERE source_path = ?").get(file) as {
|
||||
source_hash: string; cached_at: string;
|
||||
};
|
||||
|
||||
expect(failed.errors).toBe(1);
|
||||
expect(diagnostic.source_hash).toBe(captureHash(BROKEN_YAML));
|
||||
expect(diagnostic.cached_at).toBe(CAPTURE_TIME.toISOString());
|
||||
|
||||
fileProbe.afterRead = null;
|
||||
const repaired = scanWorkflowSpecFolder({ db, cache, folder, builtinDir: null });
|
||||
|
||||
expect(repaired).toEqual({ scanned: 1, valid: 1, errors: 0, removed: 0, skipped: 0 });
|
||||
});
|
||||
|
||||
it("keeps a warm aged Library list content-free", async () => {
|
||||
const file = join(folder, "warm.yaml");
|
||||
fs.writeFileSync(file, VALID_YAML("warm-list"));
|
||||
const cache = new WorkflowSpecCache(db, () => CAPTURE_TIME);
|
||||
cache.readThrough(file);
|
||||
statAt(file);
|
||||
const app = makeLibraryApp({ db, cache, folder, root });
|
||||
fileProbe.readPaths.length = 0;
|
||||
|
||||
const response = await app.request("/api/specs/library?kind=workflow");
|
||||
const entries = await response.json() as Array<{ name: string; sourcePath: string }>;
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(entries.some((entry) => entry.name === "warm-list" && entry.sourcePath === file)).toBe(true);
|
||||
expect(readsOf(file)).toBe(0);
|
||||
});
|
||||
|
||||
it("keeps aged persisted entries content-free after reopening the database with a fresh cache", async () => {
|
||||
const dbFile = join(root, "workflow-specs.sqlite");
|
||||
db.close();
|
||||
db = createDb(dbFile);
|
||||
migrateSpecCache(db);
|
||||
const file = join(folder, "restart.yaml");
|
||||
fs.writeFileSync(file, VALID_YAML("restart-list"));
|
||||
const cache = new WorkflowSpecCache(db, () => CAPTURE_TIME);
|
||||
cache.readThrough(file);
|
||||
statAt(file);
|
||||
|
||||
db.close();
|
||||
db = createDb(dbFile);
|
||||
const freshCache = new WorkflowSpecCache(db);
|
||||
expect(freshCache.getByNameVersion("restart-list", "1")?.cachedAt).toBe(CAPTURE_TIME.toISOString());
|
||||
const app = makeLibraryApp({ db, cache: freshCache, folder, root });
|
||||
fileProbe.readPaths.length = 0;
|
||||
|
||||
const response = await app.request("/api/specs/library?kind=workflow");
|
||||
const entries = await response.json() as Array<{ name: string; sourcePath: string }>;
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(entries.some((entry) => entry.name === "restart-list" && entry.sourcePath === file)).toBe(true);
|
||||
expect(readsOf(file)).toBe(0);
|
||||
});
|
||||
|
||||
it("refreshes a touched unchanged user file once, then keeps warm and restarted Library lists content-free", async () => {
|
||||
const dbFile = join(root, "workflow-specs-touch.sqlite");
|
||||
db.close();
|
||||
db = createDb(dbFile);
|
||||
migrateSpecCache(db);
|
||||
const file = join(folder, "touched-unchanged.yaml");
|
||||
const contents = VALID_YAML("touched-unchanged");
|
||||
fs.writeFileSync(file, contents);
|
||||
let observedAt = CAPTURE_TIME;
|
||||
const cache = new WorkflowSpecCache(db, () => observedAt);
|
||||
cache.readThrough(file);
|
||||
|
||||
// Keep the bytes unchanged while advancing both stat observations into the next second.
|
||||
statAt(file, TOUCHED_STAT_TIME);
|
||||
observedAt = P3_REFRESH_TIME;
|
||||
const app = makeLibraryApp({ db, cache, folder, root });
|
||||
fileProbe.readPaths.length = 0;
|
||||
|
||||
const firstResponse = await app.request("/api/specs/library?kind=workflow");
|
||||
const firstEntries = await firstResponse.json() as Array<{ name: string; sourcePath: string; sourceType: string }>;
|
||||
const firstReads = readsOf(file);
|
||||
const firstRow = db.prepare("SELECT cached_at, source_hash, status FROM workflow_specs WHERE source_path = ?").get(file) as {
|
||||
cached_at: string; source_hash: string; status: string;
|
||||
};
|
||||
const readThroughCachedAt = cache.readThrough(file).cachedAt;
|
||||
|
||||
fileProbe.readPaths.length = 0;
|
||||
const secondResponse = await app.request("/api/specs/library?kind=workflow");
|
||||
const secondEntries = await secondResponse.json() as Array<{ name: string; sourcePath: string; sourceType: string }>;
|
||||
const secondReads = readsOf(file);
|
||||
const secondCachedAt = (db.prepare("SELECT cached_at FROM workflow_specs WHERE source_path = ?").get(file) as { cached_at: string }).cached_at;
|
||||
|
||||
db.close();
|
||||
db = createDb(dbFile);
|
||||
const freshCache = new WorkflowSpecCache(db);
|
||||
const restartedRow = db.prepare("SELECT cached_at, source_hash, status FROM workflow_specs WHERE source_path = ?").get(file) as {
|
||||
cached_at: string; source_hash: string; status: string;
|
||||
};
|
||||
const restartedApp = makeLibraryApp({ db, cache: freshCache, folder, root });
|
||||
fileProbe.readPaths.length = 0;
|
||||
const restartedResponse = await restartedApp.request("/api/specs/library?kind=workflow");
|
||||
const restartedEntries = await restartedResponse.json() as Array<{ name: string; sourcePath: string; sourceType: string }>;
|
||||
const restartedReads = readsOf(file);
|
||||
|
||||
const hasUserEntry = (entries: Array<{ name: string; sourcePath: string; sourceType: string }>) =>
|
||||
entries.some((entry) => entry.name === "touched-unchanged" && entry.sourcePath === file && entry.sourceType === "user_file");
|
||||
expect(firstResponse.status).toBe(200);
|
||||
expect(hasUserEntry(firstEntries)).toBe(true);
|
||||
expect(secondResponse.status).toBe(200);
|
||||
expect(hasUserEntry(secondEntries)).toBe(true);
|
||||
expect(restartedResponse.status).toBe(200);
|
||||
expect(hasUserEntry(restartedEntries)).toBe(true);
|
||||
expect({
|
||||
firstReads,
|
||||
firstCachedAt: firstRow.cached_at,
|
||||
firstHash: firstRow.source_hash,
|
||||
firstStatus: firstRow.status,
|
||||
readThroughCachedAt,
|
||||
secondReads,
|
||||
secondCachedAt,
|
||||
restartedReads,
|
||||
restartedCachedAt: restartedRow.cached_at,
|
||||
restartedHash: restartedRow.source_hash,
|
||||
restartedStatus: restartedRow.status,
|
||||
}).toEqual({
|
||||
firstReads: 1,
|
||||
firstCachedAt: P3_REFRESH_TIME.toISOString(),
|
||||
firstHash: captureHash(contents),
|
||||
firstStatus: "valid",
|
||||
readThroughCachedAt: P3_REFRESH_TIME.toISOString(),
|
||||
secondReads: 0,
|
||||
secondCachedAt: P3_REFRESH_TIME.toISOString(),
|
||||
restartedReads: 0,
|
||||
restartedCachedAt: P3_REFRESH_TIME.toISOString(),
|
||||
restartedHash: captureHash(contents),
|
||||
restartedStatus: "valid",
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
["immediately", 0],
|
||||
["at the first lookup five seconds later", 5_000],
|
||||
] as const)("reviews the current Library source after a same-tick rewrite %s", async (_timing, delayMs) => {
|
||||
const file = join(folder, "same-tick.yaml");
|
||||
const versionOne = SAME_TICK_YAML("1", "one");
|
||||
const versionTwo = SAME_TICK_YAML("2", "two");
|
||||
expect(Buffer.byteLength(versionOne, "utf8")).toBe(Buffer.byteLength(versionTwo, "utf8"));
|
||||
|
||||
fs.writeFileSync(file, versionOne);
|
||||
statAt(file, CAPTURE_TIME.getTime());
|
||||
const initialStat = { ...fileProbe.statOverrides.get(file)! };
|
||||
expect(initialStat).toEqual({ mtimeMs: CAPTURE_TIME.getTime(), ctimeMs: CAPTURE_TIME.getTime() });
|
||||
let observedAt = CAPTURE_TIME.getTime();
|
||||
const cache = new WorkflowSpecCache(db, () => new Date(observedAt));
|
||||
const app = makeLibraryApp({ db, cache, folder, root });
|
||||
|
||||
const initialListResponse = await app.request("/api/specs/library?kind=workflow");
|
||||
const initialEntries = await initialListResponse.json() as Array<{
|
||||
id: string; name: string; version: string; sourcePath: string; sourceType: string;
|
||||
}>;
|
||||
const initialEntry = initialEntries.find((entry) => entry.sourcePath === file
|
||||
&& entry.sourceType === "user_file"
|
||||
&& entry.name === "same-tick-flow"
|
||||
&& entry.version === "1");
|
||||
expect(initialListResponse.status).toBe(200);
|
||||
expect(initialEntry).toMatchObject({
|
||||
id: "workflow:same-tick-flow:1",
|
||||
name: "same-tick-flow",
|
||||
version: "1",
|
||||
sourcePath: file,
|
||||
sourceType: "user_file",
|
||||
});
|
||||
expect(initialEntry).toBeDefined();
|
||||
|
||||
const initialRow = db.prepare("SELECT cached_at, source_hash, status FROM workflow_specs WHERE source_path = ? AND name = ? AND version = '1'")
|
||||
.get(file, "same-tick-flow") as { cached_at: string; source_hash: string; status: string };
|
||||
expect(initialRow).toEqual({ cached_at: CAPTURE_TIME.toISOString(), source_hash: captureHash(versionOne), status: "valid" });
|
||||
|
||||
const beforeUnchangedReviewReads = readsOf(file);
|
||||
const initialReviewResponse = await app.request(`/api/specs/library/${encodeURIComponent(initialEntry!.id)}/review`);
|
||||
const initialReview = await initialReviewResponse.json() as {
|
||||
version: string; sourcePath: string; purpose: string; steps: Array<{ objective: string }>;
|
||||
};
|
||||
expect(initialReviewResponse.status).toBe(200);
|
||||
expect(initialReview).toMatchObject({
|
||||
version: "1",
|
||||
sourcePath: file,
|
||||
purpose: "Objective one",
|
||||
steps: [expect.objectContaining({ objective: "Step one." })],
|
||||
});
|
||||
expect(readsOf(file)).toBeGreaterThan(beforeUnchangedReviewReads);
|
||||
const unchangedRow = db.prepare("SELECT cached_at, source_hash FROM workflow_specs WHERE source_path = ? AND name = ? AND version = '1'")
|
||||
.get(file, "same-tick-flow") as { cached_at: string; source_hash: string };
|
||||
expect(unchangedRow).toEqual({ cached_at: CAPTURE_TIME.toISOString(), source_hash: captureHash(versionOne) });
|
||||
|
||||
const readsBeforeRewrite = readsOf(file);
|
||||
fs.writeFileSync(file, versionTwo);
|
||||
statAt(file, CAPTURE_TIME.getTime());
|
||||
expect(fileProbe.statOverrides.get(file)).toEqual(initialStat);
|
||||
observedAt = CAPTURE_TIME.getTime() + delayMs;
|
||||
const expectedLookupAt = new Date(observedAt);
|
||||
|
||||
// This ordinary Library list is the first request after the rewrite, including in the T+5s case.
|
||||
const currentListResponse = await app.request("/api/specs/library?kind=workflow");
|
||||
const currentEntries = await currentListResponse.json() as Array<{
|
||||
id: string; name: string; version: string; sourcePath: string; sourceType: string;
|
||||
}>;
|
||||
const currentEntry = currentEntries.find((entry) => entry.sourcePath === file
|
||||
&& entry.sourceType === "user_file"
|
||||
&& entry.name === "same-tick-flow"
|
||||
&& entry.version === "2");
|
||||
expect(currentListResponse.status).toBe(200);
|
||||
expect(currentEntry).toMatchObject({
|
||||
id: "workflow:same-tick-flow:2",
|
||||
name: "same-tick-flow",
|
||||
version: "2",
|
||||
sourcePath: file,
|
||||
sourceType: "user_file",
|
||||
});
|
||||
expect(currentEntry).toBeDefined();
|
||||
expect(readsOf(file)).toBeGreaterThan(readsBeforeRewrite);
|
||||
expect(fileProbe.statOverrides.get(file)).toEqual(initialStat);
|
||||
|
||||
const currentRow = db.prepare("SELECT cached_at, source_hash, status FROM workflow_specs WHERE source_path = ? AND name = ? AND version = '2'")
|
||||
.get(file, "same-tick-flow") as { cached_at: string; source_hash: string; status: string };
|
||||
expect(currentRow).toEqual({ cached_at: expectedLookupAt.toISOString(), source_hash: captureHash(versionTwo), status: "valid" });
|
||||
|
||||
const currentReviewResponse = await app.request(`/api/specs/library/${encodeURIComponent(currentEntry!.id)}/review`);
|
||||
const currentReview = await currentReviewResponse.json() as {
|
||||
version: string; sourcePath: string; purpose: string; steps: Array<{ objective: string }>;
|
||||
};
|
||||
expect(currentReviewResponse.status).toBe(200);
|
||||
expect(currentReview).toMatchObject({
|
||||
version: "2",
|
||||
sourcePath: file,
|
||||
purpose: "Objective two",
|
||||
steps: [expect.objectContaining({ objective: "Step two." })],
|
||||
});
|
||||
expect(fileProbe.statOverrides.get(file)).toEqual(initialStat);
|
||||
|
||||
const historyResponse = await app.request(`/api/specs/library/${encodeURIComponent("workflow:same-tick-flow:1")}/review`);
|
||||
const historicalReview = await historyResponse.json() as {
|
||||
version: string; sourcePath: string; purpose: string; steps: Array<{ objective: string }>;
|
||||
};
|
||||
expect(historyResponse.status).toBe(200);
|
||||
expect(historicalReview).toMatchObject({
|
||||
version: "1",
|
||||
sourcePath: file,
|
||||
purpose: "Objective one",
|
||||
steps: [expect.objectContaining({ objective: "Step one." })],
|
||||
});
|
||||
});
|
||||
|
||||
it("does not regress a newer SQLite-style cache timestamp when the injected clock moves backwards", () => {
|
||||
const file = join(folder, "backwards-clock.yaml");
|
||||
fs.writeFileSync(file, VALID_YAML("backwards-clock"));
|
||||
let observedAt = NEWER_VERIFIED_TIME;
|
||||
const cache = new WorkflowSpecCache(db, () => observedAt);
|
||||
cache.readThrough(file);
|
||||
db.prepare("UPDATE workflow_specs SET cached_at = ? WHERE source_path = ?").run(NEWER_SQLITE_TIMESTAMP, file);
|
||||
|
||||
observedAt = BACKWARDS_TIME;
|
||||
const row = cache.readThrough(file);
|
||||
const stored = db.prepare("SELECT cached_at FROM workflow_specs WHERE source_path = ?").get(file) as { cached_at: string };
|
||||
|
||||
expect(row.cachedAt).toBe(NEWER_SQLITE_TIMESTAMP);
|
||||
expect(stored.cached_at).toBe(NEWER_SQLITE_TIMESTAMP);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["without reading an identical copy", false],
|
||||
["after reading an identical copy", true],
|
||||
] as const)("re-reads the owning file's edit %s", (_scenario, readIdenticalCopy) => {
|
||||
const file = join(folder, "owner.yaml");
|
||||
const duplicateDir = join(root, "other");
|
||||
fs.mkdirSync(duplicateDir);
|
||||
const duplicate = join(duplicateDir, "copy.yaml");
|
||||
const originalContents = VALID_YAML("duplicate-flow");
|
||||
const editedContents = originalContents.replace("objective: capture fixture", "objective: edited capture fixture");
|
||||
fs.writeFileSync(file, originalContents);
|
||||
fs.writeFileSync(duplicate, originalContents);
|
||||
let observedAt = CAPTURE_TIME;
|
||||
const cache = new WorkflowSpecCache(db, () => observedAt);
|
||||
cache.readThrough(file);
|
||||
|
||||
// P's bytes really change after T0, but the stat fixture is deterministic and precedes T2.
|
||||
fileProbe.readPaths.length = 0;
|
||||
fs.writeFileSync(file, editedContents);
|
||||
statAt(file, TOUCHED_STAT_TIME);
|
||||
observedAt = P3_REFRESH_TIME;
|
||||
const copyRow = readIdenticalCopy ? cache.readThrough(duplicate) : null;
|
||||
const beforeScan = db.prepare("SELECT source_path, cached_at FROM workflow_specs WHERE name = ? AND version = '1'").get("duplicate-flow") as {
|
||||
source_path: string; cached_at: string;
|
||||
};
|
||||
const result = scanWorkflowSpecFolder({ db, cache, folder, builtinDir: null });
|
||||
const afterScan = db.prepare("SELECT source_path, source_hash, purpose, cached_at FROM workflow_specs WHERE name = ? AND version = '1'").get("duplicate-flow") as {
|
||||
source_path: string; source_hash: string; purpose: string; cached_at: string;
|
||||
};
|
||||
|
||||
expect({
|
||||
copySourcePath: copyRow?.sourcePath ?? null,
|
||||
copyCachedAt: copyRow?.cachedAt ?? null,
|
||||
copyReads: readsOf(duplicate),
|
||||
ownerReads: readsOf(file),
|
||||
beforeScanSourcePath: beforeScan.source_path,
|
||||
beforeScanCachedAt: beforeScan.cached_at,
|
||||
result,
|
||||
afterScanSourcePath: afterScan.source_path,
|
||||
afterScanHash: afterScan.source_hash,
|
||||
afterScanPurpose: afterScan.purpose,
|
||||
afterScanCachedAt: afterScan.cached_at,
|
||||
}).toEqual({
|
||||
copySourcePath: readIdenticalCopy ? file : null,
|
||||
copyCachedAt: readIdenticalCopy ? CAPTURE_TIME.toISOString() : null,
|
||||
copyReads: readIdenticalCopy ? 1 : 0,
|
||||
ownerReads: 1,
|
||||
beforeScanSourcePath: file,
|
||||
beforeScanCachedAt: CAPTURE_TIME.toISOString(),
|
||||
result: { scanned: 1, valid: 1, errors: 0, removed: 0, skipped: 0 },
|
||||
afterScanSourcePath: file,
|
||||
afterScanHash: captureHash(editedContents),
|
||||
afterScanPurpose: "edited capture fixture",
|
||||
afterScanCachedAt: P3_REFRESH_TIME.toISOString(),
|
||||
});
|
||||
});
|
||||
|
||||
it("lists an identical built-in alias with two user reads while keeping edited user source links", async () => {
|
||||
const builtin = join(root, "builtins", "same.yaml");
|
||||
const user = join(folder, "same.yaml");
|
||||
const identicalYaml = VALID_YAML("same");
|
||||
const expectedSourceId = `workflow-source:${captureHash(user)}`;
|
||||
let observedAt = CAPTURE_TIME;
|
||||
const cache = new WorkflowSpecCache(db, () => observedAt);
|
||||
const app = makeLibraryApp({ db, cache, folder, root });
|
||||
|
||||
fs.writeFileSync(builtin, identicalYaml);
|
||||
cache.readThrough(builtin);
|
||||
fs.writeFileSync(user, identicalYaml);
|
||||
statAt(builtin);
|
||||
statAt(user);
|
||||
|
||||
const readThroughSpy = vi.spyOn(cache, "readThrough");
|
||||
fileProbe.readPaths.length = 0;
|
||||
|
||||
const firstList = await app.request("/api/specs/library?kind=workflow");
|
||||
const firstEntries = await firstList.json() as Array<{
|
||||
id: string; kind: string; name: string; version: string; sourcePath: string; sourceType: string; isBuiltIn: boolean;
|
||||
}>;
|
||||
const firstPassCounts = {
|
||||
status: firstList.status,
|
||||
userReads: readsOf(user),
|
||||
builtinReads: readsOf(builtin),
|
||||
readThroughPaths: readThroughSpy.mock.calls.map(([specPath]) => specPath),
|
||||
};
|
||||
|
||||
fileProbe.readPaths.length = 0;
|
||||
readThroughSpy.mockClear();
|
||||
const secondList = await app.request("/api/specs/library?kind=workflow");
|
||||
const secondPassCounts = {
|
||||
status: secondList.status,
|
||||
userReads: readsOf(user),
|
||||
builtinReads: readsOf(builtin),
|
||||
readThroughPaths: readThroughSpy.mock.calls.map(([specPath]) => specPath),
|
||||
};
|
||||
|
||||
expect(firstPassCounts).toEqual({
|
||||
status: 200,
|
||||
userReads: 2,
|
||||
builtinReads: 1,
|
||||
readThroughPaths: [user],
|
||||
});
|
||||
expect(secondPassCounts).toEqual({
|
||||
status: 200,
|
||||
userReads: 2,
|
||||
builtinReads: 1,
|
||||
readThroughPaths: [user],
|
||||
});
|
||||
expect(firstEntries).toEqual([
|
||||
expect.objectContaining({
|
||||
id: expectedSourceId,
|
||||
kind: "workflow",
|
||||
name: "same",
|
||||
version: "1",
|
||||
sourcePath: user,
|
||||
sourceType: "user_file",
|
||||
isBuiltIn: false,
|
||||
}),
|
||||
]);
|
||||
expect(db.prepare("SELECT source_path, source_hash, cached_at FROM workflow_specs WHERE name = 'same' AND version = '1'").get())
|
||||
.toEqual({
|
||||
source_path: builtin,
|
||||
source_hash: captureHash(identicalYaml),
|
||||
cached_at: CAPTURE_TIME.toISOString(),
|
||||
});
|
||||
|
||||
const withBuiltins = await (await app.request("/api/specs/library?kind=workflow&include_builtins=true")).json() as Array<{
|
||||
id: string; sourcePath: string; sourceType: string; isBuiltIn: boolean;
|
||||
}>;
|
||||
expect(withBuiltins.map(({ id, sourcePath, sourceType, isBuiltIn }) => ({ id, sourcePath, sourceType, isBuiltIn }))).toEqual([
|
||||
{ id: "workflow:same:1", sourcePath: builtin, sourceType: "builtin", isBuiltIn: true },
|
||||
{ id: expectedSourceId, sourcePath: user, sourceType: "user_file", isBuiltIn: false },
|
||||
]);
|
||||
|
||||
const aliasReview = await app.request(`/api/specs/library/${encodeURIComponent(expectedSourceId)}/review`);
|
||||
expect(aliasReview.status).toBe(200);
|
||||
expect(await aliasReview.json()).toMatchObject({
|
||||
name: "same",
|
||||
version: "1",
|
||||
sourcePath: user,
|
||||
isBuiltIn: false,
|
||||
libraryEntryId: expectedSourceId,
|
||||
purpose: "capture fixture",
|
||||
});
|
||||
|
||||
// Deleting the identical user copy removes the synthetic alias entry without removing the canonical built-in row.
|
||||
fs.rmSync(user);
|
||||
fileProbe.statOverrides.delete(user);
|
||||
const syncAfterDelete = await app.request("/api/specs/library/sync", { method: "POST" });
|
||||
expect(syncAfterDelete.status).toBe(200);
|
||||
expect(await syncAfterDelete.json()).toEqual([]);
|
||||
expect(await (await app.request("/api/specs/library?kind=workflow")).json()).toEqual([]);
|
||||
expect((await app.request(`/api/specs/library/${encodeURIComponent(expectedSourceId)}/review`)).status).toBe(404);
|
||||
expect(db.prepare("SELECT source_path, cached_at FROM workflow_specs WHERE name = 'same' AND version = '1'").get())
|
||||
.toEqual({ source_path: builtin, cached_at: CAPTURE_TIME.toISOString() });
|
||||
|
||||
// Control: an ordinary edited user copy owns the cache row and still receives its source-specific review link.
|
||||
const editedYaml = identicalYaml.replace("objective: capture fixture", "objective: edited capture fixture");
|
||||
fs.writeFileSync(user, editedYaml);
|
||||
statAt(user, TOUCHED_STAT_TIME);
|
||||
observedAt = P3_REFRESH_TIME;
|
||||
|
||||
const editedList = await app.request("/api/specs/library?kind=workflow");
|
||||
const editedEntries = await editedList.json() as Array<{
|
||||
id: string; sourcePath: string; sourceType: string; isBuiltIn: boolean;
|
||||
}>;
|
||||
expect(editedList.status).toBe(200);
|
||||
expect(editedEntries.map(({ id, sourcePath, sourceType, isBuiltIn }) => ({ id, sourcePath, sourceType, isBuiltIn }))).toEqual([
|
||||
{ id: "workflow:same:1", sourcePath: user, sourceType: "user_file", isBuiltIn: false },
|
||||
]);
|
||||
expect(db.prepare("SELECT source_path, source_hash, cached_at FROM workflow_specs WHERE name = 'same' AND version = '1'").get())
|
||||
.toEqual({
|
||||
source_path: user,
|
||||
source_hash: captureHash(editedYaml),
|
||||
cached_at: P3_REFRESH_TIME.toISOString(),
|
||||
});
|
||||
|
||||
statAt(user);
|
||||
fileProbe.readPaths.length = 0;
|
||||
const warmEditedList = await app.request("/api/specs/library?kind=workflow");
|
||||
expect(warmEditedList.status).toBe(200);
|
||||
expect(readsOf(user)).toBe(0);
|
||||
|
||||
fileProbe.readPaths.length = 0;
|
||||
const editedSourceReview = await app.request(`/api/specs/library/${encodeURIComponent(expectedSourceId)}/review`);
|
||||
expect(editedSourceReview.status).toBe(200);
|
||||
expect(await editedSourceReview.json()).toMatchObject({
|
||||
name: "same",
|
||||
version: "1",
|
||||
sourcePath: user,
|
||||
isBuiltIn: false,
|
||||
libraryEntryId: expectedSourceId,
|
||||
purpose: "edited capture fixture",
|
||||
});
|
||||
// SpecLibraryService.get reads the workflow YAML once on /:id/review; refreshWorkflowEntries itself adds 0 reads.
|
||||
expect(readsOf(user)).toBe(1);
|
||||
|
||||
// Exclude ordinary user-file fallback verification from the synthetic-entry skip: when the scanner skips without
|
||||
// pre-populating verifiedSourceHashes (e.g. a newer #503 diagnostic row shares the path), the route still reads
|
||||
// the ordinary user file once to verify its cached bytes (plus one SpecLibraryService.get read on 200 review)
|
||||
// and rejects changed disk bytes before SpecLibraryService.get runs.
|
||||
const fallbackSpec = join(folder, "fallback.yaml");
|
||||
const fallbackSourceId = `workflow-source:${captureHash(fallbackSpec)}`;
|
||||
const fallbackYaml = VALID_YAML("fallback-flow");
|
||||
observedAt = CAPTURE_TIME;
|
||||
fs.writeFileSync(fallbackSpec, fallbackYaml);
|
||||
cache.readThrough(fallbackSpec);
|
||||
cache.writeDiagnostic({
|
||||
sourcePath: fallbackSpec,
|
||||
sourceHash: captureHash(BROKEN_YAML),
|
||||
errorMessage: "diagnostic alongside cached v1",
|
||||
cachedAt: P3_REFRESH_TIME.toISOString(),
|
||||
});
|
||||
statAt(fallbackSpec);
|
||||
fileProbe.readPaths.length = 0;
|
||||
|
||||
const fallbackReview = await app.request(`/api/specs/library/${encodeURIComponent(fallbackSourceId)}/review`);
|
||||
expect(fallbackReview.status).toBe(200);
|
||||
expect(await fallbackReview.json()).toMatchObject({
|
||||
name: "fallback-flow",
|
||||
version: "1",
|
||||
sourcePath: fallbackSpec,
|
||||
isBuiltIn: false,
|
||||
libraryEntryId: fallbackSourceId,
|
||||
});
|
||||
expect(readsOf(fallbackSpec)).toBe(2);
|
||||
|
||||
fs.writeFileSync(fallbackSpec, BROKEN_YAML);
|
||||
fileProbe.readPaths.length = 0;
|
||||
expect((await app.request(`/api/specs/library/${encodeURIComponent(fallbackSourceId)}/review`)).status).toBe(404);
|
||||
expect(readsOf(fallbackSpec)).toBe(1);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user